diff --git a/README.md b/README.md index 9affb8f..1b626fb 100644 --- a/README.md +++ b/README.md @@ -78,11 +78,12 @@ async function loadFile(file) { ## Features -- **21 DXF entity types** — LINE, CIRCLE, ARC, ELLIPSE, SPLINE, POLYLINE, LWPOLYLINE, TEXT, MTEXT, DIMENSION, HATCH, INSERT, SOLID, 3DFACE, LEADER, MULTILEADER, MLINE, XLINE, RAY, ATTDEF, HELIX, plus ATTRIB within blocks +- **22 DXF entity types** — LINE, CIRCLE, ARC, ELLIPSE, SPLINE, POLYLINE, LWPOLYLINE, TEXT, MTEXT, DIMENSION, HATCH, INSERT, SOLID, 3DFACE, LEADER, MULTILEADER, MLINE, XLINE, RAY, ATTDEF, HELIX, REGION (contour via HATCH boundary), plus ATTRIB within blocks - **Variable-width polylines** — per-vertex `startWidth`/`endWidth` tapering, constant-width, arrows, donuts rendered as mesh geometry with miter joins +- **Standard arrowhead blocks** — DIMENSION endpoints and LEADER tips honour DIMBLK / DIMLDRBLK; all 18 stock AutoCAD blocks rendered with the correct shape (`_ClosedFilled`, `_Open*`, `_Dot*`, `_Origin*`, `_Box*`, `_Datum*`, `_ArchTick`, `_Integral`, `_None`, …) - **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 +- **Vector text** — crisp at any zoom; Liberation Sans/Serif fonts; bold, italic, underline, overline, strikethrough; MTEXT inline scoped formatting (color/font/size/decoration inside `{…}` groups) - **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 diff --git a/demo/components/StatsSection.vue b/demo/components/StatsSection.vue index c0db6f8..4aa2e70 100644 --- a/demo/components/StatsSection.vue +++ b/demo/components/StatsSection.vue @@ -21,8 +21,8 @@ interface Stat { } const stats: Stat[] = [ - { value: 21, label: "entity types", sub: "LINE · ARC · SPLINE · HATCH · INSERT · MTEXT · …" }, - { value: 945, label: "tests", sub: "100% green CI on every push" }, + { value: 22, label: "entity types", sub: "LINE · ARC · SPLINE · HATCH · INSERT · MTEXT · REGION · …" }, + { value: 1141, 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 9e75540..8d87f90 100644 --- a/demo/components/WhatsNewSection.vue +++ b/demo/components/WhatsNewSection.vue @@ -24,9 +24,19 @@ 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-render", + version: "1.6.0", + text: "REGION entities now render their contour by borrowing the boundary edges of any HATCH that references them as a source object (DXF codes 97/330) — no ACIS decoding needed. The REGION becomes pickable too, so hatched details with a REGION outline (common in AutoCAD-built profile drawings) finally show their visible boundary and fire entity-hover / entity-click events.", + }, + { + pkg: "dxf-render", + version: "1.6.0", + text: "Standard AutoCAD arrowhead blocks at DIMENSION endpoints and on LEADER / MULTILEADER tips — all 18 names now supported: filled / blank dots, ticks, boxes, origin rings, integral, open arrows at 30° / 90°, datum, and none. Previously only ticks were recognised; everything else collapsed to a default triangle.", + }, + { + pkg: "dxf-render", + version: "1.6.0", + text: "MTEXT inline scoped formatting — \\C, \\H, \\f, \\L\\l, \\O\\o, \\K\\k inside brace groups {…} now apply only to that scope and revert on the closing brace. Each line is a list of MTextRun segments with per-run color / height / bold / italic / font / underline / overline / strikethrough. \\W; (width factor) and \\Q; (obliquing angle) are now honored too.", }, { pkg: "dxf-vuer", diff --git a/demo/vite.config.ts b/demo/vite.config.ts index 5359510..e252eb7 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -1,24 +1,51 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import path from "path"; +import { arraybufferPlugin } from "../packages/dxf-render/vite-plugins/arraybuffer"; -export default defineConfig({ - plugins: [vue()], - publicDir: path.resolve(__dirname, "../public"), - server: { - port: 5173, - open: true, - }, - build: { - outDir: path.resolve(__dirname, "../dist-demo"), - emptyOutDir: true, - sourcemap: true, - rollupOptions: { - output: { - manualChunks: { - three: ["three", "three/addons/curves/NURBSCurve.js"], +// In dev (`pnpm dev`), resolve dxf-vuer/dxf-render to their source files +// instead of the pre-built dist bundles. Without these aliases, changes +// inside packages/*/src require a full `pnpm build` before they show up +// in the demo; with them, Vite's HMR picks up edits directly. +// Production builds (`pnpm build:demo`) skip the aliases and import dist +// — matching what end users get. +// The arraybufferPlugin is also dev-only: dxf-render's source imports +// fonts via `?arraybuffer`, which is a custom suffix defined by that +// plugin. Without it the .ttf files import as something opentype.js +// can't parse ("buffer is not an object"). +export default defineConfig(({ command }) => { + const useSourceAliases = command === "serve"; + return { + plugins: useSourceAliases ? [vue(), arraybufferPlugin()] : [vue()], + publicDir: path.resolve(__dirname, "../public"), + resolve: { + alias: useSourceAliases + ? { + "dxf-vuer/style.css": path.resolve(__dirname, "../packages/dxf-vuer/src/styles.css"), + "dxf-vuer": path.resolve(__dirname, "../packages/dxf-vuer/src/index.ts"), + "dxf-render": path.resolve(__dirname, "../packages/dxf-render/src/index.ts"), + // dxf-render's source files use the "@/" alias to refer to their + // own ./src — reproduce it here so Vite can follow those imports. + // dxf-vuer's source doesn't use "@/", so no conflict. + "@/": path.resolve(__dirname, "../packages/dxf-render/src") + "/", + } + : {}, + }, + server: { + port: 5173, + open: true, + }, + build: { + outDir: path.resolve(__dirname, "../dist-demo"), + emptyOutDir: true, + sourcemap: true, + rollupOptions: { + output: { + manualChunks: { + three: ["three", "three/addons/curves/NURBSCurve.js"], + }, }, }, }, - }, + }; }); diff --git a/packages/dxf-render/CHANGELOG.md b/packages/dxf-render/CHANGELOG.md index 84df6e5..12a05e0 100644 --- a/packages/dxf-render/CHANGELOG.md +++ b/packages/dxf-render/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## 1.6.0 + +### Features + +- **Standard AutoCAD arrowhead blocks** — full support for all 18 standard names at DIMENSION endpoints and on LEADER/MULTILEADER tips: `_ClosedFilled` (default), `_Closed` / `_ClosedBlank`, `_Open` / `_Open30` / `_OpenArrow`, `_DatumFilled` / `_DatumBlank`, `_ArchTick`, `_Oblique`, `_Small`, `_Tick`, `_Dot` / `_DotSmall` / `_DotBlank` / `_DotSmallBlank`, `_Origin` / `_Origin2`, `_Box` / `_BoxFilled`, `_Integral`, `_None`. New `render/arrowheads.ts` module owns the geometry; `DimVars.arrowKind` (discriminated `ArrowKind` union) replaces the previous `useTicks` boolean. The "outside arrows" flip for short dim lines now only fires for direction-dependent arrow-shape kinds — dots, ticks, boxes and origin rings sit at the endpoint as-is. For LEADERs an unknown DIMLDRBLK name falls back to the user-defined block geometry. +- **REGION entities** — parsed (minimally; ACIS modeler data skipped) and rendered as their own contour. When a HATCH lists the REGION's handle in its boundary path source objects (DXF codes 97/330), the REGION borrows the HATCH's already-parsed boundary edges as its visible curve. Drawn with the REGION's own color/layer/linetype (OCS comes from the HATCH). Pickable too. New parser pass `linkRegionsToHatchBoundaries`; new renderer `collectors/regionCollector.ts`. +- **`STYLE.widthFactor` (DXF code 41)** — now honored across TEXT, ATTRIB, ATTDEF, MTEXT and DIMENSION text. A STYLE explicitly authored as narrow (e.g. ISO-25 with `widthFactor=0.8`) no longer renders at width factor 1. Priority chain: `entity.xScale` / `entity.scale` → `STYLE.widthFactor` → `1.0`. MTEXT seeds the initial format-state `widthFactor` from STYLE; inline `\W;` still overrides. DIMENSION text resolves via DIMSTYLE.DIMTXSTY (code 340) → STYLE → widthFactor, threaded through the dim-line gap calculation so breaks stay correct under stretch. +- **MTEXT inline scoped formatting `{…}`** — `\C`, `\H`, `\f`, `\L\l`, `\O\o`, `\K\k` inside brace groups now apply only to that scope and revert on the closing brace. Previously every formatting code inside braces was stripped. The line model was replaced with a runs model: each `MTextLine` carries an ordered array of `MTextRun` segments, each with its own color/height/bold/italic/font/underline/overline/strikethrough. Word-wrap, tabs and 9-point attachment alignment now operate across all runs of a line. Overline (`\O`) and strikethrough (`\K`) decorations are also drawn (previously silently stripped). New exports: `MTextRun` interface and `getMTextLineText(line)` helper. +- **MTEXT inline `\W;` (width factor) and `\Q;` (obliquing angle)** — `\W2;` makes glyphs twice as wide; `\Q15;` shears them by 15° (positive slants right). Both codes participate in brace scoping and combine with other formatting. Word-wrap and inter-run advance account for the width factor. +- **Angular dimension precision (`DIMADEC`)** — DIMSTYLE code 179 and header `$DIMADEC` are now read; angular dim labels use them for decimal places instead of the linear `DIMDEC`. +- **Radial dimension state-machine** — radial dims are now rendered through a proper layout state-machine driven by `DIMTIH` (74), `DIMTOH` (73), `DIMTAD` (77), `DIMGAP` (147), `DIMTMOVE` (279) and `DIMUPT` (288), instead of a hard-coded ANSI horizontal layout. Aligned mode (ISO default): dim line follows the radius angle with DIMTAD-driven break/continue around text and DIMTMOVE-driven connectors. Horizontal mode (ANSI): preserved. New DIMSTYLE fields: `dimtad`, `dimgap`, `dimtmove`, `dimupt`, `dimatfit`. New header keys: `$DIMTAD`, `$DIMTIH`, `$DIMTOH`, `$DIMTMOVE`. +- **Binary DXF detection** — `parseDxf` now rejects AutoCAD Binary DXF files with a clear message (`"Binary DXF format is not supported. Save the file as ASCII (text) DXF and try again."`) instead of failing later with a cryptic scanner error. Detection is a single `startsWith("AutoCAD Binary DXF")` on the decoded text and works for sync `parseDxf`, async `parseDxfAsync`, and the Vue `loadDXFFromBuffer` pipeline. + +### Bug Fixes + +- **ATTRIB/ATTDEF FIT/ALIGNED** — `horizontalJustification = 3` (ALIGNED) or `= 5` (FIT) now render correctly between the two alignment points instead of being anchored at the second one. Affects both `renderAttribs` (inside INSERT) and `collectAttdefEntity`. +- **Wide LWPOLYLINE/POLYLINE with a width discontinuity at a vertex** — e.g. an arrow drawn as two segments (`0/0` then `120/0`). The junction is now re-emitted as a duplicate center point so the wider segment opens at its real start width instead of inheriting the previous segment's end width. Zero-width segments inside an otherwise-wide polyline are also rendered as thin lines (a zero-area mesh strip is invisible). +- **Wide LWPOLYLINE/POLYLINE inside a scaled INSERT** — thickness now scales with the parent matrix. Previously `halfWidth` stayed in entity-local units while the path was transformed, so a polyline inside an INSERT with scale 225 (typical for pre-rendered dim blocks with `_ArchTick` arrowheads) kept its 0.4-unit thickness and rendered sub-pixel. +- **Nested DIMENSION inside a block** — now uses its pre-rendered associated block (`entity.block`) when present, instead of falling through to the DIMSTYLE-synthesizing path. The dispatch in `insertCollector.ts` previously ignored `entity.block` for in-block dimensions — only the top-level dispatch checked it. New `processDimensionEntity` helper shared by all three call sites. +- **DIMENSION with a pre-rendered associated block (`entity.block`)** — rendered through the block instead of synthesized from DIMSTYLE rules. AutoCAD/Revit/Civil3D stash WYSIWYG dim geometry (lines, arrowheads, MTEXT) into anonymous `*D###` / `DIMBLOCKn-…` blocks; reading those directly bypasses any `DIMTXT × DIMSCALE` mismatch in the source DIMSTYLE. Fallback to the DIMSTYLE path when the dim has no block or the block is empty. +- **Render order** — solid fills (HATCH, SOLID, 3DFACE) no longer cover block outlines that were added to the scene before flush. Each material kind now carries an explicit `renderOrder` (`MESH=0`, `LINE=1`, `OVERLAY=2`); a stable sort runs after `GeometryCollector.flush()` so hatches draw first, then outlines, then text/arrows, regardless of when each object joined the group. +- **MULTILEADER entities from real AutoCAD-2018 files now render** — `CONTEXT_DATA` parser used wrong section codes (301/302 instead of 302/304) and squashed every closing `}` under one case; `entity.leaders` ended up empty and `entity.text` was overwritten with the literal `"LEADER_LINE{"`. Codes are now correctly paired per-level. `leaderCollector` no longer drops a LEADER_LINE that carries only the arrow-tip vertex. +- **MULTILEADER `textHeight` and `arrowSize`** — read from the correct DXF codes. `textHeight` now comes from CONTEXT_DATA code `41` (was reading `40` = OverallContentScale ≈ 1.0, making labels invisible on multi-thousand-unit drawings); `arrowSize` from entity-level code `42`. +- **MULTILEADER line and text colors** — honor AutoCAD's CmEntityColor / MLEADERSTYLE precedence chain. Entity-level color codes (`91` LeaderLineColor, top-scope `90` TextColor, `340` styleHandle, `90` PropertyOverrideFlag) are now parsed. New `parser/sections/objects.ts` reads `MLEADERSTYLE` records out of OBJECTS. `decodeCmEntityColor(raw)` decodes the 32-bit `AcCmEntityColor` value. `resolveMLeaderColor` walks: entity-override → MLEADERSTYLE → fallback. Separate `lineColor` and `textColor` are derived per MULTILEADER. +- **Spline-shaped MULTILEADER leaders** — `LeaderLineType` flag at entity-level code `170` (0=invisible, 1=straight, 2=spline) is now parsed. Spline mode interpolates a LEADER_LINE with 3+ points as a Catmull-Rom curve; the common 1-vertex case builds a cubic Bezier whose end-tangent at the landing follows the LEADER's `doglegVector`, so the curve enters the dogleg shelf tangentially. Arrow direction follows the curve's tangent at the tip. +- **Aligned radial dims with text outside the arc and diametric dims with off-segment text** — now extend the dim line past each arrow base by `arrowSize × OUTSIDE_ARROW_TAIL_RATIO`, so the arrow reads as a shaft + head instead of a bare triangle. +- **Diametric dimensions with off-segment text honor DIMTOH** — `DIMTOH=0` (ISO default) extends the diameter line past the nearest endpoint outward to the far edge of the text and rotates the text to match the diameter direction. The previous leader + horizontal text + shelf path is preserved for explicit `DIMTOH=1` (ANSI). +- **Diametric dimensions with on-segment text** — projected-onto-segment text (`t ∈ [0,1]`) renders aligned to the diameter regardless of perpendicular distance from the line. The previous `perpDist < textHeight` constraint forced default-position dims with a normal DIMTAD=1 offset into the wrong "leader + horizontal text" branch. +- **Aligned-text radial dim text position** — mirrored across the radius axis when the source CAD wrote `textPos` on the perp side opposite to the readable rotation's "above". Previously the readability flip silently inverted the perpendicular convention, putting glyph ascenders pointing at the line. +- **`DIMTIH` and `DIMTOH` read only from DIMSTYLE** — header `$DIMTIH`/`$DIMTOH` are AutoCAD's editor-current values for creating new dimensions, not defaults for rendering existing ones. Missing DIMSTYLE fields now propagate as `undefined` (interpreted as 0 / ISO aligned). +- **DIMCLRT for specialized dimension types** — angular, radial, diametric, ordinate now use DIMSTYLE's `DIMCLRT` for text color instead of inheriting the dim entity's geometry color. New `textColor` field on `DimensionTypeParams` wired through `addDimensionTextToCollector` in all four specialized helpers. +- **TEXT/MTEXT/ATTRIB/ATTDEF bold/italic via `styleName`** — STYLE-table parser now reads ACAD XDATA descriptor (code 1071) and unpacks bits 24 (italic) and 25 (bold) into `DxfStyle.bold` / `DxfStyle.italic`. Text collectors look them up at emit time so e.g. `arial b` (fontFile `Arial Bold.ttf`) renders through faux-bold. MTEXT inline `\f...|b0|i0;` still overrides per-run. +- **DIMSTYLE DIMCLRD (code 176) and DIMCLRE (code 177)** — parsed and applied so dim line / arrows / extension lines pick up the colors configured in the style instead of always inheriting the dimension entity's color. Values `0` (BYBLOCK) and `256` (BYLAYER) fall back to entity color. ACI 7/255/250/251 routed through theme-adaptive sentinels. +- **Per-entity XDATA DSTYLE override: DIMDEC (271) and DIMADEC (179)** — override chain is now `entity XDATA → DIMSTYLE → header → default` for these two as well. Previously only DIMTXT/DIMASZ/DIMSCALE were handled. +- **Short linear/rotated/aligned dimensions flip arrows outward** — when dim line length is below `2.5 × arrowSize`, arrowheads are placed with tips at the extension lines pointing inward and bases sitting outside; the dim line extends past each arrow base by a tail so each side reads as a proper arrow. Previously inward-pointing arrows formed a "bowtie" / diamond for tiny dimensions. +- **Dimension labels in decimal mode honor `DIMDEC` and `DIMZIN`** — `formatDimNumber` previously ignored `DIMDEC` and rendered with hardcoded 4-place precision. DIMZIN bit 2 (`& 4`) suppresses leading zeros, bit 3 (`& 8`) trailing zeros; when DIMZIN is absent the previous strip-trailing default is preserved. +- **DIMCLRT and MTEXT inline `\C;`** — route ACI 7/255 and grays 250/251 through theme-adaptive sentinels (`ACI7_COLOR`, `\0ACI250/251`) instead of resolving directly to a literal hex. A new `aciToColor()` helper centralizes the rule. +- **LWPOLYLINE parser no longer aborts on code 91 (Vertex Identifier)** — previously fell into the `default` branch, returned a truncated vertex list, then re-entered with another partial run padded with `{x: undefined, y: undefined}` vertices. Three.js rendered the `undefined` coords as stray lines reaching to the world origin. +- **HATCH solid fills with elliptic-arc boundary edges (edge type 3)** — DXF stores start/end angles in **degrees** for edge type 3 (codes 50/51), unlike the ELLIPSE entity (codes 41/42) which uses radians. Values were previously passed as radians, so triangulated fills collapsed. +- **Layer flag bit 2** — bit 0x02 ("frozen by default in new viewports") no longer hides layers in model space; only bit 0x01 controls `frozen` per the DXF spec. +- **Architectural dimension fractions honor `DIMDEC`** — fraction denominator is `2^DIMDEC` instead of a hardcoded 16 (1/16"). E.g. `DIMDEC=3` renders `6'-1 3/8"` instead of `6'-1 5/16"`. `DIMDEC=0` rounds to whole inches. Defaults to 1/16" when not specified. +- **Entities on layer "0" inside a block inherit the parent INSERT's effective color** — per AutoCAD convention. Includes ATTRIBs attached to INSERT. The cached block template fast path stores a `BYBLOCK_COLOR` sentinel for layer-0 ByLayer entities and resolves it to `insertColor` at instantiation. +- **`arraybufferPlugin` rewritten** to use `resolveId` + virtual modules instead of `transform`. Now works in Vite dev server (Vite's built-in asset middleware previously intercepted `.ttf?arraybuffer` requests before the plugin could run). Production output unchanged. + +### Stats + +- 1141 tests across 52 files (was 945 across 44). + ## 1.5.0 ### Features diff --git a/packages/dxf-render/README.md b/packages/dxf-render/README.md index dfe9b98..40aa3c6 100644 --- a/packages/dxf-render/README.md +++ b/packages/dxf-render/README.md @@ -15,12 +15,12 @@ For Vue 3 components, see the [dxf-vuer](https://www.npmjs.com/package/dxf-vuer) ## Why dxf-render? -- **Most entities** — 21 rendered types including all dimension variants, LEADER, MULTILEADER, MLINE +- **Most entities** — 22 rendered types including all dimension variants, LEADER, MULTILEADER, MLINE, REGION (contour via HATCH boundary) - **Variable-width polylines** — per-vertex tapering, arrows, donuts rendered as mesh with miter joins - **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** — 945 tests covering parser, renderer, and utilities +- **Battle-tested** — 1141 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 @@ -477,20 +477,36 @@ Full TypeScript types exported: `DxfData`, `DxfEntity`, `DxfLayer`, `DxfHeader`, ## Supported entities -21 rendered entity types: LINE, CIRCLE, ARC, ELLIPSE, POINT, POLYLINE, LWPOLYLINE, SPLINE, TEXT, MTEXT, DIMENSION, INSERT, SOLID, 3DFACE, HATCH, LEADER, MULTILEADER, MLINE, XLINE, RAY, ATTDEF, plus ATTRIB within INSERT blocks and HELIX via SPLINE. +22 rendered entity types: LINE, CIRCLE, ARC, ELLIPSE, POINT, POLYLINE, LWPOLYLINE, SPLINE, TEXT, MTEXT, DIMENSION, INSERT, SOLID, 3DFACE, HATCH, LEADER, MULTILEADER, MLINE, XLINE, RAY, ATTDEF, REGION, plus ATTRIB within INSERT blocks and HELIX via SPLINE. + +REGION entities are rendered without decoding their ACIS modeler data: when a HATCH lists the REGION as a source object via DXF codes 97/330, the REGION borrows that HATCH's already-parsed boundary edges (lines, arcs, ellipses, splines, polyline vertices) for its visible contour. Color, layer and linetype come from the REGION itself; OCS from the HATCH. REGIONs with no HATCH referencing them are silently skipped. POLYLINE/LWPOLYLINE support includes per-vertex variable width (tapering), constant-width segments, arrows, donuts, and bulge arcs — all rendered as triangle-strip mesh geometry with proper miter joins at corners. +### Dimension & leader arrowheads + +DIMENSION endpoints and LEADER tips honour the DXF block referenced by the DIMSTYLE (codes 342 / 341 for DIMBLK / DIMLDRBLK) or by the header `$DIMBLK`. All 18 standard AutoCAD block names are recognised and rendered with the correct shape: + +- **Arrow-shaped**: `_ClosedFilled` (AutoCAD default), `_Closed` / `_ClosedBlank`, `_Open` / `_Open30` / `_OpenArrow`, `_DatumFilled` / `_DatumBlank` +- **Ticks**: `_ArchTick`, `_Oblique`, `_Small`, `_Tick` +- **Dots**: `_Dot`, `_DotSmall`, `_DotBlank`, `_DotSmallBlank` +- **Rings**: `_Origin`, `_Origin2` +- **Boxes**: `_Box`, `_BoxFilled` +- **Other**: `_Integral`, `_None` + +Names are matched case-insensitively, with an optional leading underscore. Unknown DIMBLK names fall back to `_ClosedFilled` (matching AutoCAD's default), but for LEADERs an unknown DIMLDRBLK name first tries to render the user-defined block geometry. `DIMTSZ > 0` forces tick rendering regardless of DIMBLK. The "outside arrows" flip for short dim lines only applies to direction-dependent arrow-shape kinds — dots, ticks, boxes and origin rings always sit at the endpoint. + ## Comparison | Feature | dxf-render | dxf-viewer | dxf-parser | three-dxf | | ------------------------- | --------------------------- | ------------ | ---------- | --------- | | DXF parsing | ✅ | ✅ | ✅ | ✅ | | Three.js rendering | ✅ | ✅ | ❌ | ✅ | -| Entity types | 21 rendered | ~15 | ~15 parsed | ~8 | +| Entity types | 22 rendered | ~15 | ~15 parsed | ~8 | | Variable-width polylines | ✅ tapering, arrows, donuts | ❌ | — | ❌ | | Linetype patterns | ✅ DASHED, CENTER, DOT... | ❌ all solid | — | ❌ | | All dimension types | ✅ 7 types | linear only | — | ❌ | +| Standard arrowhead blocks | ✅ 18 kinds (DIMBLK) | ❌ | — | ❌ | | LEADER / MULTILEADER | ✅ | ❌ | — | ❌ | | HATCH patterns | ✅ 25 built-in | ✅ | — | ❌ | | OCS (Arbitrary Axis) | ✅ full | Z-flip only | — | ❌ | @@ -498,7 +514,7 @@ POLYLINE/LWPOLYLINE support includes per-vertex variable width (tapering), const | Geometry merging | ✅ | ✅ | — | ❌ | | Dark theme | ✅ instant switch | bg only | — | ❌ | | TypeScript | ✅ native | .d.ts | ✅ | ❌ | -| Tests | 945 tests | 0 | ✅ | 0 | +| Tests | 1141 tests | 0 | ✅ | 0 | | Web Worker parsing | ✅ | ✅ | ❌ | ❌ | | Parser-only entry | ✅ zero deps | ❌ | ✅ | ❌ | | Framework | agnostic | agnostic | — | agnostic | diff --git a/packages/dxf-render/src/constants/index.ts b/packages/dxf-render/src/constants/index.ts index c801ad9..c5ea830 100644 --- a/packages/dxf-render/src/constants/index.ts +++ b/packages/dxf-render/src/constants/index.ts @@ -19,6 +19,22 @@ export const DIM_TEXT_GAP = DIM_TEXT_HEIGHT * DIM_TEXT_GAP_MULTIPLIER; export const DIM_TEXT_DECIMAL_PLACES = 4; export const ARROW_SIZE = 3; export const ARROW_BASE_WIDTH_DIVISOR = 4; +/** + * When the dimension line length is below this multiple of arrow size, + * arrows are flipped to point inward from outside the extension lines — + * otherwise their bases overlap in the middle and form a "diamond". The + * dimension line is also extended outward past each arrow base by a tail + * (see OUTSIDE_ARROW_TAIL_RATIO) so each side reads as an arrow with a + * shaft instead of a bare triangle. + */ +export const OUTSIDE_ARROW_THRESHOLD_RATIO = 2.5; +/** + * Length of the tail (shaft) extending past the arrow base outward in + * outside-arrow mode, as a fraction of `arrowSize`. Without it, the dim + * line ends right at the arrow base and the result reads as two opposing + * triangles rather than two arrows. + */ +export const OUTSIDE_ARROW_TAIL_RATIO = 1.0; export const CIRCLE_SEGMENTS = 64; export const EXTENSION_LINE_DASH_SIZE = 2; export const EXTENSION_LINE_GAP_SIZE = 1; diff --git a/packages/dxf-render/src/parser/__tests__/index.test.ts b/packages/dxf-render/src/parser/__tests__/index.test.ts index 49f3ecf..af6850d 100644 --- a/packages/dxf-render/src/parser/__tests__/index.test.ts +++ b/packages/dxf-render/src/parser/__tests__/index.test.ts @@ -13,6 +13,16 @@ describe("parseDxf", () => { expect(() => parseDxf("")).toThrow("Empty file"); }); + // ── 1b. Binary DXF → clear "not supported" error ───────────────────── + + it("throws a clear error when input starts with the AutoCAD Binary DXF sentinel", () => { + // Real Binary DXF preamble: "AutoCAD Binary DXF\r\n\x1A\0" followed by binary tokens. + // We pass the prefix as-is — the scanner would otherwise read garbled token codes + // and fail with a cryptic mojibake message. + const binaryPrefix = "AutoCAD Binary DXF\r\n\x1A\0\x00\x00\x00SECTION\x00"; + expect(() => parseDxf(binaryPrefix)).toThrow(/Binary DXF format is not supported/); + }); + // ── 2. Minimal valid DXF (EOF only) ────────────────────────────────── it("returns DxfData with empty entities for a minimal DXF containing only EOF", () => { @@ -295,4 +305,87 @@ describe("parseDxf", () => { expect(result.entities[0].type).toBe("LINE"); }); }); + + // ── 11. REGION ↔ HATCH boundary linking (post-parse step) ─────────── + + describe("links REGION to its HATCH boundary", () => { + it("attaches contourBoundary in ENTITIES section", () => { + const dxfText = buildDxf( + "0", "SECTION", + "2", "ENTITIES", + "0", "HATCH", + "5", "150", + "8", "Hatch", + "2", "ANSI31", + "70", "0", + "91", "1", + "92", "1", + "93", "1", + "72", "1", + "10", "0.0", + "20", "0.0", + "11", "10.0", + "21", "0.0", + "97", "1", + "330", "151", + "0", "REGION", + "5", "151", + "8", "Profil kontura", + "0", "ENDSEC", + "0", "EOF", + ); + + const result = parseDxf(dxfText); + const region = result.entities.find((e) => e.type === "REGION"); + expect(region).toBeDefined(); + const r = region as { contourBoundary?: unknown[] }; + expect(r.contourBoundary).toBeDefined(); + expect(r.contourBoundary).toHaveLength(1); + }); + + it("attaches contourBoundary inside a BLOCK", () => { + const dxfText = buildDxf( + "0", "SECTION", + "2", "BLOCKS", + "0", "BLOCK", + "8", "0", + "2", "MyBlock", + "70", "0", + "10", "0.0", + "20", "0.0", + "3", "MyBlock", + "1", "", + "0", "HATCH", + "5", "250", + "8", "Hatch", + "2", "ANSI31", + "70", "0", + "91", "1", + "92", "1", + "93", "1", + "72", "1", + "10", "0.0", + "20", "0.0", + "11", "1.0", + "21", "0.0", + "97", "1", + "330", "251", + "0", "REGION", + "5", "251", + "8", "BlockRegion", + "0", "ENDBLK", + "5", "FF", + "0", "ENDSEC", + "0", "EOF", + ); + + const result = parseDxf(dxfText); + expect(result.blocks?.MyBlock).toBeDefined(); + const blockEntities = result.blocks!.MyBlock.entities; + const region = blockEntities.find((e) => e.type === "REGION") as { contourBoundary?: unknown[] }; + expect(region).toBeDefined(); + expect(region.contourBoundary).toBeDefined(); + expect(region.contourBoundary).toHaveLength(1); + }); + }); }); diff --git a/packages/dxf-render/src/parser/__tests__/linkRegions.test.ts b/packages/dxf-render/src/parser/__tests__/linkRegions.test.ts new file mode 100644 index 0000000..0684e7b --- /dev/null +++ b/packages/dxf-render/src/parser/__tests__/linkRegions.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { linkRegionsToHatchBoundaries } from "../linkRegions"; +import type { DxfEntity, DxfHatchEntity, DxfRegionEntity } from "@/types/dxf"; + +function makeHatch(handle: string, sourceHandles: string[]): DxfHatchEntity { + return { + type: "HATCH", + handle, + patternName: "ANSI31", + solid: false, + boundaryPaths: [{ + edges: [{ type: "line", start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }], + sourceObjectHandles: sourceHandles, + }], + extrusionDirection: { x: 0, y: 0, z: 1 }, + } as DxfHatchEntity; +} + +function makeRegion(handle: string, layer = "Profil"): DxfRegionEntity { + return { type: "REGION", handle, layer } as DxfRegionEntity; +} + +describe("linkRegionsToHatchBoundaries", () => { + it("attaches HATCH boundary to a REGION referenced as source object", () => { + const region = makeRegion("151"); + const hatch = makeHatch("150", ["151"]); + const entities: DxfEntity[] = [region, hatch]; + + linkRegionsToHatchBoundaries(entities); + + expect(region.contourBoundary).toBeDefined(); + expect(region.contourBoundary).toHaveLength(1); + expect(region.contourBoundary![0].edges).toHaveLength(1); + expect(region.contourExtrusionDirection).toEqual({ x: 0, y: 0, z: 1 }); + }); + + it("matches REGION handle case-insensitively", () => { + const region = makeRegion("abc"); + const hatch = makeHatch("150", ["ABC"]); + linkRegionsToHatchBoundaries([region, hatch]); + expect(region.contourBoundary).toBeDefined(); + }); + + it("leaves REGION untouched when no HATCH references it", () => { + const region = makeRegion("151"); + const hatch = makeHatch("150", ["999"]); + linkRegionsToHatchBoundaries([region, hatch]); + expect(region.contourBoundary).toBeUndefined(); + expect(region.contourExtrusionDirection).toBeUndefined(); + }); + + it("filters boundary paths so REGION only gets paths that list its handle", () => { + const region = makeRegion("151"); + const hatch: DxfHatchEntity = { + type: "HATCH", + handle: "150", + patternName: "ANSI31", + solid: false, + boundaryPaths: [ + { + edges: [{ type: "line", start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }], + sourceObjectHandles: ["151"], + }, + { + edges: [{ type: "line", start: { x: 5, y: 5 }, end: { x: 6, y: 5 } }], + sourceObjectHandles: ["OTHER"], + }, + ], + } as DxfHatchEntity; + + linkRegionsToHatchBoundaries([region, hatch]); + + expect(region.contourBoundary).toHaveLength(1); + expect(region.contourBoundary![0].edges![0]).toMatchObject({ + type: "line", + end: { x: 1, y: 0 }, + }); + }); + + it("works when REGION is parsed before the HATCH that references it", () => { + // Parser order is file order; REGION may appear earlier. + const region = makeRegion("151"); + const hatch = makeHatch("150", ["151"]); + linkRegionsToHatchBoundaries([region, hatch]); + expect(region.contourBoundary).toBeDefined(); + }); + + it("is a no-op on empty array", () => { + expect(() => linkRegionsToHatchBoundaries([])).not.toThrow(); + }); + + it("is a no-op when there are no HATCH entities", () => { + const region = makeRegion("151"); + linkRegionsToHatchBoundaries([region]); + expect(region.contourBoundary).toBeUndefined(); + }); +}); diff --git a/packages/dxf-render/src/parser/entities/__tests__/hatch-leader-minimal.test.ts b/packages/dxf-render/src/parser/entities/__tests__/hatch-leader-minimal.test.ts index 56fef31..7164941 100644 --- a/packages/dxf-render/src/parser/entities/__tests__/hatch-leader-minimal.test.ts +++ b/packages/dxf-render/src/parser/entities/__tests__/hatch-leader-minimal.test.ts @@ -312,6 +312,101 @@ describe("parseHatch", () => { expect(entity.patternAngle).toBe(30); }); + it("captures sourceObjectHandles from edge boundary (codes 97 + 330)", () => { + const { scanner, group } = createScannerAt( + "0", "HATCH", + "2", "ANSI31", + "70", "0", + "91", "1", + "92", "1", // external boundary + "93", "1", + "72", "1", // 1 line edge + "10", "0.0", + "20", "0.0", + "11", "10.0", + "21", "0.0", + "97", "2", + "330", "151", + "330", "1AB", + "0", "EOF", + ); + + const entity = parseHatch(scanner, group); + + expect(entity.boundaryPaths).toHaveLength(1); + expect(entity.boundaryPaths[0].sourceObjectHandles).toEqual(["151", "1AB"]); + }); + + it("captures sourceObjectHandles from polyline boundary", () => { + const { scanner, group } = createScannerAt( + "0", "HATCH", + "2", "SOLID", + "70", "1", + "91", "1", + "92", "3", // polyline + external (bit 1 + bit 2) + "72", "0", + "73", "1", + "93", "3", + "10", "0.0", + "20", "0.0", + "10", "10.0", + "20", "0.0", + "10", "10.0", + "20", "10.0", + "97", "1", + "330", "ABCDEF", + "0", "EOF", + ); + + const entity = parseHatch(scanner, group); + + expect(entity.boundaryPaths[0].sourceObjectHandles).toEqual(["ABCDEF"]); + }); + + it("normalizes sourceObjectHandles to uppercase", () => { + const { scanner, group } = createScannerAt( + "0", "HATCH", + "2", "ANSI31", + "70", "0", + "91", "1", + "92", "0", + "93", "1", + "72", "1", + "10", "0.0", + "20", "0.0", + "11", "1.0", + "21", "0.0", + "97", "1", + "330", "abc123", + "0", "EOF", + ); + + const entity = parseHatch(scanner, group); + + expect(entity.boundaryPaths[0].sourceObjectHandles).toEqual(["ABC123"]); + }); + + it("leaves sourceObjectHandles undefined when no code 97 present", () => { + const { scanner, group } = createScannerAt( + "0", "HATCH", + "2", "ANSI31", + "70", "0", + "91", "1", + "92", "0", + "93", "1", + "72", "1", + "10", "0.0", + "20", "0.0", + "11", "1.0", + "21", "0.0", + "0", "EOF", + ); + + const entity = parseHatch(scanner, group); + + expect(entity.boundaryPaths[0].sourceObjectHandles).toBeUndefined(); + }); + it("parses hatch with extrusion direction", () => { const { scanner, group } = createScannerAt( "0", "HATCH", @@ -467,16 +562,18 @@ describe("parseMultiLeader", () => { it("parses basic multileader with one leader, one line, 2 vertices", () => { const { scanner, group } = createScannerAt( "0", "MULTILEADER", - "301", "LEADER{", - "302", "LEADER_LINE{", + "300", "CONTEXT_DATA{", + "302", "LEADER{", + "10", "10.0", + "20", "10.0", + "304", "LEADER_LINE{", "10", "0.0", "20", "0.0", "10", "5.0", "20", "5.0", "305", "}", - "10", "10.0", - "20", "10.0", - "305", "}", + "303", "}", + "301", "}", "0", "EOF", ); @@ -493,18 +590,20 @@ describe("parseMultiLeader", () => { expect(leader.lastLeaderPoint).toEqual({ x: 10, y: 10 }); }); - it("parses text content from code 304", () => { + it("parses text content from code 304 (value other than LEADER_LINE{ marker)", () => { const { scanner, group } = createScannerAt( "0", "MULTILEADER", - "301", "LEADER{", - "302", "LEADER_LINE{", + "300", "CONTEXT_DATA{", + "304", "Hello World", + "302", "LEADER{", + "304", "LEADER_LINE{", "10", "0.0", "20", "0.0", - "305", "}", "10", "5.0", "20", "5.0", "305", "}", - "304", "Hello World", + "303", "}", + "301", "}", "0", "EOF", ); @@ -517,24 +616,26 @@ describe("parseMultiLeader", () => { it("parses multiple leaders", () => { const { scanner, group } = createScannerAt( "0", "MULTILEADER", + "300", "CONTEXT_DATA{", // leader 1 - "301", "LEADER{", - "302", "LEADER_LINE{", - "10", "0.0", - "20", "0.0", - "305", "}", + "302", "LEADER{", "10", "5.0", "20", "5.0", + "304", "LEADER_LINE{", + "10", "0.0", + "20", "0.0", "305", "}", + "303", "}", // leader 2 - "301", "LEADER{", - "302", "LEADER_LINE{", - "10", "20.0", - "20", "20.0", - "305", "}", + "302", "LEADER{", "10", "25.0", "20", "25.0", + "304", "LEADER_LINE{", + "10", "20.0", + "20", "20.0", "305", "}", + "303", "}", + "301", "}", "0", "EOF", ); @@ -551,14 +652,16 @@ describe("parseMultiLeader", () => { const { scanner, group } = createScannerAt( "0", "MULTILEADER", "171", "0", - "301", "LEADER{", - "302", "LEADER_LINE{", + "300", "CONTEXT_DATA{", + "302", "LEADER{", + "304", "LEADER_LINE{", "10", "0.0", "20", "0.0", - "305", "}", "10", "5.0", "20", "5.0", "305", "}", + "303", "}", + "301", "}", "0", "EOF", ); @@ -568,22 +671,27 @@ describe("parseMultiLeader", () => { expect(entity.leaders).toHaveLength(1); }); - it("parses textPosition, textHeight, and arrowSize at top level", () => { + it("parses textPosition (12 inside CONTEXT_DATA), textHeight (41 inside CONTEXT_DATA) and arrowSize (entity-level 42)", () => { const { scanner, group } = createScannerAt( "0", "MULTILEADER", - "301", "LEADER{", - "302", "LEADER_LINE{", + "300", "CONTEXT_DATA{", + "40", "1.0", // ContentScale — must NOT become textHeight + "41", "2.5", // TextHeight (CONTEXT_DATA) + "140", "0.36", // LandingGap — must NOT become textHeight + "12", "15.0", // textPosition X + "22", "15.0", + "304", "Test", + "302", "LEADER{", + "304", "LEADER_LINE{", "10", "0.0", "20", "0.0", - "305", "}", "10", "5.0", "20", "5.0", "305", "}", - "304", "Test", - "40", "2.5", - "41", "1.0", - "12", "15.0", - "22", "15.0", + "303", "}", + "301", "}", + "41", "0.18", // entity-level DoglegLength — must NOT overwrite textHeight + "42", "1.0", // ArrowHeadSize (entity-level) "0", "ENDSEC", "0", "EOF", ); @@ -597,6 +705,162 @@ describe("parseMultiLeader", () => { expect(entity.textPosition!.y).toBe(15); expect(entity.text).toBe("Test"); }); + + it("parses entity-level leaderLineType (code 170 = 2 → spline)", () => { + const { scanner, group } = createScannerAt( + "0", "MULTILEADER", + "300", "CONTEXT_DATA{", + "302", "LEADER{", + "304", "LEADER_LINE{", + "10", "0.0", + "20", "0.0", + "305", "}", + "303", "}", + "301", "}", + "170", "2", + "0", "ENDSEC", + "0", "EOF", + ); + + const entity = parseMultiLeader(scanner, group); + + expect(entity.leaderLineType).toBe(2); + }); + + it("ignores code 170 inside CONTEXT_DATA (different meaning at that scope)", () => { + const { scanner, group } = createScannerAt( + "0", "MULTILEADER", + "300", "CONTEXT_DATA{", + "170", "1", // HasBlockReference — not the entity-level LeaderLineType + "302", "LEADER{", + "304", "LEADER_LINE{", + "10", "0.0", + "20", "0.0", + "305", "}", + "303", "}", + "301", "}", + "0", "ENDSEC", + "0", "EOF", + ); + + const entity = parseMultiLeader(scanner, group); + + expect(entity.leaderLineType).toBeUndefined(); + }); + + it("does not overwrite text with the literal LEADER_LINE{ marker (real-world AutoCAD layout)", () => { + // Mirrors the actual byte sequence in todo/dxf-samples/problems/2018.dxf + // where the MText content "OL-A-70" comes via code 304 BEFORE the LEADER + // subsection is opened, and "LEADER_LINE{" arrives later — also via 304. + const { scanner, group } = createScannerAt( + "0", "MULTILEADER", + "300", "CONTEXT_DATA{", + "40", "1.0", + "10", "100.0", + "20", "200.0", + "41", "60.0", + "304", "OL-A-70", + "11", "0.0", + "21", "0.0", + "31", "1.0", + "12", "150.0", + "22", "250.0", + "32", "0.0", + "302", "LEADER{", + "290", "1", + "10", "120.0", + "20", "210.0", + "30", "0.0", + "11", "1.0", + "21", "0.0", + "31", "0.0", + "40", "33.0", + "304", "LEADER_LINE{", + "10", "80.0", + "20", "180.0", + "30", "0.0", + "305", "}", + "303", "}", + "301", "}", + "0", "ENDSEC", + "0", "EOF", + ); + + const entity = parseMultiLeader(scanner, group); + + expect(entity.text).toBe("OL-A-70"); + expect(entity.leaders).toHaveLength(1); + expect(entity.leaders[0].lines).toHaveLength(1); + expect(entity.leaders[0].lines[0].vertices).toEqual([{ x: 80, y: 180, z: 0 }]); + expect(entity.leaders[0].lastLeaderPoint).toEqual({ x: 120, y: 210, z: 0 }); + }); + + it("parses entity-level styleHandle, propertyOverrideFlag, leaderLineColorRaw and CONTEXT_DATA textColorRaw", () => { + // Mirrors the 2018.dxf MULTILEADER 47020 byte layout: inside CONTEXT_DATA + // code 90 carries TextColor; after CONTEXT_DATA closes, code 90 becomes + // PropertyOverrideFlag, code 91 — LeaderLineColor, code 340 — styleHandle. + const { scanner, group } = createScannerAt( + "0", "MULTILEADER", + "300", "CONTEXT_DATA{", + "304", "M22-G-42", + "302", "LEADER{", + "10", "65.0", + "20", "260.0", + "30", "0.0", + "90", "0", // LeaderBranchIndex inside LEADER — must NOT become textColorRaw + "304", "LEADER_LINE{", + "10", "10.0", + "20", "0.0", + "30", "0.0", + "91", "0", // LeaderLineIndex inside LEADER_LINE — must NOT become leaderLineColorRaw + "305", "}", + "303", "}", + "90", "-1023410170", // TextColor inside CONTEXT_DATA top (after LEADER closes) — byACI(6) magenta + "301", "}", + "340", "3c063", // entity-level styleHandle (lowercase should be uppercased) + "90", "17122304", // entity-level PropertyOverrideFlag + "170", "2", // entity-level LeaderLineType + "91", "-1023410170", // entity-level LeaderLineColor — byACI(6) magenta + "42", "1.0", // entity-level ArrowHeadSize + "0", "ENDSEC", + "0", "EOF", + ); + + const entity = parseMultiLeader(scanner, group); + + expect(entity.styleHandle).toBe("3C063"); + expect(entity.propertyOverrideFlag).toBe(17122304); + expect(entity.leaderLineColorRaw).toBe(-1023410170); + expect(entity.textColorRaw).toBe(-1023410170); + expect(entity.leaderLineType).toBe(2); + expect(entity.arrowSize).toBe(1); + }); + + it("does not capture color codes from nested LEADER/LEADER_LINE scopes", () => { + // Code 90 inside LEADER is LeaderBranchIndex; code 91 inside LEADER_LINE + // is LeaderLineIndex. Neither should leak into entity color fields. + const { scanner, group } = createScannerAt( + "0", "MULTILEADER", + "300", "CONTEXT_DATA{", + "302", "LEADER{", + "90", "5", // LeaderBranchIndex — must be ignored + "304", "LEADER_LINE{", + "10", "0.0", + "20", "0.0", + "91", "3", // LeaderLineIndex — must be ignored + "305", "}", + "303", "}", + "301", "}", + "0", "ENDSEC", + "0", "EOF", + ); + + const entity = parseMultiLeader(scanner, group); + + expect(entity.textColorRaw).toBeUndefined(); + expect(entity.leaderLineColorRaw).toBeUndefined(); + expect(entity.propertyOverrideFlag).toBeUndefined(); + }); }); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/dxf-render/src/parser/entities/__tests__/polyline-spline.test.ts b/packages/dxf-render/src/parser/entities/__tests__/polyline-spline.test.ts index fd93ea0..6fb2b47 100644 --- a/packages/dxf-render/src/parser/entities/__tests__/polyline-spline.test.ts +++ b/packages/dxf-render/src/parser/entities/__tests__/polyline-spline.test.ts @@ -304,6 +304,32 @@ describe("parseLWPolyline", () => { expect(entity.vertices[1].bulge).toBeUndefined(); }); + // -- Vertex identifier (code 91) interleaved with vertices ------------------ + + it("ignores per-vertex identifier (code 91) without truncating vertices", () => { + const { scanner, group } = createScannerAt( + "0", "LWPOLYLINE", + "8", "Layer1", + "90", "3", + "70", "0", + "10", "0.0", + "20", "0.0", + "10", "5.0", + "20", "5.0", + "91", "6", + "10", "10.0", + "20", "0.0", + "0", "ENDSEC", + "0", "EOF", + ); + + const entity = parseLWPolyline(scanner, group) as ILWPolylineEntity; + + expect(entity.vertices).toHaveLength(3); + expect(entity.vertices[2].x).toBe(10); + expect(entity.vertices[2].y).toBe(0); + }); + // -- With elevation --------------------------------------------------------- it("parses lwpolyline with elevation (code 38)", () => { diff --git a/packages/dxf-render/src/parser/entities/__tests__/region.test.ts b/packages/dxf-render/src/parser/entities/__tests__/region.test.ts new file mode 100644 index 0000000..8cbc33e --- /dev/null +++ b/packages/dxf-render/src/parser/entities/__tests__/region.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { parseRegion, type IRegionEntity } from "../region"; +import { createScannerAt } from "../../__tests__/test-helpers"; + +describe("parseRegion", () => { + it("parses REGION with layer and handle", () => { + const { scanner, group } = createScannerAt( + "0", "REGION", + "5", "151", + "8", "Profil kontura", + "100", "AcDbEntity", + "100", "AcDbModelerGeometry", + "290", "1", + "0", "EOF", + ); + const entity = parseRegion(scanner, group) as IRegionEntity; + expect(entity.type).toBe("REGION"); + expect(entity.handle).toBe("151"); + expect(entity.layer).toBe("Profil kontura"); + }); + + it("parses REGION with color index", () => { + const { scanner, group } = createScannerAt( + "0", "REGION", + "8", "Layer1", + "62", "5", + "0", "EOF", + ); + const entity = parseRegion(scanner, group); + expect(entity.type).toBe("REGION"); + expect(entity.colorIndex).toBe(5); + }); +}); diff --git a/packages/dxf-render/src/parser/entities/__tests__/text-dimension-insert.test.ts b/packages/dxf-render/src/parser/entities/__tests__/text-dimension-insert.test.ts index 272e23d..0f4e36d 100644 --- a/packages/dxf-render/src/parser/entities/__tests__/text-dimension-insert.test.ts +++ b/packages/dxf-render/src/parser/entities/__tests__/text-dimension-insert.test.ts @@ -425,6 +425,31 @@ describe("parseDimension", () => { // Direct code 140 should win — XDATA only sets if not already set expect(entity.textHeight).toBe(4.0); }); + + it("extracts dimdec (DIMDEC=271) and dimadec (DIMADEC=179) from XDATA DSTYLE override", () => { + const { scanner, group } = createScannerAt( + "0", "DIMENSION", + "10", "0.0", + "20", "0.0", + "70", "32", + "42", "24.005", + // XDATA: ACAD DSTYLE with DIMDEC=1 and DIMADEC=2 + "1001", "ACAD", + "1000", "DSTYLE", + "1002", "{", + "1070", "271", // DIMDEC variable + "1070", "1", // decimal places = 1 + "1070", "179", // DIMADEC variable + "1070", "2", // angular decimal places = 2 + "1002", "}", + "0", "EOF", + ); + + const entity = parseDimension(scanner, group); + + expect(entity.dimdec).toBe(1); + expect(entity.dimadec).toBe(2); + }); }); // ══════════════════════════════════════════════════════════════════════════════ diff --git a/packages/dxf-render/src/parser/entities/dimension.ts b/packages/dxf-render/src/parser/entities/dimension.ts index 306ab5d..3bc88e9 100644 --- a/packages/dxf-render/src/parser/entities/dimension.ts +++ b/packages/dxf-render/src/parser/entities/dimension.ts @@ -24,6 +24,10 @@ export interface IDimensionEntity extends IEntityBase { arrowSize?: number; /** DIMSCALE — overall dimension scale from XDATA DSTYLE override */ dimScale?: number; + /** DIMDEC — decimal places for primary units, from XDATA DSTYLE override (code 271) */ + dimdec?: number; + /** DIMADEC — decimal places for angular dimensions, from XDATA DSTYLE override (code 179) */ + dimadec?: number; } /** @@ -59,6 +63,12 @@ function applyDimStyleXData(entity: IDimensionEntity, scanner: DxfScanner): void case 40: // DIMSCALE — overall scale entity.dimScale = varValue; break; + case 271: // DIMDEC — decimal places for primary units + entity.dimdec = varValue; + break; + case 179: // DIMADEC — decimal places for angular dimensions + entity.dimadec = varValue; + break; } } curr = scanner.next(); diff --git a/packages/dxf-render/src/parser/entities/hatch.ts b/packages/dxf-render/src/parser/entities/hatch.ts index 55582c8..ecfd290 100644 --- a/packages/dxf-render/src/parser/entities/hatch.ts +++ b/packages/dxf-render/src/parser/entities/hatch.ts @@ -42,6 +42,29 @@ type IHatchEdge = IHatchEdgeLine | IHatchEdgeArc | IHatchEdgeEllipse | IHatchEdg interface IHatchBoundaryPath { edges?: IHatchEdge[]; polylineVertices?: (IPoint & { bulge?: number })[]; + sourceObjectHandles?: string[]; +} + +/** + * Read optional source-object handles (codes 97 + N × 330) that follow the edges/vertices + * of a boundary path. Returns the next group after the handles (or the same `curr` + * if there were none). + */ +function parseBoundarySourceObjects( + scanner: DxfScanner, + curr: IGroup, + path: IHatchBoundaryPath, +): IGroup { + if (curr.code !== 97) return curr; + const numSources = curr.value as number; + const handles: string[] = []; + curr = scanner.next(); + for (let i = 0; i < numSources && curr.code === 330; i++) { + handles.push(String(curr.value).toUpperCase()); + curr = scanner.next(); + } + if (handles.length > 0) path.sourceObjectHandles = handles; + return curr; } interface IHatchPatternLine { @@ -209,7 +232,9 @@ function parseEdgeBoundary(scanner: DxfScanner, curr: IGroup): { path: IHatchBou } } - return { path: { edges }, curr }; + const path: IHatchBoundaryPath = { edges }; + curr = parseBoundarySourceObjects(scanner, curr, path); + return { path, curr }; } /** @@ -261,7 +286,9 @@ function parsePolylineBoundary(scanner: DxfScanner, curr: IGroup): { path: IHatc vertices.push({ x: first.x, y: first.y, bulge: first.bulge }); } - return { path: { polylineVertices: vertices }, curr }; + const path: IHatchBoundaryPath = { polylineVertices: vertices }; + curr = parseBoundarySourceObjects(scanner, curr, path); + return { path, curr }; } /** diff --git a/packages/dxf-render/src/parser/entities/lwpolyline.ts b/packages/dxf-render/src/parser/entities/lwpolyline.ts index f1672f5..801b518 100644 --- a/packages/dxf-render/src/parser/entities/lwpolyline.ts +++ b/packages/dxf-render/src/parser/entities/lwpolyline.ts @@ -96,6 +96,9 @@ function parseLWPolylineVertices(n: number, scanner: DxfScanner): ILWVertex[] { case 42: if (curr.value !== 0) vertex.bulge = curr.value as number; break; + case 91: + // Vertex identifier (per-vertex, optional). Ignore — geometry doesn't need it. + break; default: // Unknown code — return vertices, code may belong to the entity scanner.rewind(); diff --git a/packages/dxf-render/src/parser/entities/multileader.ts b/packages/dxf-render/src/parser/entities/multileader.ts index 1afa812..e0a0d06 100644 --- a/packages/dxf-render/src/parser/entities/multileader.ts +++ b/packages/dxf-render/src/parser/entities/multileader.ts @@ -22,19 +22,46 @@ export interface IMLeaderEntity extends IEntityBase { textHeight?: number; arrowSize?: number; hasArrowHead?: boolean; + /** Entity-level LeaderLineType (code 170): 0=invisible, 1=straight, 2=spline. */ + leaderLineType?: number; + /** Entity-level MLEADERSTYLE handle (code 340, after CONTEXT_DATA closes). */ + styleHandle?: string; + /** Entity-level PropertyOverrideFlag (code 90, after CONTEXT_DATA closes). */ + propertyOverrideFlag?: number; + /** Entity-level LeaderLineColor raw CmEntityColor (code 91, after CONTEXT_DATA closes). */ + leaderLineColorRaw?: number; + /** CONTEXT_DATA TextColor raw CmEntityColor (code 90 inside CONTEXT_DATA, top scope). */ + textColorRaw?: number; } /** - * Sections are marked by group codes 300-305: - * 300 = "CONTEXT_DATA{" - * 301 = "LEADER{" (leader start) - * 302 = "LEADER_LINE{" (leader line start) - * 303 = "START_CONTEXT_DATA" / "END_CONTEXT_DATA" - * 304 = text content - * 305 = "}" (section end) - * Inside LEADER_LINE: code 10/20/30 = vertices - * Inside LEADER: code 10/20/30 = lastLeaderPoint, code 11/21/31 = doglegVector - * At top level: code 40 = textHeight, code 41 = arrowSize, code 12/22/32 = textPosition + * CONTEXT_DATA nesting in MLEADER uses paired open/close codes per level: + * 300 = "CONTEXT_DATA{" 301 = "}" (context block) + * 302 = "LEADER{" 303 = "}" (per-leader subsection) + * 304 = "LEADER_LINE{" 305 = "}" (per-line subsection) + * Code 304 is overloaded: it carries the MText content when the value is + * not the literal "LEADER_LINE{" marker. + * + * Several DXF group codes are reused at different scopes inside MLEADER and + * mean different things — we track `inContextData` / `inLeader` / `inLeaderLine` + * so the same code dispatches correctly: + * - code 40 : ContentScale (CONTEXT_DATA top), DoglegLength (inside LEADER) + * - code 41 : TextHeight (CONTEXT_DATA top), DoglegLength (entity-level) + * - code 42 : various (CONTEXT_DATA top), ArrowHeadSize (entity-level) + * - code 90 : TextColor (CONTEXT_DATA top, raw CmEntityColor), + * LeaderBranchIndex (inside LEADER, ignored), + * PropertyOverrideFlag (entity-level) + * - code 91 : LeaderLineColor (entity-level, raw CmEntityColor); + * TextBackgroundColor (CONTEXT_DATA top, not yet supported); + * LeaderLineIndex (inside LEADER_LINE, ignored) + * - code 140 : LandingGap (CONTEXT_DATA top — not text height!) + * - code 170 : LeaderLineType (entity-level only — 0/1/2) + * - code 340 : LeaderStyleId / styleHandle (entity-level); + * TextStyleId (CONTEXT_DATA top, ignored) + * Inside LEADER_LINE: code 10/20/30 = vertices. + * Inside LEADER (before LEADER_LINE): code 10/20/30 = lastLeaderPoint, + * code 11/21/31 = doglegVector. + * At CONTEXT_DATA top: code 12/22/32 = textPosition. */ export function parseMultiLeader(scanner: DxfScanner, curr: IGroup): IMLeaderEntity { const entity: IMLeaderEntity = { @@ -45,6 +72,7 @@ export function parseMultiLeader(scanner: DxfScanner, curr: IGroup): IMLeaderEnt curr = scanner.next(); + let inContextData = false; let inLeader = false; let inLeaderLine = false; let currentLeader: IMLeaderBranch | null = null; @@ -55,42 +83,49 @@ export function parseMultiLeader(scanner: DxfScanner, curr: IGroup): IMLeaderEnt switch (curr.code) { case 300: + inContextData = true; break; - case 301: { - const val = curr.value as string; - if (val === "LEADER{") { + case 301: + // "}" closing CONTEXT_DATA. + inContextData = false; + break; + case 302: { + if ((curr.value as string) === "LEADER{") { inLeader = true; currentLeader = { lines: [] }; } break; } - case 302: { + case 303: { + // "}" closing LEADER subsection. + if (inLeader && currentLeader) { + if (currentLeader.lines.length > 0) { + entity.leaders.push(currentLeader); + } + currentLeader = null; + inLeader = false; + } + break; + } + case 304: { const val = curr.value as string; if (val === "LEADER_LINE{") { inLeaderLine = true; currentLine = { vertices: [] }; + } else if (!inLeader) { + // Outside LEADER, code 304 is the MText content of the multileader. + entity.text = val; } break; } - case 303: - break; - case 304: - entity.text = curr.value as string; - break; case 305: { - // "}" -- close the innermost open section + // "}" closing LEADER_LINE subsection. if (inLeaderLine && currentLine) { if (currentLeader && currentLine.vertices.length > 0) { currentLeader.lines.push(currentLine); } currentLine = null; inLeaderLine = false; - } else if (inLeader && currentLeader) { - if (currentLeader.lines.length > 0) { - entity.leaders.push(currentLeader); - } - currentLeader = null; - inLeader = false; } break; } @@ -108,29 +143,70 @@ export function parseMultiLeader(scanner: DxfScanner, curr: IGroup): IMLeaderEnt } break; case 12: - if (!inLeader) { + if (inContextData && !inLeader) { entity.textPosition = helpers.parsePoint(scanner); } break; case 40: - if (!inLeader) { - entity.textHeight = curr.value as number; - } else if (inLeader && currentLeader && !inLeaderLine) { + if (inLeader && currentLeader && !inLeaderLine) { currentLeader.doglegLength = curr.value as number; } + // Outside LEADER: code 40 is ContentScale (CONTEXT_DATA) or unused — ignore. break; case 41: - if (!inLeader) { + if (inContextData && !inLeader) { + // CONTEXT_DATA TextHeight — already in drawing units after the + // OverallContentScale (code 40) is applied by the writer. + entity.textHeight = curr.value as number; + } + // Entity-level (after CONTEXT_DATA closes): DoglegLength — ignored; + // we don't render the dogleg shelf as a separate primitive. + break; + case 42: + if (!inContextData) { + // Entity-level ArrowHeadSize. Inside CONTEXT_DATA code 42 has a + // different meaning (line spacing / column-related), skip it. entity.arrowSize = curr.value as number; } break; + case 170: + if (!inContextData) { + entity.leaderLineType = curr.value as number; + } + break; + + case 90: + if (inContextData && !inLeader) { + entity.textColorRaw = curr.value as number; + } else if (!inContextData) { + entity.propertyOverrideFlag = curr.value as number; + } + // inside LEADER: LeaderBranchIndex — ignore + break; + case 91: + if (!inContextData) { + entity.leaderLineColorRaw = curr.value as number; + } + // inside CONTEXT_DATA top: TextBackgroundColor — not yet supported + // inside LEADER_LINE: LeaderLineIndex — ignore + break; + case 340: + if (!inContextData) { + entity.styleHandle = String(curr.value).toUpperCase(); + } + // inside CONTEXT_DATA top: TextStyleId — not yet used + break; case 100: break; case 171: - // 0 = no arrow - entity.hasArrowHead = (curr.value as number) !== 0; + // Heuristic: at entity level a LeaderLineWeight of 0 (rare) is treated + // as "no arrow". The proper signal is an empty ArrowHead block (code 342), + // which we don't resolve yet. + if (!inContextData) { + entity.hasArrowHead = (curr.value as number) !== 0; + } break; default: diff --git a/packages/dxf-render/src/parser/entities/region.ts b/packages/dxf-render/src/parser/entities/region.ts new file mode 100644 index 0000000..b7c6a21 --- /dev/null +++ b/packages/dxf-render/src/parser/entities/region.ts @@ -0,0 +1,22 @@ +import type DxfScanner from "../scanner"; +import type { IGroup } from "../scanner"; +import * as helpers from "../parseHelpers"; +import type { IEntityBase } from "../parseHelpers"; + +export interface IRegionEntity extends IEntityBase { + type: "REGION"; +} + +// REGION holds ACIS modeler geometry that we don't parse; we still capture +// handle/layer/color so it becomes a first-class entity and can be linked to +// a HATCH that references it as a boundary source (see linkRegionsToHatchBoundaries). +export function parseRegion(scanner: DxfScanner, curr: IGroup): IRegionEntity { + const entity = { type: "REGION" } as IRegionEntity; + curr = scanner.next(); + while (!scanner.isEOF()) { + if (curr.code === 0) break; + helpers.checkCommonEntityProperties(entity, curr, scanner); + curr = scanner.next(); + } + return entity; +} diff --git a/packages/dxf-render/src/parser/index.ts b/packages/dxf-render/src/parser/index.ts index 57c1a05..fe5bbe1 100644 --- a/packages/dxf-render/src/parser/index.ts +++ b/packages/dxf-render/src/parser/index.ts @@ -4,8 +4,24 @@ import { parseHeader } from "./sections/header"; import { parseTables } from "./sections/tables"; import { parseBlocks } from "./sections/blocks"; import { parseEntities } from "./sections/entities"; +import { parseObjects } from "./sections/objects"; +import { linkRegionsToHatchBoundaries } from "./linkRegions"; + +/** + * Sentinel at the start of every Binary DXF file (followed by \r\n\x1A\0). + * Pure ASCII, so it survives intact through both UTF-8 and UTF-16 decoders — + * detecting it on the decoded string catches all entry paths. + */ +const BINARY_DXF_SENTINEL = "AutoCAD Binary DXF"; export function parseDxf(dxfText: string): DxfData { + // Reject Binary DXF early: the line-based scanner reads garbled token codes + // from the binary stream and eventually throws a cryptic "Unexpected end of + // input … code ". Better to fail fast with a clear message. + if (dxfText.startsWith(BINARY_DXF_SENTINEL)) { + throw new Error("Binary DXF format is not supported. Save the file as ASCII (text) DXF and try again."); + } + const dxf = {} as DxfData; const dxfLinesArray = dxfText.split(/\r\n|\r|\n/g); const scanner = new DxfScanner(dxfLinesArray); @@ -35,6 +51,9 @@ export function parseDxf(dxfText: string): DxfData { } else if (curr.value === "TABLES") { dxf.tables = parseTables(scanner) as DxfData["tables"]; curr = scanner.lastReadGroup; + } else if (curr.value === "OBJECTS") { + dxf.objects = parseObjects(scanner); + curr = scanner.lastReadGroup; } } else { curr = scanner.next(); @@ -43,5 +62,12 @@ export function parseDxf(dxfText: string): DxfData { if (!dxf.entities) dxf.entities = []; + linkRegionsToHatchBoundaries(dxf.entities); + if (dxf.blocks) { + for (const blockName in dxf.blocks) { + linkRegionsToHatchBoundaries(dxf.blocks[blockName].entities); + } + } + return dxf; } diff --git a/packages/dxf-render/src/parser/linkRegions.ts b/packages/dxf-render/src/parser/linkRegions.ts new file mode 100644 index 0000000..44e999e --- /dev/null +++ b/packages/dxf-render/src/parser/linkRegions.ts @@ -0,0 +1,44 @@ +import type { DxfEntity, DxfHatchEntity, DxfRegionEntity } from "@/types/dxf"; +import { isHatchEntity, isRegionEntity } from "@/types/dxf"; + +/** + * Post-parse step: connect every REGION to the boundary edges of the HATCH(es) + * that list it as a source object. The HATCH stores the actual curve geometry + * (codes 92/93/72/…); the REGION stores only ACIS modeler data which we don't + * decode. By "borrowing" the HATCH boundary we get a visible/pickable contour + * for the REGION without parsing ACIS. + * + * Mutates the entities in place. Idempotent: re-running just rewrites + * contourBoundary with the same paths. + */ +export function linkRegionsToHatchBoundaries(entities: DxfEntity[]): void { + if (!entities || entities.length === 0) return; + + // Build handle -> hatch map (handles normalized to uppercase to match + // how parseBoundarySourceObjects stores them). + const handleToHatch = new Map(); + for (const e of entities) { + if (!isHatchEntity(e)) continue; + for (const path of e.boundaryPaths) { + for (const h of path.sourceObjectHandles ?? []) { + handleToHatch.set(h, e); + } + } + } + if (handleToHatch.size === 0) return; + + for (const e of entities) { + if (!isRegionEntity(e) || e.handle == null) continue; + const handle = String(e.handle).toUpperCase(); + const hatch = handleToHatch.get(handle); + if (!hatch) continue; + const pathsForRegion = hatch.boundaryPaths.filter( + (p) => p.sourceObjectHandles?.includes(handle), + ); + if (pathsForRegion.length === 0) continue; + (e as DxfRegionEntity).contourBoundary = pathsForRegion; + if (hatch.extrusionDirection) { + (e as DxfRegionEntity).contourExtrusionDirection = hatch.extrusionDirection; + } + } +} diff --git a/packages/dxf-render/src/parser/sections/__tests__/objects.test.ts b/packages/dxf-render/src/parser/sections/__tests__/objects.test.ts new file mode 100644 index 0000000..c0fba89 --- /dev/null +++ b/packages/dxf-render/src/parser/sections/__tests__/objects.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { createScanner } from "../../__tests__/test-helpers"; +import { parseObjects } from "../objects"; + +describe("parseObjects", () => { + it("parses a single MLEADERSTYLE record and keys it by uppercase handle", () => { + // Scanner starts inside the OBJECTS section, right after the section + // header — parseObjects() calls scanner.next() to read the first group. + const scanner = createScanner( + "0", "MLEADERSTYLE", + "5", "3c063", // handle (lowercase — should be uppercased) + "100", "AcDbMLeaderStyle", + "3", "Standard", + "91", "-1023410170", // LeaderLineColor — byACI(6) magenta + "93", "-1023410170", // TextColor — byACI(6) magenta + "94", "-1056964608", // BlockColor — byBlock + "0", "ENDSEC", + "0", "EOF", + ); + + const objects = parseObjects(scanner); + + expect(objects.mLeaderStyles).toBeDefined(); + const styles = objects.mLeaderStyles!; + expect(Object.keys(styles)).toEqual(["3C063"]); + + const style = styles["3C063"]; + expect(style.handle).toBe("3C063"); + expect(style.name).toBe("Standard"); + expect(style.leaderLineColorRaw).toBe(-1023410170); + expect(style.textColorRaw).toBe(-1023410170); + expect(style.blockColorRaw).toBe(-1056964608); + }); + + it("parses multiple MLEADERSTYLE records", () => { + const scanner = createScanner( + "0", "MLEADERSTYLE", + "5", "5C", + "91", "-1056964608", + "0", "MLEADERSTYLE", + "5", "3C063", + "91", "-1023410170", + "0", "ENDSEC", + "0", "EOF", + ); + + const objects = parseObjects(scanner); + const styles = objects.mLeaderStyles!; + expect(Object.keys(styles).sort()).toEqual(["3C063", "5C"]); + expect(styles["5C"].leaderLineColorRaw).toBe(-1056964608); + expect(styles["3C063"].leaderLineColorRaw).toBe(-1023410170); + }); + + it("skips non-MLEADERSTYLE objects without consuming MLEADERSTYLE that follows", () => { + // OBJECTS section in real files holds many object types (DICTIONARY, + // SCALE, ACDBPLACEHOLDER, MLINESTYLE, …). The parser must skip them + // without disturbing MLEADERSTYLE records that follow. + const scanner = createScanner( + "0", "DICTIONARY", + "5", "C", + "100", "AcDbDictionary", + "3", "ACAD_GROUP", + "350", "D", + "0", "MLINESTYLE", + "5", "18", + "2", "STANDARD", + "70", "0", + "0", "MLEADERSTYLE", + "5", "AAAA", + "91", "-1023410170", + "0", "SCALE", + "5", "BBBB", + "0", "ENDSEC", + "0", "EOF", + ); + + const objects = parseObjects(scanner); + const styles = objects.mLeaderStyles!; + expect(Object.keys(styles)).toEqual(["AAAA"]); + expect(styles["AAAA"].leaderLineColorRaw).toBe(-1023410170); + }); + + it("returns an empty map when no MLEADERSTYLE objects are present", () => { + const scanner = createScanner( + "0", "DICTIONARY", + "5", "C", + "0", "MLINESTYLE", + "5", "18", + "0", "ENDSEC", + "0", "EOF", + ); + + const objects = parseObjects(scanner); + expect(objects.mLeaderStyles).toEqual({}); + }); +}); diff --git a/packages/dxf-render/src/parser/sections/__tests__/tables.test.ts b/packages/dxf-render/src/parser/sections/__tests__/tables.test.ts index 31f6b92..96dda22 100644 --- a/packages/dxf-render/src/parser/sections/__tests__/tables.test.ts +++ b/packages/dxf-render/src/parser/sections/__tests__/tables.test.ts @@ -55,6 +55,32 @@ describe("parseTables", () => { expect(layers.HiddenLayer.colorIndex).toBe(3); }); + it("does not mark layer as frozen when only bit 2 is set in code 70", () => { + // Bit 2 (0x02) means "frozen by default in new viewports", which is a + // template setting for newly created viewports. It must not hide the + // layer in model space. Regression: previously bit 2 was OR-ed into + // `frozen`, which silently hid visible layers in many real-world files. + const scanner = createScanner( + "0", "TABLE", + "2", "LAYER", + "70", "1", + "0", "LAYER", + "2", "VisibleLayer", + "62", "5", + "70", "2", // bit 2 only — must NOT mean currently frozen + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + + const layers = tables.layer.layers as Record; + expect(layers.VisibleLayer.frozen).toBe(false); + expect(layers.VisibleLayer.locked).toBe(false); + expect(layers.VisibleLayer.visible).toBe(true); + }); + it("marks layer as frozen when bit 1 is set in code 70", () => { const scanner = createScanner( "0", "TABLE", @@ -195,7 +221,8 @@ describe("parseTables", () => { expect(layers.Layer2.visible).toBe(false); expect(layers.Layer2.colorIndex).toBe(5); - expect(layers.Layer2.frozen).toBe(true); + // flags=2 = "frozen by default in new viewports" only, not currently frozen + expect(layers.Layer2.frozen).toBe(false); }); }); @@ -389,6 +416,79 @@ describe("parseTables", () => { expect(styles.Heading.fixedHeight).toBe(5.0); expect(styles.Heading.widthFactor).toBe(0.8); }); + + it("stores STYLE handle (code 5) uppercase for DIMTXSTY cross-reference", () => { + const scanner = createScanner( + "0", "TABLE", + "2", "STYLE", + "70", "1", + "0", "STYLE", + "5", "2a3", // entity handle (mixed case → uppercased) + "2", "Standard", + "3", "arial.ttf", + "41", "0.8", // widthFactor + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + const styles = tables.style.styles as Record; + expect(styles.Standard.handle).toBe("2A3"); + expect(styles.Standard.widthFactor).toBe(0.8); + }); + + it("extracts bold/italic flags from ACAD XDATA 1071 (bits 25/24)", () => { + // Mirrors the encoding produced by AutoCAD: + // "Arial Bold.ttf" → 1071 = 33554466 = 0x02000022 (bold) + // "Arial Italic.ttf" → 1071 = 16777250 = 0x01000022 (italic) + // "Arial Bold Italic.ttf" → 1071 = 50331682 = 0x03000022 (bold+italic) + // "Arial.ttf" → 1071 = 34 = 0x00000022 (neither) + const scanner = createScanner( + "0", "TABLE", + "2", "STYLE", + "70", "4", + "0", "STYLE", + "2", "arial", + "3", "Arial.ttf", + "1001", "ACAD", + "1000", "Arial", + "1071", "34", + "0", "STYLE", + "2", "arial b", + "3", "Arial Bold.ttf", + "1001", "ACAD", + "1000", "Arial", + "1071", "33554466", + "0", "STYLE", + "2", "arial i", + "3", "Arial Italic.ttf", + "1001", "ACAD", + "1000", "Arial", + "1071", "16777250", + "0", "STYLE", + "2", "arial bi", + "3", "Arial Bold Italic.ttf", + "1001", "ACAD", + "1000", "Arial", + "1071", "50331682", + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + const styles = tables.style.styles as Record; + + expect(styles["arial"].bold).toBeUndefined(); + expect(styles["arial"].italic).toBeUndefined(); + expect(styles["arial b"].bold).toBe(true); + expect(styles["arial b"].italic).toBeUndefined(); + expect(styles["arial i"].bold).toBeUndefined(); + expect(styles["arial i"].italic).toBe(true); + expect(styles["arial bi"].bold).toBe(true); + expect(styles["arial bi"].italic).toBe(true); + }); }); // ── STYLE alongside other tables ────────────────────────────────── @@ -530,6 +630,117 @@ describe("parseTables", () => { expect(dimStyles.ARCHARR.dimtxt).toBe(0.1875); expect(dimStyles.ARCHARR.dimclrt).toBe(1); }); + + it("parses DIMSTYLE with dimclrd (code 176) and dimclre (code 177) for line colors", () => { + const scanner = createScanner( + "0", "TABLE", + "2", "DIMSTYLE", + "70", "1", + "0", "DIMSTYLE", + "2", "stil1", + "176", "5", // DIMCLRD — dimension line color (blue) + "177", "5", // DIMCLRE — extension line color (blue) + "178", "7", // DIMCLRT — text color + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + + const dimStyles = tables.dimStyle.dimStyles as Record; + expect(dimStyles.stil1.dimclrd).toBe(5); + expect(dimStyles.stil1.dimclre).toBe(5); + expect(dimStyles.stil1.dimclrt).toBe(7); + }); + + it("parses DIMTXSTY (code 340) — handle of dimension text STYLE", () => { + const scanner = createScanner( + "0", "TABLE", + "2", "DIMSTYLE", + "70", "1", + "0", "DIMSTYLE", + "2", "ISO-25", + "340", "2a3", // DIMTXSTY — text style handle (mixed case → uppercased) + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + + const dimStyles = tables.dimStyle.dimStyles as Record; + expect(dimStyles["ISO-25"].dimtxstyHandle).toBe("2A3"); + }); + + it("parses DIMSTYLE with dimtoh (code 73) and dimtih (code 74)", () => { + const scanner = createScanner( + "0", "TABLE", + "2", "DIMSTYLE", + "70", "1", + "0", "DIMSTYLE", + "2", "stil1", + "73", "0", // DIMTOH — text outside is aligned with line + "74", "0", // DIMTIH — text inside is aligned with line + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + + const dimStyles = tables.dimStyle.dimStyles as Record; + expect(dimStyles.stil1.dimtoh).toBe(0); + expect(dimStyles.stil1.dimtih).toBe(0); + }); + + it("parses DIMSTYLE DIMTAD (77), DIMGAP (147), DIMTMOVE (279), DIMUPT (288), DIMATFIT (289)", () => { + const scanner = createScanner( + "0", "TABLE", + "2", "DIMSTYLE", + "70", "1", + "0", "DIMSTYLE", + "2", "stil1", + "77", "1", // DIMTAD — text above line + "147", "0.7", // DIMGAP — gap between dim line and text + "279", "1", // DIMTMOVE — add leader when text moved + "288", "0", // DIMUPT — default text position + "289", "3", // DIMATFIT — auto-fit (best) + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + + const dimStyles = tables.dimStyle.dimStyles as Record; + expect(dimStyles.stil1.dimtad).toBe(1); + expect(dimStyles.stil1.dimgap).toBe(0.7); + expect(dimStyles.stil1.dimtmove).toBe(1); + expect(dimStyles.stil1.dimupt).toBe(0); + expect(dimStyles.stil1.dimatfit).toBe(3); + }); + + it("parses DIMSTYLE with dimadec (code 179) for angular precision", () => { + const scanner = createScanner( + "0", "TABLE", + "2", "DIMSTYLE", + "70", "1", + "0", "DIMSTYLE", + "2", "stil1", + "271", "2", // DIMDEC — linear precision + "179", "1", // DIMADEC — angular precision + "0", "ENDTAB", + "0", "ENDSEC", + "0", "EOF", + ); + + const tables = parseTables(scanner); + + const dimStyles = tables.dimStyle.dimStyles as Record; + expect(dimStyles.stil1.dimdec).toBe(2); + expect(dimStyles.stil1.dimadec).toBe(1); + }); }); // ── Empty TABLES section ──────────────────────────────────────────── diff --git a/packages/dxf-render/src/parser/sections/entities.ts b/packages/dxf-render/src/parser/sections/entities.ts index e251d7a..591dca4 100644 --- a/packages/dxf-render/src/parser/sections/entities.ts +++ b/packages/dxf-render/src/parser/sections/entities.ts @@ -26,6 +26,7 @@ import { parseWipeout } from "../entities/wipeout"; import { parseMline } from "../entities/mline"; import { parseXline } from "../entities/xline"; import { parse3DSolid } from "../entities/3dsolid"; +import { parseRegion } from "../entities/region"; type EntityHandler = (scanner: DxfScanner, curr: IGroup) => IEntityBase; @@ -73,6 +74,7 @@ const entityHandlers: Record = { XLINE: parseXline, RAY: parseXline, "3DSOLID": parse3DSolid, + REGION: parseRegion, }; /** diff --git a/packages/dxf-render/src/parser/sections/objects.ts b/packages/dxf-render/src/parser/sections/objects.ts new file mode 100644 index 0000000..aa158c2 --- /dev/null +++ b/packages/dxf-render/src/parser/sections/objects.ts @@ -0,0 +1,61 @@ +import type DxfScanner from "../scanner"; +import type { DxfMLeaderStyle, DxfObjects } from "@/types/dxf"; + +/** + * Parse the OBJECTS section. Currently extracts only MLEADERSTYLE records; + * every other object type is skipped (fast-forward to the next code-0 group). + * + * MLEADERSTYLE carries color defaults that MULTILEADER entities inherit when + * their PropertyOverrideFlag (code 90) bits for color are not set. + */ +export function parseObjects(scanner: DxfScanner): DxfObjects { + const mLeaderStyles: Record = {}; + + let curr = scanner.next(); + while (!scanner.isEOF()) { + if (curr.code === 0) { + if (curr.value === "ENDSEC") break; + if (curr.value === "MLEADERSTYLE") { + const style = parseMLeaderStyle(scanner); + if (style.handle) mLeaderStyles[style.handle.toUpperCase()] = style; + curr = scanner.lastReadGroup; + continue; + } + } + curr = scanner.next(); + } + + return { mLeaderStyles }; +} + +function parseMLeaderStyle(scanner: DxfScanner): DxfMLeaderStyle { + const style: DxfMLeaderStyle = { handle: "" }; + + let curr = scanner.next(); + while (!scanner.isEOF()) { + if (curr.code === 0) break; + switch (curr.code) { + case 5: + style.handle = String(curr.value).toUpperCase(); + break; + case 3: + // MLeaderStyleDescription (often "Standard"). Useful for debugging. + style.name = String(curr.value); + break; + case 91: + style.leaderLineColorRaw = curr.value as number; + break; + case 93: + style.textColorRaw = curr.value as number; + break; + case 94: + style.blockColorRaw = curr.value as number; + break; + default: + break; + } + curr = scanner.next(); + } + + return style; +} diff --git a/packages/dxf-render/src/parser/sections/tables.ts b/packages/dxf-render/src/parser/sections/tables.ts index 03d360d..f7b0f08 100644 --- a/packages/dxf-render/src/parser/sections/tables.ts +++ b/packages/dxf-render/src/parser/sections/tables.ts @@ -21,10 +21,16 @@ export interface ILineType { export interface IStyle { name: string; + /** Entity handle (DXF code 5) — used for DIMSTYLE.DIMTXSTY (code 340) cross-reference. */ + handle?: string; fontFile?: string; bigFont?: string; fixedHeight?: number; widthFactor?: number; + /** True if the style references a bold TTF (parsed from ACAD XDATA 1071, bit 25). */ + bold?: boolean; + /** True if the style references an italic TTF (parsed from ACAD XDATA 1071, bit 24). */ + italic?: boolean; } export interface IBlockRecord { @@ -40,11 +46,23 @@ export interface IDimStyle { dimtxt?: number; // code 140: text height (unscaled) dimtsz?: number; // code 142: tick size (>0 = use ticks instead of arrows) dimexe?: number; // code 44: extension line extension past dimension line + dimtoh?: number; // code 73: text outside arc/dim line — 0=aligned, 1=horizontal + dimtih?: number; // code 74: text inside arc/dim line — 0=aligned, 1=horizontal + dimtad?: number; // code 77: text vertical position (0=centered, 1=above, 2=outside, 3=JIS, 4=below) + dimgap?: number; // code 147: distance from dim line to text (and break-radius around text) + dimtmove?: number; // code 279: text movement (0=move dim line, 1=add leader, 2=move text only) + dimupt?: number; // code 288: 0=default text position, 1=allow user-positioned text + dimatfit?: number; // code 289: arrow/text auto-fit strategy (linear dims) + dimclrd?: number; // code 176: dimension line color (ACI index, 0=BYBLOCK, 256=BYLAYER) + dimclre?: number; // code 177: extension line color (ACI index, 0=BYBLOCK, 256=BYLAYER) dimclrt?: number; // code 178: dimension text color (ACI index) dimlunit?: number; // code 277: 2=Decimal, 4=Architectural + dimdec?: number; // code 271: decimal places for primary units (arch: 2^dimdec is fraction denominator) + dimadec?: number; // code 179: decimal places for angular dimensions dimzin?: number; // code 78: zero suppression flags dimblkHandle?: string; // code 342: handle of dimension arrow block (→ BLOCK_RECORD name) dimldrblkHandle?: string; // code 341: handle of leader arrow block (→ BLOCK_RECORD name) + dimtxstyHandle?: string; // code 340: handle of dimension text STYLE (→ DxfStyle handle) } interface IBaseTable { @@ -194,10 +212,12 @@ function parseLayers(scanner: DxfScanner): Record { curr = scanner.next(); break; case 70: { - // Bits 1 and 2: frozen and frozen by default in new viewports + // Bit 1 (0x01): frozen in current drawing. + // Bit 2 (0x02): "frozen by default in new viewports" — does not + // hide the layer in model space, so it must not affect `frozen`. + // Bit 4 (0x04): locked. const flags = curr.value as number; - layer.frozen = (flags & 1) !== 0 || (flags & 2) !== 0; - // Bit 4 (0x04): locked + layer.frozen = (flags & 1) !== 0; layer.locked = (flags & 4) !== 0; curr = scanner.next(); break; @@ -277,6 +297,10 @@ function parseStyles(scanner: DxfScanner): Record { styleName = curr.value as string; curr = scanner.next(); break; + case 5: + style.handle = (curr.value as string).toUpperCase(); + curr = scanner.next(); + break; case 3: style.fontFile = curr.value as string; curr = scanner.next(); @@ -293,6 +317,21 @@ function parseStyles(scanner: DxfScanner): Record { style.widthFactor = curr.value as number; curr = scanner.next(); break; + case 1071: { + // ACAD XDATA TrueType font flags. Packed 32-bit value with the same + // layout as MTEXT inline \f...|b|i|c|p; + // bits 0-7 : pitchAndFamily + // bits 8-15 : charset + // bit 24 : italic + // bit 25 : bold + // Empirical from the AutoCAD writer: "Arial.ttf" → 34 (0x22); + // "Arial Bold.ttf" → 33554466 (0x02000022). + const flags = curr.value as number; + if ((flags >>> 25) & 1) style.bold = true; + if ((flags >>> 24) & 1) style.italic = true; + curr = scanner.next(); + break; + } case 0: if (curr.value === "STYLE") { if (styleName) styles[styleName] = style; @@ -420,6 +459,21 @@ function parseDimStyles(scanner: DxfScanner): Record { ds.dimexe = curr.value as number; curr = scanner.next(); break; + case 73: + // DIMTOH — text outside dimension lines: 0=aligned with line, 1=horizontal. + ds.dimtoh = curr.value as number; + curr = scanner.next(); + break; + case 74: + // DIMTIH — text inside dimension lines: 0=aligned with line, 1=horizontal. + ds.dimtih = curr.value as number; + curr = scanner.next(); + break; + case 77: + // DIMTAD — text vertical position relative to the dim line. + ds.dimtad = curr.value as number; + curr = scanner.next(); + break; case 78: ds.dimzin = curr.value as number; curr = scanner.next(); @@ -432,14 +486,59 @@ function parseDimStyles(scanner: DxfScanner): Record { ds.dimtsz = curr.value as number; curr = scanner.next(); break; + case 147: + // DIMGAP — distance between dim line and text (and break-radius around text). + ds.dimgap = curr.value as number; + curr = scanner.next(); + break; + case 176: + ds.dimclrd = curr.value as number; + curr = scanner.next(); + break; + case 177: + ds.dimclre = curr.value as number; + curr = scanner.next(); + break; case 178: ds.dimclrt = curr.value as number; curr = scanner.next(); break; + case 271: + // DIMDEC: keep only first occurrence — legacy DXF files sometimes repeat code 271 + // (the second occurrence carries a different meaning in older versions). + if (ds.dimdec === undefined) ds.dimdec = curr.value as number; + curr = scanner.next(); + break; + case 179: + // DIMADEC: decimal places for angular dimensions + ds.dimadec = curr.value as number; + curr = scanner.next(); + break; case 277: ds.dimlunit = curr.value as number; curr = scanner.next(); break; + case 279: + // DIMTMOVE — text movement strategy when user moves text. + ds.dimtmove = curr.value as number; + curr = scanner.next(); + break; + case 288: + // DIMUPT — allow user-positioned text. + ds.dimupt = curr.value as number; + curr = scanner.next(); + break; + case 289: + // DIMATFIT — arrow/text auto-fit strategy (mostly linear dims). + ds.dimatfit = curr.value as number; + curr = scanner.next(); + break; + case 340: + // DIMTXSTY — handle of the STYLE record this DIMSTYLE uses for dim text. + // Stored uppercase to match the styleHandleToName map built in createDXFScene. + ds.dimtxstyHandle = (curr.value as string).toUpperCase(); + curr = scanner.next(); + break; case 341: ds.dimldrblkHandle = curr.value as string; curr = scanner.next(); diff --git a/packages/dxf-render/src/render/__tests__/arrowheads.test.ts b/packages/dxf-render/src/render/__tests__/arrowheads.test.ts new file mode 100644 index 0000000..16abad3 --- /dev/null +++ b/packages/dxf-render/src/render/__tests__/arrowheads.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from "vitest"; +import * as THREE from "three"; +import { + type ArrowKind, + classifyArrowBlock, + isArrowShape, + createArrowhead, +} from "../arrowheads"; + +const lineMat = new THREE.LineBasicMaterial({ color: 0x000000 }); +const fillMat = new THREE.MeshBasicMaterial({ color: 0x000000, side: THREE.DoubleSide }); + +const buildHead = (kind: ArrowKind, size = 4): THREE.Object3D[] => + createArrowhead({ + from: new THREE.Vector3(0, 0, 0), + tip: new THREE.Vector3(4, 0, 0), + size, + kind, + lineMaterial: lineMat, + fillMaterial: fillMat, + }); + +const positionsOf = (obj: THREE.Object3D): number[] => { + const geo = (obj as THREE.Mesh).geometry as THREE.BufferGeometry; + const attr = geo.getAttribute("position") as THREE.BufferAttribute; + return Array.from(attr.array); +}; + +describe("classifyArrowBlock", () => { + const cases: Array<[string, ArrowKind | undefined]> = [ + ["_ClosedFilled", "closed-filled"], + ["ClosedFilled", "closed-filled"], + ["_closed", "closed-blank"], + ["_ClosedBlank", "closed-blank"], + ["_Open", "open"], + ["_Open30", "open30"], + ["_OpenArrow", "open-arrow"], + ["_Open90", "open-arrow"], + ["_DatumFilled", "datum-filled"], + ["_DatumBlank", "datum-blank"], + ["_Dot", "dot"], + ["_DotSmall", "dot-small"], + ["_DotBlank", "dot-blank"], + ["_DotSmallBlank", "dot-small-blank"], + ["_Origin", "origin"], + ["_Origin2", "origin2"], + ["_Box", "box"], + ["_BoxFilled", "box-filled"], + ["_Integral", "integral"], + ["_None", "none"], + ["_ArchTick", "tick"], + ["_Oblique", "tick"], + ["_Small", "tick"], + ["_Tick", "tick"], + ]; + + for (const [name, expected] of cases) { + it(`classifies "${name}" as ${expected}`, () => { + expect(classifyArrowBlock(name)).toBe(expected); + }); + } + + it("is case-insensitive", () => { + expect(classifyArrowBlock("_dotsmall")).toBe("dot-small"); + expect(classifyArrowBlock("DotSmall")).toBe("dot-small"); + expect(classifyArrowBlock("DOTSMALL")).toBe("dot-small"); + }); + + it("returns undefined for unknown names", () => { + expect(classifyArrowBlock("hl_arrow2")).toBeUndefined(); + expect(classifyArrowBlock("_MyCustomBlock")).toBeUndefined(); + }); + + it("returns undefined for empty / nullish input", () => { + expect(classifyArrowBlock("")).toBeUndefined(); + expect(classifyArrowBlock(undefined)).toBeUndefined(); + expect(classifyArrowBlock(null)).toBeUndefined(); + }); +}); + +describe("isArrowShape", () => { + it("is true for direction-dependent arrow forms", () => { + expect(isArrowShape("closed-filled")).toBe(true); + expect(isArrowShape("closed-blank")).toBe(true); + expect(isArrowShape("open")).toBe(true); + expect(isArrowShape("open30")).toBe(true); + expect(isArrowShape("open-arrow")).toBe(true); + expect(isArrowShape("datum-filled")).toBe(true); + expect(isArrowShape("datum-blank")).toBe(true); + }); + + it("is false for symmetric / endpoint-centred forms", () => { + expect(isArrowShape("tick")).toBe(false); + expect(isArrowShape("dot")).toBe(false); + expect(isArrowShape("dot-small")).toBe(false); + expect(isArrowShape("dot-blank")).toBe(false); + expect(isArrowShape("dot-small-blank")).toBe(false); + expect(isArrowShape("origin")).toBe(false); + expect(isArrowShape("origin2")).toBe(false); + expect(isArrowShape("box")).toBe(false); + expect(isArrowShape("box-filled")).toBe(false); + expect(isArrowShape("integral")).toBe(false); + expect(isArrowShape("none")).toBe(false); + }); +}); + +describe("createArrowhead", () => { + it("returns empty array for kind=none", () => { + expect(buildHead("none")).toEqual([]); + }); + + it("creates filled triangle Mesh for closed-filled", () => { + const heads = buildHead("closed-filled"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.Mesh); + const positions = positionsOf(heads[0]); + // 3 vertices × 3 components + expect(positions).toHaveLength(9); + // First vertex is the tip + expect(positions.slice(0, 3)).toEqual([4, 0, 0]); + }); + + it("creates filled triangle Mesh for datum-filled", () => { + const heads = buildHead("datum-filled"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.Mesh); + }); + + it("creates LineSegments outline for closed-blank", () => { + const heads = buildHead("closed-blank"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.LineSegments); + // 3 edges × 2 vertices × 3 components + expect(positionsOf(heads[0])).toHaveLength(18); + }); + + it("creates two open strokes for open / open30", () => { + const heads = buildHead("open"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.LineSegments); + // 2 strokes × 2 vertices × 3 components = 12 + expect(positionsOf(heads[0])).toHaveLength(12); + }); + + it("creates filled dot Mesh for dot (radius = size/2)", () => { + const heads = buildHead("dot", 4); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.Mesh); + const positions = positionsOf(heads[0]); + // First vertex is centre + expect(positions.slice(0, 3)).toEqual([4, 0, 0]); + // Find max radial distance from centre — should equal size/2 = 2 + let maxR = 0; + for (let i = 3; i < positions.length; i += 3) { + const dx = positions[i] - 4; + const dy = positions[i + 1] - 0; + maxR = Math.max(maxR, Math.sqrt(dx * dx + dy * dy)); + } + expect(maxR).toBeCloseTo(2, 5); + }); + + it("creates filled dot Mesh for dot-small (radius = size/4)", () => { + const heads = buildHead("dot-small", 4); + const positions = positionsOf(heads[0]); + let maxR = 0; + for (let i = 3; i < positions.length; i += 3) { + const dx = positions[i] - 4; + const dy = positions[i + 1] - 0; + maxR = Math.max(maxR, Math.sqrt(dx * dx + dy * dy)); + } + expect(maxR).toBeCloseTo(1, 5); // size/4 = 1 + }); + + it("creates Line outline for dot-blank (radius = size/2)", () => { + const heads = buildHead("dot-blank", 4); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.Line); + // First vertex sits on the circle, not at centre + const positions = positionsOf(heads[0]); + const dx = positions[0] - 4; + const dy = positions[1] - 0; + expect(Math.sqrt(dx * dx + dy * dy)).toBeCloseTo(2, 5); + }); + + it("creates two concentric Line outlines for origin2", () => { + const heads = buildHead("origin2", 4); + expect(heads).toHaveLength(2); + expect(heads[0]).toBeInstanceOf(THREE.Line); + expect(heads[1]).toBeInstanceOf(THREE.Line); + + const measureR = (obj: THREE.Object3D) => { + const pos = positionsOf(obj); + const dx = pos[0] - 4; + const dy = pos[1] - 0; + return Math.sqrt(dx * dx + dy * dy); + }; + // First ring is the larger one (size/2), second is the inner (size/4) + expect(measureR(heads[0])).toBeCloseTo(2, 5); + expect(measureR(heads[1])).toBeCloseTo(1, 5); + }); + + it("creates LineSegments outline for box", () => { + const heads = buildHead("box"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.LineSegments); + // 4 edges × 2 vertices × 3 components + expect(positionsOf(heads[0])).toHaveLength(24); + }); + + it("creates filled Mesh for box-filled", () => { + const heads = buildHead("box-filled"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.Mesh); + }); + + it("creates tick LineSegments with size-scaled diagonal length", () => { + const heads = buildHead("tick", 4); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.LineSegments); + const positions = positionsOf(heads[0]); + // 1 segment, 2 endpoints + expect(positions).toHaveLength(6); + // _ArchTick block: line from (-0.5,-0.5) to (0.5,0.5), so total length is + // size*sqrt(2); each endpoint sits at size*sqrt(2)/2 from the tip. + const dx1 = positions[0] - 4; + const dy1 = positions[1] - 0; + expect(Math.sqrt(dx1 * dx1 + dy1 * dy1)).toBeCloseTo(2 * Math.SQRT2, 5); + }); + + it("creates an integral curve as a single Line", () => { + const heads = buildHead("integral"); + expect(heads).toHaveLength(1); + expect(heads[0]).toBeInstanceOf(THREE.Line); + }); + + it("scales arrowhead geometry proportionally to size", () => { + const small = buildHead("closed-filled", 1); + const big = buildHead("closed-filled", 10); + const smallPos = positionsOf(small[0]); + const bigPos = positionsOf(big[0]); + // Tip-to-base distance along x — ratio should match size ratio (10x) + const smallReach = Math.abs(smallPos[0] - smallPos[3]); + const bigReach = Math.abs(bigPos[0] - bigPos[3]); + expect(bigReach / smallReach).toBeCloseTo(10, 3); + }); +}); diff --git a/packages/dxf-render/src/render/__tests__/blockTemplateCache.test.ts b/packages/dxf-render/src/render/__tests__/blockTemplateCache.test.ts index 5c8bab6..fe2b473 100644 --- a/packages/dxf-render/src/render/__tests__/blockTemplateCache.test.ts +++ b/packages/dxf-render/src/render/__tests__/blockTemplateCache.test.ts @@ -167,6 +167,37 @@ describe("buildBlockTemplate", () => { expect(keys[0].includes(BYBLOCK_COLOR)).toBe(true); }); + it("uses BYBLOCK_COLOR sentinel for layer-0 ByLayer entities (inherit from INSERT)", () => { + // Regression: AEC Gridline Bubble — ARC on layer "0" with no explicit color + // must inherit the INSERT's color. The template can't bake the color in + // because the same block may be inserted on different colored layers. + const entities: DxfEntity[] = [ + { type: "LINE", layer: "0" } as DxfEntity, // no colorIndex → ByLayer + ]; + + const template = buildBlockTemplate("TEST", entities, makeColorCtx(), stubCollect); + + const keys = [...template.buckets.keys()]; + expect(keys.length).toBe(1); + // Key encodes "INHERIT_LAYER::BYBLOCK_COLOR" — both sentinels resolved at instantiation + expect(keys[0].startsWith(INHERIT_LAYER)).toBe(true); + expect(keys[0].includes(BYBLOCK_COLOR)).toBe(true); + }); + + it("bakes explicit color even on layer 0 (no inheritance)", () => { + const entities: DxfEntity[] = [ + { type: "LINE", layer: "0", colorIndex: 1 } as DxfEntity, // explicit ACI 1 (red) + ]; + + const template = buildBlockTemplate("TEST", entities, makeColorCtx(), stubCollect); + + const keys = [...template.buckets.keys()]; + expect(keys.length).toBe(1); + // Layer inherits, but color is explicit and gets baked + expect(keys[0].startsWith(INHERIT_LAYER)).toBe(true); + expect(keys[0].includes(BYBLOCK_COLOR)).toBe(false); + }); + it("puts ByBlock linetype entities into fallback", () => { const entities: DxfEntity[] = [ { type: "LINE", layer: "0", lineType: "BYBLOCK" } as DxfEntity, diff --git a/packages/dxf-render/src/render/__tests__/dimensions.test.ts b/packages/dxf-render/src/render/__tests__/dimensions.test.ts index e2245f0..4fef8f4 100644 --- a/packages/dxf-render/src/render/__tests__/dimensions.test.ts +++ b/packages/dxf-render/src/render/__tests__/dimensions.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import * as THREE from "three"; import { formatDimNumber, formatArchitectural, @@ -10,6 +11,8 @@ import { resolveDimVarsFromHeader, applyDimStyleVars, mergeEntityDimVars, + createLinearDimensionLines, + createRotatedDimensionLines, DEFAULT_DIM_VARS, } from "../dimensions"; import type { DxfDimensionEntity, DxfDimStyle } from "@/types/dxf"; @@ -59,6 +62,250 @@ describe("formatDimNumber", () => { it("rounds very small values beyond 4 decimal places to zero", () => { expect(formatDimNumber(0.00001)).toBe("0"); }); + + // ─── DIMDEC / DIMZIN ─────────────────────────────────────────────── + + it("honors explicit DIMDEC for decimal places", () => { + // 60.49747752399162 with DIMDEC=2 rounds to "60.50" then strips trailing → "60.5" + expect(formatDimNumber(60.49747752399162, 2)).toBe("60.5"); + expect(formatDimNumber(60.49747752399162, 0)).toBe("60"); + expect(formatDimNumber(60.49747752399162, 6)).toBe("60.497478"); + }); + + it("keeps trailing zeros when DIMZIN bit 8 is unset", () => { + // DIMZIN=0 → include trailing zeros in decimal mode + expect(formatDimNumber(60.5, 2, 0)).toBe("60.50"); + expect(formatDimNumber(60.0, 2, 0)).toBe("60.00"); + }); + + it("strips trailing zeros when DIMZIN bit 8 is set", () => { + expect(formatDimNumber(60.5, 2, 8)).toBe("60.5"); + expect(formatDimNumber(60.0, 2, 8)).toBe("60"); + }); + + it("strips leading zero when DIMZIN bit 4 is set", () => { + // 0.50 with DIMZIN bit 4 → ".50"; with bit 4 | bit 8 (=12) → ".5" + expect(formatDimNumber(0.5, 2, 4)).toBe(".50"); + expect(formatDimNumber(0.5, 2, 12)).toBe(".5"); + // Negative numbers keep their sign before the dot + expect(formatDimNumber(-0.5, 2, 12)).toBe("-.5"); + }); +}); + +// ===================================================================== +// extractDimensionData — DIMDEC/DIMZIN integration +// ===================================================================== + +describe("extractDimensionData — formatting", () => { + const baseEntity = { + type: "DIMENSION" as const, + dimensionType: 0, + actualMeasurement: 60.49747752399162, + anchorPoint: { x: 0, y: 0, z: 0 }, + middleOfText: { x: 0, y: 5, z: 0 }, + linearOrAngularPoint1: { x: -5, y: 10, z: 0 }, + linearOrAngularPoint2: { x: -5, y: 0, z: 0 }, + angle: 90, + }; + + it("honors DIMDEC=2 + DIMZIN=8 for decimal dimensions (60.4974... → 60.5)", () => { + const data = extractDimensionData(baseEntity as any, undefined, { dimlunit: 2, dimdec: 2, dimzin: 8 }); + expect(data?.dimensionText).toBe("60.5"); + }); + + it("keeps trailing zeros with DIMZIN=0 (60.4974... → 60.50)", () => { + const data = extractDimensionData(baseEntity as any, undefined, { dimlunit: 2, dimdec: 2, dimzin: 0 }); + expect(data?.dimensionText).toBe("60.50"); + }); + + it("falls back to 4 decimals when DIMDEC is not provided", () => { + const data = extractDimensionData(baseEntity as any, undefined, undefined); + expect(data?.dimensionText).toBe("60.4975"); + }); +}); + +// ===================================================================== +// createLinearDimensionLines / createRotatedDimensionLines — outside arrows +// ===================================================================== + +describe("dimension lines — outside-arrow auto-flip", () => { + // Read the tip of an arrow Mesh (first 3 floats of position buffer). + const arrowTip = (obj: THREE.Object3D): [number, number] => { + const mesh = obj as THREE.Mesh; + const pos = (mesh.geometry as THREE.BufferGeometry).getAttribute("position"); + const arr = pos.array as Float32Array; + return [arr[0], arr[1]]; + }; + + // Read the two endpoints of a Line. + const lineEnds = (obj: THREE.Object3D): { x: number[]; y: number[] } => { + const line = obj as THREE.Line; + const pos = (line.geometry as THREE.BufferGeometry).getAttribute("position"); + const arr = pos.array as Float32Array; + return { x: [arr[0], arr[3]], y: [arr[1], arr[4]] }; + }; + + const lineMat = new THREE.LineBasicMaterial(); + const extMat = new THREE.LineDashedMaterial(); + const arrowMat = new THREE.MeshBasicMaterial(); + const dv = { ...DEFAULT_DIM_VARS, arrowSize: 2 }; + const baseParams = { + dimLineMaterial: lineMat, + extensionLineMaterial: extMat, + arrowMaterial: arrowMat, + dv, + }; + + it("keeps arrows inward when dim length is well above the threshold", () => { + // 10 > 2.5 * 2 = 5 → no flip + const objs = createLinearDimensionLines({ + ...baseParams, + point1: { x: 0, y: 0 }, + point2: { x: 10, y: 0 }, + anchorPoint: { x: 5, y: 0 }, + isHorizontal: true, + }); + const arrows = objs.filter((o) => o instanceof THREE.Mesh) as THREE.Mesh[]; + expect(arrows).toHaveLength(2); + // Arrow tips at min/max (0 and 10), the original (inside) layout + const tipXs = arrows.map((a) => arrowTip(a)[0]).sort((a, b) => a - b); + expect(tipXs[0]).toBeCloseTo(0, 5); + expect(tipXs[1]).toBeCloseTo(10, 5); + // Dim line stays between min/max + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + const dimLine = lines[0]; + const { x } = lineEnds(dimLine); + expect(Math.min(...x)).toBeCloseTo(0, 5); + expect(Math.max(...x)).toBeCloseTo(10, 5); + }); + + it("flips arrows outward when dim length is below 2.5 × arrowSize", () => { + // 4 < 2.5 * 2 = 5 → flip; dim line extended by arrowSize + tail (= 2 + 2 = 4) per side + const objs = createLinearDimensionLines({ + ...baseParams, + point1: { x: 0, y: 0 }, + point2: { x: 4, y: 0 }, + anchorPoint: { x: 2, y: 0 }, + isHorizontal: true, + }); + const arrows = objs.filter((o) => o instanceof THREE.Mesh) as THREE.Mesh[]; + expect(arrows).toHaveLength(2); + // Tips still at measurement endpoints (0 and 4); the difference is direction. + const tipXs = arrows.map((a) => arrowTip(a)[0]).sort((a, b) => a - b); + expect(tipXs[0]).toBeCloseTo(0, 5); + expect(tipXs[1]).toBeCloseTo(4, 5); + // Dim line extends from -4 to 8 (arrowSize + tail on each side) + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + const dimLine = lines[0]; + const { x } = lineEnds(dimLine); + expect(Math.min(...x)).toBeCloseTo(-4, 5); + expect(Math.max(...x)).toBeCloseTo(8, 5); + }); + + it("flips arrows for a vertical rotated dimension below the threshold", () => { + // Vertical dim, length 4 < 5 → flip + const objs = createRotatedDimensionLines({ + ...baseParams, + point1: { x: -5, y: 0 }, + point2: { x: -5, y: 4 }, + anchorPoint: { x: 0, y: 2 }, + angleRad: Math.PI / 2, + }); + const arrows = objs.filter((o) => o instanceof THREE.Mesh) as THREE.Mesh[]; + expect(arrows).toHaveLength(2); + // Tips at y=0 and y=4 (the foot points along the vertical dim line at x=0) + const tipYs = arrows.map((a) => arrowTip(a)[1]).sort((a, b) => a - b); + expect(tipYs[0]).toBeCloseTo(0, 5); + expect(tipYs[1]).toBeCloseTo(4, 5); + // Dim line extends from y=-4 to y=8 (arrowSize=2 + tail=2 per side) + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + // Find the dim line (the one along x=0; extension lines are perpendicular) + const dimLine = lines.find((l) => { + const { x } = lineEnds(l); + return Math.abs(x[0]) < 0.1 && Math.abs(x[1]) < 0.1; + }); + expect(dimLine).toBeDefined(); + const { y } = lineEnds(dimLine!); + expect(Math.min(...y)).toBeCloseTo(-4, 5); + expect(Math.max(...y)).toBeCloseTo(8, 5); + }); + + it("flips arrows at the boundary case (just below the threshold)", () => { + // 4.99 < 5 → flip + const objs = createLinearDimensionLines({ + ...baseParams, + point1: { x: 0, y: 0 }, + point2: { x: 4.99, y: 0 }, + anchorPoint: { x: 2.5, y: 0 }, + isHorizontal: true, + }); + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + const { x } = lineEnds(lines[0]); + // Extended by arrowSize + tail = 4 on each side + expect(Math.min(...x)).toBeCloseTo(-4, 5); + expect(Math.max(...x)).toBeCloseTo(8.99, 5); + }); + + it("dim line extends past the arrow base by tail = arrowSize", () => { + // arrowSize=2 → arrow base sits at min-2 / max+2; dim line ends at min-4 / max+4. + // The tail is the segment between base and dim-line end (length = arrowSize). + const objs = createLinearDimensionLines({ + ...baseParams, + point1: { x: 0, y: 0 }, + point2: { x: 4, y: 0 }, + anchorPoint: { x: 2, y: 0 }, + isHorizontal: true, + }); + const arrows = objs.filter((o) => o instanceof THREE.Mesh) as THREE.Mesh[]; + // Read each arrow's two base vertices (positions 1 and 2 in the buffer) + const baseXs: number[] = []; + for (const m of arrows) { + const arr = (m.geometry as THREE.BufferGeometry).getAttribute("position").array as Float32Array; + // arrow has 3 vertices: tip (0..2), base1 (3..5), base2 (6..8). Both base xs are equal. + baseXs.push(arr[3]); + } + baseXs.sort((a, b) => a - b); + expect(baseXs[0]).toBeCloseTo(-2, 5); // min - arrowSize + expect(baseXs[1]).toBeCloseTo(6, 5); // max + arrowSize + + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + const { x } = lineEnds(lines[0]); + // Tail of length arrowSize=2 past each base: min-4 .. max+4 + expect(Math.min(...x) - baseXs[0]).toBeCloseTo(-2, 5); + expect(Math.max(...x) - baseXs[1]).toBeCloseTo(2, 5); + }); + + it("does not flip when ticks are enabled (ticks do not collide)", () => { + const objs = createLinearDimensionLines({ + ...baseParams, + point1: { x: 0, y: 0 }, + point2: { x: 4, y: 0 }, + anchorPoint: { x: 2, y: 0 }, + isHorizontal: true, + dv: { ...dv, arrowKind: "tick" as const, tickSize: 2 }, + }); + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + // Dim line stays between min/max (no extension) + const { x } = lineEnds(lines[0]); + expect(Math.min(...x)).toBeCloseTo(0, 5); + expect(Math.max(...x)).toBeCloseTo(4, 5); + }); + + it("does not flip when arrowKind=dot-small (symmetric, no collision)", () => { + const objs = createLinearDimensionLines({ + ...baseParams, + point1: { x: 0, y: 0 }, + point2: { x: 4, y: 0 }, + anchorPoint: { x: 2, y: 0 }, + isHorizontal: true, + dv: { ...dv, arrowKind: "dot-small" as const }, + }); + const lines = objs.filter((o) => o instanceof THREE.Line) as THREE.Line[]; + // Dim line stays between min/max — dots sit at the endpoints, no flip + const { x } = lineEnds(lines[0]); + expect(Math.min(...x)).toBeCloseTo(0, 5); + expect(Math.max(...x)).toBeCloseTo(4, 5); + }); }); // ===================================================================== @@ -391,29 +638,53 @@ describe("resolveDimVarsFromHeader", () => { expect(dv.extLineGap).toBe(5); // 1 * 5 }); - it("sets useTicks=true when $DIMTSZ > 0", () => { + it("sets arrowKind=tick when $DIMTSZ > 0", () => { const dv = resolveDimVarsFromHeader({ "$DIMTSZ": 2.5 }); - expect(dv.useTicks).toBe(true); + expect(dv.arrowKind).toBe("tick"); expect(dv.tickSize).toBe(2.5); }); - it("sets useTicks=false when $DIMTSZ is 0", () => { + it("defaults arrowKind to closed-filled when $DIMTSZ is 0", () => { const dv = resolveDimVarsFromHeader({ "$DIMTSZ": 0 }); - expect(dv.useTicks).toBe(false); + expect(dv.arrowKind).toBe("closed-filled"); expect(dv.tickSize).toBe(0); }); - it("sets useTicks=false when $DIMTSZ is absent", () => { + it("defaults arrowKind to closed-filled when $DIMTSZ is absent", () => { const dv = resolveDimVarsFromHeader({}); - expect(dv.useTicks).toBe(false); + expect(dv.arrowKind).toBe("closed-filled"); expect(dv.tickSize).toBe(0); }); it("scales $DIMTSZ by $DIMSCALE", () => { const dv = resolveDimVarsFromHeader({ "$DIMTSZ": 1.5, "$DIMSCALE": 4 }); - expect(dv.useTicks).toBe(true); + expect(dv.arrowKind).toBe("tick"); expect(dv.tickSize).toBe(6); }); + + it("resolves $DIMBLK=_DotSmall to arrowKind=dot-small", () => { + const dv = resolveDimVarsFromHeader({ "$DIMBLK": "_DotSmall" }); + expect(dv.arrowKind).toBe("dot-small"); + expect(dv.tickSize).toBe(0); + }); + + it("resolves $DIMBLK=_ArchTick to arrowKind=tick", () => { + const dv = resolveDimVarsFromHeader({ "$DIMBLK": "_ArchTick" }); + expect(dv.arrowKind).toBe("tick"); + // No explicit DIMTSZ → tick size follows arrowSize + expect(dv.tickSize).toBe(dv.arrowSize); + }); + + it("$DIMTSZ > 0 wins over $DIMBLK", () => { + const dv = resolveDimVarsFromHeader({ "$DIMTSZ": 2, "$DIMBLK": "_DotSmall" }); + expect(dv.arrowKind).toBe("tick"); + expect(dv.tickSize).toBe(2); + }); + + it("falls back to closed-filled for unknown $DIMBLK", () => { + const dv = resolveDimVarsFromHeader({ "$DIMBLK": "MyCustomArrow" }); + expect(dv.arrowKind).toBe("closed-filled"); + }); }); // ===================================================================== @@ -652,6 +923,49 @@ describe("formatArchitectural", () => { it("handles negative fractional value", () => { expect(formatArchitectural(-6.5)).toBe("-6\\S1/2;\""); }); + + // DIMDEC overrides — fraction denominator = 2^DIMDEC + it("uses 1/8\" precision when DIMDEC=3 (AEC AutoCAD default)", () => { + // 73.33503 → 6'-1.33503"; with denom=8 → 0.33503*8=2.68 → round to 3 → 3/8 + expect(formatArchitectural(73.33503010082154, undefined, 3)).toBe("6'-1\\S3/8;\""); + }); + + it("uses 1/16\" precision when DIMDEC=4 (default)", () => { + // Same value with denom=16 → 0.33503*16=5.36 → round to 5 → 5/16 + expect(formatArchitectural(73.33503010082154, undefined, 4)).toBe("6'-1\\S5/16;\""); + }); + + it("uses 1/4\" precision when DIMDEC=2", () => { + // 9.3 → fracPart=0.3; denom=4 → 0.3*4=1.2 → round to 1 → 1/4 + expect(formatArchitectural(9.3, undefined, 2)).toBe("9\\S1/4;\""); + }); + + it("uses 1/2\" precision when DIMDEC=1", () => { + // 9.3 → fracPart=0.3; denom=2 → 0.3*2=0.6 → round to 1 → 1/2 + expect(formatArchitectural(9.3, undefined, 1)).toBe("9\\S1/2;\""); + }); + + it("rounds to whole inches when DIMDEC=0", () => { + expect(formatArchitectural(9.3, undefined, 0)).toBe("9\""); + expect(formatArchitectural(9.7, undefined, 0)).toBe("10\""); + expect(formatArchitectural(11.7, undefined, 0)).toBe("1'"); + }); + + it("uses 1/32\" precision when DIMDEC=5", () => { + // 6 + 1/32 = 6.03125 + expect(formatArchitectural(6.03125, undefined, 5)).toBe("6\\S1/32;\""); + }); + + it("clamps absurdly large DIMDEC to safe upper bound", () => { + // DIMDEC=99 should not produce nonsense — clamped to 8 (1/256) + // 6 + 1/256 = 6.00390625, with denom=256 → fracPart*256=1 → 1/256 + expect(formatArchitectural(6.00390625, undefined, 99)).toBe("6\\S1/256;\""); + }); + + it("falls back to 1/16\" when dimdec is undefined", () => { + // Identical to the no-dimdec case — backward compatibility check + expect(formatArchitectural(6.0625)).toBe(formatArchitectural(6.0625, undefined, 4)); + }); }); // ===================================================================== @@ -708,6 +1022,20 @@ describe("extractDimensionData with DIMLUNIT=4", () => { expect(data).not.toBeNull(); expect(data!.dimensionText).toBe("custom"); }); + + it("honors DIMDEC=3 → 1/8\" precision (AEC AutoCAD default)", () => { + // Regression: AEC Plan Elev Sample, handle D9B071D01A0BB051, actualMeasurement 73.33503010082154 + // DIMSTYLE DIM96 has DIMDEC=3 → fraction denominator 2^3=8 → 3/8 (was 5/16 with hardcoded 16) + const entity = makeDimEntity({ + linearOrAngularPoint1: { x: 0, y: 0, z: 0 }, + linearOrAngularPoint2: { x: 0, y: 73.33503010082154, z: 0 }, + anchorPoint: { x: 5, y: 0, z: 0 }, + actualMeasurement: 73.33503010082154, + }); + const data = extractDimensionData(entity, DEFAULT_DIM_VARS, { dimlunit: 4, dimdec: 3 }); + expect(data).not.toBeNull(); + expect(data!.dimensionText).toBe("6'-1\\S3/8;\""); + }); }); // ===================================================================== diff --git a/packages/dxf-render/src/render/__tests__/hatch.test.ts b/packages/dxf-render/src/render/__tests__/hatch.test.ts index 44cc59e..e22253e 100644 --- a/packages/dxf-render/src/render/__tests__/hatch.test.ts +++ b/packages/dxf-render/src/render/__tests__/hatch.test.ts @@ -12,6 +12,7 @@ import { filterPolygonsByStyle, hatchArcSweep, hatchArcRadians, + hatchEllipseRadians, } from "../hatch"; import type { Point2D } from "../hatch"; import type { HatchPatternLine, HatchBoundaryPath } from "@/types/dxf"; @@ -745,6 +746,86 @@ describe("hatchArcRadians", () => { }); }); +// ── hatchEllipseRadians ──────────────────────────────────────────────── + +describe("hatchEllipseRadians", () => { + const deg = (d: number) => (d * Math.PI) / 180; + + it("converts CCW elliptic-arc edge angles from degrees to radians", () => { + // DXF code 50/51 for HATCH edge type 3 (ellipse) stores DEGREES, + // unlike the ELLIPSE entity (codes 41/42) which stores radians. + const [start, end] = hatchEllipseRadians(343.18, 368.77, true); + expect(start).toBeCloseTo(deg(343.18), 5); + expect(end).toBeCloseTo(deg(368.77), 5); + }); + + it("negates angles for CW ellipses (mirror of hatchArcRadians)", () => { + const [start, end] = hatchEllipseRadians(60, 120, false); + expect(start).toBeCloseTo(deg(-60), 5); + expect(end).toBeCloseTo(deg(-120), 5); + }); +}); + +// ── boundaryPathToPoint2DArray - ellipse edges (degrees) ─────────────── + +describe("boundaryPathToPoint2DArray - ellipse edges (degrees)", () => { + it("produces the correct start point for a CCW circle-like ellipse edge", () => { + // axisRatio=1, major axis along +X (length 10) → effectively a circle r=10. + // Start angle 0°, end angle 90°, ccw=true. + // Expected: starts at (10, 0), ends at (0, 10). + const bp: HatchBoundaryPath = { + edges: [ + { + type: "ellipse" as const, + center: { x: 0, y: 0 }, + majorAxisEndPoint: { x: 10, y: 0 }, + axisRatio: 1, + startAngle: 0, + endAngle: 90, + ccw: true, + }, + ], + }; + const pts = boundaryPathToPoint2DArray(bp); + expect(pts.length).toBeGreaterThan(2); + + // Start at angle 0°: (10, 0) + expect(pts[0].x).toBeCloseTo(10, 1); + expect(pts[0].y).toBeCloseTo(0, 1); + + // End at angle 90°: (0, 10) + const last = pts[pts.length - 1]; + expect(last.x).toBeCloseTo(0, 1); + expect(last.y).toBeCloseTo(10, 1); + }); + + it("treats angles as degrees, not radians (regression for tree_part.dxf)", () => { + // Before the fix, an angle of 343.18 was treated as ~54 revolutions + // (343.18 rad mod 2π ≈ 3.71 rad ≈ 213°), so the start point landed + // at the wrong position on the ellipse and the boundary was broken. + // After the fix, 343.18° → 5.99 rad → (cos≈0.96, sin≈-0.29). + const bp: HatchBoundaryPath = { + edges: [ + { + type: "ellipse" as const, + center: { x: 0, y: 0 }, + majorAxisEndPoint: { x: 10, y: 0 }, + axisRatio: 1, + startAngle: 343.18, + endAngle: 368.77, + ccw: true, + }, + ], + }; + const pts = boundaryPathToPoint2DArray(bp); + expect(pts.length).toBeGreaterThan(1); + + // Start at angle 343.18°: cos ≈ 0.957, sin ≈ -0.288 + expect(pts[0].x).toBeCloseTo(9.57, 1); + expect(pts[0].y).toBeCloseTo(-2.88, 1); + }); +}); + // ── CW arc boundary connectivity ──────────────────────────────────────── describe("boundaryPathToPoint2DArray - CW arc connectivity", () => { diff --git a/packages/dxf-render/src/render/__tests__/widePolyline.test.ts b/packages/dxf-render/src/render/__tests__/widePolyline.test.ts index 1a13a7d..7521077 100644 --- a/packages/dxf-render/src/render/__tests__/widePolyline.test.ts +++ b/packages/dxf-render/src/render/__tests__/widePolyline.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import * as THREE from "three"; import { resolveSegmentWidths, hasAnyWidth } from "@/render/collectors"; import { GeometryCollector } from "@/render/mergeCollectors"; import { collectPolyline } from "@/render/collectors/polylineCollector"; @@ -248,6 +249,65 @@ describe("addWidePolylineToCollector", () => { expect(mesh!.vertices[16]).toBeCloseTo(-3); // right2.y }); + it("preserves width discontinuity at segment junction (AutoCAD-style arrow)", () => { + // LWPOLYLINE: thin line (0→0) then triangular arrowhead (120→0). + // Junction at vertex 1 has prev endW=0 but next startW=120 — the strip + // must re-emit the junction so the arrow opens at half-width 60, otherwise + // the arrowhead collapses to zero width and disappears. + const entity = makeEntity([ + { x: 0, y: 0, startWidth: 0, endWidth: 0 }, + { x: 100, y: 0, startWidth: 120, endWidth: 0 }, + { x: 150, y: 0 }, + ]); + const mesh = collectAndGetMesh(entity); + expect(mesh).not.toBeNull(); + + // 4 center points (3 vertices + 1 duplicated junction) → 8 mesh vertices + expect(mesh!.vertices.length).toBe(8 * 3); + + const v = mesh!.vertices; + // pair 0 (v0): half-width 0, both at origin + expect(v[0]).toBeCloseTo(0); expect(v[1]).toBeCloseTo(0); + expect(v[3]).toBeCloseTo(0); expect(v[4]).toBeCloseTo(0); + // pair 1 (v1, end of segment 0): half-width 0, both at (100, 0) + expect(v[6]).toBeCloseTo(100); expect(v[7]).toBeCloseTo(0); + expect(v[9]).toBeCloseTo(100); expect(v[10]).toBeCloseTo(0); + // pair 2 (v1 duplicated, start of segment 1): half-width 60, ±y at (100, 0) + expect(v[12]).toBeCloseTo(100); expect(v[13]).toBeCloseTo(60); + expect(v[15]).toBeCloseTo(100); expect(v[16]).toBeCloseTo(-60); + // pair 3 (v2): half-width 0, both at (150, 0) + expect(v[18]).toBeCloseTo(150); expect(v[19]).toBeCloseTo(0); + expect(v[21]).toBeCloseTo(150); expect(v[22]).toBeCloseTo(0); + }); + + it("emits zero-width segments as thin lines inside a mixed-width polyline", () => { + // Same AutoCAD arrow as above. The shaft (segment 0, widths 0/0) must + // render as a thin line — without it the arrowhead floats in space and + // the shaft is invisible (zero-area mesh strip). + const entity = makeEntity([ + { x: 0, y: 0, startWidth: 0, endWidth: 0 }, + { x: 100, y: 0, startWidth: 120, endWidth: 0 }, + { x: 150, y: 0 }, + ]); + const collector = new GeometryCollector(); + const colorCtx = makeColorCtx(); + collectPolyline({ entity, colorCtx, collector, layer: "0" }); + + // Mesh present (the arrowhead) + expect([...collector.meshVertices.entries()].length).toBe(1); + + // Line segments present (the shaft) — exactly one segment, 2 endpoints, 6 floats + const lineEntries = [...collector.lineSegments.entries()]; + expect(lineEntries.length).toBe(1); + const lineVerts = lineEntries[0][1].toArray(); + expect(lineVerts.length).toBe(2 * 3); // 1 segment = 2 endpoints + // (0,0) → (100,0) + expect(lineVerts[0]).toBeCloseTo(0); + expect(lineVerts[1]).toBeCloseTo(0); + expect(lineVerts[3]).toBeCloseTo(100); + expect(lineVerts[4]).toBeCloseTo(0); + }); + it("handles bulge arc with variable width", () => { const entity = makeEntity([ { x: 0, y: 0, startWidth: 2, endWidth: 0, bulge: 1 }, @@ -304,4 +364,78 @@ describe("addWidePolylineToCollector", () => { // No width → no mesh, rendered as line expect(mesh).toBeNull(); }); + + it("scales width by worldMatrix scale (INSERT-scoped polyline)", () => { + // Reproduces the _ArchTick arrowhead inside a DIMENSION's pre-rendered block: + // a wide polyline inside an INSERT with uniform scale=10 should render its + // width scaled by the same factor. The local-space width is 0.4 → world-space + // width must be 4. + const entity = makeEntity( + [{ x: -0.5, y: -0.5 }, { x: 0.5, y: 0.5 }], + { width: 0.4 }, + ); + const collector = new GeometryCollector(); + const colorCtx = makeColorCtx(); + const worldMatrix = new THREE.Matrix4().makeScale(10, 10, 1); + const params: CollectEntityParams = { + entity, + colorCtx, + collector, + layer: "0", + worldMatrix, + }; + collectPolyline(params); + + const meshEntries = [...collector.meshVertices.entries()]; + expect(meshEntries.length).toBe(1); + const v = meshEntries[0][1].toArray(); + // Endpoint pair distance = width = 0.4 * scale 10 = 4 + const dx = v[0] - v[3]; + const dy = v[1] - v[4]; + expect(Math.sqrt(dx * dx + dy * dy)).toBeCloseTo(4); + + // Endpoint centers also scaled: (-0.5,-0.5)*10 = (-5,-5) + const cx = (v[0] + v[3]) / 2; + const cy = (v[1] + v[4]) / 2; + expect(cx).toBeCloseTo(-5); + expect(cy).toBeCloseTo(-5); + }); + + it("rotates correctly under worldMatrix rotation", () => { + // Horizontal width=2 polyline rotated 90° CCW: the centers go vertical, + // perpendicular offset goes horizontal, width preserved. + const entity = makeEntity( + [{ x: 0, y: 0 }, { x: 10, y: 0 }], + { width: 2 }, + ); + const collector = new GeometryCollector(); + const colorCtx = makeColorCtx(); + const worldMatrix = new THREE.Matrix4().makeRotationZ(Math.PI / 2); + const params: CollectEntityParams = { + entity, + colorCtx, + collector, + layer: "0", + worldMatrix, + }; + collectPolyline(params); + + const meshEntries = [...collector.meshVertices.entries()]; + expect(meshEntries.length).toBe(1); + const v = meshEntries[0][1].toArray(); + + // pair0 center = (0,0); pair0 left = (-1,0) (perp was +y, rotated → -x), + // pair0 right = (+1,0). + expect(v[0]).toBeCloseTo(-1); // left0.x + expect(v[1]).toBeCloseTo(0); // left0.y + expect(v[3]).toBeCloseTo(1); // right0.x + expect(v[4]).toBeCloseTo(0); // right0.y + + // pair1 center = (0,10) (was (10,0) rotated 90°) + // pair1 left = (-1,10), pair1 right = (+1,10). + expect(v[6]).toBeCloseTo(-1); // left1.x + expect(v[7]).toBeCloseTo(10); // left1.y + expect(v[9]).toBeCloseTo(1); // right1.x + expect(v[10]).toBeCloseTo(10); // right1.y + }); }); diff --git a/packages/dxf-render/src/render/arrowheads.ts b/packages/dxf-render/src/render/arrowheads.ts new file mode 100644 index 0000000..c0a3f28 --- /dev/null +++ b/packages/dxf-render/src/render/arrowheads.ts @@ -0,0 +1,438 @@ +import * as THREE from "three"; +import { EPSILON, ARROW_BASE_WIDTH_DIVISOR } from "@/constants"; + +/** + * Standard AutoCAD arrowhead block kinds. + * + * "arrow-shape" forms (closed/open/datum) are direction-dependent and benefit + * from the outside-arrows flip on short dim lines. Symmetric forms (dot/tick/ + * box/origin/integral/none) are placed at the endpoint without a flip. + * + * `closed-filled` is the AutoCAD default and the fallback for unknown blocks. + */ +export type ArrowKind = + | "closed-filled" + | "closed-blank" + | "open" + | "open30" + | "open-arrow" + | "datum-filled" + | "datum-blank" + | "tick" + | "dot" + | "dot-small" + | "dot-blank" + | "dot-small-blank" + | "origin" + | "origin2" + | "box" + | "box-filled" + | "integral" + | "none"; + +const ARROW_SHAPES = new Set([ + "closed-filled", + "closed-blank", + "open", + "open30", + "open-arrow", + "datum-filled", + "datum-blank", +]); + +/** Direction-dependent arrowhead — placed at the tip oriented along (from → tip). */ +export const isArrowShape = (kind: ArrowKind): boolean => ARROW_SHAPES.has(kind); + +/** + * Classify a DXF block name as one of the standard AutoCAD arrowhead kinds. + * Names are matched case-insensitively, with an optional leading underscore + * (DXF stores them as `_Dot`, `_DotSmall`, etc.; some authoring tools strip + * the underscore). + * + * Returns `undefined` for names not recognised as standard — callers either + * fall back to `"closed-filled"` or attempt to render the user-defined block + * geometry (e.g. LEADER's custom DIMLDRBLK pointing at a non-standard block). + * + * Empty / null / undefined name → `undefined` as well (no standard kind known). + */ +export const classifyArrowBlock = (name: string | undefined | null): ArrowKind | undefined => { + if (!name) return undefined; + let n = name.toLowerCase(); + if (n.startsWith("_")) n = n.slice(1); + + switch (n) { + case "closedfilled": + return "closed-filled"; + case "closed": + return "closed-blank"; + case "closedblank": + return "closed-blank"; + case "open": + return "open"; + case "open30": + return "open30"; + case "openarrow": + case "open90": + return "open-arrow"; + case "datumfilled": + return "datum-filled"; + case "datumblank": + return "datum-blank"; + case "dot": + return "dot"; + case "dotsmall": + return "dot-small"; + case "dotblank": + return "dot-blank"; + case "dotsmallblank": + return "dot-small-blank"; + case "origin": + return "origin"; + case "origin2": + return "origin2"; + case "box": + return "box"; + case "boxfilled": + return "box-filled"; + case "integral": + return "integral"; + case "none": + return "none"; + case "archtick": + case "oblique": + case "small": + case "tick": + return "tick"; + default: + return undefined; + } +}; + +interface ArrowheadParams { + /** Point at the tail end (used to compute direction for arrow-shaped kinds). */ + from: THREE.Vector3; + /** Tip / endpoint where the arrowhead sits. Symmetric kinds are centred here. */ + tip: THREE.Vector3; + /** Final arrowhead size in drawing units (already DIMSCALE-scaled). */ + size: number; + kind: ArrowKind; + /** Material for line / contour parts. */ + lineMaterial: THREE.LineBasicMaterial; + /** Material for filled (mesh) parts. */ + fillMaterial: THREE.Material; +} + +const DOT_SEGMENTS = 24; + +const buildDir = (from: THREE.Vector3, tip: THREE.Vector3) => { + const dx = tip.x - from.x; + const dy = tip.y - from.y; + const len = Math.sqrt(dx * dx + dy * dy); + if (len > EPSILON) { + return { dirX: dx / len, dirY: dy / len }; + } + return { dirX: 1, dirY: 0 }; +}; + +const filledTriangle = ( + tip: THREE.Vector3, + from: THREE.Vector3, + size: number, + mat: THREE.Material, +): THREE.Mesh => { + const { dirX, dirY } = buildDir(from, tip); + const width = size / ARROW_BASE_WIDTH_DIVISOR; + const perpX = dirY; + const perpY = -dirX; + + const positions = new Float32Array([ + tip.x, tip.y, tip.z, + tip.x - dirX * size + perpX * width, tip.y - dirY * size + perpY * width, tip.z, + tip.x - dirX * size - perpX * width, tip.y - dirY * size - perpY * width, tip.z, + ]); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex([0, 1, 2]); + return new THREE.Mesh(geometry, mat); +}; + +const triangleOutline = ( + tip: THREE.Vector3, + from: THREE.Vector3, + size: number, + mat: THREE.LineBasicMaterial, +): THREE.LineSegments => { + const { dirX, dirY } = buildDir(from, tip); + const width = size / ARROW_BASE_WIDTH_DIVISOR; + const perpX = dirY; + const perpY = -dirX; + + const b1x = tip.x - dirX * size + perpX * width; + const b1y = tip.y - dirY * size + perpY * width; + const b2x = tip.x - dirX * size - perpX * width; + const b2y = tip.y - dirY * size - perpY * width; + + const positions = new Float32Array([ + tip.x, tip.y, tip.z, b1x, b1y, tip.z, + b1x, b1y, tip.z, b2x, b2y, tip.z, + b2x, b2y, tip.z, tip.x, tip.y, tip.z, + ]); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + return new THREE.LineSegments(geometry, mat); +}; + +/** + * Open arrowhead: two strokes from `tip` back to the base corners, + * without the closing base segment. `widthDivisor` controls flare — + * 4 ≈ 30° (default _Open / _Open30), 2 ≈ 90° (_OpenArrow). + */ +const openArrowStrokes = ( + tip: THREE.Vector3, + from: THREE.Vector3, + size: number, + widthDivisor: number, + mat: THREE.LineBasicMaterial, +): THREE.LineSegments => { + const { dirX, dirY } = buildDir(from, tip); + const width = size / widthDivisor; + const perpX = dirY; + const perpY = -dirX; + + const b1x = tip.x - dirX * size + perpX * width; + const b1y = tip.y - dirY * size + perpY * width; + const b2x = tip.x - dirX * size - perpX * width; + const b2y = tip.y - dirY * size - perpY * width; + + const positions = new Float32Array([ + tip.x, tip.y, tip.z, b1x, b1y, tip.z, + tip.x, tip.y, tip.z, b2x, b2y, tip.z, + ]); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + return new THREE.LineSegments(geometry, mat); +}; + +const filledDot = ( + centre: THREE.Vector3, + radius: number, + mat: THREE.Material, +): THREE.Mesh => { + const positions: number[] = [centre.x, centre.y, centre.z]; + for (let i = 0; i <= DOT_SEGMENTS; i++) { + const a = (i / DOT_SEGMENTS) * Math.PI * 2; + positions.push(centre.x + radius * Math.cos(a), centre.y + radius * Math.sin(a), centre.z); + } + const indices: number[] = []; + for (let i = 1; i <= DOT_SEGMENTS; i++) indices.push(0, i, i + 1); + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(indices); + return new THREE.Mesh(geometry, mat); +}; + +const dotOutline = ( + centre: THREE.Vector3, + radius: number, + mat: THREE.LineBasicMaterial, +): THREE.Line => { + const positions: number[] = []; + for (let i = 0; i <= DOT_SEGMENTS; i++) { + const a = (i / DOT_SEGMENTS) * Math.PI * 2; + positions.push(centre.x + radius * Math.cos(a), centre.y + radius * Math.sin(a), centre.z); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + return new THREE.Line(geometry, mat); +}; + +const tickStroke = ( + tip: THREE.Vector3, + from: THREE.Vector3, + size: number, + mat: THREE.LineBasicMaterial, +): THREE.LineSegments => { + // _ArchTick block: 45° oblique line through `tip`, length = size on each + // side of `tip` along the (dir + perp) diagonal. Direction is taken from + // (from → tip), perpendicular is rotated 90° CCW. + const { dirX, dirY } = buildDir(from, tip); + const half = size * 0.5; + // Diagonal (dir + perp) normalised to length `half`. + const dx = (dirX + dirY) * half; + const dy = (dirY - dirX) * half; + const positions = new Float32Array([ + tip.x - dx, tip.y - dy, tip.z, + tip.x + dx, tip.y + dy, tip.z, + ]); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + return new THREE.LineSegments(geometry, mat); +}; + +const filledBox = ( + centre: THREE.Vector3, + half: number, + from: THREE.Vector3, + tip: THREE.Vector3, + mat: THREE.Material, +): THREE.Mesh => { + // Box is axis-aligned to the arrow direction so it reads as a marker on the + // dim line rather than a tilted square. + const { dirX, dirY } = buildDir(from, tip); + const perpX = dirY; + const perpY = -dirX; + + const corners: number[] = []; + const sx = [+1, +1, -1, -1]; + const sy = [+1, -1, -1, +1]; + for (let i = 0; i < 4; i++) { + corners.push( + centre.x + dirX * half * sx[i] + perpX * half * sy[i], + centre.y + dirY * half * sx[i] + perpY * half * sy[i], + centre.z, + ); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(corners, 3)); + geometry.setIndex([0, 1, 2, 0, 2, 3]); + return new THREE.Mesh(geometry, mat); +}; + +const boxOutline = ( + centre: THREE.Vector3, + half: number, + from: THREE.Vector3, + tip: THREE.Vector3, + mat: THREE.LineBasicMaterial, +): THREE.LineSegments => { + const { dirX, dirY } = buildDir(from, tip); + const perpX = dirY; + const perpY = -dirX; + + const sx = [+1, +1, -1, -1]; + const sy = [+1, -1, -1, +1]; + const cx: number[] = []; + const cy: number[] = []; + for (let i = 0; i < 4; i++) { + cx.push(centre.x + dirX * half * sx[i] + perpX * half * sy[i]); + cy.push(centre.y + dirY * half * sx[i] + perpY * half * sy[i]); + } + const positions = new Float32Array([ + cx[0], cy[0], centre.z, cx[1], cy[1], centre.z, + cx[1], cy[1], centre.z, cx[2], cy[2], centre.z, + cx[2], cy[2], centre.z, cx[3], cy[3], centre.z, + cx[3], cy[3], centre.z, cx[0], cy[0], centre.z, + ]); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + return new THREE.LineSegments(geometry, mat); +}; + +const integralStroke = ( + tip: THREE.Vector3, + from: THREE.Vector3, + size: number, + mat: THREE.LineBasicMaterial, +): THREE.Line => { + // _Integral: stylised ∫ — two half-circles forming an S-curve along the + // arrow direction. Total extent = size along dir, height = size/2 across. + const { dirX, dirY } = buildDir(from, tip); + const perpX = dirY; + const perpY = -dirX; + const half = size * 0.5; + const r = size * 0.25; + + // Centres of the two half-arcs (one above, one below the baseline) + const c1x = tip.x - dirX * (half * 0.5) + perpX * r; + const c1y = tip.y - dirY * (half * 0.5) + perpY * r; + const c2x = tip.x - dirX * size + dirX * (half * 0.5) - perpX * r; + const c2y = tip.y - dirY * size + dirY * (half * 0.5) - perpY * r; + + const baseAngle = Math.atan2(dirY, dirX); + const positions: number[] = []; + const segs = 12; + // Upper half-arc (centred at c1, sweeps from baseline through +perp) + for (let i = 0; i <= segs; i++) { + const t = i / segs; + const a = baseAngle + Math.PI * (1 - t); + positions.push(c1x + r * Math.cos(a), c1y + r * Math.sin(a), tip.z); + } + // Lower half-arc (centred at c2, sweeps from baseline through -perp) + for (let i = 0; i <= segs; i++) { + const t = i / segs; + const a = baseAngle + Math.PI * (1 + t); + positions.push(c2x + r * Math.cos(a), c2y + r * Math.sin(a), tip.z); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + return new THREE.Line(geometry, mat); +}; + +/** + * Build the geometry for one of the standard AutoCAD arrowhead kinds. + * + * Returns an array of THREE objects (mixed Line / LineSegments / Mesh) — the + * caller is responsible for adding them to a parent group and tagging + * `userData.dimPart` if needed for DIMCLRD vs DIMCLRE split. + * + * `from` is only consulted for direction-dependent (arrow-shape) and + * direction-aligned (tick, box) kinds. For pure dots / origin / integral + * the symbol is rotation-invariant. + */ +export const createArrowhead = (p: ArrowheadParams): THREE.Object3D[] => { + const { from, tip, size, kind, lineMaterial, fillMaterial } = p; + + switch (kind) { + case "none": + return []; + + case "closed-filled": + case "datum-filled": + return [filledTriangle(tip, from, size, fillMaterial)]; + + case "closed-blank": + case "datum-blank": + return [triangleOutline(tip, from, size, lineMaterial)]; + + case "open": + case "open30": + return [openArrowStrokes(tip, from, size, ARROW_BASE_WIDTH_DIVISOR, lineMaterial)]; + + case "open-arrow": + return [openArrowStrokes(tip, from, size, 2, lineMaterial)]; + + case "tick": + return [tickStroke(tip, from, size, lineMaterial)]; + + case "dot": + return [filledDot(tip, size / 2, fillMaterial)]; + + case "dot-small": + return [filledDot(tip, size / 4, fillMaterial)]; + + case "dot-blank": + return [dotOutline(tip, size / 2, lineMaterial)]; + + case "dot-small-blank": + return [dotOutline(tip, size / 4, lineMaterial)]; + + case "origin": + return [dotOutline(tip, size / 2, lineMaterial)]; + + case "origin2": + return [ + dotOutline(tip, size / 2, lineMaterial), + dotOutline(tip, size / 4, lineMaterial), + ]; + + case "box": + return [boxOutline(tip, size / 4, from, tip, lineMaterial)]; + + case "box-filled": + return [filledBox(tip, size / 4, from, tip, fillMaterial)]; + + case "integral": + return [integralStroke(tip, from, size, lineMaterial)]; + } +}; diff --git a/packages/dxf-render/src/render/blockTemplateCache.ts b/packages/dxf-render/src/render/blockTemplateCache.ts index 75941de..a17f8f0 100644 --- a/packages/dxf-render/src/render/blockTemplateCache.ts +++ b/packages/dxf-render/src/render/blockTemplateCache.ts @@ -1,7 +1,11 @@ import * as THREE from "three"; import type { DxfEntity } from "@/types/dxf"; import { resolveEntityColor, isThemeAdaptiveColor } from "@/utils/colorResolver"; -import { GeometryCollector } from "./mergeCollectors"; +import { + GeometryCollector, + RENDER_ORDER_MESH, + RENDER_ORDER_LINE, +} from "./mergeCollectors"; import { type RenderContext, getLineMaterial, getMeshMaterial, getPointsMaterial } from "./primitives"; import { LINETYPE_DOT_SIZE } from "@/constants"; @@ -113,14 +117,11 @@ export function buildBlockTemplate( ? INHERIT_LAYER : entity.layer; - // Determine color: ByBlock (colorIndex=0) → sentinel - let overrideColor: string | undefined; - if (entity.colorIndex === 0) { - overrideColor = BYBLOCK_COLOR; - } else { - // Resolve fixed color (ByLayer on named layer, or explicit ACI/trueColor) - overrideColor = resolveEntityColor(entity, colorCtx.layers, undefined); - } + // Determine color: ByBlock (colorIndex=0) → sentinel. + // Layer "0" + ByLayer also inherits from INSERT — resolveEntityColor handles + // both cases when we pass BYBLOCK_COLOR as the blockColor argument. The sentinel + // is replaced with insertColor at instantiation time. + const overrideColor = resolveEntityColor(entity, colorCtx.layers, BYBLOCK_COLOR); // Collect geometry in local coordinates (no worldMatrix) const collected = collectEntityFn({ @@ -299,6 +300,7 @@ export function addSharedBlockInstance( obj.matrixAutoUpdate = false; obj.matrix.copy(mat4); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_LINE; obj.userData.layerName = layer; group.add(obj); } @@ -309,6 +311,7 @@ export function addSharedBlockInstance( obj.matrixAutoUpdate = false; obj.matrix.copy(mat4); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_MESH; obj.userData.layerName = layer; group.add(obj); } @@ -319,6 +322,7 @@ export function addSharedBlockInstance( obj.matrixAutoUpdate = false; obj.matrix.copy(mat4); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_LINE; obj.userData.layerName = layer; group.add(obj); } @@ -337,6 +341,7 @@ export function addSharedBlockInstance( obj.matrixAutoUpdate = false; obj.matrix.copy(mat4); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_LINE; obj.userData.layerName = layer; group.add(obj); } diff --git a/packages/dxf-render/src/render/collectors/__tests__/attribFit.test.ts b/packages/dxf-render/src/render/collectors/__tests__/attribFit.test.ts new file mode 100644 index 0000000..06bece7 --- /dev/null +++ b/packages/dxf-render/src/render/collectors/__tests__/attribFit.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { collectAttdefEntity } from "../textCollector"; +import { GeometryCollector } from "../../mergeCollectors"; +import { MaterialCacheStore } from "../../materialCache"; +import { loadDefaultFont } from "../../text/fontManager"; +import { clearGlyphCache } from "../../text/glyphCache"; +import type { Font } from "opentype.js"; +import type { RenderContext } from "../../primitives"; +import type { DxfAttdefEntity, DxfLayer } from "@/types/dxf"; + +let font: Font; + +beforeAll(() => { + clearGlyphCache(); + font = loadDefaultFont(); +}); + +function makeCtx(): RenderContext { + return { + layers: { + "0": { name: "0", visible: true, frozen: false, colorIndex: 7, color: 0xffffff } as DxfLayer, + }, + lineTypes: {}, + globalLtScale: 1, + headerLtScale: 1, + materials: new MaterialCacheStore(), + font, + defaultTextHeight: 2.5, + } as RenderContext; +} + +/** Compute the X bounding box of all triangulated text-glyph vertices stored in the overlay mesh buffer. */ +function meshXBounds(collector: GeometryCollector): { xMin: number; xMax: number } { + let xMin = Infinity; + let xMax = -Infinity; + for (const arr of collector.overlayVertices.values()) { + for (let i = 0; i < arr.length; i += 3) { + const x = arr.at(i); + if (x < xMin) xMin = x; + if (x > xMax) xMax = x; + } + } + return { xMin, xMax }; +} + +describe("ATTDEF FIT/ALIGNED — building1.dxf _AXISO axis markers", () => { + // Reproduces the bug from building1.dxf handle 473: ATTRIB inside the _AXISO + // block (axis-marker circles around handle 46D) had horizontalJustification=5 + // (FIT) with startPoint and endPoint straddling the circle centre. Before the + // fix, renderAttribs / collectAttdefEntity used endPoint as the position and + // dropped the second alignment point, so the text rendered LEFT-aligned at the + // right edge of the circle and the digit "3"/"A" hung outside the marker. + + it("FIT: text is centered between startPoint and endPoint, not anchored at endPoint", () => { + const entity: DxfAttdefEntity = { + type: "ATTDEF", + handle: "T1", + layer: "0", + text: "3", + tag: "A", + startPoint: { x: -50, y: 0, z: 0 }, + endPoint: { x: 50, y: 0, z: 0 }, + textHeight: 20, + horizontalJustification: 5, // FIT + scale: 1, + textStyle: "STANDARD", + } as DxfAttdefEntity; + + const collector = new GeometryCollector(); + collectAttdefEntity(entity, makeCtx(), collector, "0"); + + const b = meshXBounds(collector); + expect(b.xMin).not.toBe(Infinity); + // Text should span the FIT range [-50, 50], i.e. roughly symmetric around 0. + expect(b.xMin).toBeGreaterThanOrEqual(-55); + expect(b.xMax).toBeLessThanOrEqual(55); + const center = (b.xMin + b.xMax) / 2; + expect(Math.abs(center)).toBeLessThan(5); + }); + + it("ALIGNED: text is centered between startPoint and endPoint", () => { + const entity: DxfAttdefEntity = { + type: "ATTDEF", + handle: "T2", + layer: "0", + text: "3", + tag: "A", + startPoint: { x: -30, y: 0, z: 0 }, + endPoint: { x: 30, y: 0, z: 0 }, + textHeight: 20, + horizontalJustification: 3, // ALIGNED + scale: 1, + textStyle: "STANDARD", + } as DxfAttdefEntity; + + const collector = new GeometryCollector(); + collectAttdefEntity(entity, makeCtx(), collector, "0"); + + const b = meshXBounds(collector); + expect(b.xMin).not.toBe(Infinity); + const center = (b.xMin + b.xMax) / 2; + expect(Math.abs(center)).toBeLessThan(5); + expect(b.xMin).toBeGreaterThanOrEqual(-35); + expect(b.xMax).toBeLessThanOrEqual(35); + }); + + it("RIGHT (non-FIT/ALIGNED): keeps existing behavior — text anchored at endPoint", () => { + const entity: DxfAttdefEntity = { + type: "ATTDEF", + handle: "T3", + layer: "0", + text: "3", + tag: "A", + startPoint: { x: 0, y: 0, z: 0 }, + endPoint: { x: 100, y: 0, z: 0 }, + textHeight: 20, + horizontalJustification: 2, // RIGHT + scale: 1, + textStyle: "STANDARD", + } as DxfAttdefEntity; + + const collector = new GeometryCollector(); + collectAttdefEntity(entity, makeCtx(), collector, "0"); + + const b = meshXBounds(collector); + expect(b.xMin).not.toBe(Infinity); + // RIGHT anchors text so the rightmost glyph edge sits at endPoint.x = 100. + expect(b.xMax).toBeGreaterThan(95); + expect(b.xMax).toBeLessThan(105); + }); +}); diff --git a/packages/dxf-render/src/render/collectors/__tests__/dimensionCollector.test.ts b/packages/dxf-render/src/render/collectors/__tests__/dimensionCollector.test.ts new file mode 100644 index 0000000..1a9dfe9 --- /dev/null +++ b/packages/dxf-render/src/render/collectors/__tests__/dimensionCollector.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { collectDimensionEntity } from "../dimensionCollector"; +import { MaterialCacheStore } from "../../materialCache"; +import { ACI7_COLOR } from "@/utils/colorResolver"; +import { loadDefaultFont } from "../../text/fontManager"; +import { clearGlyphCache } from "../../text/glyphCache"; +import { DEFAULT_DIM_VARS } from "../../dimensions"; +import type { Font } from "opentype.js"; +import type { DxfDimensionEntity, DxfData, DxfLayer, DxfDimStyle } from "@/types/dxf"; +import type { RenderContext } from "../../primitives"; + +let font: Font; + +class MockCollector { + meshes: { layer: string; color: string; vertices?: number[] }[] = []; + lines: { layer: string; color: string; data?: number[] }[] = []; + + addMesh(layer: string, color: string, vertices: number[], indices: number[]): void { + if (vertices.length < 9 || indices.length < 3) return; + this.meshes.push({ layer, color, vertices }); + } + addOverlayMesh(layer: string, color: string, vertices: number[], indices: number[]): void { + if (vertices.length < 9 || indices.length < 3) return; + this.meshes.push({ layer, color, vertices }); + } + addLineSegments(layer: string, color: string, data: number[]): void { + this.lines.push({ layer, color, data }); + } +} + +function makeContext(opts: { + layers?: Record; + dimStyles?: Record; + headerDimtih?: number; + headerDimtoh?: number; +} = {}): RenderContext { + return { + layers: opts.layers ?? {}, + lineTypes: {}, + globalLtScale: 1, + headerLtScale: 1, + materials: new MaterialCacheStore(), + font, + defaultTextHeight: 2.5, + dimVars: DEFAULT_DIM_VARS, + dimStyles: opts.dimStyles, + headerDimtih: opts.headerDimtih, + headerDimtoh: opts.headerDimtoh, + } as RenderContext; +} + +function makeRotatedDim(overrides: Partial = {}): DxfDimensionEntity { + return { + type: "DIMENSION", + handle: "DEADBEEF", + layer: "Kote", + dimensionType: 0, + actualMeasurement: 60.5, + anchorPoint: { x: 0, y: 0, z: 0 }, + middleOfText: { x: 0, y: 5, z: 0 }, + linearOrAngularPoint1: { x: -5, y: 10, z: 0 }, + linearOrAngularPoint2: { x: -5, y: 0, z: 0 }, + angle: 90, + styleName: "stil1", + ...overrides, + } as DxfDimensionEntity; +} + +beforeAll(() => { + clearGlyphCache(); + font = loadDefaultFont(); +}); + +describe("collectDimensionEntity — DIMCLRT theme-adaptive", () => { + it("emits ACI7_COLOR sentinel for the text when DIMSTYLE has DIMCLRT=7", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + stil1: { name: "stil1", dimclrt: 7 } as DxfDimStyle, + }, + }); + + collectDimensionEntity(makeRotatedDim(), {} as DxfData, ctx, collector as any, "Kote"); + + // Text overlay mesh must carry the theme-adaptive sentinel, not a literal "#ffffff" + expect(collector.meshes.length).toBeGreaterThan(0); + expect(collector.meshes.some((m) => m.color === ACI7_COLOR)).toBe(true); + expect(collector.meshes.every((m) => m.color !== "#ffffff")).toBe(true); + }); + + it("emits gray sentinel for DIMCLRT=250", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + stil1: { name: "stil1", dimclrt: 250 } as DxfDimStyle, + }, + }); + + collectDimensionEntity(makeRotatedDim(), {} as DxfData, ctx, collector as any, "Kote"); + + expect(collector.meshes.some((m) => m.color === "\0ACI250")).toBe(true); + }); + + it("emits literal hex for chromatic DIMCLRT (e.g. red = 1)", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + stil1: { name: "stil1", dimclrt: 1 } as DxfDimStyle, + }, + }); + + collectDimensionEntity(makeRotatedDim(), {} as DxfData, ctx, collector as any, "Kote"); + + // ACI 1 is red — literal hex, not a sentinel + expect(collector.meshes.some((m) => /^#[0-9a-f]{6}$/.test(m.color))).toBe(true); + expect(collector.meshes.every((m) => m.color.charCodeAt(0) !== 0 || m.color !== "\0ACI7")).toBe(true); + }); +}); + +describe("collectDimensionEntity — DIMCLRD/DIMCLRE line color overrides", () => { + it("uses DIMSTYLE dimclrd (blue=5) for dim/arrow lines instead of entity color (red=1)", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + // dimclrd=5 (blue) overrides entity red; dimclre undefined → falls back to entity color + stil1: { name: "stil1", dimclrd: 5 } as DxfDimStyle, + }, + }); + + // Entity color = 1 (red); dimclrd = 5 (blue). Dim line must be blue. + // middleOfText is offset perpendicular to the dim line so the dim line isn't + // split around it — otherwise on a short rotated dim the text gap can swallow + // the entire dim line and we end up with only extension lines + arrows. + collectDimensionEntity( + makeRotatedDim({ colorIndex: 1, middleOfText: { x: 5, y: 5, z: 0 } }), + {} as DxfData, + ctx, + collector as any, + "Kote", + ); + + // ACI 5 → #0000ff; ACI 1 → #ff0000. + const dimLineColors = new Set(collector.lines.map((l) => l.color)); + expect(dimLineColors.has("#0000ff")).toBe(true); + }); + + it("falls back to entity color when DIMCLRD is 0 (BYBLOCK) or 256 (BYLAYER)", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + // dimclrd=0 → BYBLOCK → use entity color + stil1: { name: "stil1", dimclrd: 0 } as DxfDimStyle, + }, + }); + + collectDimensionEntity( + makeRotatedDim({ colorIndex: 1 }), + {} as DxfData, + ctx, + collector as any, + "Kote", + ); + + // ACI 1 → #ff0000 must appear on the lines + const dimLineColors = new Set(collector.lines.map((l) => l.color)); + expect(dimLineColors.has("#ff0000")).toBe(true); + expect(dimLineColors.has("#0000ff")).toBe(false); + }); +}); + +describe("collectDimensionEntity — DIMCLRT for angular dimensions", () => { + function makeAngularDim(overrides: Partial = {}): DxfDimensionEntity { + return { + type: "DIMENSION", + handle: "C0DECAFE", + layer: "Kote", + // dimensionType 34 = 2 (angular) + 32 (default-text-position flag) — matches + // real-world AutoCAD angular dimensions in test fixtures. + dimensionType: 34, + actualMeasurement: 90, + anchorPoint: { x: 0, y: 0, z: 0 }, + middleOfText: { x: 5, y: 5, z: 0 }, + linearOrAngularPoint1: { x: 10, y: 0, z: 0 }, + linearOrAngularPoint2: { x: 0, y: 0, z: 0 }, + diameterOrRadiusPoint: { x: 0, y: 0, z: 0 }, + arcPoint: { x: 7, y: 7, z: 0 }, + text: "80°", + styleName: "stil1", + ...overrides, + } as DxfDimensionEntity; + } + + it("uses DIMCLRT theme-adaptive sentinel for angular dim text (not entity color)", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + // entity = red (ACI 1); DIMCLRT = 7 → theme-adaptive sentinel + stil1: { name: "stil1", dimclrt: 7 } as DxfDimStyle, + }, + }); + + collectDimensionEntity( + makeAngularDim({ colorIndex: 1 }), + {} as DxfData, + ctx, + collector as any, + "Kote", + ); + + // Text glyph meshes must carry the ACI7 sentinel. (Arrow meshes are still + // emitted as #ff0000 from the entity color, which is correct — without a + // DIMCLRD override the dim lines/arrows follow the entity color.) + expect(collector.meshes.length).toBeGreaterThan(0); + expect(collector.meshes.some((m) => m.color === ACI7_COLOR)).toBe(true); + }); +}); + +describe("collectDimensionEntity — diametric on-segment text", () => { + it("rotates aligned text to match diameter direction (entity 130: p10=(36.24,23.34), p15=(63.76,76.66))", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { "0": { name: "0", visible: true, frozen: false, colorIndex: 7, color: 0 } as DxfLayer }, + dimStyles: { + QCADDimStyle: { + name: "QCADDimStyle", + dimtoh: 0, // aligned (explicit) + dimtad: 1, // above line + dimgap: 0.625, + // DIMTIH (code 74) intentionally omitted — matches the real QCAD file + } as DxfDimStyle, + }, + // Simulate the file's header: $DIMTIH=1 must NOT leak into rendering of + // existing dims. Falling back to header would force horizontal text for + // every dimstyle that omits DIMTIH (DXF reference: $DIM* in HEADER are + // current values for new dims, not the source of truth for existing ones). + headerDimtih: 1, + headerDimtoh: 1, + }); + + const entity: DxfDimensionEntity = { + type: "DIMENSION", + handle: "130", + layer: "0", + // 163 = bit 7 (assoc-block) + bit 5 (default position) + 3 (diameter) + dimensionType: 163, + actualMeasurement: 60, + anchorPoint: { x: 36.24, y: 23.34, z: 0 }, + diameterOrRadiusPoint: { x: 63.76, y: 76.66, z: 0 }, + middleOfText: { x: 37.17, y: 32.05, z: 0 }, + text: "60", + textHeight: 2.5, + styleName: "QCADDimStyle", + } as DxfDimensionEntity; + + collectDimensionEntity(entity, {} as DxfData, ctx, collector as any, "0"); + + // Verify the text glyphs were emitted and that their bounding box is rotated + // (not axis-aligned). For axis-aligned text, all glyph vertices for one row of + // characters lie at the same Y. For rotated text, X and Y both vary. + const textMeshes = collector.meshes.filter((m) => m.vertices && m.vertices.length >= 9); + expect(textMeshes.length).toBeGreaterThan(0); + + // Compute the spread along X and Y of all text vertices. If text is horizontal, + // X spread >> Y spread. For a 62.7° rotated text the spread along both should + // be roughly comparable (off-axis), with Y spread well above zero. + let xMin = Infinity, xMax = -Infinity, yMin = Infinity, yMax = -Infinity; + for (const m of textMeshes) { + const v = m.vertices!; + for (let i = 0; i < v.length; i += 3) { + if (v[i] < xMin) xMin = v[i]; + if (v[i] > xMax) xMax = v[i]; + if (v[i + 1] < yMin) yMin = v[i + 1]; + if (v[i + 1] > yMax) yMax = v[i + 1]; + } + } + const xSpread = xMax - xMin; + const ySpread = yMax - yMin; + // For 62.7° rotated "60" (2.5 height ~ 4 wide): X spread ≈ 4×cos+2.5×sin ≈ 4, Y spread ≈ 4×sin+2.5×cos ≈ 4. + // If horizontal: X spread ≈ 4, Y spread ≈ 2.5. + // The discriminator: Y spread > X spread × 0.6 means definitely rotated. + expect(ySpread).toBeGreaterThan(xSpread * 0.6); + }); +}); + +describe("collectDimensionEntity — DIMDEC entity override", () => { + it("uses entity.dimdec for measurement precision over DIMSTYLE.dimdec", () => { + const collector = new MockCollector(); + const ctx = makeContext({ + layers: { + Kote: { name: "Kote", visible: true, frozen: false, colorIndex: 5, color: 255 } as DxfLayer, + }, + dimStyles: { + // DIMSTYLE says 2 decimals (would yield "24.01"), entity overrides to 1 → "24.0" + stil1: { name: "stil1", dimdec: 2 } as DxfDimStyle, + }, + }); + + // Use a measurement that produces different output for dimdec=1 vs dimdec=2 + const entity = makeRotatedDim({ + actualMeasurement: 24.0050334118132, + dimdec: 1, // entity-level XDATA override + }); + + // Just sanity-check that collection runs without error and emits meshes — + // the actual text content is in the mesh vertex data, which would require + // a font shaper to verify. Smoke test: parser→collector→fmt pipeline links. + expect(() => collectDimensionEntity(entity, {} as DxfData, ctx, collector as any, "Kote")) + .not.toThrow(); + expect(collector.lines.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/dxf-render/src/render/collectors/__tests__/processDimensionEntity.test.ts b/packages/dxf-render/src/render/collectors/__tests__/processDimensionEntity.test.ts new file mode 100644 index 0000000..8ee3597 --- /dev/null +++ b/packages/dxf-render/src/render/collectors/__tests__/processDimensionEntity.test.ts @@ -0,0 +1,189 @@ +import * as THREE from "three"; +import { describe, it, expect, beforeAll } from "vitest"; +import { processDimensionEntity } from "../insertCollector"; +import { collectEntity } from "../index"; +import { GeometryCollector } from "../../mergeCollectors"; +import { MaterialCacheStore } from "../../materialCache"; +import { loadDefaultFont } from "../../text/fontManager"; +import { clearGlyphCache } from "../../text/glyphCache"; +import { DEFAULT_DIM_VARS } from "../../dimensions"; +import type { Font } from "opentype.js"; +import type { RenderContext } from "../../primitives"; +import type { DxfData, DxfDimensionEntity, DxfBlock, DxfLayer } from "@/types/dxf"; + +let font: Font; + +beforeAll(() => { + clearGlyphCache(); + font = loadDefaultFont(); +}); + +function makeCtx(): RenderContext { + return { + layers: { + "0": { name: "0", visible: true, frozen: false, colorIndex: 7, color: 0xffffff } as DxfLayer, + }, + lineTypes: {}, + globalLtScale: 1, + headerLtScale: 1, + materials: new MaterialCacheStore(), + font, + defaultTextHeight: 2.5, + dimVars: DEFAULT_DIM_VARS, + } as RenderContext; +} + +function makeLine(handle: string, x1: number, y1: number, x2: number, y2: number): unknown { + return { + type: "LINE", + handle, + layer: "0", + colorIndex: 0, + vertices: [ + { x: x1, y: y1, z: 0 }, + { x: x2, y: y2, z: 0 }, + ], + }; +} + +describe("processDimensionEntity — pre-rendered block path", () => { + // The pre-rendered block path is the one fixed for nested DIMENSIONs in + // building1.dxf handle 17CF: a DIM inside an outer block had `entity.block = + // "*D289"`, but the in-block dispatch used to go straight into + // collectDimensionEntity, ignoring the WYSIWYG geometry stashed in *D289. + + it("renders the pre-rendered block's entities (not DIMSTYLE geometry) when entity.block is set", async () => { + const dim: DxfDimensionEntity = { + type: "DIMENSION", + handle: "D1", + layer: "0", + block: "*D_Pre", + dimensionType: 0, + actualMeasurement: 200, + anchorPoint: { x: 100, y: 200, z: 0 }, + middleOfText: { x: 200, y: 220, z: 0 }, + linearOrAngularPoint1: { x: 100, y: 0, z: 0 }, + linearOrAngularPoint2: { x: 300, y: 0, z: 0 }, + } as DxfDimensionEntity; + + const dxf: DxfData = { + entities: [dim], + blocks: { + "*D_Pre": { name: "*D_Pre", entities: [makeLine("L1", 100, 200, 300, 200)] } as DxfBlock, + }, + } as DxfData; + + const collector = new GeometryCollector(); + const group = new THREE.Group(); + await processDimensionEntity( + dim, dxf, makeCtx(), collector, "0", null, group, 0, + { lastYield: 0 }, undefined, undefined, collectEntity, undefined, + ); + + // The unique LINE from the pre-rendered block must have been collected. + // Without the fix this LINE would never appear because collectDimensionEntity + // synthesizes its own dim-line/arrow/text geometry from the DIMSTYLE. + const entries = [...collector.lineSegments.entries()]; + const allCoords = entries.flatMap(([, arr]) => arr.toArray()); + expect(allCoords).toContain(100); + expect(allCoords).toContain(300); + expect(allCoords).toContain(200); + }); + + it("scales pre-rendered block coords by worldMatrix (dim inside an outer scaled INSERT)", async () => { + // Reproduces the building1.dxf 17CF case: an outer INSERT with scale 10 + // contains a DIMENSION whose pre-rendered block has a LINE from (10,0) to + // (20,0). After processing, the line must be at (100,0)–(200,0). + const dim: DxfDimensionEntity = { + type: "DIMENSION", + handle: "D2", + layer: "0", + block: "*D_Pre", + dimensionType: 0, + actualMeasurement: 10, + anchorPoint: { x: 10, y: 0, z: 0 }, + middleOfText: { x: 15, y: 10, z: 0 }, + linearOrAngularPoint1: { x: 10, y: 0, z: 0 }, + linearOrAngularPoint2: { x: 20, y: 0, z: 0 }, + } as DxfDimensionEntity; + + const dxf: DxfData = { + entities: [dim], + blocks: { + "*D_Pre": { name: "*D_Pre", entities: [makeLine("L2", 10, 0, 20, 0)] } as DxfBlock, + }, + } as DxfData; + + const collector = new GeometryCollector(); + const group = new THREE.Group(); + const worldMatrix = new THREE.Matrix4().makeScale(10, 10, 1); + + await processDimensionEntity( + dim, dxf, makeCtx(), collector, "0", worldMatrix, group, 1, + { lastYield: 0 }, undefined, undefined, collectEntity, undefined, + ); + + const entries = [...collector.lineSegments.entries()]; + const allCoords = entries.flatMap(([, arr]) => arr.toArray()); + // Scaled endpoints: (100,0) and (200,0) + expect(allCoords).toContain(100); + expect(allCoords).toContain(200); + // Pre-scale values (10, 20) should not appear at all + expect(allCoords).not.toContain(10); + expect(allCoords).not.toContain(20); + }); + + it("falls back to DIMSTYLE path when entity.block is missing", async () => { + // No `block` field → DIMSTYLE-synthesizing path. The fallback path is what + // QCAD-emitted DXFs typically need; this guards we don't break it. + const dim: DxfDimensionEntity = { + type: "DIMENSION", + handle: "D3", + layer: "0", + dimensionType: 0, + actualMeasurement: 100, + anchorPoint: { x: 0, y: 50, z: 0 }, + middleOfText: { x: 50, y: 50, z: 0 }, + linearOrAngularPoint1: { x: 0, y: 0, z: 0 }, + linearOrAngularPoint2: { x: 100, y: 0, z: 0 }, + } as DxfDimensionEntity; + + const dxf: DxfData = { entities: [dim], blocks: {} } as DxfData; + + const collector = new GeometryCollector(); + const group = new THREE.Group(); + // The contract is just "doesn't crash" — DIMSTYLE-synthesizing path may + // emit some geometry depending on defaults. + await processDimensionEntity( + dim, dxf, makeCtx(), collector, "0", null, group, 0, + { lastYield: 0 }, undefined, undefined, collectEntity, undefined, + ); + }); + + it("falls back to DIMSTYLE path when entity.block names a missing or empty block", async () => { + const dim: DxfDimensionEntity = { + type: "DIMENSION", + handle: "D4", + layer: "0", + block: "*D_Missing", + dimensionType: 0, + actualMeasurement: 50, + anchorPoint: { x: 0, y: 0, z: 0 }, + middleOfText: { x: 25, y: 5, z: 0 }, + linearOrAngularPoint1: { x: 0, y: 0, z: 0 }, + linearOrAngularPoint2: { x: 50, y: 0, z: 0 }, + } as DxfDimensionEntity; + + const dxf: DxfData = { + entities: [dim], + blocks: { "*D_Missing": { name: "*D_Missing", entities: [] } as DxfBlock }, + } as DxfData; + + const collector = new GeometryCollector(); + const group = new THREE.Group(); + await processDimensionEntity( + dim, dxf, makeCtx(), collector, "0", null, group, 0, + { lastYield: 0 }, undefined, undefined, collectEntity, undefined, + ); + }); +}); diff --git a/packages/dxf-render/src/render/collectors/__tests__/regionCollector.test.ts b/packages/dxf-render/src/render/collectors/__tests__/regionCollector.test.ts new file mode 100644 index 0000000..da92708 --- /dev/null +++ b/packages/dxf-render/src/render/collectors/__tests__/regionCollector.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { collectRegion } from "../regionCollector"; +import { MaterialCacheStore } from "../../materialCache"; +import type { CollectEntityParams } from "../../blockTemplateCache"; +import type { DxfRegionEntity, DxfLayer } from "@/types/dxf"; +import type { RenderContext } from "../../primitives"; + +class MockCollector { + lineFromPoints: { layer: string; color: string; count: number }[] = []; + lineSegments: { layer: string; color: string; len: number }[] = []; + + addLineFromPoints(layer: string, color: string, pts: { x: number; y: number; z: number }[]): void { + this.lineFromPoints.push({ layer, color, count: pts.length }); + } + addLineSegments(layer: string, color: string, data: number[]): void { + this.lineSegments.push({ layer, color, len: data.length }); + } + addLinetypeDots(): void { /* not used in these tests */ } +} + +function makeCtx(): RenderContext { + return { + layers: { + Profil: { name: "Profil", visible: true, frozen: false, colorIndex: 3, color: 0x00ff00 } as DxfLayer, + }, + lineTypes: {}, + globalLtScale: 1, + headerLtScale: 1, + materials: new MaterialCacheStore(), + defaultTextHeight: 2.5, + } as RenderContext; +} + +function makeParams(entity: DxfRegionEntity, collector: MockCollector): CollectEntityParams { + return { + entity, + colorCtx: makeCtx(), + collector: collector as unknown as CollectEntityParams["collector"], + layer: entity.layer ?? "0", + }; +} + +describe("collectRegion", () => { + it("returns false for non-REGION entity", () => { + const collector = new MockCollector(); + const params = makeParams({ type: "HATCH" } as unknown as DxfRegionEntity, collector); + expect(collectRegion(params)).toBe(false); + }); + + it("returns false when contourBoundary is missing", () => { + const collector = new MockCollector(); + const region: DxfRegionEntity = { type: "REGION", layer: "Profil" } as DxfRegionEntity; + expect(collectRegion(makeParams(region, collector))).toBe(false); + expect(collector.lineFromPoints).toHaveLength(0); + }); + + it("emits a line from boundary edges", () => { + const collector = new MockCollector(); + const region: DxfRegionEntity = { + type: "REGION", + layer: "Profil", + handle: "151", + contourBoundary: [{ + edges: [ + { type: "line", start: { x: 0, y: 0 }, end: { x: 10, y: 0 } }, + { type: "line", start: { x: 10, y: 0 }, end: { x: 10, y: 10 } }, + ], + }], + } as DxfRegionEntity; + + expect(collectRegion(makeParams(region, collector))).toBe(true); + expect(collector.lineFromPoints).toHaveLength(1); + expect(collector.lineFromPoints[0].layer).toBe("Profil"); + expect(collector.lineFromPoints[0].count).toBe(3); // start, end1=start2, end2 + }); + + it("emits one line per boundary path", () => { + const collector = new MockCollector(); + const region: DxfRegionEntity = { + type: "REGION", + layer: "Profil", + handle: "151", + contourBoundary: [ + { edges: [{ type: "line", start: { x: 0, y: 0 }, end: { x: 1, y: 0 } }] }, + { edges: [{ type: "line", start: { x: 5, y: 5 }, end: { x: 6, y: 5 } }] }, + ], + } as DxfRegionEntity; + + expect(collectRegion(makeParams(region, collector))).toBe(true); + expect(collector.lineFromPoints).toHaveLength(2); + }); +}); diff --git a/packages/dxf-render/src/render/collectors/dimensionCollector.ts b/packages/dxf-render/src/render/collectors/dimensionCollector.ts index c3d9489..c9eeb6a 100644 --- a/packages/dxf-render/src/render/collectors/dimensionCollector.ts +++ b/packages/dxf-render/src/render/collectors/dimensionCollector.ts @@ -1,8 +1,7 @@ import * as THREE from "three"; import type { DxfEntity, DxfData } from "@/types/dxf"; import { isDimensionEntity } from "@/types/dxf"; -import { resolveEntityColor, rgbNumberToHex } from "@/utils/colorResolver"; -import ACI_PALETTE from "@/parser/acadColorIndex"; +import { resolveEntityColor, aciToColor } from "@/utils/colorResolver"; import { DEGREES_TO_RADIANS_DIVISOR } from "@/constants"; import { type RenderContext, degreesToRadians } from "../primitives"; import type { GeometryCollector } from "../mergeCollectors"; @@ -17,9 +16,9 @@ import { resolveDimVarsFromHeader, applyDimStyleVars, mergeEntityDimVars, - isTickBlock, type DimFormatOptions, } from "../dimensions"; +import { classifyArrowBlock } from "../arrowheads"; import { addDimensionTextToCollector, measureDimensionTextWidth, @@ -46,7 +45,15 @@ export function collectDimensionEntity( const matrix = worldMatrix ?? new THREE.Matrix4(); // Resolve dimension variables: header -> DIMSTYLE -> entity XDATA overrides - const dimStyleEntry = entity.styleName && colorCtx.dimStyles?.[entity.styleName]; + const dimStyleEntry = entity.styleName ? colorCtx.dimStyles?.[entity.styleName] : undefined; + + // DIMTXSTY (DIMSTYLE code 340) → STYLE name → STYLE.widthFactor (code 41). + // Applied to dim-text horizontal advance via addDimensionTextToCollector. + // Falls back to 1 (no stretch) when missing. + const dimTextStyleName = dimStyleEntry?.dimtxstyHandle + ? colorCtx.styleHandleToName?.get(dimStyleEntry.dimtxstyHandle) + : undefined; + const dimTextWidthFactor = (dimTextStyleName ? colorCtx.styles?.[dimTextStyleName]?.widthFactor : undefined) ?? 1; let baseDv = colorCtx.dimVars ?? resolveDimVarsFromHeader(undefined); // Apply DIMSTYLE-level overrides (DIMSCALE, DIMTXT, DIMASZ) between header and entity if (dimStyleEntry) { @@ -54,16 +61,26 @@ export function collectDimensionEntity( } const dv = mergeEntityDimVars(baseDv, entity); - // Resolve DIMLUNIT: DIMSTYLE -> header -> undefined (defaults) - const dimlunit = dimStyleEntry ? dimStyleEntry.dimlunit : colorCtx.headerDimlunit; - const dimzin = dimStyleEntry ? dimStyleEntry.dimzin : undefined; - const dimFmt: DimFormatOptions | undefined = dimlunit !== undefined ? { dimlunit, dimzin } : undefined; + // Resolve formatting variables: entity XDATA override → DIMSTYLE → header → undefined. + const dimlunit = dimStyleEntry?.dimlunit ?? colorCtx.headerDimlunit; + const dimzin = dimStyleEntry?.dimzin ?? colorCtx.headerDimzin; + const dimdec = entity.dimdec ?? dimStyleEntry?.dimdec ?? colorCtx.headerDimdec; + const dimadec = entity.dimadec ?? dimStyleEntry?.dimadec ?? colorCtx.headerDimadec; + // Build dimFmt whenever any of these is defined so DIMDEC alone (without DIMLUNIT) is enough. + const dimFmt: DimFormatOptions | undefined = + dimlunit !== undefined || dimzin !== undefined || dimdec !== undefined || dimadec !== undefined + ? { dimlunit, dimzin, dimdec, dimadec } + : undefined; - // DIMCLRT: dimension text color from DIMSTYLE (ACI index) - let textColor = entityColor; - if (dimStyleEntry && dimStyleEntry.dimclrt !== undefined && dimStyleEntry.dimclrt > 0 && dimStyleEntry.dimclrt <= 255) { - textColor = rgbNumberToHex(ACI_PALETTE[dimStyleEntry.dimclrt]); - } + // DIMCLRD/DIMCLRE/DIMCLRT: dimension component colors from DIMSTYLE (ACI index). + // Route through aciToColor so ACI 7/255 stay theme-adaptive — otherwise + // a DIMSTYLE with DIMCLRT=7 renders white text invisible on a light background. + // Values 0 (BYBLOCK) and 256 (BYLAYER) fall back to entity color. + const resolveDimStyleColor = (aci: number | undefined): string => + aci !== undefined && aci > 0 && aci <= 255 ? aciToColor(aci) : entityColor; + const dimColor = resolveDimStyleColor(dimStyleEntry?.dimclrd); + const extColor = resolveDimStyleColor(dimStyleEntry?.dimclre); + const textColor = resolveDimStyleColor(dimStyleEntry?.dimclrt); // DIMTSZ / DIMBLK from DIMSTYLE overrides header values if (dimStyleEntry) { @@ -73,24 +90,22 @@ export function collectDimensionEntity( const dimScale = (entity.dimScale ?? styleDimScale ?? headerDimScale) || 1; if (dimStyleEntry.dimtsz !== undefined && dimStyleEntry.dimtsz > 0) { - dv.useTicks = true; + // DIMTSZ > 0 forces tick rendering regardless of DIMBLK. + dv.arrowKind = "tick"; dv.tickSize = dimStyleEntry.dimtsz * dimScale; } else if (dimStyleEntry.dimblkHandle && colorCtx.blockHandleToName) { const blockName = colorCtx.blockHandleToName.get(dimStyleEntry.dimblkHandle); - if (blockName && isTickBlock(blockName)) { - dv.useTicks = true; - // No explicit DIMTSZ -> tick size always follows arrow size - // (entity XDATA may override arrowSize after base tickSize was set) - dv.tickSize = dv.arrowSize; - } else { - // DIMBLK is not a tick block → use standard arrows - dv.useTicks = false; - dv.tickSize = 0; - } - } else if (dv.useTicks && dimStyleEntry.dimtsz === 0) { + // Unknown / non-standard block names fall back to closed-filled (the + // AutoCAD default). Custom user blocks for dimension arrowheads are not + // dispatched through `addBlockArrowToCollector` — only leaders do that. + dv.arrowKind = classifyArrowBlock(blockName) ?? "closed-filled"; + // No explicit DIMTSZ -> tick size follows arrow size (entity XDATA may + // override arrowSize after base tickSize was set). + dv.tickSize = dv.arrowKind === "tick" ? dv.arrowSize : 0; + } else if (dv.arrowKind === "tick" && dimStyleEntry.dimtsz === 0) { // DIMSTYLE explicitly sets DIMTSZ=0 with no custom DIMBLK → default arrows - // (overrides header $DIMBLK=ARCHTICK that may have set useTicks=true) - dv.useTicks = false; + // (overrides header $DIMBLK=ARCHTICK that may have selected ticks). + dv.arrowKind = "closed-filled"; dv.tickSize = 0; } } @@ -100,8 +115,35 @@ export function collectDimensionEntity( // Resolve sentinel for Three.js material creation in dimension helpers const resolvedColor = colorCtx.materials.resolveColor(entityColor); + // DIMTIH/DIMTOH/DIMTAD/DIMGAP/DIMTMOVE control radial & diametric text layout. + // Pulled from DIMSTYLE → header. Entity XDATA overrides aren't standardized for these. + // DIMSCALE is applied to DIMGAP (length value); the rest are flags. + const headerDimScale = _dxf.header?.$DIMSCALE ?? 1; + const styleDimScale = dimStyleEntry?.dimscale; + const effectiveDimScale = (entity.dimScale ?? styleDimScale ?? headerDimScale) || 1; + + // DIMTIH/DIMTOH are looked up on the DIMSTYLE record only — `$DIMTIH`/`$DIMTOH` + // in the header are system-wide current values used when AutoCAD creates new + // dimensions, not for rendering existing ones (DXF reference for the HEADER + // block: those vars track the editor's current setting, while the per-dim + // record is the source of truth). Falling back to header here used to make + // diametric/radial dims whose dimstyle set DIMTOH=0 but omitted DIMTIH render + // text horizontally — e.g. QCAD's QCADDimStyle writes only DIMTOH, leaving + // DIMTIH unspecified, and the header's $DIMTIH=1 leaked into the "text inside" + // path. Now an unspecified DIMSTYLE field means undefined here, which the + // diametric/radial helpers interpret as 0 (aligned — the ISO default). + const dimtih = dimStyleEntry?.dimtih; + const dimtoh = dimStyleEntry?.dimtoh; + const dimtad = dimStyleEntry?.dimtad ?? colorCtx.headerDimtad; + const dimgapRaw = dimStyleEntry?.dimgap ?? colorCtx.headerDimgap; + const dimgap = dimgapRaw !== undefined ? dimgapRaw * effectiveDimScale : undefined; + const dimtmove = dimStyleEntry?.dimtmove ?? colorCtx.headerDimtmove; + // Ordinate dimension (type 6 = Y-ordinate, type 7 = X-ordinate) - const dimParams = { entity, color: resolvedColor, font, collector, layer, transform, dv }; + // textColor is the un-resolved sentinel (e.g. ACI7_COLOR) so the + // collector can keep DIMCLRT theme-adaptive — materials.resolveColor() + // happens later inside GeometryCollector.flush(). + const dimParams = { entity, color: resolvedColor, textColor, font, collector, layer, transform, dv, fmt: dimFmt, dimtih, dimtoh, dimtad, dimgap, dimtmove, widthFactor: dimTextWidthFactor }; if ((baseDimType & 0x0e) === 6) { result = createOrdinateDimension(dimParams); } else if (baseDimType === 2) { @@ -124,7 +166,7 @@ export function collectDimensionEntity( // Compute text gap from actual text width so dimension line doesn't overlap text if (dimData.textPos && dimData.dimensionText && font) { - const textWidth = measureDimensionTextWidth(font, dimData.dimensionText, dimData.textHeight); + const textWidth = measureDimensionTextWidth(font, dimData.dimensionText, dimData.textHeight, dimTextWidthFactor); const padding = dimData.textHeight * 0.5; dv.textGap = Math.max(dv.textGap, textWidth + padding); } @@ -146,12 +188,17 @@ export function collectDimensionEntity( rawText: dimData.dimensionText, height: dimData.textHeight, posX: dimData.textPos.x, posY: dimData.textPos.y, posZ: 0.2, rotation: dimAngleRad, hAlign: "center", transform, + widthFactor: dimTextWidthFactor, }); } } // Decompose geometry objects (lines, arrows) into collector. - // Use entityColor (sentinel) for collector calls so theme-switching works. + // Color per object is selected by userData.dimPart ("ext" = extension line, + // anything else = dimension line / arrows / ticks). Uses sentinel colors so + // theme-switching keeps working. + const colorFor = (obj: THREE.Object3D): string => + obj.userData?.dimPart === "ext" ? extColor : dimColor; if (result) { for (const obj of result) { if (obj instanceof THREE.Group) { @@ -163,6 +210,7 @@ export function collectDimensionEntity( const posAttr = geo.getAttribute("position") as THREE.BufferAttribute | undefined; if (!posAttr) return; const v = new THREE.Vector3(); + const partColor = colorFor(child); if (child instanceof THREE.LineSegments || child instanceof THREE.Line) { const count = posAttr.count; @@ -170,7 +218,7 @@ export function collectDimensionEntity( v.fromBufferAttribute(posAttr, i).applyMatrix4(child.matrixWorld).applyMatrix4(matrix); const x1 = v.x, y1 = v.y, z1 = v.z; v.fromBufferAttribute(posAttr, i + 1).applyMatrix4(child.matrixWorld).applyMatrix4(matrix); - collector.addLineSegments(layer, entityColor, [x1, y1, z1, v.x, v.y, v.z]); + collector.addLineSegments(layer, partColor, [x1, y1, z1, v.x, v.y, v.z]); } } else if (child instanceof THREE.Mesh) { const count = posAttr.count; @@ -184,7 +232,7 @@ export function collectDimensionEntity( if (indices.length === 0) { for (let i = 0; i < count; i++) indices.push(i); } - collector.addOverlayMesh(layer, entityColor, positions, indices); + collector.addOverlayMesh(layer, partColor, positions, indices); } }); } else { @@ -194,6 +242,7 @@ export function collectDimensionEntity( const posAttr = geo.getAttribute("position") as THREE.BufferAttribute | undefined; if (!posAttr) continue; const v = new THREE.Vector3(); + const partColor = colorFor(obj); if (obj instanceof THREE.LineSegments || obj instanceof THREE.Line) { const count = posAttr.count; @@ -201,7 +250,7 @@ export function collectDimensionEntity( v.fromBufferAttribute(posAttr, i).applyMatrix4(matrix); const x1 = v.x, y1 = v.y, z1 = v.z; v.fromBufferAttribute(posAttr, i + 1).applyMatrix4(matrix); - collector.addLineSegments(layer, entityColor, [x1, y1, z1, v.x, v.y, v.z]); + collector.addLineSegments(layer, partColor, [x1, y1, z1, v.x, v.y, v.z]); } } else if (obj instanceof THREE.Mesh) { const count = posAttr.count; @@ -215,7 +264,7 @@ export function collectDimensionEntity( if (indices.length === 0) { for (let i = 0; i < count; i++) indices.push(i); } - collector.addOverlayMesh(layer, entityColor, positions, indices); + collector.addOverlayMesh(layer, partColor, positions, indices); } } } diff --git a/packages/dxf-render/src/render/collectors/index.ts b/packages/dxf-render/src/render/collectors/index.ts index 25d46e2..d511c2e 100644 --- a/packages/dxf-render/src/render/collectors/index.ts +++ b/packages/dxf-render/src/render/collectors/index.ts @@ -12,6 +12,7 @@ import { collectFace } from "./faceCollector"; import { collectHatch } from "./hatchCollector"; import { collectMline } from "./mlineCollector"; import { collectXline } from "./xlineCollector"; +import { collectRegion } from "./regionCollector"; type EntityCollectorFn = (p: CollectEntityParams) => boolean; @@ -31,6 +32,7 @@ const entityCollectors: Record = { MLINE: collectMline, XLINE: collectXline, RAY: collectXline, + REGION: collectRegion, }; /** @@ -56,6 +58,7 @@ export { collectDimensionEntity } from "./dimensionCollector"; export { collectLeaderEntity, catmullRomSpline } from "./leaderCollector"; export { collectInsertEntity, + processDimensionEntity, MAX_RECURSION_DEPTH, type YieldState, type ProcessEntityFn, diff --git a/packages/dxf-render/src/render/collectors/insertCollector.ts b/packages/dxf-render/src/render/collectors/insertCollector.ts index 935aae3..ee90516 100644 --- a/packages/dxf-render/src/render/collectors/insertCollector.ts +++ b/packages/dxf-render/src/render/collectors/insertCollector.ts @@ -13,7 +13,7 @@ import { instantiateBlockTemplate, addSharedBlockInstance, } from "../blockTemplateCache"; -import { resolveEntityFont } from "../text/fontClassifier"; +import { resolveEntityFont, resolveStyleFlags } from "../text/fontClassifier"; import { replaceSpecialChars } from "../text/mtextParser"; import { addTextToCollector, @@ -72,21 +72,30 @@ function renderAttribs( colorCtx: RenderContext, collector: GeometryCollector, insertLayer: string, + insertColor: string, ): void { for (const attrib of attribs) { if (attrib.invisible) continue; const text = attrib.text; if (!text) continue; - const attribColor = resolveEntityColor(attrib, colorCtx.layers, colorCtx.blockColor); + // Pass insertColor as blockColor: it lets ByBlock (colorIndex=0) and + // layer "0" (ByLayer) both resolve to the parent INSERT's effective color. + const attribColor = resolveEntityColor(attrib, colorCtx.layers, insertColor); const textHeight = attrib.textHeight || colorCtx.defaultTextHeight; + const hAlign = attrib.horizontalJustification ?? HAlign.LEFT; + const isFitOrAligned = hAlign === HAlign.FIT || hAlign === HAlign.ALIGNED; const hasJustification = (attrib.horizontalJustification && attrib.horizontalJustification > 0) || (attrib.verticalJustification && attrib.verticalJustification > 0); - const posCoord = hasJustification && attrib.endPoint - ? attrib.endPoint - : attrib.startPoint; + // FIT/ALIGNED use startPoint as origin and endPoint as the second alignment + // point; other justified modes anchor at endPoint (DXF spec). + const posCoord = isFitOrAligned + ? attrib.startPoint + : hasJustification && attrib.endPoint + ? attrib.endPoint + : attrib.startPoint; if (!posCoord) continue; const attribMatrix = buildOcsMatrix(attrib.extrusionDirection); @@ -95,16 +104,31 @@ function renderAttribs( attribMatrix, ); + let endX: number | undefined; + let endY: number | undefined; + if (isFitOrAligned && attrib.endPoint) { + const ep = transformOcsPoint( + new THREE.Vector3(attrib.endPoint.x, attrib.endPoint.y, attrib.endPoint.z || 0), + attribMatrix, + ); + endX = ep.x; + endY = ep.y; + } + const rotation = attrib.rotation ? degreesToRadians(attrib.rotation) : 0; const attribFont = resolveEntityFont(attrib.textStyle, colorCtx.styles, colorCtx.serifFont, colorCtx.font!); + const attribStyleFlags = resolveStyleFlags(attrib.textStyle, colorCtx.styles); addTextToCollector({ collector, layer: insertLayer, color: attribColor, font: attribFont, text: replaceSpecialChars(text), height: textHeight, posX: attribPos.x, posY: attribPos.y, posZ: attribPos.z, rotation, - hAlign: attrib.horizontalJustification ?? HAlign.LEFT, + hAlign, vAlign: attrib.verticalJustification ?? VAlign.BASELINE, - widthFactor: attrib.scale, + widthFactor: attrib.scale ?? attribStyleFlags.widthFactor, + endPosX: endX, endPosY: endY, obliqueAngle: attrib.obliqueAngle, + bold: attribStyleFlags.bold, + italic: attribStyleFlags.italic, }); } } @@ -134,6 +158,66 @@ function addFallbackObjects( } } +// ─── DIMENSION dispatch (pre-rendered block vs DIMSTYLE) ────────────── + +/** + * Render a DIMENSION entity. If the entity has a non-empty pre-rendered + * associated block (`entity.block`, the WYSIWYG geometry stashed by + * AutoCAD / Revit / Civil3D into `*D###` / `DIMBLOCKn-…`), materialise that + * block as a synthetic identity-transform INSERT and recurse through + * `collectInsertEntity` — the caller's `worldMatrix` propagates intact, so + * dim's that live inside an outer scaled / rotated block still land correctly. + * + * Falls back to `collectDimensionEntity` (the DIMSTYLE-synthesizing path) when + * the dim has no block or the block is empty (typical for QCAD / DXF files + * that omit the rendered geometry). + * + * Shared by the top-level dispatch in `createDXFScene.ts` and both paths + * (template / slow) inside `collectInsertEntity` for nested DIMENSIONs. + */ +export async function processDimensionEntity( + entity: DxfEntity, + dxf: DxfData, + colorCtx: RenderContext, + collector: GeometryCollector, + entityLayer: string, + worldMatrix: THREE.Matrix4 | null, + fallbackGroup: THREE.Group, + depth: number, + yieldState: YieldState, + blockTemplates: Map | undefined, + sharedBlockGeos: Map | undefined, + collectEntityFn: CollectEntityFn, + processEntityFn?: ProcessEntityFn, +): Promise { + if (!isDimensionEntity(entity)) return; + const dimBlock = entity.block && dxf.blocks ? dxf.blocks[entity.block] : undefined; + if (dimBlock?.entities?.length) { + const syntheticInsert = { + type: "INSERT", + name: entity.block!, + position: { x: 0, y: 0, z: 0 }, + xScale: 1, + yScale: 1, + zScale: 1, + rotation: 0, + columnCount: 1, + rowCount: 1, + layer: entity.layer, + colorIndex: entity.colorIndex, + color: entity.color, + handle: entity.handle, + } as DxfEntity; + await collectInsertEntity( + syntheticInsert, dxf, colorCtx, collector, entityLayer, worldMatrix, + fallbackGroup, depth, yieldState, blockTemplates, sharedBlockGeos, + collectEntityFn, processEntityFn, + ); + return; + } + collectDimensionEntity(entity, dxf, colorCtx, collector, entityLayer, worldMatrix ?? undefined); +} + // ─── Main INSERT collector ──────────────────────────────────────────── /** @@ -252,7 +336,11 @@ export async function collectInsertEntity( continue; } if (entity.type === "DIMENSION" && isDimensionEntity(entity)) { - collectDimensionEntity(entity, dxf, blockColorCtx, collector, entityLayer, worldMatrix); + await processDimensionEntity( + entity, dxf, blockColorCtx, collector, entityLayer, worldMatrix, + fallbackGroup, depth + 1, yieldState, blockTemplates, sharedBlockGeos, + collectEntityFn, processEntityFn, + ); continue; } if (entity.type === "LEADER" || entity.type === "MULTILEADER" || entity.type === "MLEADER") { @@ -272,7 +360,7 @@ export async function collectInsertEntity( // Handle ATTRIBs for template path (only for first array instance) if (row === 0 && col === 0 && insertEntity.attribs && insertEntity.attribs.length > 0) { - renderAttribs(insertEntity.attribs, colorCtx, collector, insertLayer); + renderAttribs(insertEntity.attribs, colorCtx, collector, insertLayer, insertColor); } continue; @@ -304,7 +392,11 @@ export async function collectInsertEntity( continue; } if (entity.type === "DIMENSION" && isDimensionEntity(entity)) { - collectDimensionEntity(entity, dxf, blockColorCtx, collector, entityLayer, worldMatrix); + await processDimensionEntity( + entity, dxf, blockColorCtx, collector, entityLayer, worldMatrix, + fallbackGroup, depth + 1, yieldState, blockTemplates, sharedBlockGeos, + collectEntityFn, processEntityFn, + ); continue; } if (entity.type === "LEADER" || entity.type === "MULTILEADER" || entity.type === "MLEADER") { @@ -330,7 +422,7 @@ export async function collectInsertEntity( // Handle ATTRIB entities (only for first array instance) if (row === 0 && col === 0 && insertEntity.attribs && insertEntity.attribs.length > 0) { - renderAttribs(insertEntity.attribs, colorCtx, collector, insertLayer); + renderAttribs(insertEntity.attribs, colorCtx, collector, insertLayer, insertColor); } } // for col diff --git a/packages/dxf-render/src/render/collectors/leaderCollector.ts b/packages/dxf-render/src/render/collectors/leaderCollector.ts index 4af92f4..6aa83e1 100644 --- a/packages/dxf-render/src/render/collectors/leaderCollector.ts +++ b/packages/dxf-render/src/render/collectors/leaderCollector.ts @@ -1,22 +1,18 @@ import * as THREE from "three"; import type { DxfEntity, DxfData } from "@/types/dxf"; import { isLeaderEntity, isMLeaderEntity } from "@/types/dxf"; -import { resolveEntityColor } from "@/utils/colorResolver"; +import { resolveEntityColor, resolveMLeaderColor } from "@/utils/colorResolver"; import { ARROW_SIZE } from "@/constants"; import { type RenderContext, - createArrow, - createTick, getLineMaterial, getMeshMaterial, } from "../primitives"; import type { GeometryCollector } from "../mergeCollectors"; import { resolveEntityFont } from "../text/fontClassifier"; import { replaceSpecialChars } from "../text/mtextParser"; -import { - resolveDimVarsFromHeader, - isTickBlock, -} from "../dimensions"; +import { resolveDimVarsFromHeader } from "../dimensions"; +import { classifyArrowBlock, createArrowhead, type ArrowKind } from "../arrowheads"; import { addTextToCollector, HAlign, @@ -51,6 +47,59 @@ export const catmullRomSpline = (points: THREE.Vector3[], segmentsPerSpan = 12): return result; }; +/** + * Cubic Bezier through two endpoints with the curve tangent to `endTangent` + * at the END point (the dogleg landing). The arrow-side tangent is taken as + * the chord direction so the curve only bends near the landing — this matches + * the look AutoCAD draws for a spline MLEADER with a single line vertex + * (vertex = arrow tip, lastLeaderPoint = landing, doglegVector = shelf direction). + * + * The end-tangent vector points OUTWARD from the curve (along the shelf away + * from the leader); the control point is placed in the OPPOSITE direction so + * the curve approaches the landing tangent to the shelf. + */ +const sampleBezierLeader = ( + arrowTip: THREE.Vector3, + landing: THREE.Vector3, + endTangent: THREE.Vector3, + segments = 24, +): THREE.Vector3[] => { + const dx = landing.x - arrowTip.x; + const dy = landing.y - arrowTip.y; + const dz = landing.z - arrowTip.z; + const dist = Math.hypot(dx, dy, dz); + if (dist === 0) return [arrowTip.clone(), landing.clone()]; + + const handle = dist / 3; + // Arrow-side: aim toward landing along the chord (no extra bend at the tip). + const c1 = new THREE.Vector3( + arrowTip.x + (dx / dist) * handle, + arrowTip.y + (dy / dist) * handle, + arrowTip.z + (dz / dist) * handle, + ); + // Landing-side: tangent to the shelf — control point sits OPPOSITE + // the dogleg direction so the curve flows into the shelf direction. + const tLen = Math.hypot(endTangent.x, endTangent.y, endTangent.z) || 1; + const c2 = new THREE.Vector3( + landing.x - (endTangent.x / tLen) * handle, + landing.y - (endTangent.y / tLen) * handle, + landing.z - (endTangent.z / tLen) * handle, + ); + + const result: THREE.Vector3[] = []; + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const u = 1 - t; + const u2 = u * u, u3 = u2 * u, t2 = t * t, t3 = t2 * t; + result.push(new THREE.Vector3( + u3 * arrowTip.x + 3 * u2 * t * c1.x + 3 * u * t2 * c2.x + t3 * landing.x, + u3 * arrowTip.y + 3 * u2 * t * c1.y + 3 * u * t2 * c2.y + t3 * landing.y, + u3 * arrowTip.z + 3 * u2 * t * c1.z + 3 * u * t2 * c2.z + t3 * landing.z, + )); + } + return result; +}; + /** * Collect LEADER/MULTILEADER entity: lines and arrows decomposed into collector, * text rendered as vector glyphs directly into collector. @@ -65,7 +114,11 @@ export function collectLeaderEntity( ): void { const styleName = isLeaderEntity(entity) ? entity.styleName : undefined; const font = resolveEntityFont(styleName, colorCtx.styles, colorCtx.serifFont, colorCtx.font!); - const entityColor = resolveEntityColor(entity, colorCtx.layers, colorCtx.blockColor); + // entityColor drives leader lines and arrows. For MULTILEADER it gets + // reassigned below to the resolved line color (entity override > style > + // ByLayer); the text body uses a separate `textColor` resolved the same way + // but with the TextColor override bit and MLEADERSTYLE TextColor. + let entityColor = resolveEntityColor(entity, colorCtx.layers, colorCtx.blockColor); const matrix = worldMatrix ?? new THREE.Matrix4(); const v = new THREE.Vector3(); @@ -78,22 +131,52 @@ export function collectLeaderEntity( } }; - const addArrowToCollector = (from: THREE.Vector3, to: THREE.Vector3, size: number) => { - const arrow = createArrow(from, to, size, getMeshMaterial(entityColor, colorCtx.materials)); - const geo = arrow.geometry as THREE.BufferGeometry; - const posAttr = geo.getAttribute("position") as THREE.BufferAttribute; - const count = posAttr.count; - const positions: number[] = []; - for (let i = 0; i < count; i++) { - v.fromBufferAttribute(posAttr, i).applyMatrix4(matrix); - positions.push(v.x, v.y, v.z); - } - const index = geo.getIndex(); - const indices = index ? Array.from(index.array) : []; - if (indices.length === 0) { - for (let i = 0; i < count; i++) indices.push(i); + /** + * Build any standard AutoCAD arrowhead (closed-filled, dot, tick, box, ...) + * and decompose it into the collector. `kind` defaults to closed-filled — + * the AutoCAD default arrow used when DIMLDRBLK is unset. + */ + const addArrowheadToCollector = ( + from: THREE.Vector3, to: THREE.Vector3, size: number, kind: ArrowKind = "closed-filled", + ) => { + const lineMat = getLineMaterial(entityColor, colorCtx.materials); + const fillMat = getMeshMaterial(entityColor, colorCtx.materials); + const heads = createArrowhead({ from, tip: to, size, kind, lineMaterial: lineMat, fillMaterial: fillMat }); + for (const head of heads) { + const geo = (head as THREE.Mesh | THREE.Line | THREE.LineSegments).geometry as THREE.BufferGeometry; + const posAttr = geo.getAttribute("position") as THREE.BufferAttribute; + const count = posAttr.count; + + if (head instanceof THREE.LineSegments) { + const verts: number[] = []; + for (let i = 0; i < count; i++) { + v.fromBufferAttribute(posAttr, i).applyMatrix4(matrix); + verts.push(v.x, v.y, v.z); + } + collector.addLineSegments(layer, entityColor, verts); + } else if (head instanceof THREE.Line) { + const verts: number[] = []; + for (let i = 0; i < count - 1; i++) { + v.fromBufferAttribute(posAttr, i).applyMatrix4(matrix); + const x1 = v.x, y1 = v.y, z1 = v.z; + v.fromBufferAttribute(posAttr, i + 1).applyMatrix4(matrix); + verts.push(x1, y1, z1, v.x, v.y, v.z); + } + collector.addLineSegments(layer, entityColor, verts); + } else if (head instanceof THREE.Mesh) { + const positions: number[] = []; + for (let i = 0; i < count; i++) { + v.fromBufferAttribute(posAttr, i).applyMatrix4(matrix); + positions.push(v.x, v.y, v.z); + } + const index = geo.getIndex(); + const indices = index ? Array.from(index.array) : []; + if (indices.length === 0) { + for (let i = 0; i < count; i++) indices.push(i); + } + collector.addOverlayMesh(layer, entityColor, positions, indices); + } } - collector.addOverlayMesh(layer, entityColor, positions, indices); }; // Resolve arrow block for LEADER: DIMSTYLE code 341 (DIMLDRBLK) -> block name @@ -135,19 +218,6 @@ export function collectLeaderEntity( return true; }; - const addTickToCollector = (point: THREE.Vector3, dimAngle: number) => { - const tick = createTick(point, baseDv.tickSize || baseDv.arrowSize, dimAngle, - getLineMaterial(entityColor, colorCtx.materials)); - const geo = tick.geometry as THREE.BufferGeometry; - const posAttr = geo.getAttribute("position") as THREE.BufferAttribute; - const verts: number[] = []; - for (let i = 0; i < posAttr.count; i++) { - v.fromBufferAttribute(posAttr, i).applyMatrix4(matrix); - verts.push(v.x, v.y, v.z); - } - collector.addLineSegments(layer, entityColor, verts); - }; - if (entity.type === "LEADER" && isLeaderEntity(entity) && entity.vertices.length >= 2) { const rawPoints = entity.vertices.map( (vt) => new THREE.Vector3(vt.x, vt.y, vt.z || 0), @@ -168,44 +238,98 @@ export function collectLeaderEntity( const d = (points[i].x - points[0].x) ** 2 + (points[i].y - points[0].y) ** 2; if (d >= arrowSize * arrowSize) break; } - const dx = points[0].x - points[baseIdx].x; - const dy = points[0].y - points[baseIdx].y; - const angle = Math.atan2(dy, dx); - let drawn = false; - // Try custom arrow block from DIMLDRBLK - if (leaderArrowBlockName && !isTickBlock(leaderArrowBlockName)) { - drawn = addBlockArrowToCollector(leaderArrowBlockName, points[0], angle, arrowSize); - } - if (!drawn) { - // Leaders use ticks only if DIMLDRBLK explicitly specifies a tick block. - // Do NOT inherit useTicks from baseDv — that's for dimension arrowheads. - if (leaderArrowBlockName && isTickBlock(leaderArrowBlockName)) { - addTickToCollector(points[0], angle); - } else { - addArrowToCollector(points[baseIdx], points[0], arrowSize); - } + const angle = Math.atan2(points[0].y - points[baseIdx].y, points[0].x - points[baseIdx].x); + // DIMLDRBLK resolution: try standard kinds first; non-standard names fall + // back to rendering the user-defined block geometry; if that fails, use + // the AutoCAD default (closed-filled). Leaders never inherit DIMBLK's + // tick kind from baseDv — that's for dimension arrowheads only. + const leaderArrowKind = classifyArrowBlock(leaderArrowBlockName); + if (leaderArrowKind !== undefined) { + addArrowheadToCollector(points[baseIdx], points[0], arrowSize, leaderArrowKind); + } else if (leaderArrowBlockName) { + const drawn = addBlockArrowToCollector(leaderArrowBlockName, points[0], angle, arrowSize); + if (!drawn) addArrowheadToCollector(points[baseIdx], points[0], arrowSize); + } else { + addArrowheadToCollector(points[baseIdx], points[0], arrowSize); } } } else if ((entity.type === "MULTILEADER" || entity.type === "MLEADER") && isMLeaderEntity(entity) && entity.leaders.length > 0) { + // Resolve MULTILEADER colors. AutoCAD precedence: + // 1. Entity-level CmEntityColor when its PropertyOverrideFlag bit is set + // 2. MLEADERSTYLE color (looked up by entity.styleHandle, code 340) + // 3. ByLayer / ByBlock via resolveEntityColor + // Bits in propertyOverrideFlag: bit 1 = LeaderLineColor, bit 15 = TextColor. + const styleHandle = entity.styleHandle; + const style = styleHandle && colorCtx.mLeaderStyles + ? colorCtx.mLeaderStyles[styleHandle.toUpperCase()] + : undefined; + const overrideFlag = entity.propertyOverrideFlag ?? 0; + const lineColor = resolveMLeaderColor( + entity.leaderLineColorRaw, + (overrideFlag & (1 << 1)) !== 0, + style?.leaderLineColorRaw, + entity, + colorCtx.layers, + colorCtx.blockColor, + ); + const textColor = resolveMLeaderColor( + entity.textColorRaw, + (overrideFlag & (1 << 15)) !== 0, + style?.textColorRaw, + entity, + colorCtx.layers, + colorCtx.blockColor, + ); + entityColor = lineColor; + const arrowSize = entity.arrowSize || ARROW_SIZE; + const isSpline = entity.leaderLineType === 2; for (const leader of entity.leaders) { for (const line of leader.lines) { - if (line.vertices.length < 2) continue; - const points = line.vertices.map( + // A LEADER_LINE can carry just the arrow tip (1 vertex) — the dogleg + // landing then comes from the parent LEADER's lastLeaderPoint, giving + // the second point needed to draw a segment. + const rawPoints = line.vertices.map( (vt) => new THREE.Vector3(vt.x, vt.y, vt.z || 0), ); if (leader.lastLeaderPoint) { - points.push(new THREE.Vector3( + rawPoints.push(new THREE.Vector3( leader.lastLeaderPoint.x, leader.lastLeaderPoint.y, leader.lastLeaderPoint.z || 0, )); } + if (rawPoints.length < 2) continue; + + let points = rawPoints; + if (isSpline) { + if (rawPoints.length === 2 && leader.doglegVector) { + // Single arrow-tip vertex + landing — bend toward the shelf. + points = sampleBezierLeader( + rawPoints[0], + rawPoints[1], + new THREE.Vector3( + leader.doglegVector.x, + leader.doglegVector.y, + leader.doglegVector.z || 0, + ), + ); + } else if (rawPoints.length >= 3) { + // Several vertices — interpolate them as a Catmull-Rom curve. + points = catmullRomSpline(rawPoints); + } + // 2 points without a dogleg vector: fall through to a straight line. + } + addLeaderLineToCollector(points); if (entity.hasArrowHead !== false && points.length >= 2) { - addArrowToCollector(points[1], points[0], arrowSize); + // Arrow direction follows the curve's tangent at the tip — for a + // spline, points[1] is the next sampled point on the curve. + // MULTILEADER arrowhead style is selected by the MLEADERSTYLE; we + // currently always render closed-filled (the AutoCAD default). + addArrowheadToCollector(points[1], points[0], arrowSize); } } } @@ -222,7 +346,7 @@ export function collectLeaderEntity( posY = v.y; } addTextToCollector({ - collector, layer, color: entityColor, font, text: textContent, height: textHeight, + collector, layer, color: textColor, font, text: textContent, height: textHeight, posX, posY, posZ: 0, hAlign: HAlign.LEFT, vAlign: VAlign.MIDDLE, }); } diff --git a/packages/dxf-render/src/render/collectors/polylineCollector.ts b/packages/dxf-render/src/render/collectors/polylineCollector.ts index 06bd921..8ca0b53 100644 --- a/packages/dxf-render/src/render/collectors/polylineCollector.ts +++ b/packages/dxf-render/src/render/collectors/polylineCollector.ts @@ -123,9 +123,17 @@ const addWidePolylineToCollector = ( segPts = [p1, p2]; } - // Skip shared junction point for subsequent segments; - // for the closing segment of closed polylines, also skip the last point (duplicate of first) - const first = (i === 0) ? 0 : 1; + // Decide whether to keep the shared junction point. Normally we skip it + // (already emitted as the end of the previous segment), but when adjacent + // segments have a width discontinuity (prev endW != current startW, e.g. + // an AutoCAD-style arrow: 0/0 followed by 120/0), we must re-emit the + // junction so the new segment opens at its real start width. + let skipJunction = i > 0; + if (skipJunction) { + const prevEndW = resolveSegmentWidths(verts[i - 1], entity).endW; + if (Math.abs(prevEndW - startW) > EPSILON) skipJunction = false; + } + const first = skipJunction ? 1 : 0; const last = (isClosed && i === segCount - 1) ? segPts.length - 1 : segPts.length; for (let j = first; j < last; j++) { @@ -138,11 +146,7 @@ const addWidePolylineToCollector = ( const n = allCenters.length; if (n < 2) return; - // Phase 2: Transform OCS → WCS - if (ocsMatrix) for (const p of allCenters) p.applyMatrix4(ocsMatrix); - if (worldMatrix) for (const p of allCenters) p.applyMatrix4(worldMatrix); - - // Phase 3: Compute miter normals and build left/right offset vertices. + // Phase 2: Compute miter normals and build left/right offset vertices. // At interior points, use miter join (intersection of adjacent offset lines) // to maintain constant perpendicular width along each segment. // Miter factor is clamped to MITER_LIMIT to prevent spikes at acute angles. @@ -229,6 +233,22 @@ const addWidePolylineToCollector = ( ); } + // Phase 3: Transform offset vertices from local → OCS → WCS. + // Width is computed in local space so worldMatrix scale propagates to thickness + // (matters when a wide polyline lives inside a scaled INSERT block, e.g. the + // _ArchTick polyline inside DIMENSION pre-rendered blocks). + if (ocsMatrix || worldMatrix) { + const tmp = new THREE.Vector3(); + for (let i = 0; i < vertices.length; i += 3) { + tmp.set(vertices[i], vertices[i + 1], vertices[i + 2]); + if (ocsMatrix) tmp.applyMatrix4(ocsMatrix); + if (worldMatrix) tmp.applyMatrix4(worldMatrix); + vertices[i] = tmp.x; + vertices[i + 1] = tmp.y; + vertices[i + 2] = tmp.z; + } + } + // Phase 4: Build triangle strip indices const pairCount = vertices.length / 6; if (pairCount < 2) return; @@ -245,6 +265,27 @@ const addWidePolylineToCollector = ( } collector.addMesh(layer, color, vertices, indices); + + // Phase 5: Zero-width segments inside an otherwise-wide polyline render as + // a thin line (per AutoCAD convention — a 0/0 segment is the shaft of an + // arrow whose head is the next 120/0 segment). Without this they collapse + // to a zero-area mesh strip and disappear. + for (let i = 0; i < segCount; i++) { + const v1 = verts[i]; + const v2 = verts[(i + 1) % verts.length]; + const { startW, endW } = resolveSegmentWidths(v1, entity); + if (Math.abs(startW) > EPSILON || Math.abs(endW) > EPSILON) continue; + + const p1 = new THREE.Vector3(v1.x, v1.y, 0); + const p2 = new THREE.Vector3(v2.x, v2.y, 0); + const segPts = (v1.bulge && Math.abs(v1.bulge) > EPSILON) + ? createBulgeArc(p1, p2, v1.bulge) + : [p1, p2]; + + if (ocsMatrix) for (const p of segPts) p.applyMatrix4(ocsMatrix); + if (worldMatrix) for (const p of segPts) p.applyMatrix4(worldMatrix); + collector.addLineFromPoints(layer, color, segPts); + } }; /** diff --git a/packages/dxf-render/src/render/collectors/regionCollector.ts b/packages/dxf-render/src/render/collectors/regionCollector.ts new file mode 100644 index 0000000..718a43e --- /dev/null +++ b/packages/dxf-render/src/render/collectors/regionCollector.ts @@ -0,0 +1,44 @@ +import { isRegionEntity } from "@/types/dxf"; +import { resolveEntityColor } from "@/utils/colorResolver"; +import { resolveEntityLinetype } from "@/utils/linetypeResolver"; +import { buildOcsMatrix, transformOcsPoints } from "@/utils/ocsTransform"; +import type { CollectEntityParams } from "../blockTemplateCache"; +import { boundaryPathToLinePoints } from "../hatch"; +import { addLineToCollector, applyWorld } from "./helpers"; + +/** + * Collect a REGION entity into the GeometryCollector. + * REGION holds ACIS modeler data we don't decode; instead we render the contour + * "borrowed" from a HATCH that references this REGION as a boundary source + * (linked at parse time, see linkRegionsToHatchBoundaries). Color, layer and + * linetype come from the REGION itself — only the curve geometry is shared + * with the HATCH. + */ +export function collectRegion(p: CollectEntityParams): boolean { + const { entity, colorCtx, collector, layer, worldMatrix, overrideColor } = p; + if (!isRegionEntity(entity)) return false; + if (!entity.contourBoundary || entity.contourBoundary.length === 0) return false; + + const entityColor = overrideColor ?? resolveEntityColor(entity, colorCtx.layers, colorCtx.blockColor); + const ltInfo = resolveEntityLinetype( + entity, colorCtx.layers, colorCtx.lineTypes, + colorCtx.globalLtScale, colorCtx.blockLineType, colorCtx.headerLtScale, + ); + const pattern = ltInfo?.pattern; + + const ocsMatrix = buildOcsMatrix(entity.contourExtrusionDirection); + + for (const bp of entity.contourBoundary) { + const pts = boundaryPathToLinePoints(bp); + if (pts.length > 1) { + addLineToCollector( + collector, + layer, + entityColor, + applyWorld(transformOcsPoints(pts, ocsMatrix), worldMatrix), + pattern, + ); + } + } + return true; +} diff --git a/packages/dxf-render/src/render/collectors/textCollector.ts b/packages/dxf-render/src/render/collectors/textCollector.ts index ed99efa..3c89cae 100644 --- a/packages/dxf-render/src/render/collectors/textCollector.ts +++ b/packages/dxf-render/src/render/collectors/textCollector.ts @@ -4,7 +4,7 @@ import { resolveEntityColor } from "@/utils/colorResolver"; import { buildOcsMatrix, transformOcsPoint } from "@/utils/ocsTransform"; import { type RenderContext, degreesToRadians } from "../primitives"; import type { GeometryCollector } from "../mergeCollectors"; -import { resolveEntityFont } from "../text/fontClassifier"; +import { resolveEntityFont, resolveStyleFlags } from "../text/fontClassifier"; import { replaceSpecialChars, parseTextWithUnderline, parseMTextContent } from "../text/mtextParser"; import { addTextToCollector, @@ -25,6 +25,7 @@ export function collectTextOrMText( worldMatrix?: THREE.Matrix4, ): void { const font = resolveEntityFont(entity.textStyle, colorCtx.styles, colorCtx.serifFont, colorCtx.font!); + const styleFlags = resolveStyleFlags(entity.textStyle, colorCtx.styles); const entityColor = resolveEntityColor(entity, colorCtx.layers, colorCtx.blockColor); const textContent = entity.text; if (!textContent) return; @@ -97,9 +98,13 @@ export function collectTextOrMText( posX: pos.x, posY: pos.y, posZ: pos.z, rotation, hAlign: entity.halign ?? HAlign.LEFT, vAlign: entity.valign ?? VAlign.BASELINE, - widthFactor: (entity.xScale ?? 1) * mirrorWidthFactor, + // STYLE.widthFactor (DXF code 41) is the fallback when the TEXT entity + // doesn't carry its own xScale (code 41). Entity-level wins per DXF spec. + widthFactor: (entity.xScale ?? styleFlags.widthFactor ?? 1) * mirrorWidthFactor, endPosX: endX, endPosY: endY, underline: parsed.underline, + bold: styleFlags.bold, + italic: styleFlags.italic, }); } else { @@ -130,7 +135,7 @@ export function collectTextOrMText( height *= Math.sqrt(m[4] * m[4] + m[5] * m[5]); } - const lines = parseMTextContent(textContent, height); + const lines = parseMTextContent(textContent, height, styleFlags.widthFactor); addMTextToCollector({ collector, layer, color: entityColor, font, lines, defaultHeight: height, posX: pos.x, posY: pos.y, posZ: pos.z, rotation, @@ -141,6 +146,8 @@ export function collectTextOrMText( width: entity.width && entity.width >= height * 0.05 ? entity.width : undefined, serifFont: colorCtx.serifFont, lineSpacingFactor: entity.lineSpacingFactor, + bold: styleFlags.bold, + italic: styleFlags.italic, }); } @@ -159,7 +166,18 @@ export function collectAttdefEntity( if (entity.invisible) return; const text = entity.text || entity.tag; if (!text) return; - const posCoord = entity.startPoint; + + const hAlign = entity.horizontalJustification ?? HAlign.LEFT; + const vAlign = entity.verticalJustification ?? VAlign.BASELINE; + const isFitOrAligned = hAlign === HAlign.FIT || hAlign === HAlign.ALIGNED; + const hasJustification = hAlign > 0 || vAlign > 0; + // FIT/ALIGNED use startPoint as origin and endPoint as the second alignment + // point; other justified modes anchor at endPoint (DXF spec). + const posCoord = isFitOrAligned + ? entity.startPoint + : hasJustification && entity.endPoint + ? entity.endPoint + : entity.startPoint; if (!posCoord) return; const entityColor = resolveEntityColor(entity, colorCtx.layers, colorCtx.blockColor); @@ -169,16 +187,32 @@ export function collectAttdefEntity( new THREE.Vector3(posCoord.x, posCoord.y, posCoord.z || 0), ocsMatrix, ); + + let endX: number | undefined; + let endY: number | undefined; + if (isFitOrAligned && entity.endPoint) { + const ep = transformOcsPoint( + new THREE.Vector3(entity.endPoint.x, entity.endPoint.y, entity.endPoint.z || 0), + ocsMatrix, + ); + endX = ep.x; + endY = ep.y; + } + const rotation = entity.rotation ? degreesToRadians(entity.rotation) : 0; const font = resolveEntityFont(entity.textStyle, colorCtx.styles, colorCtx.serifFont, colorCtx.font!); + const styleFlags = resolveStyleFlags(entity.textStyle, colorCtx.styles); addTextToCollector({ collector, layer, color: entityColor, font, text: replaceSpecialChars(text), height: textHeight, posX: pos.x, posY: pos.y, posZ: pos.z, rotation, - hAlign: entity.horizontalJustification ?? HAlign.LEFT, - vAlign: entity.verticalJustification ?? VAlign.BASELINE, - widthFactor: entity.scale, + hAlign, + vAlign, + widthFactor: entity.scale ?? styleFlags.widthFactor, + endPosX: endX, endPosY: endY, obliqueAngle: entity.obliqueAngle, + bold: styleFlags.bold, + italic: styleFlags.italic, }); } diff --git a/packages/dxf-render/src/render/createDXFScene.ts b/packages/dxf-render/src/render/createDXFScene.ts index ae0c412..8fb9dc6 100644 --- a/packages/dxf-render/src/render/createDXFScene.ts +++ b/packages/dxf-render/src/render/createDXFScene.ts @@ -28,9 +28,9 @@ import { computePointDisplaySize, collectTextOrMText, collectAttdefEntity, - collectDimensionEntity, collectLeaderEntity, collectInsertEntity, + processDimensionEntity, type YieldState, } from "./collectors"; @@ -52,12 +52,14 @@ const COLLECTABLE_TYPES = new Set([ "LINE", "CIRCLE", "ARC", "ELLIPSE", "LWPOLYLINE", "POLYLINE", "SPLINE", "POINT", "SOLID", "3DFACE", "HATCH", - "MLINE", "XLINE", "RAY", + "MLINE", "XLINE", "RAY", "REGION", ]); -/** Recognized but non-renderable entity types — silently skipped */ +/** Recognized but non-renderable entity types — silently skipped. + * REGION is here too: it goes through `collectEntity` first (drawn via a HATCH-borrowed + * boundary when available); REGIONs without an associated HATCH fall back here. */ const NON_RENDERABLE_TYPES = new Set([ - "VIEWPORT", "IMAGE", "WIPEOUT", "3DSOLID", + "VIEWPORT", "IMAGE", "WIPEOUT", "3DSOLID", "REGION", ]); /** Yield control to the browser so the UI stays responsive */ @@ -168,9 +170,19 @@ export async function createThreeObjectsFromDXF( } } - // DIMSTYLE table and header $DIMLUNIT for architectural dimension formatting + // DIMSTYLE table and header $DIMLUNIT/$DIMDEC for architectural dimension formatting const dimStyles = dxf.tables?.dimStyle?.dimStyles; + // MLEADERSTYLE objects (from OBJECTS section, keyed by handle) for MULTILEADER color fallback + const mLeaderStyles = dxf.objects?.mLeaderStyles; const headerDimlunit = dxf.header?.$DIMLUNIT; + const headerDimdec = dxf.header?.$DIMDEC; + const headerDimadec = dxf.header?.$DIMADEC; + const headerDimzin = dxf.header?.$DIMZIN; + const headerDimtad = dxf.header?.$DIMTAD; + const headerDimtih = dxf.header?.$DIMTIH; + const headerDimtoh = dxf.header?.$DIMTOH; + const headerDimgap = dxf.header?.$DIMGAP; + const headerDimtmove = dxf.header?.$DIMTMOVE; // Build handle -> name map from BLOCK_RECORD for DIMBLK resolution let blockHandleToName: Map | undefined; @@ -182,6 +194,16 @@ export async function createThreeObjectsFromDXF( } } + // Build handle -> name map from STYLE for DIMTXSTY (DIMSTYLE code 340) resolution + let styleHandleToName: Map | undefined; + const styleRecords = dxf.tables?.style?.styles; + if (styleRecords) { + styleHandleToName = new Map(); + for (const rec of Object.values(styleRecords)) { + if (rec.handle) styleHandleToName.set(rec.handle.toUpperCase(), rec.name); + } + } + const materials = new MaterialCacheStore(); materials.darkTheme = darkTheme ?? false; @@ -200,8 +222,18 @@ export async function createThreeObjectsFromDXF( defaultTextHeight, mirrText, dimStyles, + mLeaderStyles, headerDimlunit, + headerDimdec, + headerDimadec, + headerDimzin, + headerDimtad, + headerDimtih, + headerDimtoh, + headerDimgap, + headerDimtmove, blockHandleToName, + styleHandleToName, }; // Compute clip size for XLINE/RAY from drawing extents @@ -329,9 +361,15 @@ export async function createThreeObjectsFromDXF( continue; } - // Vector text: collect DIMENSION directly (lines decomposed, text via collector) + // Vector text: collect DIMENSION directly (lines decomposed, text via collector). + // Routes through the pre-rendered block path when entity.block is non-empty, + // falling back to the DIMSTYLE-synthesizing path otherwise. if (entity.type === "DIMENSION" && isDimensionEntity(entity)) { - collectDimensionEntity(entity, dxf, colorCtx, collector, layer); + await processDimensionEntity( + entity, dxf, colorCtx, collector, layer, null, + group, 0, yieldState, blockTemplates, sharedBlockGeos, + collectEntity, undefined, + ); continue; } @@ -367,6 +405,13 @@ export async function createThreeObjectsFromDXF( group.add(obj); } + // The renderer has sortObjects=false, so Three.js iterates children in array order. + // Sort children by renderOrder (stable in ES2019+) so fills draw first, then outlines, + // then text/arrow overlays — regardless of when each object joined the group during + // entity processing. Without this, INSERT block outlines added via the shared-geometry + // fast path end up hidden under HATCH meshes that arrive later from flush(). + group.children.sort((a, b) => a.renderOrder - b.renderOrder); + const totalIssues = errors.length + unsupportedTypes.length; if (totalIssues > 0) { const warningParts = []; diff --git a/packages/dxf-render/src/render/dimensions.ts b/packages/dxf-render/src/render/dimensions.ts index a24f3a6..68849ef 100644 --- a/packages/dxf-render/src/render/dimensions.ts +++ b/packages/dxf-render/src/render/dimensions.ts @@ -11,29 +11,27 @@ import { EXTENSION_LINE_DASH_SIZE, EXTENSION_LINE_GAP_SIZE, EXTENSION_LINE_EXTENSION, + OUTSIDE_ARROW_THRESHOLD_RATIO, + OUTSIDE_ARROW_TAIL_RATIO, DEGREES_TO_RADIANS_DIVISOR, EPSILON, CIRCLE_SEGMENTS, MIN_ARC_SEGMENTS, } from "@/constants"; -import { createArrow, createTick } from "./primitives"; import { replaceSpecialChars } from "./text/mtextParser"; import type { GeometryCollector } from "./mergeCollectors"; import { addDimensionTextToCollector, measureDimensionTextWidth } from "./text/vectorTextBuilder"; - -/** - * Check if a DIMBLK block name represents a tick mark (oblique stroke). - * Common tick block names: _ArchTick, ArchTick, _OBLIQUE, Oblique, _Tick. - */ -export const isTickBlock = (name: string): boolean => { - if (!name) return false; - const n = name.toLowerCase(); - return n.includes("tick") || n.includes("oblique"); -}; +import { type ArrowKind, classifyArrowBlock, isArrowShape, createArrowhead } from "./arrowheads"; /** * Resolved dimension variable set. Values are final (already scaled by DIMSCALE). * Priority: entity XDATA override > header $DIM* × $DIMSCALE > hardcoded defaults. + * + * `arrowKind` controls the shape rendered at each dim line endpoint: + * - "tick" — DIMTSZ > 0 or DIMBLK names a tick block (_ArchTick, _Oblique, ...). + * `tickSize` carries the explicit DIMTSZ or follows arrowSize. + * - "closed-filled" — AutoCAD default (filled triangle). + * - other kinds — resolved from DIMBLK at the collector level. */ export interface DimVars { arrowSize: number; @@ -42,7 +40,7 @@ export interface DimVars { extLineDash: number; extLineGap: number; extLineExtension: number; // DIMEXE: extension line overshoot past dimension line - useTicks: boolean; + arrowKind: ArrowKind; tickSize: number; } @@ -54,7 +52,7 @@ export const DEFAULT_DIM_VARS: DimVars = { extLineDash: EXTENSION_LINE_DASH_SIZE, extLineGap: EXTENSION_LINE_GAP_SIZE, extLineExtension: EXTENSION_LINE_EXTENSION, - useTicks: false, + arrowKind: "closed-filled", tickSize: 0, }; @@ -81,12 +79,17 @@ export function resolveDimVarsFromHeader( const extLineExtension = (header.$DIMEXE ?? EXTENSION_LINE_EXTENSION) * scale; const dimtsz = header.$DIMTSZ ?? 0; - const dimblk = header.$DIMBLK ?? ""; - const useTicks = dimtsz > 0 || isTickBlock(dimblk); - // When using ticks: DIMTSZ provides explicit size, otherwise fall back to arrowSize - const tickSize = !useTicks ? 0 : dimtsz > 0 ? dimtsz * scale : arrowSize; - - return { arrowSize, textHeight, textGap, extLineDash, extLineGap, extLineExtension, useTicks, tickSize }; + const dimblk = header.$DIMBLK; + // DIMTSZ > 0 forces tick rendering regardless of DIMBLK (matches AutoCAD). + // Unknown DIMBLK names fall back to closed-filled (the AutoCAD default). + const arrowKind: ArrowKind = dimtsz > 0 + ? "tick" + : classifyArrowBlock(dimblk) ?? "closed-filled"; + // For tick: DIMTSZ provides explicit size, otherwise fall back to arrowSize. + // Non-tick kinds ignore tickSize. + const tickSize = arrowKind !== "tick" ? 0 : dimtsz > 0 ? dimtsz * scale : arrowSize; + + return { arrowSize, textHeight, textGap, extLineDash, extLineGap, extLineExtension, arrowKind, tickSize }; } /** @@ -154,7 +157,7 @@ export function applyDimStyleVars( } // When ticks are derived from arrowSize (DIMTSZ=0 + tick block), keep them in sync - if (result.useTicks && result.tickSize > 0 && dimStyle.dimtsz === undefined) { + if (result.arrowKind === "tick" && result.tickSize > 0 && dimStyle.dimtsz === undefined) { result.tickSize = result.arrowSize; } @@ -179,12 +182,33 @@ export function applyDimStyleVars( /** Shared params for dimension type functions (ordinate, radial, diametric, angular) */ export interface DimensionTypeParams { entity: DxfDimensionEntity; + /** Color for dimension geometry (lines/arrows). Resolved from DIMCLRD or entity color. */ color: string; + /** Color for dimension text. Resolved from DIMCLRT or entity color. Defaults to `color`. */ + textColor?: string; font?: Font; collector?: GeometryCollector; layer?: string; transform?: readonly number[]; dv?: DimVars; + /** Dimension formatting options (DIMDEC, DIMZIN, DIMADEC, DIMLUNIT). */ + fmt?: DimFormatOptions; + /** DIMTOH (DIMSTYLE code 73): text outside arc/dim — 0=aligned, 1=horizontal. Undefined treated as 0. */ + dimtoh?: number; + /** DIMTIH (DIMSTYLE code 74): text inside arc/dim — 0=aligned, 1=horizontal. Undefined treated as 0. */ + dimtih?: number; + /** DIMTAD (DIMSTYLE code 77): text vertical position (0=centered, 1=above, 2=outside, 3=JIS, 4=below). + * When undefined the ISO default (1, above line) is assumed. */ + dimtad?: number; + /** DIMGAP (DIMSTYLE code 147): gap from the dim line to the text bounding box, + * and the radius used to break the dim line around centered text. Already scaled by DIMSCALE. */ + dimgap?: number; + /** DIMTMOVE (DIMSTYLE code 279): how to draw text the user repositioned — + * 0=move dim line, 1=add leader, 2=move text only. Default 0. */ + dimtmove?: number; + /** STYLE.widthFactor (DXF code 41) for the dim text style referenced by + * DIMSTYLE.DIMTXSTY (code 340). Horizontal stretch only. Defaults to 1. */ + widthFactor?: number; } /** Params for createLinearDimensionLines */ @@ -230,6 +254,25 @@ export interface DimensionGroupParams { /** Params for emitStackedText (vectorTextBuilder.ts) */ +/** + * Tag userData.dimPart on dimension geometry so the collector can resolve + * separate colors for dimension line + arrows ("dim") vs extension lines ("ext"). + * Walks the provided objects (and their descendants). Any object whose material + * matches `extMaterial` is tagged "ext"; everything else is tagged "dim". + */ +export const tagDimParts = ( + objects: THREE.Object3D[], + extMaterial?: THREE.Material, +): void => { + for (const obj of objects) { + obj.traverse((child) => { + const mat = (child as THREE.Mesh).material as THREE.Material | undefined; + if (!mat) return; + child.userData.dimPart = (extMaterial && mat === extMaterial) ? "ext" : "dim"; + }); + } +}; + /** Line defined by two points for intersectLines2D */ export interface Line2D { x1: number; @@ -287,26 +330,38 @@ export const createLinearDimensionLines = (p: LinearDimensionLinesParams): THREE const max = Math.max(getMainCoord(point1), getMainCoord(point2)); const anchorFixed = getFixedCoord(anchorPoint); - // Split dimension line around text if text lies on the line - if (textPos && Math.abs(getFixedCoord(textPos) - anchorFixed) < 1) { + // When the dim line is too short to fit two inward-pointing arrows, flip them + // to point inward from outside. Arrow bases sit at min - arrowSize / max + arrowSize; + // the dim line extends one extra `tail` past each base so the arrows read as + // arrows (shaft + head) rather than two opposing triangles. Only applies to + // arrow-shape kinds — dots/ticks/boxes always sit at the endpoint. + const useOutsideArrows = isArrowShape(dv.arrowKind) && (max - min) < OUTSIDE_ARROW_THRESHOLD_RATIO * dv.arrowSize; + const outsideOffset = dv.arrowSize * (1 + OUTSIDE_ARROW_TAIL_RATIO); + const dimMin = useOutsideArrows ? min - outsideOffset : min; + const dimMax = useOutsideArrows ? max + outsideOffset : max; + + // Split dimension line around text only when arrows fit inside — + // outside-arrow mode keeps a continuous extended line; the text usually sits + // off the line (per file) and the gap would land outside the measurement. + if (textPos && !useOutsideArrows && Math.abs(getFixedCoord(textPos) - anchorFixed) < 1) { const gapStart = getMainCoord(textPos) - dv.textGap / 2; const gapEnd = getMainCoord(textPos) + dv.textGap / 2; - if (min < gapStart) { + if (dimMin < gapStart) { objects.push( createExtensionLine( - createVec3(min, anchorFixed, 0), + createVec3(dimMin, anchorFixed, 0), createVec3(gapStart, anchorFixed, 0), dimLineMaterial, ), ); } - if (max > gapEnd) { + if (dimMax > gapEnd) { objects.push( createExtensionLine( createVec3(gapEnd, anchorFixed, 0), - createVec3(max, anchorFixed, 0), + createVec3(dimMax, anchorFixed, 0), dimLineMaterial, ), ); @@ -314,8 +369,8 @@ export const createLinearDimensionLines = (p: LinearDimensionLinesParams): THREE } else { objects.push( createExtensionLine( - createVec3(min, anchorFixed, 0), - createVec3(max, anchorFixed, 0), + createVec3(dimMin, anchorFixed, 0), + createVec3(dimMax, anchorFixed, 0), dimLineMaterial, ), ); @@ -342,13 +397,21 @@ export const createLinearDimensionLines = (p: LinearDimensionLinesParams): THREE ); } - if (dv.useTicks) { - const dimAngle = isHorizontal ? 0 : Math.PI / 2; - objects.push(createTick(createVec3(min, anchorFixed, 0.1), dv.tickSize, dimAngle, dimLineMaterial)); - objects.push(createTick(createVec3(max, anchorFixed, 0.1), dv.tickSize, dimAngle, dimLineMaterial)); + const headSize = dv.arrowKind === "tick" ? dv.tickSize : dv.arrowSize; + const pushHead = (from: THREE.Vector3, tip: THREE.Vector3) => { + objects.push(...createArrowhead({ + from, tip, size: headSize, kind: dv.arrowKind, + lineMaterial: dimLineMaterial, fillMaterial: arrowMaterial, + })); + }; + + if (useOutsideArrows) { + // Flipped: tips at min/max pointing inward, bases at dimMin/dimMax (outside) + pushHead(createVec3(dimMin, anchorFixed, 0.1), createVec3(min, anchorFixed, 0.1)); + pushHead(createVec3(dimMax, anchorFixed, 0.1), createVec3(max, anchorFixed, 0.1)); } else { - objects.push(createArrow(createVec3(max, anchorFixed, 0.1), createVec3(min, anchorFixed, 0.1), dv.arrowSize, arrowMaterial)); - objects.push(createArrow(createVec3(min, anchorFixed, 0.1), createVec3(max, anchorFixed, 0.1), dv.arrowSize, arrowMaterial)); + pushHead(createVec3(max, anchorFixed, 0.1), createVec3(min, anchorFixed, 0.1)); + pushHead(createVec3(min, anchorFixed, 0.1), createVec3(max, anchorFixed, 0.1)); } return objects; @@ -398,8 +461,29 @@ export const createRotatedDimensionLines = (p: RotatedDimensionLinesParams): THR 0, ); - // Split dimension line around text if text lies on it (perpendicular distance < 1) - if (textPos) { + // When the dim line is too short to fit two inward-pointing arrows, flip them + // to point inward from outside. Arrow bases sit at tMin - arrowSize / tMax + arrowSize; + // the dim line extends one extra `tail` past each base so the arrows read as + // arrows (shaft + head) rather than two opposing triangles. Only applies to + // arrow-shape kinds — dots/ticks/boxes always sit at the endpoint. + const useOutsideArrows = isArrowShape(dv.arrowKind) && (tMax - tMin) < OUTSIDE_ARROW_THRESHOLD_RATIO * dv.arrowSize; + const outsideOffset = dv.arrowSize * (1 + OUTSIDE_ARROW_TAIL_RATIO); + const tDimMin = useOutsideArrows ? tMin - outsideOffset : tMin; + const tDimMax = useOutsideArrows ? tMax + outsideOffset : tMax; + const dimMinPt = new THREE.Vector3( + anchorPoint.x + tDimMin * dirX, + anchorPoint.y + tDimMin * dirY, + 0, + ); + const dimMaxPt = new THREE.Vector3( + anchorPoint.x + tDimMax * dirX, + anchorPoint.y + tDimMax * dirY, + 0, + ); + + // Split dimension line around text only when arrows fit inside — + // in outside-arrow mode the dim line stays continuous along the extended range. + if (textPos && !useOutsideArrows) { const tText = (textPos.x - anchorPoint.x) * dirX + (textPos.y - anchorPoint.y) * dirY; const perpDist = Math.abs( -(textPos.x - anchorPoint.x) * dirY + (textPos.y - anchorPoint.y) * dirX, @@ -409,10 +493,10 @@ export const createRotatedDimensionLines = (p: RotatedDimensionLinesParams): THR const gapStart = tText - dv.textGap / 2; const gapEnd = tText + dv.textGap / 2; - if (tMin < gapStart) { + if (tDimMin < gapStart) { objects.push( createExtensionLine( - minPt, + dimMinPt, new THREE.Vector3( anchorPoint.x + gapStart * dirX, anchorPoint.y + gapStart * dirY, @@ -422,7 +506,7 @@ export const createRotatedDimensionLines = (p: RotatedDimensionLinesParams): THR ), ); } - if (tMax > gapEnd) { + if (tDimMax > gapEnd) { objects.push( createExtensionLine( new THREE.Vector3( @@ -430,16 +514,16 @@ export const createRotatedDimensionLines = (p: RotatedDimensionLinesParams): THR anchorPoint.y + gapEnd * dirY, 0, ), - maxPt, + dimMaxPt, dimLineMaterial, ), ); } } else { - objects.push(createExtensionLine(minPt, maxPt, dimLineMaterial)); + objects.push(createExtensionLine(dimMinPt, dimMaxPt, dimLineMaterial)); } } else { - objects.push(createExtensionLine(minPt, maxPt, dimLineMaterial)); + objects.push(createExtensionLine(dimMinPt, dimMaxPt, dimLineMaterial)); } const p1 = new THREE.Vector3(point1.x, point1.y, 0); @@ -452,12 +536,21 @@ export const createRotatedDimensionLines = (p: RotatedDimensionLinesParams): THR objects.push(createExtensionLine(p2, foot2, extensionLineMaterial, dv.extLineExtension)); } - if (dv.useTicks) { - objects.push(createTick(new THREE.Vector3(minPt.x, minPt.y, 0.1), dv.tickSize, angleRad, dimLineMaterial)); - objects.push(createTick(new THREE.Vector3(maxPt.x, maxPt.y, 0.1), dv.tickSize, angleRad, dimLineMaterial)); + const headSize = dv.arrowKind === "tick" ? dv.tickSize : dv.arrowSize; + const pushHead = (from: THREE.Vector3, tip: THREE.Vector3) => { + objects.push(...createArrowhead({ + from, tip, size: headSize, kind: dv.arrowKind, + lineMaterial: dimLineMaterial, fillMaterial: arrowMaterial, + })); + }; + + if (useOutsideArrows) { + // Flipped: tips at minPt/maxPt pointing inward, bases at dimMinPt/dimMaxPt (outside) + pushHead(new THREE.Vector3(dimMinPt.x, dimMinPt.y, 0.1), new THREE.Vector3(minPt.x, minPt.y, 0.1)); + pushHead(new THREE.Vector3(dimMaxPt.x, dimMaxPt.y, 0.1), new THREE.Vector3(maxPt.x, maxPt.y, 0.1)); } else { - objects.push(createArrow(new THREE.Vector3(maxPt.x, maxPt.y, 0.1), new THREE.Vector3(minPt.x, minPt.y, 0.1), dv.arrowSize, arrowMaterial)); - objects.push(createArrow(new THREE.Vector3(minPt.x, minPt.y, 0.1), new THREE.Vector3(maxPt.x, maxPt.y, 0.1), dv.arrowSize, arrowMaterial)); + pushHead(new THREE.Vector3(maxPt.x, maxPt.y, 0.1), new THREE.Vector3(minPt.x, minPt.y, 0.1)); + pushHead(new THREE.Vector3(minPt.x, minPt.y, 0.1), new THREE.Vector3(maxPt.x, maxPt.y, 0.1)); } return objects; @@ -466,6 +559,8 @@ export const createRotatedDimensionLines = (p: RotatedDimensionLinesParams): THR export interface DimFormatOptions { dimlunit?: number; // 2=Decimal, 4=Architectural dimzin?: number; // Zero suppression flags + dimdec?: number; // Decimal places for primary units (arch: 2^dimdec = fraction denominator) + dimadec?: number; // Decimal places for angular dimensions } export const extractDimensionData = (entity: DxfDimensionEntity, dv: DimVars = DEFAULT_DIM_VARS, fmt?: DimFormatOptions) => { @@ -480,8 +575,8 @@ export const extractDimensionData = (entity: DxfDimensionEntity, dv: DimVars = D const formatMeasurement = (value: number): string => fmt?.dimlunit === 4 - ? formatArchitectural(value, fmt.dimzin) - : formatDimNumber(value); + ? formatArchitectural(value, fmt.dimzin, fmt.dimdec) + : formatDimNumber(value, fmt?.dimdec, fmt?.dimzin); // Detect radial dimension BEFORE generating text to add "R" prefix if (!point1 && !point2 && diameterOrRadiusPoint && anchorPoint) { @@ -512,7 +607,7 @@ export const extractDimensionData = (entity: DxfDimensionEntity, dv: DimVars = D } if (!isRadial && dimensionText && !isNaN(parseFloat(dimensionText)) && fmt?.dimlunit !== 4) { - dimensionText = formatDimNumber(parseFloat(dimensionText)); + dimensionText = formatDimNumber(parseFloat(dimensionText), fmt?.dimdec, fmt?.dimzin); } if (!point1 || !point2 || !anchorPoint || !dimensionText) { @@ -565,14 +660,17 @@ export const createDimensionGroup = (p: DimensionGroupParams): THREE.Group => { ), ); - const arrow = createArrow( - new THREE.Vector3(centerX, centerY, 0.1), - new THREE.Vector3(edgeX, edgeY, 0.1), - dv.arrowSize, - arrowMaterial, - ); - dimGroup.add(arrow); + const headSize = dv.arrowKind === "tick" ? dv.tickSize : dv.arrowSize; + const heads = createArrowhead({ + from: new THREE.Vector3(centerX, centerY, 0.1), + tip: new THREE.Vector3(edgeX, edgeY, 0.1), + size: headSize, kind: dv.arrowKind, + lineMaterial: dimLineMaterial, fillMaterial: arrowMaterial, + }); + heads.forEach((h) => dimGroup.add(h)); + // No extension lines in this radial branch — everything is "dim". + tagDimParts([dimGroup]); return dimGroup; } @@ -600,21 +698,41 @@ export const createDimensionGroup = (p: DimensionGroupParams): THREE.Group => { dimensionObjects.forEach((obj) => dimGroup.add(obj)); + // Tag children so the collector can split into dim/ext colors per DIMCLRD/DIMCLRE. + tagDimParts([dimGroup], extensionLineMaterial); + return dimGroup; }; /** - * Format dimension number: up to DIM_TEXT_DECIMAL_PLACES digits, no trailing zeros. - * 28 -> "28", 28.28 -> "28.28", 28.10 -> "28.1" + * Format a dimension number with optional `decimals` (DIMDEC for linear, + * DIMADEC for angular) and `dimzin` zero-suppression flags. In decimal mode + * DIMZIN uses bit 2 (4) for leading zeros and bit 3 (8) for trailing zeros. + * + * When `dimzin` is undefined trailing zeros are stripped — preserves the + * pre-DIMDEC default (caller passes the raw value). + */ +export const formatDimNumber = (value: number, decimals?: number, dimzin?: number): string => { + const places = decimals ?? DIM_TEXT_DECIMAL_PLACES; + let s = value.toFixed(places); + const stripTrailing = dimzin === undefined || (dimzin & 8) !== 0; + const stripLeading = dimzin !== undefined && (dimzin & 4) !== 0; + if (stripTrailing) s = parseFloat(s).toString(); + if (stripLeading) s = s.replace(/^(-?)0\./, "$1."); + return s; +}; + +/** + * Default DIMDEC for architectural fractions when not specified by DIMSTYLE/header. + * 2^4 = 16 → 1/16" precision. */ -export const formatDimNumber = (value: number): string => - parseFloat(value.toFixed(DIM_TEXT_DECIMAL_PLACES)).toString(); +const DEFAULT_ARCH_DIMDEC = 4; /** - * Max fraction denominator for architectural dimensions. - * 2^4 = 16 → 1/16" precision (standard DIMDEC=4). + * Upper bound on DIMDEC for architectural fractions. 2^8 = 1/256" is more than enough; + * higher values would produce nonsensical denominators and risk Math.round overflow. */ -const ARCH_FRAC_DENOM = 16; +const MAX_ARCH_DIMDEC = 8; /** * Format a measurement in inches as architectural: feet'-inches". @@ -625,8 +743,15 @@ const ARCH_FRAC_DENOM = 16; * bit 2 (4): suppress 0 feet → "4\"" instead of "0'-4\"" * bit 3 (8): suppress 0 inches → "7'" instead of "7'-0\"" * Default (dimzin=0): suppress both zero feet and zero inches. + * + * dimdec sets the fraction denominator as 2^dimdec (DXF code 271 / $DIMDEC): + * 0 → whole inches only; 1 → 1/2; 2 → 1/4; 3 → 1/8; 4 → 1/16 (default); 5 → 1/32; ... */ -export const formatArchitectural = (totalInches: number, dimzin?: number): string => { +export const formatArchitectural = ( + totalInches: number, + dimzin?: number, + dimdec?: number, +): string => { const sign = totalInches < 0 ? "-" : ""; const abs = Math.abs(totalInches); let feet = Math.floor(abs / 12); @@ -634,12 +759,21 @@ export const formatArchitectural = (totalInches: number, dimzin?: number): strin let wholeInches = Math.floor(remInches); const fracPart = remInches - wholeInches; + const dec = Math.min(Math.max(dimdec ?? DEFAULT_ARCH_DIMDEC, 0), MAX_ARCH_DIMDEC); + const denom = 1 << dec; // 2^dec; dec=0 → 1 (no fraction, round to whole inch) + // Convert fractional part to nearest fraction with power-of-2 denominator let fracNum = 0; let fracDen = 1; - if (fracPart > 1 / (ARCH_FRAC_DENOM * 2)) { - fracNum = Math.round(fracPart * ARCH_FRAC_DENOM); - fracDen = ARCH_FRAC_DENOM; + if (denom === 1) { + // DIMDEC=0: round fractional part to nearest whole inch + if (fracPart >= 0.5) { + wholeInches++; + if (wholeInches >= 12) { feet++; wholeInches -= 12; } + } + } else if (fracPart > 1 / (denom * 2)) { + fracNum = Math.round(fracPart * denom); + fracDen = denom; if (fracNum >= fracDen) { // Fraction rounds up to next whole inch fracNum = 0; @@ -715,7 +849,9 @@ export const cleanDimensionMText = (rawText: string): string => { * No arrows or dashed lines -- solid lines only (per AutoCAD convention). */ export const createOrdinateDimension = (p: DimensionTypeParams): THREE.Object3D[] | null => { - const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS } = p; + const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS, fmt } = p; + const textColor = p.textColor ?? color; + const widthFactor = p.widthFactor ?? 1; const feature = entity.linearOrAngularPoint1; // Code 13 -- point on object const leader = entity.linearOrAngularPoint2; // Code 14 -- end of diagonal const textPos = entity.middleOfText; // Code 11 @@ -726,11 +862,11 @@ export const createOrdinateDimension = (p: DimensionTypeParams): THREE.Object3D[ const measurement = entity.actualMeasurement; if (dimensionText && typeof measurement === "number") { - dimensionText = dimensionText.replace(/<>/g, formatDimNumber(measurement)); + dimensionText = dimensionText.replace(/<>/g, formatDimNumber(measurement, fmt?.dimdec, fmt?.dimzin)); } if (!dimensionText && typeof measurement === "number") { - dimensionText = formatDimNumber(measurement); + dimensionText = formatDimNumber(measurement, fmt?.dimdec, fmt?.dimzin); } if (!dimensionText) return null; @@ -742,10 +878,10 @@ export const createOrdinateDimension = (p: DimensionTypeParams): THREE.Object3D[ // Create text mesh first to determine actual width for leader endpoint let actualTextWidth = 0; if (textPos) { - actualTextWidth = measureDimensionTextWidth(font!, dimensionText, textHeight); + actualTextWidth = measureDimensionTextWidth(font!, dimensionText, textHeight, widthFactor); addDimensionTextToCollector({ - collector: collector!, layer: layer!, color, font: font!, rawText: dimensionText, height: textHeight, - posX: textPos.x, posY: textPos.y, posZ: 0.2, transform, + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: textPos.x, posY: textPos.y, posZ: 0.2, transform, widthFactor, }); } @@ -837,15 +973,62 @@ export const createOrdinateDimension = (p: DimensionTypeParams): THREE.Object3D[ } } - return objects.length > 0 ? objects : null; + if (objects.length === 0) return null; + tagDimParts(objects); + return objects; }; /** * Create a radial dimension (type 4). - * Line from text edge to the point on the arc, arrow pointing outward at the arc. + * + * Layout state-machine driven by DIMSTYLE variables: + * DIMTIH/DIMTOH (codes 74/73): aligned vs horizontal text + * DIMTAD (code 77): vertical position of text relative to dim line + * DIMGAP (code 147): gap between dim line and text (and break-radius + * around centered text) + * DIMTMOVE (code 279): behaviour when user repositions the text + * + * Entity-level signals: + * bit 5 of dimensionType (=32): default text position (auto-placed by CAD) + * + * Geometry: + * 1. Project textPos onto the radius direction → (along, perp) coords. + * 2. textInside = (along ≤ radius) + * aligned = textInside ? DIMTIH≠1 : DIMTOH≠1 + * breakLine = aligned ∧ DIMTAD=0 (text crosses the dim line) + * perpOffset = |perp| (how far text sits off the line) + * 3. Pick aligned text rotation (radius angle, flipped to [-π/2, π/2]). + * 4. Build dim line(s): + * • aligned, !breakLine + * – text outside arc: no line from centre; short leader from arrow base + * outward to (near-arc edge of text along radius), only when room + * – text inside arc: single line from centre to (radius − arrowSize), + * i.e. up to the arrow base on the centre side + * • aligned, breakLine + * two segments around the text projection on the radius line + * (gap = halfTextWidth + DIMGAP each side) + * • !aligned (horizontal text) + * existing leader + horizontal-text + underline path + * 5. Arrow at arcPt: + * • text outside / aligned: body OUTSIDE arc, tip points inward + * • text inside / aligned: body INSIDE arc, tip points outward + * • horizontal: body follows the leader (from tailEndPoint) + * 6. Place text at textPos with the chosen rotation. + * 7. If the user moved the text (defaultPosition bit clear) AND DIMTMOVE=1, + * add an extra leader from the projection of textPos on the radius line + * down to textPos itself — the "moved with leader" style. + * + * Caveats / not yet implemented: + * • Text positions OFF the radius line are handled (we honour textPos as-is) + * but only DIMTMOVE=1 adds a connector — DIMTMOVE=2 ("move text only") + * still draws the radius without a connector, which differs from AutoCAD. + * • DIMUPT, DIMATFIT — parsed but not used here yet. + * • ACAD_DSTYLE_DIMRADIAL_EXTENSION XDATA on the entity is ignored. */ export const createRadialDimension = (p: DimensionTypeParams): THREE.Object3D[] | null => { - const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS } = p; + const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS, fmt } = p; + const textColor = p.textColor ?? color; + const widthFactor = p.widthFactor ?? 1; const center = entity.anchorPoint; // code 10 const arcPt = entity.diameterOrRadiusPoint; // code 15 const textPos = entity.middleOfText; // code 11 @@ -853,85 +1036,259 @@ export const createRadialDimension = (p: DimensionTypeParams): THREE.Object3D[] if (!center || !arcPt) return null; let dimensionText = entity.text; - // Fallback: compute radius from coordinates if actualMeasurement is absent const measurement = entity.actualMeasurement ?? Math.sqrt((center.x - arcPt.x) ** 2 + (center.y - arcPt.y) ** 2); if (dimensionText) { - const measStr = "R" + formatDimNumber(measurement); + const measStr = "R" + formatDimNumber(measurement, fmt?.dimdec, fmt?.dimzin); dimensionText = dimensionText.replace(/<>/g, measStr); } - if (!dimensionText) { - dimensionText = "R" + formatDimNumber(measurement); + dimensionText = "R" + formatDimNumber(measurement, fmt?.dimdec, fmt?.dimzin); } const textHeight = entity.textHeight || dv.textHeight; const objects: THREE.Object3D[] = []; const lineMat = new THREE.LineBasicMaterial({ color }); const arrowMat = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }); + const headSize = dv.arrowKind === "tick" ? dv.tickSize : dv.arrowSize; + const pushHead = (from: THREE.Vector3, tip: THREE.Vector3) => { + objects.push(...createArrowhead({ + from, tip, size: headSize, kind: dv.arrowKind, + lineMaterial: lineMat, fillMaterial: arrowMat, + })); + }; - const arcVec = new THREE.Vector3(arcPt.x, arcPt.y, 0); - - // Direction from arcPt toward center (inward) - const dx = center.x - arcPt.x; - const dy = center.y - arcPt.y; - const len = Math.sqrt(dx * dx + dy * dy); - const dirX = len > EPSILON ? dx / len : 1; - const dirY = len > EPSILON ? dy / len : 0; - - // tailEndPoint determines arrow direction (from tail toward arc point) - let tailEndPoint: THREE.Vector3 | null = null; + // Radius direction (centre → arcPt) and its perpendicular (CCW 90°). + const radius = measurement; + const rdx = arcPt.x - center.x; + const rdy = arcPt.y - center.y; + const rlen = Math.sqrt(rdx * rdx + rdy * rdy); + if (rlen < EPSILON) return null; + const outDirX = rdx / rlen; + const outDirY = rdy / rlen; + const perpDirX = -outDirY; + const perpDirY = outDirX; + + // Resolved DIMSTYLE variables with ISO defaults. + const dimtih = p.dimtih ?? 0; + const dimtoh = p.dimtoh ?? 0; + const dimtad = p.dimtad ?? 1; // 1 = text above line (ISO default) + // Default DIMGAP follows the historical 0.4 × textHeight that the previous + // implementation used; replaced when DIMSTYLE actually sets it. + const dimgap = p.dimgap ?? textHeight * 0.4; + const dimtmove = p.dimtmove ?? 0; + // Bit 5 (=32) of code 70 = "default position", per DXF spec. + const defaultPosition = ((entity.dimensionType ?? 0) & 32) !== 0; + + if (!textPos) { + // No text: full radius line + outward-pointing arrow. Acts as a sane fallback. + const lineGeom = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(center.x, center.y, 0), + new THREE.Vector3(arcPt.x, arcPt.y, 0), + ]); + objects.push(new THREE.Line(lineGeom, lineMat)); + pushHead( + new THREE.Vector3(center.x, center.y, 0.1), + new THREE.Vector3(arcPt.x, arcPt.y, 0.1), + ); + tagDimParts(objects); + return objects; + } - if (textPos) { - // textPos is the middle of text per DXF spec ("middle point of dimension text") - // Underline Y for leader geometry: bottom of text area + // Decompose textPos relative to centre into (along radius, perpendicular). + const trx = textPos.x - center.x; + const tryy = textPos.y - center.y; + const textAlong = trx * outDirX + tryy * outDirY; + const textPerp = trx * perpDirX + tryy * perpDirY; + const textInside = textAlong <= radius; + const aligned = textInside ? dimtih !== 1 : dimtoh !== 1; + + if (!aligned) { + // ── Horizontal mode (DIMTIH=1 or DIMTOH=1, ANSI-style) ───────────────── + // Leader + horizontal text + underline (existing behaviour preserved). + const arcVec = new THREE.Vector3(arcPt.x, arcPt.y, 0); const underlineY = textPos.y - textHeight / 2; - - // Compute where the leader line intersects the text underline horizontal + const leaderDx = center.x - arcPt.x; + const leaderDy = center.y - arcPt.y; + const leaderLen = Math.sqrt(leaderDx * leaderDx + leaderDy * leaderDy); + const leaderDirX = leaderLen > EPSILON ? leaderDx / leaderLen : 1; + const leaderDirY = leaderLen > EPSILON ? leaderDy / leaderLen : 0; let intersectX = textPos.x; - if (Math.abs(dirY) > EPSILON) { - const t = (underlineY - arcPt.y) / dirY; - intersectX = arcPt.x + t * dirX; + if (Math.abs(leaderDirY) > EPSILON) { + const t = (underlineY - arcPt.y) / leaderDirY; + intersectX = arcPt.x + t * leaderDirX; } - - const textWidth = measureDimensionTextWidth(font!, dimensionText, textHeight); + const textWidth = measureDimensionTextWidth(font!, dimensionText, textHeight, widthFactor); addDimensionTextToCollector({ - collector: collector!, layer: layer!, color, font: font!, rawText: dimensionText, height: textHeight, - posX: textPos.x, posY: textPos.y, posZ: 0.2, transform, + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: textPos.x, posY: textPos.y, posZ: 0.2, transform, widthFactor, }); - + const tailEndPoint = new THREE.Vector3(intersectX, underlineY, 0); + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([arcVec, tailEndPoint]), + lineMat, + )); const textLeft = textPos.x - textWidth / 2; const textRight = textPos.x + textWidth / 2; - - // Leader line from arc point to text underline - tailEndPoint = new THREE.Vector3(intersectX, underlineY, 0); - const tailGeom = new THREE.BufferGeometry().setFromPoints([arcVec, tailEndPoint]); - objects.push(new THREE.Line(tailGeom, lineMat)); - - // Underline extends from leader intersection to far text edge const underlineLeft = intersectX <= textPos.x ? intersectX : textLeft; const underlineRight = intersectX <= textPos.x ? textRight : intersectX; - const underlineGeom = new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(underlineLeft, underlineY, 0), - new THREE.Vector3(underlineRight, underlineY, 0), - ]); - objects.push(new THREE.Line(underlineGeom, lineMat)); + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(underlineLeft, underlineY, 0), + new THREE.Vector3(underlineRight, underlineY, 0), + ]), + lineMat, + )); + pushHead( + new THREE.Vector3(tailEndPoint.x, tailEndPoint.y, 0.1), + new THREE.Vector3(arcPt.x, arcPt.y, 0.1), + ); + tagDimParts(objects); + return objects; } - // Arrow at arc point, directed from the line origin (tail or center) toward the arc - const arrowFrom = tailEndPoint - ? new THREE.Vector3(tailEndPoint.x, tailEndPoint.y, 0.1) - : new THREE.Vector3(center.x, center.y, 0.1); - const arrow = createArrow( - arrowFrom, - new THREE.Vector3(arcPt.x, arcPt.y, 0.1), - dv.arrowSize, - arrowMat, - ); - objects.push(arrow); + // ── Aligned mode ─────────────────────────────────────────────────────── + // Use the "readable" radius angle in [-π/2, π/2]. With that rotation the text + // ascender direction lands on the perpendicular side opposite to the readable + // flip (CCW 90° from the readable baseline). + let textAngle = Math.atan2(outDirY, outDirX); + if (textAngle > Math.PI / 2) textAngle -= Math.PI; + if (textAngle < -Math.PI / 2) textAngle += Math.PI; + const aboveDirX = -Math.sin(textAngle); + const aboveDirY = Math.cos(textAngle); + // Project "above" onto our perpDir to know which perp side is "above" for the + // readable rotation. + const abovePerpSign = aboveDirX * perpDirX + aboveDirY * perpDirY; // +1 or -1 + + // For default-position dims (CAD chose textPos automatically), if textPos sits + // on the opposite perp side from "above" of the readable rotation we mirror + // its perpendicular offset so the glyphs end up on the correct side. This + // matches how AutoCAD would auto-lay-out the same dim and avoids upside-down + // text. User-positioned dims (defaultPosition bit clear) are respected as-is. + let renderTextX = textPos.x; + let renderTextY = textPos.y; + const onLine = Math.abs(textPerp) < textHeight * 0.1; + if (defaultPosition && !onLine) { + // Desired perp side = sign(abovePerpSign). Current textPos.perp side = sign(textPerp). + const currentSign = textPerp >= 0 ? 1 : -1; + if (currentSign * abovePerpSign < 0) { + // Reflect textPos perpendicularly through the radius axis so it lands on + // the "above" side of the readable rotation. + const newPerp = -textPerp; + const footX = center.x + outDirX * textAlong; + const footY = center.y + outDirY * textAlong; + renderTextX = footX + perpDirX * newPerp; + renderTextY = footY + perpDirY * newPerp; + } + } + + const textWidth = measureDimensionTextWidth(font!, dimensionText, textHeight); + const halfWidth = textWidth / 2; + // The line breaks around the text only when DIMTAD=0 (text centred on line) + // — DIMTAD≥1 keeps the line continuous because the text is offset + // perpendicular by `dimgap + textHeight/2`. + const breakLine = dimtad === 0; + + if (textInside) { + // Text projects inside the arc. The dim line runs from centre up to the + // arrow base; the arrow sits at arcPt pointing OUTWARD (body inside arc). + const arrowBaseDist = Math.max(0, radius - dv.arrowSize); + if (breakLine && textAlong > EPSILON) { + const nearDist = Math.max(0, textAlong - halfWidth - dimgap); + const farDist = textAlong + halfWidth + dimgap; + if (nearDist > EPSILON) { + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(center.x, center.y, 0), + new THREE.Vector3(center.x + outDirX * nearDist, center.y + outDirY * nearDist, 0), + ]), + lineMat, + )); + } + if (farDist < arrowBaseDist) { + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(center.x + outDirX * farDist, center.y + outDirY * farDist, 0), + new THREE.Vector3(center.x + outDirX * arrowBaseDist, center.y + outDirY * arrowBaseDist, 0), + ]), + lineMat, + )); + } + } else if (arrowBaseDist > EPSILON) { + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(center.x, center.y, 0), + new THREE.Vector3(center.x + outDirX * arrowBaseDist, center.y + outDirY * arrowBaseDist, 0), + ]), + lineMat, + )); + } + pushHead( + new THREE.Vector3(center.x, center.y, 0.1), + new THREE.Vector3(arcPt.x, arcPt.y, 0.1), + ); + } else { + // Text outside the arc. ISO convention: NO line from the centre — just + // an arrow at arcPt pointing inward (body outside the arc on the text side) + // and a leader extending OUTWARD along the radius: + // – DIMTAD=0: leader stops at the near-arc edge of the text (text breaks line) + // – DIMTAD≥1: leader runs UNDER the text up to its far (outer) edge + const arrowSizeLen = dv.arrowSize; + const arrowBaseAlong = radius + arrowSizeLen; + // Minimum tail past the arrow base, so the arrow always reads as + // "tip + shaft" rather than a bare triangle (same logic as short + // linear dims, see OUTSIDE_ARROW_TAIL_RATIO). + const arrowTail = arrowSizeLen * OUTSIDE_ARROW_TAIL_RATIO; + const naturalLeaderEnd = breakLine + ? textAlong - halfWidth - dimgap + : textAlong + halfWidth; + const leaderEndAlong = Math.max(naturalLeaderEnd, arrowBaseAlong + arrowTail); + if (leaderEndAlong > arrowBaseAlong) { + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(center.x + outDirX * arrowBaseAlong, center.y + outDirY * arrowBaseAlong, 0), + new THREE.Vector3(center.x + outDirX * leaderEndAlong, center.y + outDirY * leaderEndAlong, 0), + ]), + lineMat, + )); + } + // Arrow `from` lies further out so the head places the tip-side base + // OUTSIDE the arc — arrow points inward toward the arc. + pushHead( + new THREE.Vector3(center.x + outDirX * arrowBaseAlong, center.y + outDirY * arrowBaseAlong, 0.1), + new THREE.Vector3(arcPt.x, arcPt.y, 0.1), + ); + } - return objects.length > 0 ? objects : null; + // DIMTMOVE=1 (user moved text + leader): drop a connector from the projected + // foot of textPos on the radius line down to textPos itself. defaultPosition + // means CAD placed the text, so this connector is skipped in that case. + // The threshold is generous — we only draw if perpendicular offset is well + // beyond the natural DIMTAD vertical gap. + if (!defaultPosition && dimtmove === 1) { + const naturalPerp = dimtad >= 1 ? dimgap + textHeight / 2 : 0; + if (Math.abs(textPerp) > naturalPerp + textHeight) { + const footX = center.x + outDirX * textAlong; + const footY = center.y + outDirY * textAlong; + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(footX, footY, 0), + new THREE.Vector3(renderTextX, renderTextY, 0), + ]), + lineMat, + )); + } + } + + addDimensionTextToCollector({ + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: renderTextX, posY: renderTextY, posZ: 0.2, rotation: textAngle, hAlign: "center", transform, + widthFactor, + }); + + tagDimParts(objects); + return objects; }; /** @@ -940,7 +1297,9 @@ export const createRadialDimension = (p: DimensionTypeParams): THREE.Object3D[] * Text can be along the line or offset with a leader. */ export const createDiametricDimension = (p: DimensionTypeParams): THREE.Object3D[] | null => { - const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS } = p; + const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS, fmt } = p; + const widthFactor = p.widthFactor ?? 1; + const textColor = p.textColor ?? color; const p10 = entity.anchorPoint; // code 10 -- first point on circle const p15 = entity.diameterOrRadiusPoint; // code 15 -- opposite point const textPos = entity.middleOfText; // code 11 @@ -952,17 +1311,24 @@ export const createDiametricDimension = (p: DimensionTypeParams): THREE.Object3D Math.sqrt((p10.x - p15.x) ** 2 + (p10.y - p15.y) ** 2); if (dimensionText) { - dimensionText = dimensionText.replace(/<>/g, formatDimNumber(measurement)); + dimensionText = dimensionText.replace(/<>/g, formatDimNumber(measurement, fmt?.dimdec, fmt?.dimzin)); } if (!dimensionText) { - dimensionText = formatDimNumber(measurement); + dimensionText = formatDimNumber(measurement, fmt?.dimdec, fmt?.dimzin); } const textHeight = entity.textHeight || dv.textHeight; const objects: THREE.Object3D[] = []; const lineMat = new THREE.LineBasicMaterial({ color }); const arrowMat = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }); + const headSize = dv.arrowKind === "tick" ? dv.tickSize : dv.arrowSize; + const pushHead = (from: THREE.Vector3, tip: THREE.Vector3) => { + objects.push(...createArrowhead({ + from, tip, size: headSize, kind: dv.arrowKind, + lineMaterial: lineMat, fillMaterial: arrowMat, + })); + }; const cx = (p10.x + p15.x) / 2; const cy = (p10.y + p15.y) / 2; @@ -973,18 +1339,27 @@ export const createDiametricDimension = (p: DimensionTypeParams): THREE.Object3D const dir10x = len10 > EPSILON ? dx10 / len10 : 1; const dir10y = len10 > EPSILON ? dy10 / len10 : 0; - // Determine if text sits on the diameter line (within textHeight perpendicular distance - // and between endpoints). This controls arrow direction: outward when text is inside, - // inward when text is offset outside. + // Determine if text projects onto the diameter segment (t ∈ [0,1]). When it + // does we render aligned text along the diameter — the previous perpendicular + // distance constraint (perpDist < textHeight) used to also require text to sit + // right on the line, but that excluded perfectly normal cases where the source + // CAD placed the text slightly offset perpendicular (DIMTAD=1 + DIMGAP). On-segment + // text deserves the aligned treatment regardless of perpendicular offset; only + // text whose projection falls OUTSIDE the segment is treated as "leader + shelf". let textOnLine = false; + let textT = 0; + let textPerpDiam = 0; + let fullDiamLen = 0; if (textPos) { - const fullLen = len10 * 2; - if (fullLen > EPSILON) { - const ldx = (p10.x - p15.x) / fullLen; - const ldy = (p10.y - p15.y) / fullLen; - const t = ((textPos.x - p15.x) * ldx + (textPos.y - p15.y) * ldy) / fullLen; - const perpDist = Math.abs(-(textPos.x - p15.x) * ldy + (textPos.y - p15.y) * ldx); - textOnLine = perpDist < textHeight && t >= 0 && t <= 1; + fullDiamLen = len10 * 2; + if (fullDiamLen > EPSILON) { + // Unit vector p15 → p10 (so t=0 at p15 and t=1 at p10). + const ux = (p10.x - p15.x) / fullDiamLen; + const uy = (p10.y - p15.y) / fullDiamLen; + textT = ((textPos.x - p15.x) * ux + (textPos.y - p15.y) * uy) / fullDiamLen; + // Signed perpendicular (CCW 90° from baseline). Used for DIMTAD mirroring. + textPerpDiam = -(textPos.x - p15.x) * uy + (textPos.y - p15.y) * ux; + textOnLine = textT >= 0 && textT <= 1; } } @@ -995,17 +1370,13 @@ export const createDiametricDimension = (p: DimensionTypeParams): THREE.Object3D p10.y + arrowSign * dir10y * dv.arrowSize, 0.1, ); - objects.push( - createArrow(arrow10From, new THREE.Vector3(p10.x, p10.y, 0.1), dv.arrowSize, arrowMat), - ); + pushHead(arrow10From, new THREE.Vector3(p10.x, p10.y, 0.1)); const arrow15From = new THREE.Vector3( p15.x - arrowSign * dir10x * dv.arrowSize, p15.y - arrowSign * dir10y * dv.arrowSize, 0.1, ); - objects.push( - createArrow(arrow15From, new THREE.Vector3(p15.x, p15.y, 0.1), dv.arrowSize, arrowMat), - ); + pushHead(arrow15From, new THREE.Vector3(p15.x, p15.y, 0.1)); const diamLineGeom = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(p15.x, p15.y, 0), @@ -1014,55 +1385,150 @@ export const createDiametricDimension = (p: DimensionTypeParams): THREE.Object3D objects.push(new THREE.Line(diamLineGeom, lineMat)); if (textPos && textOnLine) { - // Text along diameter line -- rotated to match line angle - let angle = Math.atan2(p10.y - p15.y, p10.x - p15.x); - if (angle > Math.PI / 2) angle -= Math.PI; - if (angle < -Math.PI / 2) angle += Math.PI; + // Text projects onto the diameter segment. DIMTIH=1 (horizontal) overrides + // the aligned default — ANSI-style sheets use that. Otherwise we render + // aligned text along the diameter direction with a readability flip. + const aligned = p.dimtih !== 1; + let angle = 0; + let renderX = textPos.x; + let renderY = textPos.y; + if (aligned) { + angle = Math.atan2(p10.y - p15.y, p10.x - p15.x); + if (angle > Math.PI / 2) angle -= Math.PI; + if (angle < -Math.PI / 2) angle += Math.PI; + + // Mirror textPos across the diameter axis for default-position dims when + // the source CAD placed it on the perp side opposite to the readable + // rotation's "above" — same trick as radial. Without this the readable + // flip can put glyph ascenders pointing at the diameter line. + const defaultPosition = ((entity.dimensionType ?? 0) & 32) !== 0; + const onLineExact = Math.abs(textPerpDiam) < textHeight * 0.1; + if (defaultPosition && !onLineExact && fullDiamLen > EPSILON) { + const ux = (p10.x - p15.x) / fullDiamLen; + const uy = (p10.y - p15.y) / fullDiamLen; + // perpDir = CCW 90° from u (which is the readable "above" of `angle` + // before the flip — after flipping into the readable half-turn, the + // sign relationship still holds: text's "above" lies on the +perpDiam + // side after any single flip). + const perpDirX = -uy; + const perpDirY = ux; + const aboveDirX = -Math.sin(angle); + const aboveDirY = Math.cos(angle); + const abovePerpSign = aboveDirX * perpDirX + aboveDirY * perpDirY; + const currentSign = textPerpDiam >= 0 ? 1 : -1; + if (currentSign * abovePerpSign < 0) { + // Reflect textPos perpendicularly across the diameter axis. + const footT = textT; + const footX = p15.x + ux * (footT * fullDiamLen); + const footY = p15.y + uy * (footT * fullDiamLen); + renderX = footX - perpDirX * textPerpDiam; + renderY = footY - perpDirY * textPerpDiam; + } + } + } addDimensionTextToCollector({ - collector: collector!, layer: layer!, color, font: font!, rawText: dimensionText, height: textHeight, - posX: textPos.x, posY: textPos.y, posZ: 0.2, rotation: angle, transform, + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: renderX, posY: renderY, posZ: 0.2, rotation: angle, transform, widthFactor, }); } else if (textPos) { - // Text offset outside -- leader from nearest line end toward text + // Text projects OFF the diameter segment (t < 0 or t > 1). + // + // DIMTOH=1 (ANSI horizontal): old leader + horizontal text + underline shelf. + // DIMTOH=0/undefined (ISO aligned, default): extend the diameter line past the + // nearest endpoint to under the text, render text rotated to match the + // diameter angle. No horizontal shelf. const dist10 = (textPos.x - p10.x) ** 2 + (textPos.y - p10.y) ** 2; const dist15 = (textPos.x - p15.x) ** 2 + (textPos.y - p15.y) ** 2; const nearPt = dist10 <= dist15 ? p10 : p15; - const dxN = cx - nearPt.x; - const dyN = cy - nearPt.y; - const lenN = Math.sqrt(dxN * dxN + dyN * dyN); - const dirNx = lenN > EPSILON ? dxN / lenN : 1; - const dirNy = lenN > EPSILON ? dyN / lenN : 0; - // Underline Y for leader geometry: bottom of text area - const underlineY = textPos.y - textHeight / 2; - - let intersectX = textPos.x; - if (Math.abs(dirNy) > EPSILON) { - const t = (underlineY - nearPt.y) / dirNy; - intersectX = nearPt.x + t * dirNx; - } - - const textWidth = measureDimensionTextWidth(font!, dimensionText, textHeight); - addDimensionTextToCollector({ - collector: collector!, layer: layer!, color, font: font!, rawText: dimensionText, height: textHeight, - posX: textPos.x, posY: textPos.y, posZ: 0.2, transform, - }); - - const textLeft = textPos.x - textWidth / 2; - const textRight = textPos.x + textWidth / 2; + if (p.dimtoh !== 1 && fullDiamLen > EPSILON) { + // ── ISO aligned-with-extension ────────────────────────────────────── + // Pick outDir = unit vector pointing FROM the center OUTWARD through nearPt. + // The extension continues past nearPt in that same outward direction. + const outDirX = (nearPt.x - cx) / (fullDiamLen / 2); + const outDirY = (nearPt.y - cy) / (fullDiamLen / 2); + // Distance from nearPt to textPos projected onto outDir (always ≥ 0 here + // because textPos is past nearPt along outDir). + const projAlong = (textPos.x - nearPt.x) * outDirX + (textPos.y - nearPt.y) * outDirY; + const textWidth = measureDimensionTextWidth(font!, dimensionText, textHeight, widthFactor); + const halfWidth = textWidth / 2; + const dimgap = p.dimgap ?? textHeight * 0.4; + const arrowTail = dv.arrowSize * OUTSIDE_ARROW_TAIL_RATIO; + + // Extension length on the near-text side: from nearPt out to the far text + // edge along the diameter, but at least past the arrow base + tail so the + // arrow reads as "tip + shaft" rather than a bare triangle. + const extensionEnd = Math.max(projAlong + halfWidth + dimgap, dv.arrowSize + arrowTail); + if (extensionEnd > EPSILON) { + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(nearPt.x, nearPt.y, 0), + new THREE.Vector3(nearPt.x + outDirX * extensionEnd, nearPt.y + outDirY * extensionEnd, 0), + ]), + lineMat, + )); + } - const nearVec = new THREE.Vector3(nearPt.x, nearPt.y, 0); - const tailEnd = new THREE.Vector3(intersectX, underlineY, 0); - const tailGeom = new THREE.BufferGeometry().setFromPoints([nearVec, tailEnd]); - objects.push(new THREE.Line(tailGeom, lineMat)); + // The OTHER endpoint also needs a tail — same shaft-effect, no text there. + const farPt = nearPt === p10 ? p15 : p10; + const farOutDirX = (farPt.x - cx) / (fullDiamLen / 2); + const farOutDirY = (farPt.y - cy) / (fullDiamLen / 2); + const farTailLen = dv.arrowSize + arrowTail; + objects.push(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(farPt.x, farPt.y, 0), + new THREE.Vector3(farPt.x + farOutDirX * farTailLen, farPt.y + farOutDirY * farTailLen, 0), + ]), + lineMat, + )); + + // Rotation: same readable flip as the on-segment aligned path. + let angle = Math.atan2(p10.y - p15.y, p10.x - p15.x); + if (angle > Math.PI / 2) angle -= Math.PI; + if (angle < -Math.PI / 2) angle += Math.PI; + addDimensionTextToCollector({ + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: textPos.x, posY: textPos.y, posZ: 0.2, rotation: angle, transform, widthFactor, + }); + } else { + // ── ANSI horizontal-with-shelf ───────────────────────────────────── + const dxN = cx - nearPt.x; + const dyN = cy - nearPt.y; + const lenN = Math.sqrt(dxN * dxN + dyN * dyN); + const dirNx = lenN > EPSILON ? dxN / lenN : 1; + const dirNy = lenN > EPSILON ? dyN / lenN : 0; + + // Underline Y for leader geometry: bottom of text area + const underlineY = textPos.y - textHeight / 2; + + let intersectX = textPos.x; + if (Math.abs(dirNy) > EPSILON) { + const t = (underlineY - nearPt.y) / dirNy; + intersectX = nearPt.x + t * dirNx; + } - const underlineLeft = intersectX <= textPos.x ? intersectX : textLeft; - const underlineRight = intersectX <= textPos.x ? textRight : intersectX; - const underlineGeom = new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(underlineLeft, underlineY, 0), - new THREE.Vector3(underlineRight, underlineY, 0), - ]); - objects.push(new THREE.Line(underlineGeom, lineMat)); + const textWidth = measureDimensionTextWidth(font!, dimensionText, textHeight, widthFactor); + addDimensionTextToCollector({ + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: textPos.x, posY: textPos.y, posZ: 0.2, transform, widthFactor, + }); + + const textLeft = textPos.x - textWidth / 2; + const textRight = textPos.x + textWidth / 2; + + const nearVec = new THREE.Vector3(nearPt.x, nearPt.y, 0); + const tailEnd = new THREE.Vector3(intersectX, underlineY, 0); + const tailGeom = new THREE.BufferGeometry().setFromPoints([nearVec, tailEnd]); + objects.push(new THREE.Line(tailGeom, lineMat)); + + const underlineLeft = intersectX <= textPos.x ? intersectX : textLeft; + const underlineRight = intersectX <= textPos.x ? textRight : intersectX; + const underlineGeom = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(underlineLeft, underlineY, 0), + new THREE.Vector3(underlineRight, underlineY, 0), + ]); + objects.push(new THREE.Line(underlineGeom, lineMat)); + } } else { const diamLineGeom2 = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(p15.x, p15.y, 0), @@ -1070,12 +1536,14 @@ export const createDiametricDimension = (p: DimensionTypeParams): THREE.Object3D ]); objects.push(new THREE.Line(diamLineGeom2, lineMat)); addDimensionTextToCollector({ - collector: collector!, layer: layer!, color, font: font!, rawText: dimensionText, height: textHeight, - posX: cx, posY: cy, posZ: 0.2, transform, + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: cx, posY: cy, posZ: 0.2, transform, widthFactor, }); } - return objects.length > 0 ? objects : null; + if (objects.length === 0) return null; + tagDimParts(objects); + return objects; }; /** @@ -1116,7 +1584,9 @@ export const isAngleInSweep = (startAngle: number, endAngle: number, testAngle: * Arc between two rays with extension lines, arrows, and angle text in degrees. */ export const createAngularDimension = (p: DimensionTypeParams): THREE.Object3D[] | null => { - const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS } = p; + const { entity, color, font, collector, layer, transform, dv = DEFAULT_DIM_VARS, fmt } = p; + const textColor = p.textColor ?? color; + const widthFactor = p.widthFactor ?? 1; const p13 = entity.linearOrAngularPoint1; // code 13 -- end 1 of first line const p14 = entity.linearOrAngularPoint2; // code 14 -- end 2 of first line const p15 = entity.diameterOrRadiusPoint; // code 15 -- end 1 of second line @@ -1238,10 +1708,11 @@ export const createAngularDimension = (p: DimensionTypeParams): THREE.Object3D[] let dimensionText = entity.text; const measurement = entity.actualMeasurement; - // Angular measurement is stored in radians; convert to degrees for display + // Angular measurement is stored in radians; convert to degrees for display. + // Use DIMADEC for decimal places (defaults to 0 in AutoCAD if not specified). if (typeof measurement === "number") { const degrees = (measurement * DEGREES_TO_RADIANS_DIVISOR) / Math.PI; - const measStr = formatDimNumber(degrees) + "\u00B0"; + const measStr = formatDimNumber(degrees, fmt?.dimadec, fmt?.dimzin) + "\u00B0"; if (dimensionText) { dimensionText = dimensionText.replace(/<>/g, measStr); } else { @@ -1323,31 +1794,33 @@ export const createAngularDimension = (p: DimensionTypeParams): THREE.Object3D[] ); objects.push(extLineB); - // Arrowheads or tick marks at arc endpoints - if (dv.useTicks) { - // Tick marks: oriented along arc tangent at each endpoint - objects.push(createTick(new THREE.Vector3(arcStartPt.x, arcStartPt.y, 0.1), dv.tickSize, startAngle + Math.PI / 2, lineMat)); - objects.push(createTick(new THREE.Vector3(arcEndPt.x, arcEndPt.y, 0.1), dv.tickSize, endAngle + Math.PI / 2, lineMat)); - } else { - // Arrows follow arc curvature (chord direction, not pure tangent) - const arrowArcAngle = dv.arrowSize / radius; - - const innerStartA = startAngle + arrowArcAngle; - const arrowStartFrom = new THREE.Vector3( - vertex.x + radius * Math.cos(innerStartA), - vertex.y + radius * Math.sin(innerStartA), - 0.1, - ); - objects.push(createArrow(arrowStartFrom, new THREE.Vector3(arcStartPt.x, arcStartPt.y, 0.1), dv.arrowSize, arrowMat)); + // Arrowheads at arc endpoints. Direction is taken along the chord (inward from + // an interior point offset by arrowSize/radius along the arc) for arrow-shape + // kinds; symmetric kinds (dot/tick/box) ignore direction. + const headSize = dv.arrowKind === "tick" ? dv.tickSize : dv.arrowSize; + const pushHead = (from: THREE.Vector3, tip: THREE.Vector3) => { + objects.push(...createArrowhead({ + from, tip, size: headSize, kind: dv.arrowKind, + lineMaterial: lineMat, fillMaterial: arrowMat, + })); + }; - const innerEndA = endAngle - arrowArcAngle; - const arrowEndFrom = new THREE.Vector3( - vertex.x + radius * Math.cos(innerEndA), - vertex.y + radius * Math.sin(innerEndA), - 0.1, - ); - objects.push(createArrow(arrowEndFrom, new THREE.Vector3(arcEndPt.x, arcEndPt.y, 0.1), dv.arrowSize, arrowMat)); - } + const arrowArcAngle = dv.arrowSize / radius; + const innerStartA = startAngle + arrowArcAngle; + const arrowStartFrom = new THREE.Vector3( + vertex.x + radius * Math.cos(innerStartA), + vertex.y + radius * Math.sin(innerStartA), + 0.1, + ); + pushHead(arrowStartFrom, new THREE.Vector3(arcStartPt.x, arcStartPt.y, 0.1)); + + const innerEndA = endAngle - arrowArcAngle; + const arrowEndFrom = new THREE.Vector3( + vertex.x + radius * Math.cos(innerEndA), + vertex.y + radius * Math.sin(innerEndA), + 0.1, + ); + pushHead(arrowEndFrom, new THREE.Vector3(arcEndPt.x, arcEndPt.y, 0.1)); // --- Render text --- if (dimensionText) { @@ -1359,10 +1832,12 @@ export const createAngularDimension = (p: DimensionTypeParams): THREE.Object3D[] } addDimensionTextToCollector({ - collector: collector!, layer: layer!, color, font: font!, rawText: dimensionText, height: textHeight, - posX: textX, posY: textY, posZ: 0.2, rotation: textRotation, transform, + collector: collector!, layer: layer!, color: textColor, font: font!, rawText: dimensionText, height: textHeight, + posX: textX, posY: textY, posZ: 0.2, rotation: textRotation, transform, widthFactor, }); } - return objects.length > 0 ? objects : null; + if (objects.length === 0) return null; + tagDimParts(objects, dashedMat); + return objects; }; diff --git a/packages/dxf-render/src/render/hatch.ts b/packages/dxf-render/src/render/hatch.ts index 6e1aa12..c1e1958 100644 --- a/packages/dxf-render/src/render/hatch.ts +++ b/packages/dxf-render/src/render/hatch.ts @@ -22,6 +22,18 @@ export const hatchArcRadians = (startDeg: number, endDeg: number, ccw: boolean): return [-startDeg * toRad, -endDeg * toRad]; }; +/** + * Convert hatch elliptic-arc edge angles (codes 50/51) from DXF degrees to radians. + * HATCH edge type 3 stores start/end angles in degrees per the DXF spec convention + * for codes 50/51, unlike the ELLIPSE entity which uses radians (codes 41/42). + * CW edges are negated for the same reason as hatchArcRadians(). + */ +export const hatchEllipseRadians = (startDeg: number, endDeg: number, ccw: boolean): [number, number] => { + const toRad = Math.PI / 180; + if (ccw) return [startDeg * toRad, endDeg * toRad]; + return [-startDeg * toRad, -endDeg * toRad]; +}; + /** * Compute arc/ellipse sweep for hatch boundary edges. * Applies the ccw flag to determine CW/CCW direction, then fixes @@ -61,10 +73,11 @@ const getEdgeStartPoint = (edge: HatchEdge): { x: number; y: number } => { y: edge.center.y + edge.radius * Math.sin(startRad), }; } else if (edge.type === "ellipse") { + const [startRad, endRad] = hatchEllipseRadians(edge.startAngle, edge.endAngle, edge.ccw); const pts = generateEllipsePoints( edge.center.x, edge.center.y, 0, edge.majorAxisEndPoint.x, edge.majorAxisEndPoint.y, - edge.axisRatio, edge.startAngle, edge.endAngle, + edge.axisRatio, startRad, endRad, edge.ccw, 1, ); return pts.length > 0 ? { x: pts[0].x, y: pts[0].y } : { x: 0, y: 0 }; @@ -270,10 +283,11 @@ export const addEdgeToPath = (shapePath: THREE.ShapePath, edge: HatchEdge): void !edge.ccw, // THREE.js: aClockwise=true means CW, DXF ccw=true means CCW ); } else if (edge.type === "ellipse") { + const [startRad, endRad] = hatchEllipseRadians(edge.startAngle, edge.endAngle, edge.ccw); const pts = generateEllipsePoints( edge.center.x, edge.center.y, 0, edge.majorAxisEndPoint.x, edge.majorAxisEndPoint.y, - edge.axisRatio, edge.startAngle, edge.endAngle, + edge.axisRatio, startRad, endRad, edge.ccw, ); for (let i = 1; i < pts.length; i++) { @@ -352,10 +366,11 @@ export const boundaryPathToLinePoints = (bp: HatchBoundaryPath): THREE.Vector3[] ); } } else if (edge.type === "ellipse") { + const [startRad, endRad] = hatchEllipseRadians(edge.startAngle, edge.endAngle, edge.ccw); const ePts = generateEllipsePoints( edge.center.x, edge.center.y, 0, edge.majorAxisEndPoint.x, edge.majorAxisEndPoint.y, - edge.axisRatio, edge.startAngle, edge.endAngle, + edge.axisRatio, startRad, endRad, edge.ccw, ); // Skip first point if points already exist (to avoid duplicates at edge junctions) @@ -431,13 +446,12 @@ export const boundaryPathToPoint2DArray = (bp: HatchBoundaryPath): Point2D[] => const rotation = Math.atan2(majorY, majorX); const cosR = Math.cos(rotation); const sinR = Math.sin(rotation); - let startAngle = edge.startAngle; - let endAngle = edge.endAngle; + // HATCH edge angles are in degrees per DXF code 50/51 convention + let [startAngle, endAngle] = hatchEllipseRadians(edge.startAngle, edge.endAngle, edge.ccw); const isFullEllipse = Math.abs(endAngle - startAngle - 2 * Math.PI) < EPSILON || Math.abs(endAngle - startAngle) < EPSILON; if (isFullEllipse) { startAngle = 0; endAngle = 2 * Math.PI; } - // Ellipse angles are already in radians const sweepAngle = hatchArcSweep(startAngle, endAngle, edge.ccw); const segments = Math.max( MIN_ARC_SEGMENTS, diff --git a/packages/dxf-render/src/render/mergeCollectors.ts b/packages/dxf-render/src/render/mergeCollectors.ts index cdef956..15cfe66 100644 --- a/packages/dxf-render/src/render/mergeCollectors.ts +++ b/packages/dxf-render/src/render/mergeCollectors.ts @@ -4,6 +4,16 @@ import { MaterialCacheStore } from "./materialCache"; import { isThemeAdaptiveColor } from "@/utils/colorResolver"; import { LINETYPE_DOT_SIZE } from "@/constants"; +// ─── Render order ─────────────────────────────────────────────────── +// With depthTest disabled on all materials, draw order is decided by +// Three.js's sort: lower renderOrder draws first (back), higher draws last (front). +// Block-instance LineSegments added directly to the group via addSharedBlockInstance +// would otherwise be covered by HATCH meshes that arrive later from flush(). + +export const RENDER_ORDER_MESH = 0; +export const RENDER_ORDER_LINE = 1; +export const RENDER_ORDER_OVERLAY = 2; + // ─── Growable typed arrays ────────────────────────────────────────── /** @@ -277,7 +287,8 @@ export class GeometryCollector { flush(materials: MaterialCacheStore): THREE.Object3D[] { const objects: THREE.Object3D[] = []; - // Merged Meshes — rendered first (behind lines/points) + // Merged Meshes — rendered first (behind lines/points). renderOrder=0 keeps fills + // (HATCH, SOLID) behind block outlines that were added to the group before flush(). for (const [key, vArr] of this.meshVertices) { const iArr = this.meshIndices.get(key); if (!iArr || vArr.length < 9 || iArr.length < 3) continue; @@ -292,6 +303,7 @@ export class GeometryCollector { geo.setIndex(new THREE.BufferAttribute(iArr.toUint32Array(), 1)); const obj = new THREE.Mesh(geo, mat); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_MESH; obj.userData.layerName = layer; objects.push(obj); } else { @@ -306,6 +318,7 @@ export class GeometryCollector { geo.setIndex(new THREE.BufferAttribute(allIdx.slice(start, end), 1)); const obj = new THREE.Mesh(geo, mat); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_MESH; obj.userData.layerName = layer; objects.push(obj); } @@ -322,6 +335,7 @@ export class GeometryCollector { geo.setAttribute("position", posAttr); const obj = new THREE.LineSegments(geo, mat); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_LINE; obj.userData.layerName = lyr; return obj; }); @@ -337,6 +351,7 @@ export class GeometryCollector { geo.setAttribute("position", posAttr); const obj = new THREE.Points(geo, mat); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_LINE; obj.userData.layerName = lyr; return obj; }); @@ -365,6 +380,7 @@ export class GeometryCollector { geo.setAttribute("position", posAttr); const obj = new THREE.Points(geo, mat!); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_LINE; obj.userData.layerName = lyr; return obj; }); @@ -384,6 +400,7 @@ export class GeometryCollector { geo.setIndex(new THREE.BufferAttribute(iArr.toUint32Array(), 1)); const obj = new THREE.Mesh(geo, mat); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_OVERLAY; obj.userData.layerName = layer; objects.push(obj); } else { @@ -396,6 +413,7 @@ export class GeometryCollector { geo.setIndex(new THREE.BufferAttribute(allIdx.slice(start, end), 1)); const obj = new THREE.Mesh(geo, mat); obj.frustumCulled = false; + obj.renderOrder = RENDER_ORDER_OVERLAY; obj.userData.layerName = layer; objects.push(obj); } diff --git a/packages/dxf-render/src/render/pickingIndex.ts b/packages/dxf-render/src/render/pickingIndex.ts index cf8ec79..ad3409e 100644 --- a/packages/dxf-render/src/render/pickingIndex.ts +++ b/packages/dxf-render/src/render/pickingIndex.ts @@ -19,6 +19,7 @@ import { isHatchEntity, isMlineEntity, isXlineEntity, + isRegionEntity, isTextEntity, isAttdefEntity, isAttribEntity, @@ -310,6 +311,11 @@ function computeLocalBBox(entity: DxfEntity): THREE.Box3 | null { return hatchBBox(entity); } + if (isRegionEntity(entity)) { + if (!entity.contourBoundary?.length) return null; + return hatchBBox({ boundaryPaths: entity.contourBoundary }); + } + if (isMlineEntity(entity)) { return boxFromVertices(entity.vertices); } diff --git a/packages/dxf-render/src/render/primitives.ts b/packages/dxf-render/src/render/primitives.ts index 3fc3039..9e7730a 100644 --- a/packages/dxf-render/src/render/primitives.ts +++ b/packages/dxf-render/src/render/primitives.ts @@ -1,6 +1,6 @@ import * as THREE from "three"; import type { Font } from "opentype.js"; -import type { DxfLayer, DxfLineType, DxfStyle, DxfDimStyle } from "@/types/dxf"; +import type { DxfLayer, DxfLineType, DxfStyle, DxfDimStyle, DxfMLeaderStyle } from "@/types/dxf"; import { MaterialCacheStore } from "./materialCache"; import { isThemeAdaptiveColor } from "@/utils/colorResolver"; import { applyLinetypePattern, type PatternGeometry } from "@/utils/linetypeResolver"; @@ -8,7 +8,6 @@ import { EPSILON, CIRCLE_SEGMENTS, MIN_ARC_SEGMENTS, - ARROW_BASE_WIDTH_DIVISOR, DEGREES_TO_RADIANS_DIVISOR, POINT_MARKER_SIZE, LINETYPE_DOT_SIZE, @@ -42,6 +41,14 @@ export interface DimensionContext { dimVars: import("./dimensions").DimVars; dimStyles?: Record; headerDimlunit?: number; + headerDimdec?: number; + headerDimadec?: number; + headerDimzin?: number; + headerDimtad?: number; + headerDimtih?: number; + headerDimtoh?: number; + headerDimgap?: number; + headerDimtmove?: number; blockHandleToName?: Map; } @@ -57,8 +64,18 @@ export interface RenderContext extends ColorContext, LinetypeContext { pointDisplaySize?: number; // Computed PDSIZE in drawing units dimVars?: import("./dimensions").DimVars; // Resolved dimension variables dimStyles?: Record; // DIMSTYLE table for dimension formatting + mLeaderStyles?: Record; // MLEADERSTYLE objects keyed by handle (uppercase) headerDimlunit?: number; // $DIMLUNIT from header (fallback for dimension formatting) + headerDimdec?: number; // $DIMDEC from header (fallback for decimal places / architectural fraction precision) + headerDimadec?: number; // $DIMADEC from header (fallback for angular dimension decimal places) + headerDimzin?: number; // $DIMZIN from header (fallback for zero-suppression flags) + headerDimtad?: number; // $DIMTAD from header (text vertical position relative to dim line) + headerDimtih?: number; // $DIMTIH from header (text inside arc/dim — aligned or horizontal) + headerDimtoh?: number; // $DIMTOH from header (text outside arc/dim — aligned or horizontal) + headerDimgap?: number; // $DIMGAP from header (gap between dim line and text, break-radius around text) + headerDimtmove?: number; // $DIMTMOVE from header (text movement strategy when user moves text) blockHandleToName?: Map; // BLOCK_RECORD handle → name (for DIMBLK resolution) + styleHandleToName?: Map; // STYLE handle → name (for DIMTXSTY resolution) xlineClipSize?: number; // Half-length for clipping XLINE/RAY to drawing extents originOffset?: { x: number; y: number; z: number }; // Subtracted from coords for Float32 precision } @@ -249,79 +266,6 @@ export const createBulgeArc = ( return points; }; -/** - * Create an arrow (triangle) for dimension lines. - * Direction is computed as normalized vector from `from` to `tip`. - */ -export const createArrow = ( - from: THREE.Vector3, - tip: THREE.Vector3, - size: number, - material: THREE.Material, -): THREE.Mesh => { - const dx = tip.x - from.x; - const dy = tip.y - from.y; - const len = Math.sqrt(dx * dx + dy * dy); - const dirX = len > EPSILON ? dx / len : 1; - const dirY = len > EPSILON ? dy / len : 0; - - const width = size / ARROW_BASE_WIDTH_DIVISOR; - - const perpX = dirY; - const perpY = -dirX; - - const base1 = new THREE.Vector3( - tip.x - dirX * size + perpX * width, - tip.y - dirY * size + perpY * width, - tip.z, - ); - - const base2 = new THREE.Vector3( - tip.x - dirX * size - perpX * width, - tip.y - dirY * size - perpY * width, - tip.z, - ); - - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute( - "position", - new THREE.Float32BufferAttribute( - [tip.x, tip.y, tip.z, base1.x, base1.y, base1.z, base2.x, base2.y, base2.z], - 3, - ), - ); - geometry.setIndex([0, 1, 2]); - - return new THREE.Mesh(geometry, material); -}; - -/** - * Create an architectural tick mark (oblique stroke at 45°) for dimension lines. - * Models the _ArchTick block: a line from (-0.5,-0.5) to (0.5,0.5), scaled by `size` (DIMASZ) - * and rotated by `dimAngle`. - */ -export const createTick = ( - point: THREE.Vector3, - size: number, - dimAngle: number, - material: THREE.LineBasicMaterial, -): THREE.LineSegments => { - // _ArchTick block local endpoint: (0.5, 0.5) scaled by size - const half = size * 0.5; - // Rotate local endpoint by dimAngle - const cosA = Math.cos(dimAngle); - const sinA = Math.sin(dimAngle); - const dx = half * cosA - half * sinA; - const dy = half * sinA + half * cosA; - const verts = new Float32Array([ - point.x - dx, point.y - dy, point.z, - point.x + dx, point.y + dy, point.z, - ]); - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(verts, 3)); - return new THREE.LineSegments(geometry, material); -}; - export const setLayerName = (obj: THREE.Object3D | THREE.Object3D[], layerName: string) => { if (Array.isArray(obj)) { obj.forEach((o) => { diff --git a/packages/dxf-render/src/render/text/__tests__/fontClassifier.test.ts b/packages/dxf-render/src/render/text/__tests__/fontClassifier.test.ts index 325a8b0..a16e142 100644 --- a/packages/dxf-render/src/render/text/__tests__/fontClassifier.test.ts +++ b/packages/dxf-render/src/render/text/__tests__/fontClassifier.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { classifyFont, resolveEntityFont } from "../fontClassifier"; +import { classifyFont, resolveEntityFont, resolveStyleFlags } from "../fontClassifier"; import type { DxfStyle } from "@/types/dxf"; describe("classifyFont", () => { @@ -97,3 +97,42 @@ describe("resolveEntityFont", () => { expect(resolveEntityFont("Heading", styles, serifFont, sansFont, "Arial")).toBe(sansFont); }); }); + +describe("resolveStyleFlags", () => { + const styles: Record = { + plain: { name: "plain", fontFile: "Arial.ttf" }, + bold: { name: "bold", fontFile: "Arial Bold.ttf", bold: true }, + italic: { name: "italic", fontFile: "Arial Italic.ttf", italic: true }, + boldItalic: { name: "boldItalic", fontFile: "Arial Bold Italic.ttf", bold: true, italic: true }, + narrow: { name: "narrow", fontFile: "Arial.ttf", widthFactor: 0.8 }, + }; + + it("returns empty object when style is missing", () => { + expect(resolveStyleFlags(undefined, styles)).toEqual({}); + expect(resolveStyleFlags("plain", undefined)).toEqual({}); + expect(resolveStyleFlags("unknown", styles)).toEqual({}); + }); + + it("returns no flags for a plain style", () => { + const flags = resolveStyleFlags("plain", styles); + expect(flags.bold).toBeUndefined(); + expect(flags.italic).toBeUndefined(); + expect(flags.widthFactor).toBeUndefined(); + }); + + it("returns bold for a bold style", () => { + expect(resolveStyleFlags("bold", styles)).toEqual({ bold: true, italic: undefined, widthFactor: undefined }); + }); + + it("returns italic for an italic style", () => { + expect(resolveStyleFlags("italic", styles)).toEqual({ bold: undefined, italic: true, widthFactor: undefined }); + }); + + it("returns both flags for bold-italic", () => { + expect(resolveStyleFlags("boldItalic", styles)).toEqual({ bold: true, italic: true, widthFactor: undefined }); + }); + + it("returns widthFactor when STYLE.widthFactor (DXF code 41) is set", () => { + expect(resolveStyleFlags("narrow", styles).widthFactor).toBe(0.8); + }); +}); diff --git a/packages/dxf-render/src/render/text/__tests__/text.test.ts b/packages/dxf-render/src/render/text/__tests__/text.test.ts index 69303f3..33138d5 100644 --- a/packages/dxf-render/src/render/text/__tests__/text.test.ts +++ b/packages/dxf-render/src/render/text/__tests__/text.test.ts @@ -3,12 +3,16 @@ import { replaceSpecialChars, parseTextWithUnderline, parseMTextContent, + getMTextLineText, getMTextHAlign, getTextHAlign, getMTextVAlign, getTextVAlign, } from "../mtextParser"; +/** Plain text of a parsed MTextLine — concatenation of all run texts. */ +const lineText = (line: { runs: { text: string }[] }) => getMTextLineText(line as never); + // ── replaceSpecialChars ────────────────────────────────────────────────── describe("replaceSpecialChars", () => { @@ -126,93 +130,94 @@ describe("parseTextWithUnderline", () => { // ── parseMTextContent ──────────────────────────────────────────────────── describe("parseMTextContent", () => { - it("parses plain text into a single MTextLine", () => { + it("parses plain text into a single MTextLine with one run", () => { const result = parseMTextContent("Hello World"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Hello World"); - expect(result[0].color).toBeUndefined(); - expect(result[0].height).toBeUndefined(); + expect(result[0].runs).toHaveLength(1); + expect(result[0].runs[0].text).toBe("Hello World"); + expect(result[0].runs[0].color).toBeUndefined(); + expect(result[0].runs[0].height).toBeUndefined(); }); it("splits text by \\P into multiple lines", () => { const result = parseMTextContent("Line 1\\PLine 2\\PLine 3"); expect(result).toHaveLength(3); - expect(result[0].text).toBe("Line 1"); - expect(result[1].text).toBe("Line 2"); - expect(result[2].text).toBe("Line 3"); + expect(lineText(result[0])).toBe("Line 1"); + expect(lineText(result[1])).toBe("Line 2"); + expect(lineText(result[2])).toBe("Line 3"); }); it("sets ACI color with \\C; (ACI 1 = red)", () => { const result = parseMTextContent("\\C1;Red text"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Red text"); - expect(result[0].color).toBe("#ff0000"); + expect(lineText(result[0])).toBe("Red text"); + expect(result[0].runs[0].color).toBe("#ff0000"); }); it("ACI color persists across lines", () => { const result = parseMTextContent("\\C5;Blue\\PStill blue"); expect(result).toHaveLength(2); // ACI 5 = 255 = 0x0000FF = "#0000ff" - expect(result[0].color).toBe("#0000ff"); - expect(result[1].color).toBe("#0000ff"); + expect(result[0].runs[0].color).toBe("#0000ff"); + expect(result[1].runs[0].color).toBe("#0000ff"); }); it("resets color to undefined with \\C0; (ByBlock)", () => { const result = parseMTextContent("\\C1;Red\\P\\C0;Default"); expect(result).toHaveLength(2); - expect(result[0].color).toBe("#ff0000"); - expect(result[1].color).toBeUndefined(); + expect(result[0].runs[0].color).toBe("#ff0000"); + expect(result[1].runs[0].color).toBeUndefined(); }); it("resets color to undefined with \\C256; (ByLayer)", () => { const result = parseMTextContent("\\C1;Red\\P\\C256;Default"); expect(result).toHaveLength(2); - expect(result[0].color).toBe("#ff0000"); - expect(result[1].color).toBeUndefined(); + expect(result[0].runs[0].color).toBe("#ff0000"); + expect(result[1].runs[0].color).toBeUndefined(); }); it("sets height with \\H; (absolute)", () => { const result = parseMTextContent("\\H2.5;Big text"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Big text"); - expect(result[0].height).toBe(2.5); + expect(lineText(result[0])).toBe("Big text"); + expect(result[0].runs[0].height).toBe(2.5); }); it("sets height with \\Hx; (relative multiplier)", () => { const result = parseMTextContent("\\H1.5x;Title\\P\\H0.666667x;Body", 240); expect(result).toHaveLength(2); - expect(result[0].text).toBe("Title"); - expect(result[0].height).toBeCloseTo(360, 1); // 240 * 1.5 - expect(result[1].text).toBe("Body"); - expect(result[1].height).toBeCloseTo(240, 0); // 360 * 0.666667 + expect(lineText(result[0])).toBe("Title"); + expect(result[0].runs[0].height).toBeCloseTo(360, 1); // 240 * 1.5 + expect(lineText(result[1])).toBe("Body"); + expect(result[1].runs[0].height).toBeCloseTo(240, 0); // 360 * 0.666667 }); it("relative \\Hx; without defaultHeight uses 1 as base", () => { const result = parseMTextContent("\\H2x;Double"); expect(result).toHaveLength(1); - expect(result[0].height).toBe(2); // 1 * 2 + expect(result[0].runs[0].height).toBe(2); // 1 * 2 }); it("sets font, bold, and italic with \\f...;", () => { const result = parseMTextContent("\\fArial|b1|i1|c0|p0;Styled"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Styled"); - expect(result[0].fontFamily).toBe("Arial"); - expect(result[0].bold).toBe(true); - expect(result[0].italic).toBe(true); + expect(lineText(result[0])).toBe("Styled"); + expect(result[0].runs[0].fontFamily).toBe("Arial"); + expect(result[0].runs[0].bold).toBe(true); + expect(result[0].runs[0].italic).toBe(true); }); it("converts literal escape sequences: \\\\ -> \\, \\{ -> {, \\} -> }", () => { const result = parseMTextContent("A\\\\B\\{C\\}D"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("A\\B{C}D"); + expect(lineText(result[0])).toBe("A\\B{C}D"); }); it("converts Unicode escapes \\U+XXXX to characters", () => { // U+0041 = 'A', U+00E9 = 'e with acute' const result = parseMTextContent("\\U+0041\\U+00E9"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("A\u00E9"); + expect(lineText(result[0])).toBe("A\u00E9"); }); it("parses stacked text \\Stop^bottom;", () => { @@ -225,93 +230,112 @@ describe("parseMTextContent", () => { it("renders \\S3#8; as inline flat fraction text '3/8'", () => { const result = parseMTextContent("\\S3#8;"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("3/8"); + expect(lineText(result[0])).toBe("3/8"); expect(result[0].stackedTop).toBeUndefined(); expect(result[0].stackedBottom).toBeUndefined(); }); - it("brace-scoped \\H does not affect subsequent lines", () => { + it("brace-scoped \\H reverts after closing }", () => { const result = parseMTextContent( - "\\H0.5x;Normal\\P{\\H0.7x;\\S3#8;}rest\\PStill normal", + "\\H0.5x;Normal\\P{\\H0.7x;inner}rest\\PStill normal", 18, ); expect(result).toHaveLength(3); - expect(result[0].height).toBeCloseTo(9); // 18 * 0.5 - expect(result[1].text).toBe("3/8rest"); // \S3#8; rendered as inline fraction - expect(result[1].height).toBeCloseTo(9); // \H0.7x inside braces stripped (mixed content) - expect(result[2].height).toBeCloseTo(9); // height unchanged - }); - - it("applies \\H inside braces when all content is braced (section marker)", () => { + // Line 0: height = 18 * 0.5 = 9 + expect(result[0].runs[0].height).toBeCloseTo(9); + // Line 1: "inner" got scoped \H0.7x \u2014 9 * 0.7 = 6.3 + // "rest" reverts to the outer state (9) + expect(result[1].runs).toHaveLength(2); + expect(result[1].runs[0].text).toBe("inner"); + expect(result[1].runs[0].height).toBeCloseTo(6.3); + expect(result[1].runs[1].text).toBe("rest"); + expect(result[1].runs[1].height).toBeCloseTo(9); + // Line 2: outer state (9) carries over + expect(result[2].runs[0].height).toBeCloseTo(9); + }); + + it("applies \\H inside braces (section marker)", () => { // Section marker: {\H0.75x;A4.2} with base height 3.0 const result = parseMTextContent("{\\H0.75x;A4.2}", 3.0); expect(result).toHaveLength(1); - expect(result[0].text).toBe("A4.2"); - expect(result[0].height).toBeCloseTo(2.25); // 3.0 * 0.75 + expect(lineText(result[0])).toBe("A4.2"); + expect(result[0].runs[0].height).toBeCloseTo(2.25); // 3.0 * 0.75 }); - it("applies absolute \\H inside braces when all content is braced", () => { + it("applies absolute \\H inside braces", () => { const result = parseMTextContent("{\\H1.5;Small text}", 5.0); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Small text"); - expect(result[0].height).toBeCloseTo(1.5); // absolute height + expect(lineText(result[0])).toBe("Small text"); + expect(result[0].runs[0].height).toBeCloseTo(1.5); }); it("replaces \\~ (non-breaking space) with a regular space", () => { const result = parseMTextContent("Hello\\~World"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Hello World"); + expect(lineText(result[0])).toBe("Hello World"); }); it("replaces \\N (column break) with a space", () => { const result = parseMTextContent("Col1\\NCol2"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Col1 Col2"); + expect(lineText(result[0])).toBe("Col1 Col2"); }); it("removes grouping braces {}", () => { const result = parseMTextContent("{grouped text}"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("grouped text"); + expect(lineText(result[0])).toBe("grouped text"); }); - it("sets underline with \\L and strips overline/strikethrough (\\O, \\K)", () => { + it("sets underline with \\L; \\O sets overline; \\K sets strikethrough", () => { const result = parseMTextContent("\\LUnderlined\\OOverlined\\KStrikethrough"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("UnderlinedOverlinedStrikethrough"); - expect(result[0].underline).toBe(true); - }); - - it("\\l turns off underline", () => { + // Each format toggle starts a new run + expect(result[0].runs).toHaveLength(3); + expect(result[0].runs[0].text).toBe("Underlined"); + expect(result[0].runs[0].underline).toBe(true); + expect(result[0].runs[1].text).toBe("Overlined"); + expect(result[0].runs[1].underline).toBe(true); + expect(result[0].runs[1].overline).toBe(true); + expect(result[0].runs[2].text).toBe("Strikethrough"); + expect(result[0].runs[2].strikethrough).toBe(true); + }); + + it("\\l turns off underline mid-line, creating two runs", () => { const result = parseMTextContent("\\LUnderlined\\l Normal"); expect(result).toHaveLength(1); - expect(result[0].underline).toBe(true); - expect(result[0].text).toBe("Underlined Normal"); + expect(result[0].runs).toHaveLength(2); + expect(result[0].runs[0].text).toBe("Underlined"); + expect(result[0].runs[0].underline).toBe(true); + expect(result[0].runs[1].text).toBe(" Normal"); + expect(result[0].runs[1].underline).toBeUndefined(); }); it("underline persists across \\P line breaks", () => { const result = parseMTextContent("\\LLine 1\\PLine 2"); expect(result).toHaveLength(2); - expect(result[0].underline).toBe(true); - expect(result[1].underline).toBe(true); + expect(result[0].runs[0].underline).toBe(true); + expect(result[1].runs[0].underline).toBe(true); }); it("no underline by default", () => { const result = parseMTextContent("Normal text"); expect(result).toHaveLength(1); - expect(result[0].underline).toBeUndefined(); + expect(result[0].runs[0].underline).toBeUndefined(); }); - it("removes \\W, \\T, \\Q, \\A formatting codes", () => { + it("parses \\W width factor and \\Q oblique angle; skips \\T and \\A", () => { const result = parseMTextContent("\\W1.5;\\T0.1;\\Q15;\\A1;text"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("text"); + expect(lineText(result[0])).toBe("text"); + expect(result[0].runs[0].widthFactor).toBe(1.5); + expect(result[0].runs[0].obliqueAngle).toBe(15); }); it("parses paragraph indent \\pi,l;", () => { const result = parseMTextContent("\\pi-13.5,l18,t18;indented text"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("indented text"); + expect(lineText(result[0])).toBe("indented text"); expect(result[0].firstIndent).toBe(-13.5); expect(result[0].leftMargin).toBe(18); }); @@ -319,7 +343,7 @@ describe("parseMTextContent", () => { it("parses paragraph indent \\pi; without left margin", () => { const result = parseMTextContent("\\pi2;indented text"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("indented text"); + expect(lineText(result[0])).toBe("indented text"); expect(result[0].firstIndent).toBe(2); expect(result[0].leftMargin).toBeUndefined(); }); @@ -327,28 +351,190 @@ describe("parseMTextContent", () => { it("strips \\pxqc; alignment code", () => { const result = parseMTextContent("\\pxqc;centered text"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("centered text"); + expect(lineText(result[0])).toBe("centered text"); }); it("preserves empty lines from \\P\\P as paragraph spacing", () => { const result = parseMTextContent("First\\P\\PLast"); expect(result).toHaveLength(3); - expect(result[0].text).toBe("First"); - expect(result[1].text).toBe(""); - expect(result[2].text).toBe("Last"); + expect(lineText(result[0])).toBe("First"); + expect(result[1].runs).toEqual([]); // empty middle line \u2014 no runs + expect(lineText(result[2])).toBe("Last"); }); it("applies DXF special chars (%%d, %%c, etc.) inside MTEXT", () => { const result = parseMTextContent("Angle: 45%%d, Dia: %%c20"); expect(result).toHaveLength(1); - expect(result[0].text).toBe("Angle: 45\u00B0, Dia: \u230020"); + expect(lineText(result[0])).toBe("Angle: 45\u00B0, Dia: \u230020"); }); it("preserves ^I as tab character in MTEXT lines", () => { const result = parseMTextContent("X-00^IREFRIGERATOR^I^I\\PX-02^IKITCHEN SINK"); expect(result).toHaveLength(2); - expect(result[0].text).toBe("X-00\tREFRIGERATOR\t\t"); - expect(result[1].text).toBe("X-02\tKITCHEN SINK"); + expect(lineText(result[0])).toBe("X-00\tREFRIGERATOR\t\t"); + expect(lineText(result[1])).toBe("X-02\tKITCHEN SINK"); + }); + + // \u2500\u2500 Inline run scoping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 + + it("brace-scoped \\C produces two runs with different colors", () => { + // Real-world case from 03.Profili.dxf MTEXT D4EC3: + // "Profil rama{\C252; Profile frame}" + // \u2192 "Profil rama" inherits entity color, " Profile frame" uses ACI 252. + const result = parseMTextContent("Profil rama{\\C252; Profile frame}"); + expect(result).toHaveLength(1); + expect(result[0].runs).toHaveLength(2); + expect(result[0].runs[0].text).toBe("Profil rama"); + expect(result[0].runs[0].color).toBeUndefined(); + expect(result[0].runs[1].text).toBe(" Profile frame"); + // ACI 252 → fixed gray hex (sentinels apply only to ACI 7, 250, 251) + expect(result[0].runs[1].color).toBe("#848484"); + }); + + it("scoped color reverts to outer color after closing }", () => { + const result = parseMTextContent("\\C1;red{\\C5;blue}red-again"); + expect(result).toHaveLength(1); + expect(result[0].runs).toHaveLength(3); + expect(result[0].runs[0].text).toBe("red"); + expect(result[0].runs[0].color).toBe("#ff0000"); + expect(result[0].runs[1].text).toBe("blue"); + expect(result[0].runs[1].color).toBe("#0000ff"); + expect(result[0].runs[2].text).toBe("red-again"); + expect(result[0].runs[2].color).toBe("#ff0000"); + }); + + it("nested braces nest format scopes", () => { + const result = parseMTextContent("\\C1;a{\\C5;b{\\C3;c}b}a"); + expect(result).toHaveLength(1); + const runs = result[0].runs; + expect(runs).toHaveLength(5); + expect(runs.map((r) => r.text)).toEqual(["a", "b", "c", "b", "a"]); + expect(runs.map((r) => r.color)).toEqual([ + "#ff0000", // ACI 1 red + "#0000ff", // ACI 5 blue + "#00ff00", // ACI 3 green + "#0000ff", // back to blue + "#ff0000", // back to red + ]); + }); + + it("scoped \\f with bold creates a bold run, restoring after }", () => { + const result = parseMTextContent("plain{\\fArial|b1;BOLD}rest"); + expect(result).toHaveLength(1); + const runs = result[0].runs; + expect(runs).toHaveLength(3); + expect(runs[0].text).toBe("plain"); + expect(runs[0].bold).toBeUndefined(); + expect(runs[1].text).toBe("BOLD"); + expect(runs[1].bold).toBe(true); + expect(runs[1].fontFamily).toBe("Arial"); + expect(runs[2].text).toBe("rest"); + expect(runs[2].bold).toBeUndefined(); + }); + + it("scoped \\O produces an overline run only inside braces", () => { + const result = parseMTextContent("a{\\Ob}c"); + expect(result).toHaveLength(1); + const runs = result[0].runs; + expect(runs).toHaveLength(3); + expect(runs[1].text).toBe("b"); + expect(runs[1].overline).toBe(true); + expect(runs[0].overline).toBeUndefined(); + expect(runs[2].overline).toBeUndefined(); + }); + + it("scoped \\K produces a strikethrough run only inside braces", () => { + const result = parseMTextContent("keep{\\Kgone}still"); + expect(result).toHaveLength(1); + const runs = result[0].runs; + expect(runs).toHaveLength(3); + expect(runs[1].text).toBe("gone"); + expect(runs[1].strikethrough).toBe(true); + expect(runs[0].strikethrough).toBeUndefined(); + expect(runs[2].strikethrough).toBeUndefined(); + }); + + it("inline \\L mid-line creates a second run with underline on", () => { + const result = parseMTextContent("plain\\Ltail"); + expect(result).toHaveLength(1); + expect(result[0].runs).toHaveLength(2); + expect(result[0].runs[0].text).toBe("plain"); + expect(result[0].runs[0].underline).toBeUndefined(); + expect(result[0].runs[1].text).toBe("tail"); + expect(result[0].runs[1].underline).toBe(true); + }); + + it("scoped color does not leak into next paragraph", () => { + // Closing brace at end of line \u2014 outer color was undefined, + // so the next line must not carry the inner color. + const result = parseMTextContent("{\\C1;red}\\Pnext"); + expect(result).toHaveLength(2); + expect(result[0].runs[0].color).toBe("#ff0000"); + expect(result[1].runs[0].color).toBeUndefined(); + }); + + it("inline \\W mid-line flushes run and stretches following text", () => { + const result = parseMTextContent("plain\\W2;wide"); + expect(result).toHaveLength(1); + expect(result[0].runs).toHaveLength(2); + expect(result[0].runs[0].text).toBe("plain"); + expect(result[0].runs[0].widthFactor).toBeUndefined(); + expect(result[0].runs[1].text).toBe("wide"); + expect(result[0].runs[1].widthFactor).toBe(2); + }); + + it("scoped \\W reverts to outer width after closing }", () => { + const result = parseMTextContent("a{\\W1.5;b}c"); + expect(result).toHaveLength(1); + const runs = result[0].runs; + expect(runs).toHaveLength(3); + expect(runs[0].widthFactor).toBeUndefined(); + expect(runs[1].widthFactor).toBe(1.5); + expect(runs[2].widthFactor).toBeUndefined(); + }); + + it("rejects non-positive \\W values", () => { + const result = parseMTextContent("\\W0;text"); + expect(result[0].runs[0].widthFactor).toBeUndefined(); + }); + + it("defaultWidthFactor seeds initial state — runs without \\W inherit STYLE.widthFactor", () => { + // Wired from STYLE.widthFactor (DXF code 41) via the third parameter. + const result = parseMTextContent("plain", undefined, 0.8); + expect(result[0].runs[0].widthFactor).toBe(0.8); + }); + + it("inline \\W overrides defaultWidthFactor for the following run", () => { + const result = parseMTextContent("a\\W1.5;b", undefined, 0.8); + const runs = result[0].runs; + expect(runs[0].widthFactor).toBe(0.8); + expect(runs[1].widthFactor).toBe(1.5); + }); + + it("defaultWidthFactor=1 is ignored (no widthFactor field on runs)", () => { + const result = parseMTextContent("plain", undefined, 1); + expect(result[0].runs[0].widthFactor).toBeUndefined(); + }); + + it("\\Q accepts negative oblique angles", () => { + const result = parseMTextContent("\\Q-12.5;slanted"); + expect(result[0].runs[0].obliqueAngle).toBe(-12.5); + }); + + it("scoped \\Q reverts after closing }", () => { + const result = parseMTextContent("a{\\Q20;b}c"); + const runs = result[0].runs; + expect(runs).toHaveLength(3); + expect(runs[0].obliqueAngle).toBeUndefined(); + expect(runs[1].obliqueAngle).toBe(20); + expect(runs[2].obliqueAngle).toBeUndefined(); + }); + + it("combines \\W and \\Q on the same run", () => { + const result = parseMTextContent("\\W2;\\Q15;wq"); + expect(result[0].runs).toHaveLength(1); + expect(result[0].runs[0].widthFactor).toBe(2); + expect(result[0].runs[0].obliqueAngle).toBe(15); }); }); diff --git a/packages/dxf-render/src/render/text/__tests__/vectorTextBuilder.test.ts b/packages/dxf-render/src/render/text/__tests__/vectorTextBuilder.test.ts index b69d3c0..f5abc65 100644 --- a/packages/dxf-render/src/render/text/__tests__/vectorTextBuilder.test.ts +++ b/packages/dxf-render/src/render/text/__tests__/vectorTextBuilder.test.ts @@ -8,7 +8,7 @@ import { HAlign, VAlign, } from "../vectorTextBuilder"; -import type { MTextLine } from "../mtextParser"; +import type { MTextLine, MTextRun } from "../mtextParser"; import { loadDefaultFont } from "../fontManager"; import { clearGlyphCache } from "../glyphCache"; import type { Font } from "opentype.js"; @@ -86,6 +86,41 @@ function mp(overrides: Partial[0]> & { co }; } +/** Helper to build a single-run MTextLine for tests. */ +function line( + text: string, + opts: { + color?: string; + height?: number; + bold?: boolean; + italic?: boolean; + underline?: boolean; + overline?: boolean; + strikethrough?: boolean; + fontFamily?: string; + stackedTop?: string; + stackedBottom?: string; + leftMargin?: number; + firstIndent?: number; + } = {}, +): MTextLine { + const run: MTextRun = { text }; + if (opts.color !== undefined) run.color = opts.color; + if (opts.height !== undefined) run.height = opts.height; + if (opts.bold) run.bold = true; + if (opts.italic) run.italic = true; + if (opts.underline) run.underline = true; + if (opts.overline) run.overline = true; + if (opts.strikethrough) run.strikethrough = true; + if (opts.fontFamily) run.fontFamily = opts.fontFamily; + const ml: MTextLine = { runs: text === "" ? [] : [run] }; + if (opts.stackedTop !== undefined) ml.stackedTop = opts.stackedTop; + if (opts.stackedBottom !== undefined) ml.stackedBottom = opts.stackedBottom; + if (opts.leftMargin !== undefined) ml.leftMargin = opts.leftMargin; + if (opts.firstIndent !== undefined) ml.firstIndent = opts.firstIndent; + return ml; +} + /** Helper to create a DimensionTextParams object with defaults */ function dp(overrides: Partial[0]> & { collector: any; font: Font }) { return { @@ -356,7 +391,7 @@ describe("vectorTextBuilder", () => { describe("addMTextToCollector — basic", () => { it("produces mesh data for multiline text", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "Line one" }, { text: "Line two" }]; + const lines: MTextLine[] = [line("Line one"), line("Line two")]; addMTextToCollector(mp({ collector: c as any, font, lines })); expect(c.meshCalls.length).toBe(2); expect(c.totalVertices).toBeGreaterThan(0); @@ -370,14 +405,14 @@ describe("vectorTextBuilder", () => { it("single line works like addTextToCollector", () => { const c = new MockCollector(); - addMTextToCollector(mp({ collector: c as any, font, lines: [{ text: "Hello" }] })); + addMTextToCollector(mp({ collector: c as any, font, lines: [line("Hello")] })); expect(c.meshCalls.length).toBe(1); expect(c.totalVertices).toBeGreaterThan(0); }); it("all z-coordinates match posZ", () => { const c = new MockCollector(); - addMTextToCollector(mp({ collector: c as any, font, lines: [{ text: "A" }, { text: "B" }], posZ: 7 })); + addMTextToCollector(mp({ collector: c as any, font, lines: [line("A"), line("B")], posZ: 7 })); for (const call of c.meshCalls) { for (let i = 2; i < call.vertices.length; i += 3) { expect(call.vertices[i]).toBe(7); @@ -387,7 +422,7 @@ describe("vectorTextBuilder", () => { it("produces nothing for zero height", () => { const c = new MockCollector(); - addMTextToCollector(mp({ collector: c as any, font, lines: [{ text: "Hello" }], defaultHeight: 0 })); + addMTextToCollector(mp({ collector: c as any, font, lines: [line("Hello")], defaultHeight: 0 })); expect(c.meshCalls.length).toBe(0); }); }); @@ -395,7 +430,7 @@ describe("vectorTextBuilder", () => { describe("addMTextToCollector — attachment points", () => { it("TOP_LEFT (1): text extends below and right of position", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "AB" }, { text: "CD" }]; + const lines: MTextLine[] = [line("AB"), line("CD")]; addMTextToCollector(mp({ collector: c as any, font, lines, posX: 50, posY: 50, attachmentPoint: 1 })); const b = c.getBounds(); expect(b.xMin).toBeGreaterThanOrEqual(48); @@ -404,7 +439,7 @@ describe("vectorTextBuilder", () => { it("BOTTOM_RIGHT (9): text extends above and left of position", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "AB" }, { text: "CD" }]; + const lines: MTextLine[] = [line("AB"), line("CD")]; addMTextToCollector(mp({ collector: c as any, font, lines, posX: 50, posY: 50, attachmentPoint: 9 })); const b = c.getBounds(); expect(b.xMax).toBeLessThanOrEqual(52); @@ -413,7 +448,7 @@ describe("vectorTextBuilder", () => { it("MIDDLE_CENTER (5): text is centered around position", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "AB" }, { text: "CD" }]; + const lines: MTextLine[] = [line("AB"), line("CD")]; addMTextToCollector(mp({ collector: c as any, font, lines, posX: 50, posY: 50, attachmentPoint: 5 })); const b = c.getBounds(); const midX = (b.xMin + b.xMax) / 2; @@ -427,8 +462,8 @@ describe("vectorTextBuilder", () => { it("lines with different colors produce separate addMesh calls", () => { const c = new MockCollector(); const lines: MTextLine[] = [ - { text: "Red line", color: "#ff0000" }, - { text: "Blue line", color: "#0000ff" }, + line("Red line", { color: "#ff0000" }), + line("Blue line", { color: "#0000ff" }), ]; addMTextToCollector(mp({ collector: c as any, font, lines })); const colors = c.meshCalls.map(call => call.color); @@ -438,17 +473,123 @@ describe("vectorTextBuilder", () => { it("lines without color use default entity color", () => { const c = new MockCollector(); - addMTextToCollector(mp({ collector: c as any, font, lines: [{ text: "Default" }], color: "#abcdef" })); + addMTextToCollector(mp({ collector: c as any, font, lines: [line("Default")], color: "#abcdef" })); expect(c.meshCalls[0].color).toBe("#abcdef"); }); }); + describe("addMTextToCollector — multi-run lines (inline scoping)", () => { + it("two runs with different colors produce two mesh calls with those colors", () => { + // Simulates `Profil rama{\C252; Profile frame}` after parsing. + const c = new MockCollector(); + const lines: MTextLine[] = [{ + runs: [ + { text: "Profil rama" }, + { text: " Profile frame", color: "#848484" }, + ], + }]; + addMTextToCollector(mp({ collector: c as any, font, lines, color: "#000000" })); + const colors = c.meshCalls.map((call) => call.color); + expect(colors).toContain("#000000"); // first run falls back to entity color + expect(colors).toContain("#848484"); // second run uses its scoped color + }); + + it("multi-run line places runs sequentially along X without overlap", () => { + const c = new MockCollector(); + const lines: MTextLine[] = [{ + runs: [ + { text: "AA", color: "#ff0000" }, + { text: "BB", color: "#00ff00" }, + ], + }]; + addMTextToCollector(mp({ collector: c as any, font, lines, posX: 0, attachmentPoint: 1 })); + // Bounds of the red call (run 1) must end before the green call (run 2) starts + const red = c.meshCalls.find((call) => call.color === "#ff0000"); + const green = c.meshCalls.find((call) => call.color === "#00ff00"); + expect(red).toBeDefined(); + expect(green).toBeDefined(); + const xMax = (verts: number[]) => { + let m = -Infinity; + for (let i = 0; i < verts.length; i += 3) if (verts[i] > m) m = verts[i]; + return m; + }; + const xMin = (verts: number[]) => { + let m = Infinity; + for (let i = 0; i < verts.length; i += 3) if (verts[i] < m) m = verts[i]; + return m; + }; + // Allow tiny float slack: red ends roughly where green starts + expect(xMin(green!.vertices)).toBeGreaterThan(xMax(red!.vertices) - 0.5); + }); + + it("overline emits a line segment in addition to the glyph mesh", () => { + const c = new MockCollector(); + const lines: MTextLine[] = [{ runs: [{ text: "X", overline: true }] }]; + addMTextToCollector(mp({ collector: c as any, font, lines })); + expect(c.lineCalls.length).toBe(1); + }); + + it("strikethrough emits a line segment in addition to the glyph mesh", () => { + const c = new MockCollector(); + const lines: MTextLine[] = [{ runs: [{ text: "X", strikethrough: true }] }]; + addMTextToCollector(mp({ collector: c as any, font, lines })); + expect(c.lineCalls.length).toBe(1); + }); + + it("underline still works through the new run pathway", () => { + const c = new MockCollector(); + const lines: MTextLine[] = [{ runs: [{ text: "X", underline: true }] }]; + addMTextToCollector(mp({ collector: c as any, font, lines })); + expect(c.lineCalls.length).toBe(1); + }); + + it("run-level widthFactor stretches the horizontal extent", () => { + const c1 = new MockCollector(); + const c2 = new MockCollector(); + addMTextToCollector(mp({ collector: c1 as any, font, lines: [{ runs: [{ text: "MM" }] }] })); + addMTextToCollector(mp({ collector: c2 as any, font, lines: [{ runs: [{ text: "MM", widthFactor: 2 }] }] })); + const w1 = c1.getBounds().xMax - c1.getBounds().xMin; + const w2 = c2.getBounds().xMax - c2.getBounds().xMin; + expect(w2).toBeGreaterThan(w1 * 1.8); + }); + + it("run-level widthFactor pushes the next run further along X", () => { + const c = new MockCollector(); + const lines: MTextLine[] = [{ + runs: [ + { text: "MM", color: "#ff0000", widthFactor: 2 }, + { text: "MM", color: "#00ff00" }, + ], + }]; + addMTextToCollector(mp({ collector: c as any, font, lines, posX: 0, attachmentPoint: 1 })); + const red = c.meshCalls.find((call) => call.color === "#ff0000")!; + const green = c.meshCalls.find((call) => call.color === "#00ff00")!; + // The green run starts after the stretched red run, so its xMin must be + // noticeably larger than for an unstretched two-glyph string. + const xMinGreen = Math.min(...green.vertices.filter((_, i) => i % 3 === 0)); + const xMaxRed = Math.max(...red.vertices.filter((_, i) => i % 3 === 0)); + expect(xMinGreen).toBeGreaterThan(xMaxRed - 0.5); + }); + + it("run-level obliqueAngle shears glyph X by Y", () => { + const c0 = new MockCollector(); + const cSlant = new MockCollector(); + addMTextToCollector(mp({ collector: c0 as any, font, lines: [{ runs: [{ text: "I" }] }] })); + addMTextToCollector(mp({ collector: cSlant as any, font, lines: [{ runs: [{ text: "I", obliqueAngle: 30 }] }] })); + // Sheared text has a wider xMax than upright text (since top of glyph + // shifts right by tan(30°) × glyphHeight ≈ 0.577 × h). + const w0 = c0.getBounds().xMax - c0.getBounds().xMin; + const wSlant = cSlant.getBounds().xMax - cSlant.getBounds().xMin; + expect(wSlant).toBeGreaterThan(w0); + }); + }); + describe("addMTextToCollector — per-line height", () => { it("different line heights render at different sizes", () => { const cSmall = new MockCollector(); const cLarge = new MockCollector(); - addMTextToCollector(mp({ collector: cSmall as any, font, lines: [{ text: "A", height: 5 }] })); - addMTextToCollector(mp({ collector: cLarge as any, font, lines: [{ text: "A", height: 20 }] })); + addMTextToCollector(mp({ collector: cSmall as any, font, lines: [line("A", { height: 5 })] })); + addMTextToCollector(mp({ collector: cLarge as any, font, lines: [line("A", { height: 20 })] })); const hSmall = cSmall.getBounds().yMax - cSmall.getBounds().yMin; const hLarge = cLarge.getBounds().yMax - cLarge.getBounds().yMin; expect(hLarge).toBeGreaterThan(hSmall * 2); @@ -458,7 +599,7 @@ describe("vectorTextBuilder", () => { describe("addMTextToCollector — word wrapping", () => { it("long text with width constraint produces multiple lines", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "Hello World Test" }]; + const lines: MTextLine[] = [line("Hello World Test")]; const helloWidth = measureTextWidth(font, "Hello World", 10); addMTextToCollector(mp({ collector: c as any, font, lines, width: helloWidth * 0.8, attachmentPoint: 1 })); expect(c.meshCalls.length).toBeGreaterThan(1); @@ -466,14 +607,14 @@ describe("vectorTextBuilder", () => { it("short text stays single line when width is large enough", () => { const c = new MockCollector(); - addMTextToCollector(mp({ collector: c as any, font, lines: [{ text: "Hi" }], width: 1000, attachmentPoint: 1 })); + addMTextToCollector(mp({ collector: c as any, font, lines: [line("Hi")], width: 1000, attachmentPoint: 1 })); expect(c.meshCalls.length).toBe(1); }); it("width=0 or undefined skips wrapping", () => { const c1 = new MockCollector(); const c2 = new MockCollector(); - const lines: MTextLine[] = [{ text: "Hello World Test" }]; + const lines: MTextLine[] = [line("Hello World Test")]; addMTextToCollector(mp({ collector: c1 as any, font, lines, width: 0, attachmentPoint: 1 })); addMTextToCollector(mp({ collector: c2 as any, font, lines, attachmentPoint: 1 })); expect(c1.meshCalls.length).toBe(1); @@ -484,7 +625,7 @@ describe("vectorTextBuilder", () => { describe("addMTextToCollector — stacked text", () => { it("line with stackedTop/stackedBottom renders extra geometry", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "Main", stackedTop: "1", stackedBottom: "2" }]; + const lines: MTextLine[] = [line("Main", { stackedTop: "1", stackedBottom: "2" })]; addMTextToCollector(mp({ collector: c as any, font, lines })); expect(c.meshCalls.length).toBeGreaterThanOrEqual(3); expect(c.totalVertices).toBeGreaterThan(0); @@ -492,7 +633,7 @@ describe("vectorTextBuilder", () => { it("stacked text without main text still renders fractions", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "", stackedTop: "1", stackedBottom: "2" }]; + const lines: MTextLine[] = [line("", { stackedTop: "1", stackedBottom: "2" })]; addMTextToCollector(mp({ collector: c as any, font, lines })); expect(c.meshCalls.length).toBeGreaterThanOrEqual(2); }); @@ -502,7 +643,7 @@ describe("vectorTextBuilder", () => { it("90° rotation changes text direction", () => { const c0 = new MockCollector(); const c90 = new MockCollector(); - const lines: MTextLine[] = [{ text: "ABC" }]; + const lines: MTextLine[] = [line("ABC")]; addMTextToCollector(mp({ collector: c0 as any, font, lines, rotation: 0 })); addMTextToCollector(mp({ collector: c90 as any, font, lines, rotation: Math.PI / 2 })); const b0 = c0.getBounds(); @@ -513,7 +654,7 @@ describe("vectorTextBuilder", () => { it("multiline rotation positions lines perpendicular to text direction", () => { const c = new MockCollector(); - const lines: MTextLine[] = [{ text: "A" }, { text: "B" }]; + const lines: MTextLine[] = [line("A"), line("B")]; addMTextToCollector(mp({ collector: c as any, font, lines, rotation: Math.PI / 2, attachmentPoint: 1 })); const b = c.getBounds(); const w = b.xMax - b.xMin; @@ -527,8 +668,8 @@ describe("vectorTextBuilder", () => { // With narrow column width, tab lines must NOT be wrapped — tabs define layout. const cTabs = new MockCollector(); const cNoTabs = new MockCollector(); - const linesWithTabs: MTextLine[] = [{ text: "AB\tCD\t\t" }, { text: "EF" }]; - const linesNoTabs: MTextLine[] = [{ text: "AB CD" }, { text: "EF" }]; + const linesWithTabs: MTextLine[] = [line("AB\tCD\t\t"), line("EF")]; + const linesNoTabs: MTextLine[] = [line("AB CD"), line("EF")]; addMTextToCollector(mp({ collector: cTabs as any, font, lines: linesWithTabs, width: 50, attachmentPoint: 1 })); addMTextToCollector(mp({ collector: cNoTabs as any, font, lines: linesNoTabs, width: 50, attachmentPoint: 1 })); // Both have 2 visual lines — tab lines are not split by word wrap @@ -541,8 +682,8 @@ describe("vectorTextBuilder", () => { const cTab = new MockCollector(); const cSpaces = new MockCollector(); // Tab stop is font-proportional, so A\tB places B much further than "A B" - addMTextToCollector(mp({ collector: cTab as any, font, lines: [{ text: "A\tB" }] })); - addMTextToCollector(mp({ collector: cSpaces as any, font, lines: [{ text: "A B" }] })); + addMTextToCollector(mp({ collector: cTab as any, font, lines: [line("A\tB")] })); + addMTextToCollector(mp({ collector: cSpaces as any, font, lines: [line("A B")] })); const bTab = cTab.getBounds(); const bSpaces = cSpaces.getBounds(); expect(bTab.xMax - bTab.xMin).toBeGreaterThan(bSpaces.xMax - bSpaces.xMin); @@ -552,8 +693,8 @@ describe("vectorTextBuilder", () => { const cTabs = new MockCollector(); const cNoTabs = new MockCollector(); // Very wide column (1000 units) — trailing tabs stripped, same visual result - const linesWithTabs: MTextLine[] = [{ text: "AB\tCD\t" }, { text: "EF" }]; - const linesNoTabs: MTextLine[] = [{ text: "AB\tCD" }, { text: "EF" }]; + const linesWithTabs: MTextLine[] = [line("AB\tCD\t"), line("EF")]; + const linesNoTabs: MTextLine[] = [line("AB\tCD"), line("EF")]; addMTextToCollector(mp({ collector: cTabs as any, font, lines: linesWithTabs, width: 1000, attachmentPoint: 1 })); addMTextToCollector(mp({ collector: cNoTabs as any, font, lines: linesNoTabs, width: 1000, attachmentPoint: 1 })); const bTabs = cTabs.getBounds(); @@ -563,7 +704,7 @@ describe("vectorTextBuilder", () => { it("tabs without column width are expanded to spaces (no crash)", () => { const c = new MockCollector(); - addMTextToCollector(mp({ collector: c as any, font, lines: [{ text: "X-00\tREFRIGERATOR\t\t" }] })); + addMTextToCollector(mp({ collector: c as any, font, lines: [line("X-00\tREFRIGERATOR\t\t")] })); expect(c.meshCalls.length).toBeGreaterThan(0); }); @@ -571,7 +712,7 @@ describe("vectorTextBuilder", () => { // Tab-containing lines skip word wrap regardless of width constraint const cNarrow = new MockCollector(); const cWide = new MockCollector(); - const lines: MTextLine[] = [{ text: "A\tB\t" }, { text: "X" }]; + const lines: MTextLine[] = [line("A\tB\t"), line("X")]; addMTextToCollector(mp({ collector: cNarrow as any, font, lines: [...lines], width: 81, attachmentPoint: 1 })); addMTextToCollector(mp({ collector: cWide as any, font, lines: [...lines], width: 1000, attachmentPoint: 1 })); const bNarrow = cNarrow.getBounds(); @@ -615,6 +756,32 @@ describe("vectorTextBuilder", () => { addDimensionTextToCollector(dp({ collector: c2 as any, font })); expect(c1.totalVertices).toBe(c2.totalVertices); }); + + it("widthFactor=0.8 makes dim text ~20% narrower (height unchanged)", () => { + // Wired through DIMSTYLE.DIMTXSTY → STYLE.widthFactor (DXF code 41). + // Horizontal advance scales by widthFactor; cap height stays the same. + const baseline = new MockCollector(); + const narrow = new MockCollector(); + addDimensionTextToCollector(dp({ collector: baseline as any, font, hAlign: "left" })); + addDimensionTextToCollector(dp({ collector: narrow as any, font, hAlign: "left", widthFactor: 0.8 })); + + const bb = baseline.getBounds(); + const bn = narrow.getBounds(); + const widthBase = bb.xMax - bb.xMin; + const widthNarrow = bn.xMax - bn.xMin; + expect(widthNarrow).toBeCloseTo(widthBase * 0.8, 0); + + // Vertical extent unchanged (widthFactor is horizontal-only) + const heightBase = bb.yMax - bb.yMin; + const heightNarrow = bn.yMax - bn.yMin; + expect(heightNarrow).toBeCloseTo(heightBase, 1); + }); + + it("measureDimensionTextWidth scales by widthFactor", () => { + const wBase = measureDimensionTextWidth(font, "25.40", 10); + const wNarrow = measureDimensionTextWidth(font, "25.40", 10, 0.8); + expect(wNarrow).toBeCloseTo(wBase * 0.8, 1); + }); }); describe("addDimensionTextToCollector — stacked text", () => { diff --git a/packages/dxf-render/src/render/text/fontClassifier.ts b/packages/dxf-render/src/render/text/fontClassifier.ts index 0c77af0..d71e505 100644 --- a/packages/dxf-render/src/render/text/fontClassifier.ts +++ b/packages/dxf-render/src/render/text/fontClassifier.ts @@ -82,3 +82,19 @@ export function resolveEntityFont( // 3. Default return sansFont; } + +/** + * Resolve bold/italic flags and the horizontal width factor from a STYLE-table + * entry referenced by `textStyle`. Returns undefined for missing fields so + * callers can apply their own fallback (e.g. MTEXT inline \f...|b0; should be + * able to turn off entity-level bold; TEXT.xScale beats STYLE.widthFactor). + */ +export function resolveStyleFlags( + textStyle: string | undefined, + styles: Record | undefined, +): { bold?: boolean; italic?: boolean; widthFactor?: number } { + if (!textStyle || !styles) return {}; + const style = styles[textStyle]; + if (!style) return {}; + return { bold: style.bold, italic: style.italic, widthFactor: style.widthFactor }; +} diff --git a/packages/dxf-render/src/render/text/mtextParser.ts b/packages/dxf-render/src/render/text/mtextParser.ts index 8d2d176..d2e48bf 100644 --- a/packages/dxf-render/src/render/text/mtextParser.ts +++ b/packages/dxf-render/src/render/text/mtextParser.ts @@ -1,8 +1,11 @@ -import ACI_PALETTE from "@/parser/acadColorIndex"; -import { rgbNumberToHex } from "@/utils/colorResolver"; +import { aciToColor } from "@/utils/colorResolver"; -/** MTEXT line with optional color, height, and style overrides */ -export interface MTextLine { +/** + * MTEXT run: a contiguous span of characters within a line sharing one set of + * formatting attributes. Created on every inline format change ({}, \C, \H, + * \f, \L\l, \O\o, \K\k) so each segment can be rendered independently. + */ +export interface MTextRun { text: string; color?: string; height?: number; @@ -10,10 +13,25 @@ export interface MTextLine { italic?: boolean; fontFamily?: string; underline?: boolean; - stackedTop?: string; // \Stop^bottom; -> superscript - stackedBottom?: string; // \Stop^bottom; -> subscript - leftMargin?: number; // \p...l... left margin (drawing units) - firstIndent?: number; // \p...i... first-line indent (drawing units) + overline?: boolean; + strikethrough?: boolean; + /** Horizontal stretch factor (DXF `\W;`, analogous to TEXT code 41). */ + widthFactor?: number; + /** Glyph slant in degrees (DXF `\Q;`, analogous to TEXT code 51). */ + obliqueAngle?: number; +} + +/** + * MTEXT line: an ordered list of runs plus paragraph-level attributes. + * Stacked fractions (\S) are stored on the line, not on a run, because \S + * is a single self-contained segment that doesn't nest inside braces. + */ +export interface MTextLine { + runs: MTextRun[]; + leftMargin?: number; + firstIndent?: number; + stackedTop?: string; + stackedBottom?: string; } /** @@ -26,9 +44,9 @@ export interface MTextLine { */ export const replaceSpecialChars = (text: string, preserveTabs = false): string => text - .replace(/%%[dD]/g, "\u00B0") - .replace(/%%[pP]/g, "\u00B1") - .replace(/%%[cC]/g, "\u2300") + .replace(/%%[dD]/g, "°") + .replace(/%%[pP]/g, "±") + .replace(/%%[cC]/g, "⌀") .replace(/%%[uUoO]/g, "") // toggle underline/overline — remove .replace(/%%(\d{3})/g, (_, code) => String.fromCharCode(parseInt(code))) // DXF caret notation: ^I = tab, ^^ = literal caret, ^X = control char @@ -46,170 +64,313 @@ export function parseTextWithUnderline(rawText: string): { text: string; underli return { text: replaceSpecialChars(rawText), underline }; } +/** Concatenate run texts to recover the plain text of an MTextLine. */ +export const getMTextLineText = (line: MTextLine): string => + line.runs.map((r) => r.text).join(""); + +interface FormatState { + color: string | undefined; + height: number | undefined; + bold: boolean; + italic: boolean; + fontFamily: string | undefined; + underline: boolean; + overline: boolean; + strikethrough: boolean; + widthFactor: number | undefined; + obliqueAngle: number | undefined; +} + +const initialState = (): FormatState => ({ + color: undefined, + height: undefined, + bold: false, + italic: false, + fontFamily: undefined, + underline: false, + overline: false, + strikethrough: false, + widthFactor: undefined, + obliqueAngle: undefined, +}); + +const cloneState = (s: FormatState): FormatState => ({ ...s }); + +const snapshotRun = (text: string, s: FormatState): MTextRun => { + const run: MTextRun = { text }; + if (s.color !== undefined) run.color = s.color; + if (s.height !== undefined) run.height = s.height; + if (s.bold) run.bold = true; + if (s.italic) run.italic = true; + if (s.fontFamily !== undefined) run.fontFamily = s.fontFamily; + if (s.underline) run.underline = true; + if (s.overline) run.overline = true; + if (s.strikethrough) run.strikethrough = true; + if (s.widthFactor !== undefined) run.widthFactor = s.widthFactor; + if (s.obliqueAngle !== undefined) run.obliqueAngle = s.obliqueAngle; + return run; +}; + +const restorePlaceholders = (s: string): string => + s.replace(/\x01/g, "\\").replace(/\x02/g, "{").replace(/\x03/g, "}"); + /** - * Parse MTEXT formatting into an array of lines with color and height. - * Handles: \P (line break), \C; (ACI color), \H; (height), - * \f...; (font), %%d/%%p/%%c (special chars), {}, \L/\O/\K, etc. + * Parse MTEXT formatting into an array of lines, each composed of one or more + * formatted runs. Brace groups {…} create a scope: format changes inside the + * group apply only to that scope and are popped on the matching `}`. + * + * Supported inline codes: \P (line break), \C;/\c; (ACI color), + * \H[x]; (height, absolute or relative multiplier), \f|...; + * (font + bold/italic flags), \L/\l (underline on/off), + * \O/\o (overline on/off), \K/\k (strikethrough on/off), + * \S; (stacked fraction; `#` separator becomes inline "a/b"), + * \W; (width factor, horizontal stretch), + * \Q; (obliquing angle in degrees), + * \p…; (paragraph indent/left margin), \~ (NBSP), \N (column break -> space), + * \U+XXXX (Unicode), %%d/%%p/%%c/%%nnn (special chars), ^I (tab), ^^ (caret). + * Codes \T (tracking) and \A (per-run baseline shift) are accepted and skipped. */ -export const parseMTextContent = (rawText: string, defaultHeight?: number): MTextLine[] => { - // Protect literal escape sequences with placeholders - // so they are not consumed by the formatting parser (\\ -> \, \{ -> {, \} -> }) +export const parseMTextContent = ( + rawText: string, + defaultHeight?: number, + defaultWidthFactor?: number, +): MTextLine[] => { + // Protect literal \\, \{, \} from formatting parser via placeholders. let text = rawText.replace(/\\\\/g, "\x01").replace(/\\\{/g, "\x02").replace(/\\\}/g, "\x03"); - - // Unicode characters by code: \U+XXXX -> character + // Unicode escapes \U+XXXX -> character (done before special-char pass so the + // resulting characters can't be misread as %% sequences) text = text.replace(/\\U\+([0-9A-Fa-f]{4})/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)), ); - text = replaceSpecialChars(text, true); - // Split by \P (MTEXT line break) - const rawLines = text.split(/\\P/); + const state = initialState(); + // Seed the base width factor from STYLE.widthFactor (DXF code 41) — runs + // without inline \W; inherit it. Skip when 1 (or unset) so unmarked runs + // stay as MTextRun without a widthFactor field. + if (defaultWidthFactor !== undefined && defaultWidthFactor !== 1) { + state.widthFactor = defaultWidthFactor; + } + const stack: FormatState[] = []; const lines: MTextLine[] = []; - let currentColor: string | undefined; - let currentHeight: number | undefined; - let currentBold = false; - let currentItalic = false; - let currentFont: string | undefined; - let currentUnderline = false; - - for (const rawLine of rawLines) { - let clean = rawLine; - - let lineFont = currentFont; - let lineBold = currentBold; - let lineItalic = currentItalic; - let firstFontInLine = true; - - // Brace scoping: formatting codes inside {…} are scoped — they apply - // within the braces but don't persist to subsequent lines. - // \H inside braces: apply to this line's height only when ALL text is - // inside brace groups (e.g. {\H0.75x;A4.2}). When there's content - // outside braces (e.g. {\H0.7x;text}rest), \H is stripped since our - // line-level model can't represent mixed heights within one line. - let bracedHeight: number | undefined; - const allContentInBraces = /^\s*(\{[^{}]*\}\s*)+$/.test(clean); - clean = clean.replace(/\{([^{}]*)\}/g, (_, inner: string) => { - // Extract \H from braces — apply scoped height without persisting - if (allContentInBraces) { - inner = inner.replace(/\\H([\d.]+)(x?);/gi, (__, val, suffix) => { - const v = parseFloat(val); - if (suffix === "x" || suffix === "X") { - bracedHeight = (currentHeight ?? defaultHeight ?? 1) * v; - } else { - bracedHeight = v; - } - return ""; - }); - } else { - inner = inner.replace(/\\H[\d.]+x?;/gi, ""); - } - return inner - .replace(/\\f[^|;]*\|?[^;]*;/g, "") - .replace(/\\[cC]\d+;/g, ""); - }); - - // Font: \fFontName|b1|i0|c0|p0; — extract font name, bold, italic - // First \f in line determines the visible text style for this line, - // last \f updates carry-over state for subsequent lines - clean = clean.replace(/\\f([^|;]*)\|?[^;]*;/g, (fullMatch, fontName) => { - if (fontName) currentFont = fontName; - const boldMatch = fullMatch.match(/\|b(\d)/); - const italicMatch = fullMatch.match(/\|i(\d)/); - if (boldMatch) currentBold = boldMatch[1] === "1"; - if (italicMatch) currentItalic = italicMatch[1] === "1"; - if (firstFontInLine) { - lineFont = currentFont; - lineBold = currentBold; - lineItalic = currentItalic; - firstFontInLine = false; - } - return ""; - }); + let line: MTextLine = { runs: [] }; + let runText = ""; + + const flushRun = () => { + if (runText.length === 0) return; + line.runs.push(snapshotRun(restorePlaceholders(runText), state)); + runText = ""; + }; + + const pushLine = () => { + flushRun(); + lines.push(line); + line = { runs: [] }; + }; + + for (let i = 0; i < text.length; ) { + const ch = text[i]; + + if (ch === "{") { + flushRun(); + stack.push(cloneState(state)); + i++; + continue; + } + + if (ch === "}") { + flushRun(); + const popped = stack.pop(); + if (popped) Object.assign(state, popped); + i++; + continue; + } - // ACI color: \C; or \c; - clean = clean.replace(/\\[cC](\d+);/g, (_, indexStr) => { - const idx = parseInt(indexStr); + if (ch !== "\\") { + runText += ch; + i++; + continue; + } + + // ── command starting with '\' ───────────────────────────────────── + const rest = text.slice(i); + + // \P — line break + if (rest.startsWith("\\P")) { + pushLine(); + i += 2; + continue; + } + // \~ — non-breaking space (rendered as regular space) + if (rest.startsWith("\\~")) { + runText += " "; + i += 2; + continue; + } + // \N — column break (treat as space) + if (rest.startsWith("\\N")) { + runText += " "; + i += 2; + continue; + } + + // \C; / \c; — ACI color + const colorMatch = rest.match(/^\\[cC](\d+);/); + if (colorMatch) { + flushRun(); + const idx = parseInt(colorMatch[1]); if (idx === 0 || idx === 256) { - currentColor = undefined; // ByBlock/ByLayer — use entity color + state.color = undefined; // ByBlock/ByLayer — inherit from entity } else if (idx >= 1 && idx <= 255) { - currentColor = rgbNumberToHex(ACI_PALETTE[idx]); - } - return ""; - }); - - // Height: \H; (absolute) or \Hx; (relative multiplier) - clean = clean.replace(/\\H([\d.]+)(x?);/gi, (_, val, suffix) => { - const v = parseFloat(val); - if (suffix === "x" || suffix === "X") { - currentHeight = (currentHeight ?? defaultHeight ?? 1) * v; - } else { - currentHeight = v; + state.color = aciToColor(idx); } - return ""; - }); - - // Paragraph formatting: \p[i][,l][,r][,t]; or \pxq[lcr]; - let lineLeftMargin: number | undefined; - let lineFirstIndent: number | undefined; - clean = clean.replace(/\\p([^;]*);/g, (_, params: string) => { - const iMatch = params.match(/i([+-]?[\d.]+)/); - if (iMatch) lineFirstIndent = parseFloat(iMatch[1]); - const lMatch = params.match(/l([\d.]+)/); - if (lMatch) lineLeftMargin = parseFloat(lMatch[1]); - return ""; - }); - // Width, tracking, oblique, alignment: \W, \T, \Q, \A - clean = clean.replace(/\\[WTQA][\d.+-]+;/gi, ""); - // Underline toggle: \L starts, \l ends; track state across lines - let lineUnderline = currentUnderline; - clean = clean.replace(/\\([LOKlok])/g, (_, code: string) => { - if (code === "L") { currentUnderline = true; lineUnderline = true; } - else if (code === "l") currentUnderline = false; - // O, o, K, k: overline/strikethrough — strip silently - return ""; - }); - // Fractions: \Stop^bottom; or \Stop/bottom; -> stacked fields - // \Stop#bottom; -> inline flat text "top/bottom" (horizontal bar fraction) - let lineStackedTop: string | undefined; - let lineStackedBottom: string | undefined; - clean = clean.replace(/\\S([^^/#;]*)([\^/#])([^;]*);/g, (_, top, sep, bottom) => { + i += colorMatch[0].length; + continue; + } + + // \H[x]; — height (absolute or relative multiplier) + const heightMatch = rest.match(/^\\H([\d.]+)(x?);/i); + if (heightMatch) { + flushRun(); + const v = parseFloat(heightMatch[1]); + const relative = heightMatch[2] === "x" || heightMatch[2] === "X"; + state.height = relative ? (state.height ?? defaultHeight ?? 1) * v : v; + i += heightMatch[0].length; + continue; + } + + // \f|b<0|1>|i<0|1>|c|p; — font + bold/italic + const fontMatch = rest.match(/^\\f([^;]*);/); + if (fontMatch) { + flushRun(); + const params = fontMatch[1]; + const semi = params.indexOf("|"); + const name = (semi === -1 ? params : params.slice(0, semi)).trim(); + if (name) state.fontFamily = name; + const bMatch = params.match(/\|b(\d)/); + const iMatch = params.match(/\|i(\d)/); + if (bMatch) state.bold = bMatch[1] === "1"; + if (iMatch) state.italic = iMatch[1] === "1"; + i += fontMatch[0].length; + continue; + } + + // \L / \l — underline on / off + if (rest.startsWith("\\L")) { + flushRun(); + state.underline = true; + i += 2; + continue; + } + if (rest.startsWith("\\l")) { + flushRun(); + state.underline = false; + i += 2; + continue; + } + // \O / \o — overline on / off + if (rest.startsWith("\\O")) { + flushRun(); + state.overline = true; + i += 2; + continue; + } + if (rest.startsWith("\\o")) { + flushRun(); + state.overline = false; + i += 2; + continue; + } + // \K / \k — strikethrough on / off + if (rest.startsWith("\\K")) { + flushRun(); + state.strikethrough = true; + i += 2; + continue; + } + if (rest.startsWith("\\k")) { + flushRun(); + state.strikethrough = false; + i += 2; + continue; + } + + // \S; — stacked fraction (or inline a/b with `#`) + const stackedMatch = rest.match(/^\\S([^^/#;]*)([\^/#])([^;]*);/); + if (stackedMatch) { + const top = stackedMatch[1].trim(); + const sep = stackedMatch[2]; + const bottom = stackedMatch[3].trim(); if (sep === "#") { - return `${top.trim()}/${bottom.trim()}`; // inline fraction + runText += `${top}/${bottom}`; + } else { + flushRun(); + line.stackedTop = top; + line.stackedBottom = bottom; } - lineStackedTop = top.trim(); - lineStackedBottom = bottom.trim(); - return ""; - }); - // Non-breaking space - clean = clean.replace(/\\~/g, " "); - // Column break \N -> space - clean = clean.replace(/\\N/g, " "); - // Grouping braces (literal ones are already protected by placeholders) - clean = clean.replace(/[{}]/g, ""); - // Remaining unknown escape sequences \X...; - clean = clean.replace(/\\[a-zA-Z][^;]*;/g, ""); - - // Restore literal characters from placeholders - clean = clean.replace(/\x01/g, "\\").replace(/\x02/g, "{").replace(/\x03/g, "}"); - - // Always push lines — empty lines (\P\P) serve as paragraph spacing - lines.push({ - text: clean, - color: currentColor, - height: bracedHeight ?? currentHeight, - bold: lineBold, - italic: lineItalic, - fontFamily: lineFont, - underline: lineUnderline || undefined, - stackedTop: lineStackedTop, - stackedBottom: lineStackedBottom, - leftMargin: lineLeftMargin, - firstIndent: lineFirstIndent, - }); + i += stackedMatch[0].length; + continue; + } + + // \p; — paragraph indent / left margin / alignment + const paragraphMatch = rest.match(/^\\p([^;]*);/); + if (paragraphMatch) { + const params = paragraphMatch[1]; + const iMatchPara = params.match(/i([+-]?[\d.]+)/); + if (iMatchPara) line.firstIndent = parseFloat(iMatchPara[1]); + const lMatchPara = params.match(/l([\d.]+)/); + if (lMatchPara) line.leftMargin = parseFloat(lMatchPara[1]); + i += paragraphMatch[0].length; + continue; + } + + // \W; — width factor (horizontal stretch). Positive values only. + const widthMatch = rest.match(/^\\W([\d.]+);/); + if (widthMatch) { + flushRun(); + const v = parseFloat(widthMatch[1]); + if (Number.isFinite(v) && v > 0) state.widthFactor = v; + i += widthMatch[0].length; + continue; + } + + // \Q; — obliquing angle in degrees (glyph slant). + const obliqueMatch = rest.match(/^\\Q(-?[\d.]+);/); + if (obliqueMatch) { + flushRun(); + const v = parseFloat(obliqueMatch[1]); + if (Number.isFinite(v)) state.obliqueAngle = v; + i += obliqueMatch[0].length; + continue; + } + + // \T / \A — tracking / per-run baseline shift (not yet implemented, skip). + const skipMatch = rest.match(/^\\[TA][^;]*;/i); + if (skipMatch) { + i += skipMatch[0].length; + continue; + } + + // Unknown \...; — consume up to and including the next ';' + const unknownTerminated = rest.match(/^\\[a-zA-Z][^;]*;/); + if (unknownTerminated) { + i += unknownTerminated[0].length; + continue; + } + // Unknown \ without semicolon — drop the two chars + const unknownShort = rest.match(/^\\[a-zA-Z]/); + if (unknownShort) { + i += 2; + continue; + } + + // Lone backslash at end of input — treat as literal + runText += ch; + i++; } + pushLine(); return lines; }; diff --git a/packages/dxf-render/src/render/text/vectorTextBuilder.ts b/packages/dxf-render/src/render/text/vectorTextBuilder.ts index 56b887e..2272f38 100644 --- a/packages/dxf-render/src/render/text/vectorTextBuilder.ts +++ b/packages/dxf-render/src/render/text/vectorTextBuilder.ts @@ -1,7 +1,7 @@ import type { Font, Glyph } from "opentype.js"; import { getTriangulatedGlyph, type GlyphData } from "./glyphCache"; import type { GeometryCollector } from "../mergeCollectors"; -import type { MTextLine } from "./mtextParser"; +import type { MTextLine, MTextRun } from "./mtextParser"; import { cleanDimensionMText } from "../dimensions"; import { classifyFont } from "./fontClassifier"; @@ -136,6 +136,8 @@ export interface TextParams { italic?: boolean; obliqueAngle?: number; underline?: boolean; + overline?: boolean; + strikethrough?: boolean; } export interface MTextParams { @@ -153,6 +155,10 @@ export interface MTextParams { width?: number; serifFont?: Font; lineSpacingFactor?: number; + /** Entity-level bold default — used when an MTextRun does not specify its own bold flag. */ + bold?: boolean; + /** Entity-level italic default — used when an MTextRun does not specify its own italic flag. */ + italic?: boolean; } export interface DimensionTextParams { @@ -168,6 +174,11 @@ export interface DimensionTextParams { rotation?: number; hAlign?: "left" | "center" | "right"; transform?: readonly number[]; + /** + * Horizontal width factor (STYLE.widthFactor, DXF code 41). Applied uniformly + * to glyph advance and to the measured width. Defaults to 1 when unset. + */ + widthFactor?: number; } // ── addTextToCollector ──────────────────────────────────────────────── @@ -357,32 +368,69 @@ export function addTextToCollector(p: TextParams): void { collector.addOverlayMesh(layer, color, allPositions, allIndices); } - // Emit underline line segment below text - if (p.underline && m.totalAdvance > 0) { - const ulX1 = (m.bounds.xMin - originX) * scaleX; - const ulX2 = (m.bounds.xMax - originX) * scaleX; - const ulLocalY = (-UNDERLINE_OFFSET - originY) * scaleY; - - let wx1 = posX + ulX1 * cos - ulLocalY * sin; - let wy1 = posY + ulX1 * sin + ulLocalY * cos; - let wz1 = posZ; - let wx2 = posX + ulX2 * cos - ulLocalY * sin; - let wy2 = posY + ulX2 * sin + ulLocalY * cos; - let wz2 = posZ; - - if (transform) { - const t1x = transform[0] * wx1 + transform[4] * wy1 + transform[8] * wz1 + transform[12]; - const t1y = transform[1] * wx1 + transform[5] * wy1 + transform[9] * wz1 + transform[13]; - const t1z = transform[2] * wx1 + transform[6] * wy1 + transform[10] * wz1 + transform[14]; - wx1 = t1x; wy1 = t1y; wz1 = t1z; - const t2x = transform[0] * wx2 + transform[4] * wy2 + transform[8] * wz2 + transform[12]; - const t2y = transform[1] * wx2 + transform[5] * wy2 + transform[9] * wz2 + transform[13]; - const t2z = transform[2] * wx2 + transform[6] * wy2 + transform[10] * wz2 + transform[14]; - wx2 = t2x; wy2 = t2y; wz2 = t2z; + // Emit underline / strikethrough / overline segments + if (m.totalAdvance > 0) { + const capRatio = getCapHeightRatio(font); + if (p.underline) { + emitTextDecoration(collector, layer, color, m, originX, originY, + scaleX, scaleY, -UNDERLINE_OFFSET, posX, posY, posZ, cos, sin, transform); + } + if (p.strikethrough) { + emitTextDecoration(collector, layer, color, m, originX, originY, + scaleX, scaleY, capRatio * STRIKETHROUGH_RATIO, posX, posY, posZ, cos, sin, transform); } + if (p.overline) { + emitTextDecoration(collector, layer, color, m, originX, originY, + scaleX, scaleY, capRatio + OVERLINE_OFFSET, posX, posY, posZ, cos, sin, transform); + } + } +} - collector.addLineSegments(layer, color, [wx1, wy1, wz1, wx2, wy2, wz2]); +/** + * Emit a horizontal decoration line spanning the text bounds at the given + * Y position in normalized font units (baseline = 0, cap height = capRatio). + * Applies the same scaling, rotation and transform as the parent text run. + */ +function emitTextDecoration( + collector: GeometryCollector, + layer: string, + color: string, + m: TextMetrics, + originX: number, + originY: number, + scaleX: number, + scaleY: number, + yFontUnits: number, + posX: number, + posY: number, + posZ: number, + cos: number, + sin: number, + transform?: readonly number[], +): void { + const x1 = (m.bounds.xMin - originX) * scaleX; + const x2 = (m.bounds.xMax - originX) * scaleX; + const localY = (yFontUnits - originY) * scaleY; + + let wx1 = posX + x1 * cos - localY * sin; + let wy1 = posY + x1 * sin + localY * cos; + let wz1 = posZ; + let wx2 = posX + x2 * cos - localY * sin; + let wy2 = posY + x2 * sin + localY * cos; + let wz2 = posZ; + + if (transform) { + const t1x = transform[0] * wx1 + transform[4] * wy1 + transform[8] * wz1 + transform[12]; + const t1y = transform[1] * wx1 + transform[5] * wy1 + transform[9] * wz1 + transform[13]; + const t1z = transform[2] * wx1 + transform[6] * wy1 + transform[10] * wz1 + transform[14]; + wx1 = t1x; wy1 = t1y; wz1 = t1z; + const t2x = transform[0] * wx2 + transform[4] * wy2 + transform[8] * wz2 + transform[12]; + const t2y = transform[1] * wx2 + transform[5] * wy2 + transform[9] * wz2 + transform[13]; + const t2z = transform[2] * wx2 + transform[6] * wy2 + transform[10] * wz2 + transform[14]; + wx2 = t2x; wy2 = t2y; wz2 = t2z; } + + collector.addLineSegments(layer, color, [wx1, wy1, wz1, wx2, wy2, wz2]); } // ── Faux bold/italic constants ───────────────────────────────────────── @@ -416,6 +464,10 @@ const ITALIC_SLANT = Math.tan((12 * Math.PI) / 180); const BOLD_OFFSET = 0.02; /** Underline position below baseline as fraction of height (normalized units) */ const UNDERLINE_OFFSET = 0.15; +/** Overline position above cap height as fraction of height (normalized units) */ +const OVERLINE_OFFSET = 0.1; +/** Strikethrough position as fraction of cap height (0.5 = center of cap) */ +const STRIKETHROUGH_RATIO = 0.5; // ── MTEXT support ────────────────────────────────────────────────────── @@ -440,38 +492,134 @@ function mtextHAlignToEnum(hAlign: "left" | "center" | "right"): number { } /** - * Word wrap text to fit within a maximum width (in world units). - * Splits by spaces; single words wider than maxWidth stay on their own line. - * Uses incremental advance accumulation O(n) instead of re-measuring the full line O(n²). + * Resolve the effective font for a run, applying serif/sans classification + * when a serif fallback font is supplied and the run carries a fontFamily. + */ +function resolveRunFont(run: MTextRun, font: Font, serifFont: Font | undefined): Font { + if (serifFont && run.fontFamily) { + return classifyFont(run.fontFamily) === "serif" ? serifFont : font; + } + return font; +} + +/** Measure a run's advance width in world units, given its effective font/height. */ +function measureRunWidth( + run: MTextRun, + font: Font, + serifFont: Font | undefined, + defaultHeight: number, +): number { + if (!run.text) return 0; + const f = resolveRunFont(run, font, serifFont); + const h = run.height ?? defaultHeight; + const emScale = h / getCapHeightRatio(f); + const widthFactor = run.widthFactor ?? 1; + return measureText(f, run.text).totalAdvance * emScale * widthFactor; +} + +/** Build runs by slicing a base run with a new text value (preserves all formatting). */ +const sliceRun = (run: MTextRun, text: string): MTextRun => ({ ...run, text }); + +/** + * Word-wrap a line's runs to fit within maxWidth (world units). Splits each + * run's text on spaces, measures each token with the run's own font/height, + * and greedily packs tokens into wrapped lines. Format boundaries are + * preserved: contiguous tokens from the same run on the same wrapped line + * are coalesced back into a single MTextRun. */ -function wrapTextToWidth(font: Font, text: string, height: number, maxWidth: number): string[] { - if (!text) return [text]; - const words = text.split(" "); - if (words.length <= 1) return [text]; - - const emScale = height / getCapHeightRatio(font); - const spaceAdv = measureText(font, " ").totalAdvance; - - const lines: string[] = []; - let currentLine = words[0]; - let lineAdv = measureText(font, words[0]).totalAdvance; - - for (let i = 1; i < words.length; i++) { - const wordAdv = measureText(font, words[i]).totalAdvance; - const testAdv = lineAdv + spaceAdv + wordAdv; - // 2% tolerance: font metric rounding (sCapHeight override, advance precision) - // can make text slightly wider than the original AutoCAD measurement - if (testAdv * emScale > maxWidth * 1.02 && currentLine.length > 0) { - lines.push(currentLine); - currentLine = words[i]; - lineAdv = wordAdv; +function wrapLineRunsToWidth( + line: MTextLine, + font: Font, + serifFont: Font | undefined, + defaultHeight: number, + maxWidth: number, +): MTextLine[] { + type Token = { runIdx: number; text: string; isSpace: boolean; adv: number }; + const tokens: Token[] = []; + for (let r = 0; r < line.runs.length; r++) { + const run = line.runs[r]; + if (!run.text) continue; + const f = resolveRunFont(run, font, serifFont); + const h = run.height ?? defaultHeight; + const emScale = h / getCapHeightRatio(f); + const widthFactor = run.widthFactor ?? 1; + // Split keeping space groups: " word1 word2 " -> ["", " ", "word1", " ", "word2", " "] + const parts = run.text.split(/( +)/); + for (const part of parts) { + if (part === "") continue; + tokens.push({ + runIdx: r, + text: part, + isSpace: part.charAt(0) === " ", + adv: measureText(f, part).totalAdvance * emScale * widthFactor, + }); + } + } + if (tokens.length === 0) return [line]; + + // Greedy wrap. 2% tolerance matches sCapHeight rounding (see legacy comment). + const wrapped: Token[][] = [[]]; + let currentAdv = 0; + for (const tok of tokens) { + const isFirstOnLine = wrapped[wrapped.length - 1].length === 0; + if ( + !tok.isSpace && + !isFirstOnLine && + currentAdv + tok.adv > maxWidth * 1.02 + ) { + wrapped.push([tok]); + currentAdv = tok.adv; + } else if (tok.isSpace && isFirstOnLine) { + // Skip leading spaces on a wrapped continuation line + continue; } else { - currentLine += " " + words[i]; - lineAdv = testAdv; + wrapped[wrapped.length - 1].push(tok); + currentAdv += tok.adv; + } + } + + return wrapped + .filter((toks) => toks.length > 0) + .map((toks, wi) => { + const runs: MTextRun[] = []; + let bufRunIdx = -1; + let buf = ""; + for (const tok of toks) { + if (tok.runIdx !== bufRunIdx) { + if (buf) runs.push(sliceRun(line.runs[bufRunIdx], buf)); + bufRunIdx = tok.runIdx; + buf = tok.text; + } else { + buf += tok.text; + } + } + if (buf) runs.push(sliceRun(line.runs[bufRunIdx], buf)); + return { + runs, + leftMargin: line.leftMargin, + // Only the first wrapped line keeps the first-line indent + firstIndent: wi === 0 ? line.firstIndent : undefined, + }; + }); +} + +/** Split a line's runs at every '\t' boundary. Returns an array of segments, + * each segment being a list of (run-sliced) runs that render between two + * consecutive tab stops. Trailing empty segments (from trailing tabs) are + * preserved so the caller can drop them as column-width padding. + */ +function splitRunsByTab(runs: MTextRun[]): MTextRun[][] { + const segments: MTextRun[][] = [[]]; + for (const run of runs) { + if (!run.text) continue; + const parts = run.text.split("\t"); + if (parts[0]) segments[segments.length - 1].push(sliceRun(run, parts[0])); + for (let p = 1; p < parts.length; p++) { + segments.push([]); + if (parts[p]) segments[segments.length - 1].push(sliceRun(run, parts[p])); } } - lines.push(currentLine); - return lines; + return segments; } interface StackedTextParams { @@ -580,10 +728,110 @@ function emitStackedText(p: StackedTextParams): void { } } +/** Effective height of a run, falling back to defaultHeight. */ +const runHeight = (run: MTextRun, defaultHeight: number): number => + run.height ?? defaultHeight; + +/** Total advance of a list of runs in world units. */ +function measureRunsWidth( + runs: MTextRun[], + font: Font, + serifFont: Font | undefined, + defaultHeight: number, +): number { + let total = 0; + for (const r of runs) total += measureRunWidth(r, font, serifFont, defaultHeight); + return total; +} + +/** + * Emit a single MTEXT line composed of one or more formatted runs. + * Total line width is measured across all runs so the alignment offset + * positions the whole line correctly; runs are then placed left-to-right + * with accumulated xCursor. + */ +function emitMTextLine( + runs: MTextRun[], + fallbackColor: string, + font: Font, + serifFont: Font | undefined, + defaultHeight: number, + collector: GeometryCollector, + layer: string, + posX: number, + posY: number, + posZ: number, + rotation: number, + cos: number, + sin: number, + hAlign: "left" | "center" | "right", + vAlign: number, + entityBold?: boolean, + entityItalic?: boolean, +): void { + if (runs.length === 0) return; + + const totalWidth = measureRunsWidth(runs, font, serifFont, defaultHeight); + let startOffset = 0; + if (hAlign === "center") startOffset = -totalWidth / 2; + else if (hAlign === "right") startOffset = -totalWidth; + + let xCursor = startOffset; + for (const run of runs) { + if (!run.text) continue; + const f = resolveRunFont(run, font, serifFont); + const h = runHeight(run, defaultHeight); + const wx = posX + xCursor * cos; + const wy = posY + xCursor * sin; + addTextToCollector({ + collector, + layer, + color: run.color ?? fallbackColor, + font: f, + text: run.text, + height: h, + posX: wx, + posY: wy, + posZ, + rotation, + hAlign: HAlign.LEFT, + vAlign, + // Entity-level bold/italic is the default; an inline \f...|b0|i0; override + // can still turn it off (or on) on a per-run basis. + bold: run.bold ?? entityBold, + italic: run.italic ?? entityItalic, + underline: run.underline, + overline: run.overline, + strikethrough: run.strikethrough, + widthFactor: run.widthFactor, + obliqueAngle: run.obliqueAngle, + }); + xCursor += measureRunWidth(run, font, serifFont, defaultHeight); + } +} + +/** + * Strip trailing runs whose text is only whitespace tabs and runs that become + * empty after rstrip — trailing tabs in MTEXT are column-width padding, not + * visible content. Returns a new array; original runs are not mutated. + */ +function stripTrailingTabs(runs: MTextRun[]): MTextRun[] { + if (runs.length === 0) return runs; + const out = runs.slice(); + while (out.length > 0) { + const last = out[out.length - 1]; + const stripped = last.text.replace(/\t+$/, ""); + if (stripped === last.text) break; + if (stripped === "") out.pop(); + else { out[out.length - 1] = sliceRun(last, stripped); break; } + } + return out; +} + /** * Add MTEXT entity lines to GeometryCollector as triangulated mesh. * Handles multiline text with word wrapping, 9 attachment points, - * stacked text (fractions), and per-line color/height overrides. + * stacked text (fractions), and inline run-level formatting. */ export function addMTextToCollector(p: MTextParams): void { const { @@ -592,6 +840,7 @@ export function addMTextToCollector(p: MTextParams): void { rotation = 0, attachmentPoint = 1, width, serifFont, lineSpacingFactor, + bold: entityBold, italic: entityItalic, } = p; if (lines.length === 0 || defaultHeight <= 0) return; const lineSpacing = (lineSpacingFactor || 1) * DXF_LINE_SPACING_BASE; @@ -608,44 +857,42 @@ export function addMTextToCollector(p: MTextParams): void { continue; } - let processedText = line.text; - const hadTabs = processedText.includes("\t"); - + const hasTabs = line.runs.some((r) => r.text.includes("\t")); + let processedRuns = line.runs; // Tab-containing lines define columnar layout (tables, schedules). - // Keep \t characters — they will be rendered at exact tab stop positions. - // Strip trailing tabs — they are column-width padding, not visible content. - if (hadTabs) { - processedText = processedText.replace(/\t+$/, ""); - } + // Strip trailing tabs as column-width padding. + if (hasTabs) processedRuns = stripTrailingTabs(processedRuns); // Word wrap (only when width constraint is set and line has no tabs) - if (!hadTabs && width && width > 0) { - const lineHeight = line.height || defaultHeight; + if (!hasTabs && width && width > 0) { const margin = line.leftMargin || 0; const effectiveWidth = width - margin; - const wrapped = wrapTextToWidth(font, processedText, lineHeight, effectiveWidth > 0 ? effectiveWidth : width); - for (let wi = 0; wi < wrapped.length; wi++) { - expandedLines.push({ - ...line, - text: wrapped[wi], - // Only first wrapped line gets firstIndent - firstIndent: wi === 0 ? line.firstIndent : undefined, - }); - } + const wrapped = wrapLineRunsToWidth( + line, + font, + serifFont, + defaultHeight, + effectiveWidth > 0 ? effectiveWidth : width, + ); + for (const w of wrapped) expandedLines.push(w); } else { - expandedLines.push({ ...line, text: processedText }); + expandedLines.push({ ...line, runs: processedRuns }); } } if (expandedLines.length === 0) return; - // 2. Compute total block height + // 2. Compute total block height. Use the tallest run on each line as the + // line's effective height (matters when a line mixes run heights). + const lineHeightFor = (line: MTextLine): number => { + let h = 0; + for (const r of line.runs) h = Math.max(h, runHeight(r, defaultHeight)); + return h > 0 ? h : defaultHeight; + }; + let totalHeight = 0; - for (const line of expandedLines) { - totalHeight += (line.height || defaultHeight) * lineSpacing; - } - // Remove trailing spacing from last line - const lastLineHeight = expandedLines[expandedLines.length - 1].height || defaultHeight; + for (const line of expandedLines) totalHeight += lineHeightFor(line) * lineSpacing; + const lastLineHeight = lineHeightFor(expandedLines[expandedLines.length - 1]); totalHeight = totalHeight - lastLineHeight * lineSpacing + lastLineHeight; // 3. Determine alignment from attachment point (1-9) @@ -665,57 +912,51 @@ export function addMTextToCollector(p: MTextParams): void { } // 4. Emit each line - const hAlignEnum = mtextHAlignToEnum(hAlign); const cos = Math.cos(rotation); const sin = Math.sin(rotation); let lineYOffset = 0; // accumulates downward (negative Y in local coords) for (const line of expandedLines) { - const lineHeight = line.height || defaultHeight; - const lineColor = line.color || color; - - // Per-line font: use inline \f fontFamily to pick sans/serif - let lineFont = font; - if (serifFont && line.fontFamily) { - lineFont = classifyFont(line.fontFamily) === "serif" ? serifFont : font; - } + const lineHeight = lineHeightFor(line); + // Stacked lines (\S) currently use the first run's formatting for the + // main text and fraction. Mixing formatting around \S is rare and not + // representable in the legacy emitStackedText model. + const firstRun = line.runs[0]; // Paragraph indentation: leftMargin + firstIndent (in drawing units) const indentX = (line.leftMargin || 0) + (line.firstIndent || 0); - // Local offset from insertion point (in text-local coordinates) const localY = groupYOffset + lineYOffset; - // Apply rotation to get world position, including paragraph indent const worldX = posX - localY * sin + indentX * cos; const worldY = posY + localY * cos + indentX * sin; if (line.stackedTop || line.stackedBottom) { + const mainText = line.runs.map((r) => r.text).join(""); + const stackedFont = firstRun ? resolveRunFont(firstRun, font, serifFont) : font; + const stackedColor = firstRun?.color ?? color; emitStackedText({ - collector, layer, color: lineColor, font: lineFont, - mainText: line.text, stackedTop: line.stackedTop || "", stackedBottom: line.stackedBottom || "", + collector, layer, color: stackedColor, font: stackedFont, + mainText, stackedTop: line.stackedTop || "", stackedBottom: line.stackedBottom || "", height: lineHeight, posX: worldX, posY: worldY, posZ, rotation, hAlign, - bold: line.bold, italic: line.italic, + bold: firstRun?.bold ?? entityBold, italic: firstRun?.italic ?? entityItalic, }); - } else if (line.text.includes("\t")) { + } else if (line.runs.some((r) => r.text.includes("\t"))) { // Render tab-separated segments at exact tab stop positions. // Tab grid = multiples of tabStopWidth (4 × defaultHeight). - // With sCapHeight overridden to match Arial, positions match AutoCAD exactly. - const segments = line.text.split("\t"); - const emScale = lineHeight / getCapHeightRatio(lineFont); + const segments = splitRunsByTab(line.runs); let segLocalX = 0; for (let si = 0; si < segments.length; si++) { - if (segments[si]) { + const seg = segments[si]; + if (seg.length > 0) { const segWX = worldX + segLocalX * cos; const segWY = worldY + segLocalX * sin; - addTextToCollector({ - collector, layer, color: lineColor, font: lineFont, - text: segments[si], height: lineHeight, - posX: segWX, posY: segWY, posZ, - rotation, hAlign: HAlign.LEFT, vAlign: rowVAlign, - bold: line.bold, italic: line.italic, - underline: line.underline, - }); - segLocalX += measureText(lineFont, segments[si]).totalAdvance * emScale; + emitMTextLine( + seg, color, font, serifFont, defaultHeight, + collector, layer, segWX, segWY, posZ, rotation, cos, sin, + "left", rowVAlign, + entityBold, entityItalic, + ); + segLocalX += measureRunsWidth(seg, font, serifFont, defaultHeight); } // Advance to next tab stop after each segment except the last if (si < segments.length - 1) { @@ -723,14 +964,12 @@ export function addMTextToCollector(p: MTextParams): void { } } } else { - addTextToCollector({ - collector, layer, color: lineColor, font: lineFont, - text: line.text, height: lineHeight, - posX: worldX, posY: worldY, posZ, - rotation, hAlign: hAlignEnum, vAlign: rowVAlign, - bold: line.bold, italic: line.italic, - underline: line.underline, - }); + emitMTextLine( + line.runs, color, font, serifFont, defaultHeight, + collector, layer, worldX, worldY, posZ, rotation, cos, sin, + hAlign, rowVAlign, + entityBold, entityItalic, + ); } lineYOffset -= lineHeight * lineSpacing; @@ -745,8 +984,14 @@ const STACKED_REGEX = /^(.*?)\\S([^^/#;]*)[\^/#]([^;]*);(.*)$/; /** * Measure dimension text width in world units. * Cleans MTEXT formatting, handles stacked fractions (\S). + * `widthFactor` (STYLE.widthFactor / DXF code 41) scales horizontal advance. */ -export function measureDimensionTextWidth(font: Font, rawText: string, height: number): number { +export function measureDimensionTextWidth( + font: Font, + rawText: string, + height: number, + widthFactor: number = 1, +): number { const cleaned = cleanDimensionMText(rawText); const stackedMatch = cleaned.match(STACKED_REGEX); @@ -758,8 +1003,8 @@ export function measureDimensionTextWidth(font: Font, rawText: string, height: n const stackedHeight = height * STACKED_RATIO; const capRatio = getCapHeightRatio(font); - const mainEmScale = height / capRatio; - const stackedEmScale = stackedHeight / capRatio; + const mainEmScale = (height / capRatio) * widthFactor; + const stackedEmScale = (stackedHeight / capRatio) * widthFactor; const mainAdvance = mainText ? measureText(font, mainText).totalAdvance * mainEmScale : 0; const topAdvance = topText ? measureText(font, topText).totalAdvance * stackedEmScale : 0; const bottomAdvance = bottomText @@ -774,7 +1019,7 @@ export function measureDimensionTextWidth(font: Font, rawText: string, height: n // Plain text: strip remaining \S patterns const plain = cleaned.replace(/\\S[^;]*;/g, "").trim(); - return measureTextWidth(font, plain, height); + return measureTextWidth(font, plain, height, widthFactor); } /** @@ -789,6 +1034,7 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { rotation = 0, hAlign = "center", transform, + widthFactor = 1, } = p; const cleaned = cleanDimensionMText(rawText); if (!cleaned.trim() || height <= 0) return; @@ -805,10 +1051,11 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { const cos = Math.cos(rotation); const sin = Math.sin(rotation); - // Measure widths to compute horizontal alignment (using em scale) + // Measure widths to compute horizontal alignment (using em scale). + // widthFactor (STYLE.widthFactor, DXF code 41) scales horizontal advance only. const capRatio = getCapHeightRatio(font); - const mainEmScale = height / capRatio; - const stackedEmScale = stackedHeight / capRatio; + const mainEmScale = (height / capRatio) * widthFactor; + const stackedEmScale = (stackedHeight / capRatio) * widthFactor; const mainAdvance = mainText ? measureText(font, mainText).totalAdvance * mainEmScale : 0; const topAdvance = topText ? measureText(font, topText).totalAdvance * stackedEmScale : 0; const bottomAdvance = bottomText @@ -833,7 +1080,7 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { collector, layer, color, font, text: mainText, height, posX: curX, posY: curY, posZ, rotation, hAlign: HAlign.LEFT, vAlign: VAlign.MIDDLE, - transform, + transform, widthFactor, }); curX += (mainAdvance + gap) * cos; curY += (mainAdvance + gap) * sin; @@ -841,20 +1088,23 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { // Fractions: centered vertically around posY (= dimension midpoint). // Extra gap so digits don't touch the horizontal separator line. - const vGap = mainEmScale * 0.12; + // vGap uses the unscaled em (height / capRatio) so vertical spacing is + // unaffected by widthFactor — only horizontal stretches. + const vGap = (height / capRatio) * 0.12; + const stackedEmY = stackedHeight / capRatio; const topMetrics = topText ? measureText(font, topText) : null; const bottomMetrics = bottomText ? measureText(font, bottomText) : null; const topVisualH = topMetrics - ? (topMetrics.bounds.yMax - topMetrics.bounds.yMin) * stackedEmScale + ? (topMetrics.bounds.yMax - topMetrics.bounds.yMin) * stackedEmY : 0; const bottomVisualH = bottomMetrics - ? (bottomMetrics.bounds.yMax - bottomMetrics.bounds.yMin) * stackedEmScale + ? (bottomMetrics.bounds.yMax - bottomMetrics.bounds.yMin) * stackedEmY : 0; const totalStackH = topVisualH + vGap + bottomVisualH; const halfStack = totalStackH / 2; if (topText && topMetrics) { - const topBaseY = halfStack - topMetrics.bounds.yMax * stackedEmScale; + const topBaseY = halfStack - topMetrics.bounds.yMax * stackedEmY; const topCenterX = (stackedWidth - topAdvance) / 2; const topX = curX + topCenterX * cos - topBaseY * sin; const topY = curY + topCenterX * sin + topBaseY * cos; @@ -862,12 +1112,12 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { collector, layer, color, font, text: topText, height: stackedHeight, posX: topX, posY: topY, posZ, rotation, hAlign: HAlign.LEFT, vAlign: VAlign.BASELINE, - transform, + transform, widthFactor, }); } if (bottomText && bottomMetrics) { - const bottomBaseY = -halfStack - bottomMetrics.bounds.yMin * stackedEmScale; + const bottomBaseY = -halfStack - bottomMetrics.bounds.yMin * stackedEmY; const bottomCenterX = (stackedWidth - bottomAdvance) / 2; const bottomX = curX + bottomCenterX * cos - bottomBaseY * sin; const bottomY = curY + bottomCenterX * sin + bottomBaseY * cos; @@ -875,7 +1125,7 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { collector, layer, color, font, text: bottomText, height: stackedHeight, posX: bottomX, posY: bottomY, posZ, rotation, hAlign: HAlign.LEFT, vAlign: VAlign.BASELINE, - transform, + transform, widthFactor, }); } @@ -912,7 +1162,7 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { collector, layer, color, font, text: suffixText, height, posX: suffX, posY: suffY, posZ, rotation, hAlign: HAlign.LEFT, vAlign: VAlign.MIDDLE, - transform, + transform, widthFactor, }); } } else { @@ -924,7 +1174,7 @@ export function addDimensionTextToCollector(p: DimensionTextParams): void { collector, layer, color, font, text: plain, height, posX, posY, posZ, rotation, hAlign: hAlignEnum, vAlign: VAlign.MIDDLE, - transform, + transform, widthFactor, }); } } diff --git a/packages/dxf-render/src/types/dxf.ts b/packages/dxf-render/src/types/dxf.ts index 251185e..9d4b3ad 100644 --- a/packages/dxf-render/src/types/dxf.ts +++ b/packages/dxf-render/src/types/dxf.ts @@ -124,6 +124,8 @@ export interface DxfDimensionEntity extends DxfEntityBase { arcPoint?: DxfVertex; // code 16 arrowSize?: number; // DIMASZ from XDATA DSTYLE override dimScale?: number; // DIMSCALE from XDATA DSTYLE override + dimdec?: number; // DIMDEC from XDATA DSTYLE override (code 271): decimal places for primary units + dimadec?: number; // DIMADEC from XDATA DSTYLE override (code 179): decimal places for angular } export interface DxfAttribEntity extends DxfEntityBase { @@ -234,6 +236,8 @@ export type HatchEdge = HatchLineEdge | HatchArcEdge | HatchEllipseEdge | HatchS export interface HatchBoundaryPath { edges?: HatchEdge[]; polylineVertices?: DxfVertex[]; + /** Handles of source entities this boundary path was generated from (DXF codes 97/330). */ + sourceObjectHandles?: string[]; } export interface HatchPatternLine { @@ -284,6 +288,18 @@ export interface DxfMLeaderEntity extends DxfEntityBase { textHeight?: number; arrowSize?: number; hasArrowHead?: boolean; // Defaults to true for MLEADER + /** Entity-level LeaderLineType (DXF code 170): 0=invisible, 1=straight, 2=spline. */ + leaderLineType?: number; + /** Entity-level MLEADERSTYLE handle (DXF code 340, post-CONTEXT_DATA). */ + styleHandle?: string; + /** Entity-level PropertyOverrideFlag (DXF code 90, post-CONTEXT_DATA). + * Bitmask: bit 1 = LeaderLineColor, bit 15 = TextColor, etc. + * When a bit is clear, the entity inherits from MLEADERSTYLE. */ + propertyOverrideFlag?: number; + /** Entity-level LeaderLineColor raw CmEntityColor value (DXF code 91, post-CONTEXT_DATA). */ + leaderLineColorRaw?: number; + /** CONTEXT_DATA TextColor raw CmEntityColor value (DXF code 92, inside CONTEXT_DATA). */ + textColorRaw?: number; } export interface DxfAttdefEntity extends DxfEntityBase { @@ -342,6 +358,15 @@ export interface DxfXlineEntity extends DxfEntityBase { direction: DxfVertex; } +export interface DxfRegionEntity extends DxfEntityBase { + type: "REGION"; + /** Boundary edges borrowed from a HATCH whose source object handle points to this REGION. + * Populated post-parsing by linkRegionsToHatchBoundaries(); empty when no HATCH references this REGION. */ + contourBoundary?: HatchBoundaryPath[]; + /** Extrusion direction (normal) of the HATCH that owns the borrowed boundary — used to apply the same OCS. */ + contourExtrusionDirection?: DxfVertex; +} + export interface DxfUnknownEntity extends DxfEntityBase { type: string; [key: string]: unknown; @@ -367,6 +392,7 @@ export type DxfEntity = | DxfAttribEntity | DxfMlineEntity | DxfXlineEntity + | DxfRegionEntity | DxfUnknownEntity; export function isLineEntity(entity: DxfEntity): entity is DxfLineEntity { @@ -445,12 +471,22 @@ export function isXlineEntity(entity: DxfEntity): entity is DxfXlineEntity { return entity.type === "XLINE" || entity.type === "RAY"; } +export function isRegionEntity(entity: DxfEntity): entity is DxfRegionEntity { + return entity.type === "REGION"; +} + export interface DxfStyle { name: string; + /** Entity handle (DXF code 5) — used for DIMSTYLE.DIMTXSTY (code 340) cross-reference. */ + handle?: string; fontFile?: string; bigFont?: string; fixedHeight?: number; widthFactor?: number; + /** True if the style references a bold TTF (parsed from ACAD XDATA 1071, bit 25). */ + bold?: boolean; + /** True if the style references an italic TTF (parsed from ACAD XDATA 1071, bit 24). */ + italic?: boolean; } export interface DxfLayer { @@ -483,11 +519,23 @@ export interface DxfDimStyle { dimtxt?: number; // code 140: text height (unscaled) dimtsz?: number; // code 142: tick size (>0 = use ticks instead of arrows) dimexe?: number; // code 44: extension line extension past dimension line + dimtoh?: number; // code 73: text outside dimension lines — 0=aligned with line, 1=horizontal + dimtih?: number; // code 74: text inside dimension lines — 0=aligned with line, 1=horizontal + dimtad?: number; // code 77: text vertical position (0=centered, 1=above, 2=outside, 3=JIS, 4=below) + dimgap?: number; // code 147: distance from dim line to text (and break-radius around text) + dimtmove?: number; // code 279: text movement (0=move dim line, 1=add leader, 2=move text only) + dimupt?: number; // code 288: 0=default text position, 1=allow user-positioned text + dimatfit?: number; // code 289: arrow/text auto-fit strategy (linear dims) + dimclrd?: number; // code 176: dimension line color (ACI index, 0=BYBLOCK, 256=BYLAYER) + dimclre?: number; // code 177: extension line color (ACI index, 0=BYBLOCK, 256=BYLAYER) dimclrt?: number; // code 178: dimension text color (ACI index) dimlunit?: number; // code 277: 2=Decimal, 4=Architectural + dimdec?: number; // code 271: decimal places for primary units (arch: 2^dimdec is fraction denominator) + dimadec?: number; // code 179: decimal places for angular dimensions dimzin?: number; // code 78: zero suppression flags dimblkHandle?: string; // code 342: handle of dimension arrow block (-> BLOCK_RECORD name) dimldrblkHandle?: string; // code 341: handle of leader arrow block (-> BLOCK_RECORD name) + dimtxstyHandle?: string; // code 340: handle of dimension text STYLE (-> DxfStyle handle) } export interface DxfTables { @@ -532,11 +580,27 @@ export interface DxfBlock { xrefPath?: string; } +export interface DxfMLeaderStyle { + handle: string; + name?: string; + /** Raw CmEntityColor for LeaderLineColor (DXF code 91). */ + leaderLineColorRaw?: number; + /** Raw CmEntityColor for TextColor (DXF code 93). */ + textColorRaw?: number; + /** Raw CmEntityColor for BlockColor (DXF code 94). */ + blockColorRaw?: number; +} + +export interface DxfObjects { + mLeaderStyles?: Record; +} + export interface DxfData { entities: DxfEntity[]; header?: DxfHeader; tables?: DxfTables; blocks?: Record; + objects?: DxfObjects; } export interface DxfStatistics { diff --git a/packages/dxf-render/src/types/header.ts b/packages/dxf-render/src/types/header.ts index 26de202..c368724 100644 --- a/packages/dxf-render/src/types/header.ts +++ b/packages/dxf-render/src/types/header.ts @@ -48,6 +48,23 @@ export interface DxfHeader { $DIMBLK?: string; /** Dimension linear unit format (2 = Decimal, 4 = Architectural, ...). */ $DIMLUNIT?: number; + /** Decimal places for primary dimension units. For architectural units, the + * fraction denominator is `2 ** $DIMDEC` (3 → 1/8", 4 → 1/16", default 4). */ + $DIMDEC?: number; + /** Decimal places for angular dimensions. Defaults to 0 in AutoCAD. */ + $DIMADEC?: number; + /** Zero-suppression flags for dimension text. For decimal mode: + * bit 2 (4) suppresses leading zeros, bit 3 (8) suppresses trailing zeros. */ + $DIMZIN?: number; + /** Text vertical position: 0=centered on line, 1=above line, 2=outside, 3=JIS, 4=below. */ + $DIMTAD?: number; + /** Text inside arc/extension lines: 0=aligned with dim line, 1=force horizontal. */ + $DIMTIH?: number; + /** Text outside arc/extension lines: 0=aligned with dim line, 1=force horizontal. */ + $DIMTOH?: number; + /** Text movement strategy when user moves text: + * 0=move dim line with text, 1=add leader, 2=move text only. */ + $DIMTMOVE?: number; // ── Point display ────────────────────────────────────────────────── /** Point display mode (0 = dot, 1 = none, 2 = plus, 3 = cross, ...). */ diff --git a/packages/dxf-render/src/utils/__tests__/colorResolver.test.ts b/packages/dxf-render/src/utils/__tests__/colorResolver.test.ts index c2935c3..f95a0f7 100644 --- a/packages/dxf-render/src/utils/__tests__/colorResolver.test.ts +++ b/packages/dxf-render/src/utils/__tests__/colorResolver.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { rgbNumberToHex, resolveEntityColor, ACI7_COLOR, resolveAci7Hex, isThemeAdaptiveColor, resolveThemeColor } from "@/utils/colorResolver"; +import { rgbNumberToHex, resolveEntityColor, ACI7_COLOR, resolveAci7Hex, isThemeAdaptiveColor, resolveThemeColor, aciToColor, decodeCmEntityColor, resolveMLeaderColor } from "@/utils/colorResolver"; import type { DxfEntity, DxfLayer } from "@/types/dxf"; // Helper to create a minimal DxfEntity for testing color resolution. @@ -118,6 +118,37 @@ describe("resolveThemeColor", () => { }); }); +// ── aciToColor ──────────────────────────────────────────────────────── + +describe("aciToColor", () => { + it("returns ACI7_COLOR sentinel for index 7 (white-on-light / black-on-dark)", () => { + expect(aciToColor(7)).toBe(ACI7_COLOR); + }); + + it("returns ACI7_COLOR sentinel for index 255", () => { + expect(aciToColor(255)).toBe(ACI7_COLOR); + }); + + it("returns dark-gray sentinel for index 250", () => { + const color = aciToColor(250); + expect(isThemeAdaptiveColor(color)).toBe(true); + expect(color).toBe("\0ACI250"); + }); + + it("returns dark-gray sentinel for index 251", () => { + const color = aciToColor(251); + expect(isThemeAdaptiveColor(color)).toBe(true); + expect(color).toBe("\0ACI251"); + }); + + it("returns concrete hex for chromatic ACI indices", () => { + // ACI 1 is red — should be a literal hex, not a sentinel + const color = aciToColor(1); + expect(isThemeAdaptiveColor(color)).toBe(false); + expect(color).toMatch(/^#[0-9a-f]{6}$/); + }); +}); + // ── resolveEntityColor — ACI 250-252 sentinels ──────────────────────── describe("resolveEntityColor — dark gray sentinels", () => { @@ -258,4 +289,196 @@ describe("resolveEntityColor", () => { const result = resolveEntityColor(entity, {}); expect(result).toBe(ACI7_COLOR); }); + + // -- Layer "0" inside a block inherits from INSERT (AutoCAD convention) -- + + it("layer \"0\" with ByLayer inherits blockColor when set", () => { + // Regression: AEC Gridline Bubble — ATTRIBs/ARC on layer "0" inside an + // INSERT on a green-coded layer must render green, not white. + const layers = makeLayer("0", { colorIndex: 7, color: 0 }); + const entity = makeEntity({ layer: "0" }); // no explicit color → ByLayer + const result = resolveEntityColor(entity, layers, "#00ff00"); + expect(result).toBe("#00ff00"); + }); + + it("empty layer name with ByLayer inherits blockColor when set", () => { + const entity = makeEntity({ layer: "" }); + const result = resolveEntityColor(entity, {}, "#123456"); + expect(result).toBe("#123456"); + }); + + it("layer \"0\" with ByLayer falls back to ACI 7 when no blockColor", () => { + // Top-level entity on layer "0" — backward-compatible: white/black. + const layers = makeLayer("0", { colorIndex: 7, color: 0 }); + const entity = makeEntity({ layer: "0" }); + const result = resolveEntityColor(entity, layers); + expect(result).toBe(ACI7_COLOR); + }); + + it("layer \"0\" with explicit colorIndex=5 uses entity color, not blockColor", () => { + // Explicit color always wins, even on layer "0". + const layers = makeLayer("0", { colorIndex: 7, color: 0 }); + const entity = makeEntity({ layer: "0", colorIndex: 5 }); + const result = resolveEntityColor(entity, layers, "#00ff00"); + // ACI 5 = blue (0x0000FF) + expect(result).toBe("#0000ff"); + }); + + it("layer \"0\" with ByBlock uses blockColor (regression-protect existing path)", () => { + // colorIndex=0 (ByBlock) → blockColor branch fires first, layer-0 branch never reached. + const entity = makeEntity({ layer: "0", colorIndex: 0 }); + const result = resolveEntityColor(entity, {}, "#abcdef"); + expect(result).toBe("#abcdef"); + }); + + it("non-zero layer with ByLayer ignores blockColor, uses layer color", () => { + // Layer "WALL" entity inside a block keeps the WALL layer color, not INSERT's. + const layers = makeLayer("WALL", { colorIndex: 3, color: 0x00FF00 }); + const entity = makeEntity({ layer: "WALL" }); + const result = resolveEntityColor(entity, layers, "#ff0000"); + expect(result).toBe("#00ff00"); + }); +}); + +// ── decodeCmEntityColor ─────────────────────────────────────────────── + +describe("decodeCmEntityColor", () => { + it("decodes 0xC0000000 as byLayer", () => { + // -1073741824 in two's complement === 0xC0000000 + expect(decodeCmEntityColor(-1073741824)).toEqual({ method: "byLayer" }); + }); + + it("decodes 0xC1000000 as byBlock", () => { + // -1056964608 === 0xC1000000 + expect(decodeCmEntityColor(-1056964608)).toEqual({ method: "byBlock" }); + }); + + it("decodes 0xC2RRGGBB as byColor with RGB in low 24 bits", () => { + // 0xC2FF00FF — magenta as truecolor. + // 0xC2000000 = -1040187392; + 0xFF00FF (16711935) = -1023475457 + const dec = decodeCmEntityColor(-1023475457); + expect(dec).toEqual({ method: "byColor", rgb: 0xFF00FF }); + }); + + it("decodes 0xC3000006 as byACI with index 6 (magenta)", () => { + // -1023410170 === 0xC3000006 — the value seen in 2018.dxf for MLEADER LeaderLineColor + expect(decodeCmEntityColor(-1023410170)).toEqual({ method: "byACI", aci: 6 }); + }); + + it("decodes 0xC3000001 as byACI with index 1 (red)", () => { + // 0xC3 << 24 | 1 = -1023410175 + expect(decodeCmEntityColor(-1023410175)).toEqual({ method: "byACI", aci: 1 }); + }); + + it("decodes 0xC4000000 as byPen", () => { + // -1006632960 === 0xC4000000 + expect(decodeCmEntityColor(-1006632960)).toEqual({ method: "byPen" }); + }); + + it("decodes 0xC5000000 as foreground", () => { + // -989855744 === 0xC5000000 + expect(decodeCmEntityColor(-989855744)).toEqual({ method: "foreground" }); + }); + + it("decodes 0xC8000000 as none", () => { + // -939524096 === 0xC8000000 + expect(decodeCmEntityColor(-939524096)).toEqual({ method: "none" }); + }); + + it("returns unknown for high bytes outside 0xC0..0xC8", () => { + // 0x00000006 — no color-method byte + expect(decodeCmEntityColor(6)).toEqual({ method: "unknown" }); + }); + + it("returns null for undefined input", () => { + expect(decodeCmEntityColor(undefined)).toBeNull(); + }); + + it("returns null for NaN input", () => { + expect(decodeCmEntityColor(Number.NaN)).toBeNull(); + }); +}); + +// ── resolveMLeaderColor ────────────────────────────────────────────── + +describe("resolveMLeaderColor", () => { + // Reference values from 2018.dxf MULTILEADER handle 47020. + const MAGENTA_RAW = -1023410170; // 0xC3000006 — byACI(6) + const BY_LAYER_RAW = -1073741824; // 0xC0000000 + + const greenLayer = makeLayer("0-13", { colorIndex: 3, color: 0x00FF00 }); + + it("uses entity color when override bit is set (matches 2018.dxf scenario inverted)", () => { + const entity = makeEntity({ layer: "0-13" }); + const result = resolveMLeaderColor( + MAGENTA_RAW, true, undefined, + entity, greenLayer, + ); + // ACI 6 = magenta (0xFF00FF) — concrete hex (ACI 6 is chromatic, not theme-adaptive) + expect(result).toBe("#ff00ff"); + }); + + it("falls back to style color when override bit is clear", () => { + // This is the 2018.dxf case: entity carries byACI(6) but override bit is OFF, + // so AutoCAD reads from the style. Both entity-level and style happen to be + // byACI(6) → magenta either way, but the logic must pick the style. + const entity = makeEntity({ layer: "0-13", colorIndex: 0xFF00 }); + const result = resolveMLeaderColor( + MAGENTA_RAW, false, MAGENTA_RAW, + entity, greenLayer, + ); + expect(result).toBe("#ff00ff"); + }); + + it("entity color is ignored when override bit is clear and style raw differs", () => { + // Verifies the entity → style precedence: override OFF means entity color + // doesn't apply. Style says byACI(1) = red — that wins. + const entity = makeEntity({ layer: "0-13" }); + const result = resolveMLeaderColor( + MAGENTA_RAW, false, -1023410175, // style raw = 0xC3000001 = byACI(1) = red + entity, greenLayer, + ); + expect(result).toBe("#ff0000"); + }); + + it("byLayer entity color falls through to resolveEntityColor (layer color wins)", () => { + // entity-override active but method is byLayer → no direct color produced, + // style absent → resolveEntityColor returns layer color (ACI 3 = green). + const entity = makeEntity({ layer: "0-13" }); + const result = resolveMLeaderColor( + BY_LAYER_RAW, true, undefined, + entity, greenLayer, + ); + expect(result).toBe("#00ff00"); + }); + + it("byColor entity color produces concrete RGB hex", () => { + const entity = makeEntity({ layer: "0-13" }); + const raw = -1023475457; // 0xC2FF00FF — byColor RGB(255,0,255) + const result = resolveMLeaderColor( + raw, true, undefined, + entity, greenLayer, + ); + expect(result).toBe("#ff00ff"); + }); + + it("no entity raw and no style: fully falls back to layer color", () => { + const entity = makeEntity({ layer: "0-13" }); + const result = resolveMLeaderColor( + undefined, false, undefined, + entity, greenLayer, + ); + expect(result).toBe("#00ff00"); + }); + + it("byACI 7 returns ACI7 sentinel (theme-adaptive)", () => { + const entity = makeEntity({ layer: "0-13" }); + // 0xC3000007 — byACI(7) + const raw = -1023410169; + const result = resolveMLeaderColor( + raw, true, undefined, + entity, greenLayer, + ); + expect(result).toBe(ACI7_COLOR); + }); }); diff --git a/packages/dxf-render/src/utils/colorResolver.ts b/packages/dxf-render/src/utils/colorResolver.ts index 2b799e5..3d31ea8 100644 --- a/packages/dxf-render/src/utils/colorResolver.ts +++ b/packages/dxf-render/src/utils/colorResolver.ts @@ -45,6 +45,106 @@ const aciGraySentinel = (colorIndex: number): string | null => { return null; }; +/** + * Map an ACI (AutoCAD Color Index, 1-255) to a color string. + * Indices 7/255 and 250/251 return theme-adaptive sentinels — the rest + * are converted directly to hex from the ACI palette. Use this anywhere + * an ACI index is the source of a color so the theme switch keeps working. + */ +export function aciToColor(colorIndex: number): string { + if (colorIndex === 7 || colorIndex === 255) return ACI7_COLOR; + const gray = aciGraySentinel(colorIndex); + if (gray) return gray; + return rgbNumberToHex(ACI_PALETTE[colorIndex]); +} + +/** + * AcCmEntityColor color method (high byte of a 32-bit packed CmColor value). + * Used by MLEADER codes 90/91/93 and MLEADERSTYLE codes 91/93/94. + */ +export type CmColorMethod = + | "byLayer" // 0xC0 + | "byBlock" // 0xC1 + | "byColor" // 0xC2 — RGB in low 24 bits + | "byACI" // 0xC3 — ACI index in low byte + | "byPen" // 0xC4 + | "foreground" // 0xC5 + | "none" // 0xC8 + | "unknown"; + +export interface DecodedCmColor { + method: CmColorMethod; + /** ACI index (1..255), only set when method === "byACI". */ + aci?: number; + /** RGB packed as 0xRRGGBB, only set when method === "byColor". */ + rgb?: number; +} + +/** + * Decode a 32-bit AcCmEntityColor value. The high byte encodes the color + * method; the meaning of the low 24 bits depends on the method. + * + * Common source: MLEADER entity-level code 91 (LeaderLineColor), CONTEXT_DATA + * code 90 (TextColor), MLEADERSTYLE codes 91/93/94. Returns null for invalid + * inputs (non-finite numbers). + */ +export function decodeCmEntityColor(raw: number | undefined): DecodedCmColor | null { + if (raw === undefined || !Number.isFinite(raw)) return null; + const u = raw < 0 ? raw + 0x100000000 : raw; + const hi = (u >>> 24) & 0xFF; + switch (hi) { + case 0xC0: return { method: "byLayer" }; + case 0xC1: return { method: "byBlock" }; + case 0xC2: return { method: "byColor", rgb: u & 0xFFFFFF }; + case 0xC3: return { method: "byACI", aci: u & 0xFF }; + case 0xC4: return { method: "byPen" }; + case 0xC5: return { method: "foreground" }; + case 0xC8: return { method: "none" }; + default: return { method: "unknown" }; + } +} + +/** Convert a decoded CmEntityColor to a renderable color string, or null + * if the method requires falling back to layer/block (byLayer, byBlock, + * byPen, foreground, none, unknown). */ +function cmColorToRenderable(dec: DecodedCmColor | null): string | null { + if (!dec) return null; + if (dec.method === "byACI" && dec.aci !== undefined && dec.aci >= 1 && dec.aci <= 255) { + return aciToColor(dec.aci); + } + if (dec.method === "byColor" && dec.rgb !== undefined) { + return rgbNumberToHex(dec.rgb); + } + return null; +} + +/** + * Resolve a MULTILEADER line or text color following AutoCAD precedence: + * 1. Entity-level CmEntityColor — when the matching PropertyOverrideFlag + * bit is set (caller decides via `overrideActive`). + * 2. MLEADERSTYLE CmEntityColor — when entity override is absent. + * 3. Existing ByLayer/ByBlock resolution via resolveEntityColor. + * + * byLayer/byBlock methods in either CmEntityColor fall through to step 3 so + * the regular layer/block inheritance still applies. + */ +export function resolveMLeaderColor( + entityRaw: number | undefined, + overrideActive: boolean, + styleRaw: number | undefined, + entity: DxfEntity, + layers: Record, + blockColor?: string, +): string { + if (overrideActive) { + const c = cmColorToRenderable(decodeCmEntityColor(entityRaw)); + if (c) return c; + } + const c = cmColorToRenderable(decodeCmEntityColor(styleRaw)); + if (c) return c; + return resolveEntityColor(entity, layers, blockColor); +} + /** * Resolve entity color following AutoCAD priority rules: * trueColor (code 420) > colorIndex (code 62) > layerColor @@ -71,37 +171,27 @@ export function resolveEntityColor( if (trueColor !== undefined) { return rgbNumberToHex(trueColor); } - // ACI 7 and 255 are white in palette, rendered as black on light / white on dark - if (colorIndex === 7 || colorIndex === 255) { - return ACI7_COLOR; - } - // Dark ACI grays: theme-adaptive sentinels - const graySentinel = aciGraySentinel(colorIndex); - if (graySentinel) return graySentinel; - return rgbNumberToHex(ACI_PALETTE[colorIndex]); + return aciToColor(colorIndex); } // ByLayer (colorIndex === 256, unset, or other) const layerName = entity.layer; + + // Special case: an entity on layer "0" inside a BLOCK (or an ATTRIB attached + // to an INSERT) inherits its parent INSERT's effective color. AutoCAD treats + // layer "0" inside blocks as a sentinel for "use the inserter's color". + if ((!layerName || layerName === "0") && blockColor !== undefined) { + return blockColor; + } + if (layerName && layers[layerName]) { const layer = layers[layerName]; // layer.color is an ACI palette RGB value (from getAcadColor), not trueColor if (layer.color !== undefined && layer.color !== 0) { - const layerColorIndex = layer.colorIndex; - if (layerColorIndex === 7 || layerColorIndex === 255) { - return ACI7_COLOR; - } - const layerGraySentinel = aciGraySentinel(layerColorIndex); - if (layerGraySentinel) return layerGraySentinel; - return rgbNumberToHex(layer.color); + return aciToColor(layer.colorIndex); } if (layer.colorIndex >= 1 && layer.colorIndex <= 255) { - if (layer.colorIndex === 7 || layer.colorIndex === 255) { - return ACI7_COLOR; - } - const layerGraySentinel = aciGraySentinel(layer.colorIndex); - if (layerGraySentinel) return layerGraySentinel; - return rgbNumberToHex(ACI_PALETTE[layer.colorIndex]); + return aciToColor(layer.colorIndex); } } diff --git a/packages/dxf-render/vite-plugins/arraybuffer.ts b/packages/dxf-render/vite-plugins/arraybuffer.ts index 761082e..884b63c 100644 --- a/packages/dxf-render/vite-plugins/arraybuffer.ts +++ b/packages/dxf-render/vite-plugins/arraybuffer.ts @@ -1,23 +1,44 @@ import fs from "fs"; -/** Inline binary files as ArrayBuffer via `?arraybuffer` import suffix. */ +/** + * Inline binary files as an ArrayBuffer via the `?arraybuffer` import suffix. + * + * Implementation: at `resolveId` we replace the source with a virtual-module + * id (prefixed with `\0`). This bypasses Vite's built-in asset middleware, + * which would otherwise serve `.ttf?arraybuffer` as a static file in dev and + * never give the plugin a chance to transform it. The matching `load` hook + * reads the real file from disk and emits a tiny JS module whose default + * export is the file's contents as ArrayBuffer. + */ export function arraybufferPlugin() { + const PREFIX = "\0arraybuffer:"; + return { name: "vite-plugin-arraybuffer", - transform(_code: string, id: string) { - const [filePath, query] = id.split("?"); - if (query !== "arraybuffer") return null; + enforce: "pre" as const, + + async resolveId(this: { resolve: (s: string, i?: string, o?: object) => Promise<{ id: string } | null> }, source: string, importer?: string) { + const qIndex = source.indexOf("?"); + if (qIndex < 0) return null; + const query = source.slice(qIndex + 1); + if (!new URLSearchParams(query).has("arraybuffer")) return null; + const bare = source.slice(0, qIndex); + const resolved = await this.resolve(bare, importer, { skipSelf: true }); + if (!resolved) return null; + return PREFIX + resolved.id; + }, + + load(id: string) { + if (!id.startsWith(PREFIX)) return null; + const filePath = id.slice(PREFIX.length); const buffer = fs.readFileSync(filePath); const base64 = buffer.toString("base64"); - return { - code: [ - `const b=atob("${base64}");`, - `const u=new Uint8Array(b.length);`, - `for(let i=0;i