refactor: extract viewer2d workspace package

This commit is contained in:
2026-07-29 09:03:41 +09:00
parent 95fa6a452b
commit 5e10bb1be4
27 changed files with 1588 additions and 704 deletions
+20
View File
@@ -0,0 +1,20 @@
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
export class Viewer2D {
constructor(container: HTMLElement);
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
load(result: unknown, opts?: { keepView?: boolean }): void;
/** Zoom-extents to the loaded drawing. */
fit(): void;
/** Resync renderer/camera to the container size (call after un-hiding). */
resize(): void;
onSelect(cb: (entity: unknown) => void): void;
setTheme(dark: boolean): void;
setGrid(visible: boolean): void;
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void;
getZoomPercent(): number;
onViewChange(cb: () => void): void;
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
stopMeasure(): void;
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
/* tslint:disable */
/* eslint-disable */
export function parse_dwg(bytes: Uint8Array): any;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly parse_dwg: (a: number, b: number) => [number, number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -0,0 +1,286 @@
/* @ts-self-types="./acadrust_dwg.d.ts" */
/**
* @param {Uint8Array} bytes
* @returns {any}
*/
export function parse_dwg(bytes) {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_dwg(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg_Error_92b29b0548f8b746: function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg_String_8564e559799eccda: function(arg0, arg1) {
const ret = String(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
},
__wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_new_32b398fb48b6d94a: function() {
const ret = new Array();
return ret;
},
__wbg_new_7796ffc7ed656783: function() {
const ret = new Map();
return ret;
},
__wbg_new_da52cf8fe3429cb2: function() {
const ret = new Object();
return ret;
},
__wbg_set_575dd786d51585f8: function(arg0, arg1, arg2) {
const ret = arg0.set(arg1, arg2);
return ret;
},
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
arg0[arg1] = arg2;
},
__wbg_set_8a16b38e4805b298: function(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
},
__wbindgen_cast_0000000000000001: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0) {
// Cast intrinsic for `I64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./acadrust_dwg_bg.js": import0,
};
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('acadrust_dwg_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
@@ -0,0 +1,9 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const parse_dwg: (a: number, b: number) => [number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_start: () => void;
@@ -0,0 +1,17 @@
{
"name": "dwg-wasm",
"type": "module",
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
"version": "0.1.0",
"license": "MIT",
"files": [
"acadrust_dwg_bg.wasm",
"acadrust_dwg.js",
"acadrust_dwg.d.ts"
],
"main": "acadrust_dwg.js",
"types": "acadrust_dwg.d.ts",
"sideEffects": [
"./snippets/*"
]
}
+20
View File
@@ -0,0 +1,20 @@
/**
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
* (wasm-pack --target web, committed under ./acadrust-dwg).
*/
import init, { parse_dwg } from './acadrust-dwg/acadrust_dwg.js';
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
import type { CadParseResult } from './dwgParser';
let ready: Promise<void> | null = null;
/** Initialize the acadrust WASM module once (idempotent). */
export function initAcadrustParser(): Promise<void> {
if (!ready) ready = init({ module_or_path: wasmUrl }).then(() => undefined);
return ready;
}
/** Parse a DWG buffer. Requires initAcadrustParser() first. */
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
return parse_dwg(bytes) as CadParseResult;
}
+63
View File
@@ -0,0 +1,63 @@
/**
* AutoCAD Color Index (ACI) → hex RGB integer.
* Returns null for BYBLOCK (0) and BYLAYER (256) — caller uses layer/default color.
*/
function hsl2hex(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return Math.round((r + m) * 255) * 65536
+ Math.round((g + m) * 255) * 256
+ Math.round((b + m) * 255);
}
// ACI 0-9: exact fixed colors
const FIXED = [
null, // 0: BYBLOCK
0xFF0000, // 1: red
0xFFFF00, // 2: yellow
0x00FF00, // 3: green
0x00FFFF, // 4: cyan
0x0000FF, // 5: blue
0xFF00FF, // 6: magenta
0xFFFFFF, // 7: white
0x414141, // 8: dark gray
0x808080, // 9: gray
];
// ACI 10-249: 24 hue groups × 10 saturation/lightness variants
const VARIANTS_SL = [
[1.00, 0.50], // 0: full
[1.00, 0.28], // 1: dark
[0.65, 0.65], // 2: light pastel
[0.65, 0.38], // 3: pastel dark
[0.35, 0.80], // 4: pale
[0.35, 0.48], // 5: pale mid
[0.18, 0.68], // 6: faint light
[0.18, 0.38], // 7: faint dark
[0.00, 0.55], // 8: light gray
[0.00, 0.30], // 9: dark gray
];
export function aciToHex(aci) {
if (aci == null || aci === 0 || aci === 256) return null;
if (aci <= 9) return FIXED[aci] ?? null;
if (aci >= 250 && aci <= 255) {
const v = Math.round(40 + (aci - 250) * 43);
return v * 65536 + v * 256 + v;
}
if (aci >= 10 && aci <= 249) {
const idx = aci - 10;
const [s, l] = VARIANTS_SL[idx % 10];
return hsl2hex(Math.floor(idx / 10) * 15, s, l);
}
return 0xC9D1D9;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Decode a DXF buffer to text.
*
* Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE
* (e.g. ANSI_949) that no longer matches the bytes — so the header is
* unreliable. Strategy: try UTF-8 strictly (self-validating); on failure
* fall back to the header's single-byte codepage. The DXF group/value
* structure is ASCII regardless, so this only affects TEXT/MTEXT strings.
*/
export function decodeDxf(buf: ArrayBuffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
/* not valid UTF-8 → legacy single-byte codepage */
}
const head = new TextDecoder('latin1').decode(
new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)),
);
const cp = head
.match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1]
?.trim()
.toLowerCase();
let label = 'windows-1252';
if (cp) {
if (/949/.test(cp)) label = 'euc-kr';
else if (/932/.test(cp)) label = 'shift_jis';
else if (/936/.test(cp)) label = 'gbk';
else if (/950/.test(cp)) label = 'big5';
}
try {
return new TextDecoder(label).decode(buf);
} catch {
return new TextDecoder('utf-8').decode(buf);
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0,
* consumed unmodified; see rust/dwg-wasm). Emits the parseResult shape
* Viewer2D.load() consumes. Lazy-imported so the wasm is only fetched when a
* DWG is actually opened.
*
* History: until 2026-07-10 this module selected between acadrust and
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
* after side-by-side validation (PROGRESS.md Phase 15).
*/
export type CadParseResult = {
version?: string;
vars?: Record<string, unknown>;
entities?: unknown[];
tables?: Record<string, unknown>;
stats?: Record<string, unknown>;
};
/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
await initAcadrustParser();
return parseDwgAcadrust(bytes);
}
+7
View File
@@ -0,0 +1,7 @@
/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */
export function dxfToParseResult(dxf: unknown): {
version: string;
vars: Record<string, unknown>;
entities: unknown[];
tables: { blocks: unknown[]; layers: unknown[] };
};
+160
View File
@@ -0,0 +1,160 @@
/**
* dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that
* Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension
* block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF.
*
* Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the
* raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD
* handle its child entities are owned by, and INSERT/DIMENSION reference their
* block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the
* incoherent handles and synthesize one consistent integer handle PER BLOCK NAME:
* - block table entry handle = {value: H}
* - every child entity ownerHandle = {value: H}
* - INSERT.blockHeaderHandle = {value: H of insert.name}
* - DIMENSION.blockHeaderHandle = {value: H of dim.block}
* Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so
* Viewer2D's space-handle filter keeps them visible.
*
* Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but
* INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here.
*
* Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent.
*/
const DEG2RAD = Math.PI / 180;
const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p);
/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */
function adaptEntity(e, nameToH) {
const o = { ...e };
delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI
const t = (e.type || '').toUpperCase();
switch (t) {
case 'LINE':
if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); }
break;
case 'POLYLINE':
case 'LWPOLYLINE':
o.type = 'LWPOLYLINE';
o.points = (e.vertices || []).map(vec);
o.bulges = (e.vertices || []).map((v) => v.bulge || 0);
o.closed = !!e.shape || ((e.flags || 0) & 1) === 1;
break;
case 'ARC':
case 'CIRCLE':
o.center = vec(e.center); // radius + (radian) angles already match
break;
case 'ELLIPSE':
o.center = vec(e.center);
o.majorAxis = vec(e.majorAxisEndPoint);
o.ratio = e.axisRatio;
o.startParam = e.startAngle;
o.endParam = e.endAngle;
break;
case 'POINT':
o.position = vec(e.position);
break;
case 'SOLID':
case '3DFACE': {
const p = e.points || [];
if (p[0]) o.corner1 = vec(p[0]);
if (p[1]) o.corner2 = vec(p[1]);
if (p[2]) o.corner3 = vec(p[2]);
if (p[3]) o.corner4 = vec(p[3]);
o.type = 'SOLID';
break;
}
case 'TEXT':
case 'ATTRIB':
case 'ATTDEF':
o.insertionPoint = vec(e.startPoint || e.position);
o.alignmentPt = vec(e.endPoint);
o.rotationAngle = (e.rotation || 0) * DEG2RAD;
o.horizAlignment = e.halign || 0;
o.vertAlignment = e.valign || 0;
o.textHeight = e.textHeight ?? e.height;
break;
case 'MTEXT':
o.insertionPoint = vec(e.position);
o.textHeight = e.height ?? e.textHeight;
if (e.directionVector) o.xAxisDir = vec(e.directionVector);
else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) };
break;
case 'INSERT': {
o.insertionPoint = vec(e.position);
o.scaleX = e.xScale ?? 1;
o.scaleY = e.yScale ?? 1;
o.rotation = (e.rotation || 0) * DEG2RAD;
const h = nameToH.get(e.name);
if (h != null) o.blockHeaderHandle = { value: h };
break;
}
case 'DIMENSION': {
const h = nameToH.get(e.block);
if (h != null) o.blockHeaderHandle = { value: h };
break;
}
default:
break;
}
return o;
}
/**
* Convert a full dxf-parser result → Viewer2D parseResult.
* @param {object} dxf output of `new DxfParser().parseSync(text)`
* @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}}
*/
export function dxfToParseResult(dxf) {
const blocks = dxf.blocks || {};
const names = Object.keys(blocks);
const nameToH = new Map();
names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles
const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n));
const msH = msName != null ? nameToH.get(msName) : 0;
const entities = [];
// Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle.
for (const e of (dxf.entities || [])) {
const o = adaptEntity(e, nameToH);
o.ownerHandle = { value: msH };
entities.push(o);
}
// Block-definition entities → owned by their block's synthetic handle.
for (const n of names) {
const H = nameToH.get(n);
for (const e of (blocks[n].entities || [])) {
const o = adaptEntity(e, nameToH);
o.ownerHandle = { value: H };
entities.push(o);
}
}
const blockTable = names.map((n) => ({
name: n,
handle: { value: nameToH.get(n) },
basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 },
}));
const layerSrc = dxf.tables?.layer?.layers || {};
const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex }));
return {
version: 'dxf',
vars: dxf.header || {},
entities,
tables: { blocks: blockTable, layers: layerTable },
};
}
+5
View File
@@ -0,0 +1,5 @@
/** HATCH entity handler for dxf-parser's registerEntityHandler(). */
export default class HatchHandler {
ForEntityName: string;
parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown;
}
+190
View File
@@ -0,0 +1,190 @@
/**
* HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops
* HATCH silently). Registered via the library's public `registerEntityHandler()`,
* so dxf-parser itself stays unmodified (MIT-clean).
*
* Emits the shape Viewer2D's HATCH renderer consumes:
* entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] }
* Viewer2D fills solid hatches (with island/hole nesting) and draws boundary
* outlines; bulge arcs on polyline boundaries are honored.
*
* Boundary kinds handled:
* - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42)
* - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points
* ELLIPSE(72=3), SPLINE(72=4, control-point polyline)
*
* Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the
* boundary outline as a proxy, matching its DWG behavior.
*/
const DEG2RAD = Math.PI / 180;
const ARC_STEPS = 48;
function tessArc(cx, cy, r, a0, a1, ccw, out) {
let span = a1 - a0;
if (ccw) { while (span <= 0) span += Math.PI * 2; }
else { while (span >= 0) span -= Math.PI * 2; }
const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS));
for (let i = 0; i <= steps; i++) {
const a = a0 + (span * i) / steps;
out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
}
export default class HatchHandler {
constructor() {
this.ForEntityName = 'HATCH';
}
parseEntity(scanner, curr) {
const entity = { type: curr.value, paths: [], solidFill: false };
curr = scanner.next();
while (!scanner.isEOF()) {
if (curr.code === 0) break;
switch (curr.code) {
case 70: // solid fill flag
entity.solidFill = curr.value !== 0;
break;
case 2: // pattern name
entity.patternName = curr.value;
break;
case 8: // layer
entity.layer = curr.value;
break;
case 62: // color index
entity.colorIndex = curr.value;
break;
case 91: { // number of boundary paths → parse them by count
const n = curr.value;
let g = scanner.next();
for (let i = 0; i < n && !scanner.isEOF(); i++) {
g = this._readPath(scanner, entity, g);
}
curr = g; // g is the group following the last path's data
continue; // curr already advanced — skip the trailing next()
}
default:
break;
}
curr = scanner.next();
}
return entity;
}
/** Parse one boundary path starting at its 92 group; return the next group after it. */
_readPath(scanner, entity, g) {
// g.code should be 92 (path type flag); if not, skip until we find it.
while (!scanner.isEOF() && g.code !== 92) g = scanner.next();
const flag = g.value | 0;
const isPolyline = (flag & 2) !== 0;
const path = { points: [], bulges: [] };
let cur = scanner.next();
if (isPolyline) {
let hasBulge = false;
while (cur.code === 72 || cur.code === 73) {
if (cur.code === 72) hasBulge = cur.value !== 0;
cur = scanner.next();
}
let numVerts = 0;
if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); }
for (let v = 0; v < numVerts && !scanner.isEOF(); v++) {
let x = 0, y = 0, b = 0;
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); }
path.points.push({ x, y });
path.bulges.push(b);
}
} else {
let numEdges = 0;
if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); }
for (let e = 0; e < numEdges && !scanner.isEOF(); e++) {
let edgeType = 0;
if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); }
cur = this._readEdge(scanner, path, edgeType, cur);
}
}
// Consume the per-path trailing "source boundary objects" block (97 + 330×count).
if (cur.code === 97) {
const srcN = cur.value | 0;
cur = scanner.next();
for (let s = 0; s < srcN && !scanner.isEOF(); s++) {
if (cur.code === 330) cur = scanner.next();
else break;
}
}
if (path.points.length >= 2) entity.paths.push(path);
return cur;
}
/** Tessellate one edge into path.points; return the next group. */
_readEdge(scanner, path, edgeType, cur) {
if (edgeType === 1) {
// LINE: 10/20 start, 11/21 end
let sx = 0, sy = 0, ex = 0, ey = 0;
if (cur.code === 10) { sx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { sy = cur.value; cur = scanner.next(); }
if (cur.code === 11) { ex = cur.value; cur = scanner.next(); }
if (cur.code === 21) { ey = cur.value; cur = scanner.next(); }
path.points.push({ x: sx, y: sy });
path.points.push({ x: ex, y: ey }); // last edge's end closes the loop
} else if (edgeType === 2) {
// ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw
let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1;
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
if (cur.code === 40) { r = cur.value; cur = scanner.next(); }
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points);
} else if (edgeType === 3) {
// ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw
let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1;
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
if (cur.code === 11) { mx = cur.value; cur = scanner.next(); }
if (cur.code === 21) { my = cur.value; cur = scanner.next(); }
if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); }
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
const major = Math.hypot(mx, my);
const minor = major * ratio;
const rot = Math.atan2(my, mx);
let span = a1 - a0;
if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; }
else { while (span >= 0) span -= Math.PI * 2; }
const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72));
for (let i = 0; i <= steps; i++) {
const t = a0 + (span * i) / steps;
const ex = major * Math.cos(t), ey = minor * Math.sin(t);
path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) });
}
} else if (edgeType === 4) {
// SPLINE: approximate by its control points (10/20). Skip knots/weights.
// 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights
let numKnots = 0, numCtrl = 0;
const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; };
while (!scanner.isEOF()) {
if (cur.code === 95) { numKnots = readInt(); }
else if (cur.code === 96) { numCtrl = readInt(); }
else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); }
else break;
}
for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next();
for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) {
let x = 0, y = 0;
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
if (cur.code === 42) cur = scanner.next(); // weight
path.points.push({ x, y });
}
} else {
// Unknown edge type: advance one group to avoid stalling.
cur = scanner.next();
}
return cur;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* 2D file-format detection for the 2D+3D merge dispatch.
*
* 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser.
* Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer.
* Kept dependency-free so both the dropzone and the controller can import it
* without pulling in either viewer.
*/
export const DWG_EXT = /\.dwg$/i;
export const DXF_EXT = /\.dxf$/i;
/** True if the name is a binary DWG (parsed by the WASM parser). */
export function isDwg(name: string): boolean {
return DWG_EXT.test(name);
}
/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */
export function isDxf(name: string): boolean {
return DXF_EXT.test(name);
}
/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */
export function is2D(name: string): boolean {
return DWG_EXT.test(name) || DXF_EXT.test(name);
}
+42
View File
@@ -0,0 +1,42 @@
import { Viewer2D } from './Viewer2D.js';
import { parseDwgBuffer, type CadParseResult } from './dwgParser';
import { is2D, isDwg } from './ext2d';
import { parseDxfBuffer } from './parseDxf';
import { setDefaultFontUrl } from './slugText';
export type { CadParseResult } from './dwgParser';
export type CadInput = {
name: string;
data: ArrayBuffer | Uint8Array;
};
export type Viewer2DOptions = {
fontUrl?: string;
};
function toArrayBuffer(data: ArrayBuffer | Uint8Array): ArrayBuffer {
if (data instanceof ArrayBuffer) return data;
return data.slice().buffer as ArrayBuffer;
}
export function isCad2DFile(name: string): boolean {
return is2D(name);
}
export async function parseCad(input: CadInput): Promise<CadParseResult> {
const buffer = toArrayBuffer(input.data);
if (isDwg(input.name)) return parseDwgBuffer(new Uint8Array(buffer));
if (is2D(input.name)) return parseDxfBuffer(buffer);
const head = new TextDecoder('latin1').decode(
new Uint8Array(buffer, 0, Math.min(6, buffer.byteLength)),
);
if (head.startsWith('AC')) return parseDwgBuffer(new Uint8Array(buffer));
return parseDxfBuffer(buffer);
}
export function createViewer2D(container: HTMLElement, options: Viewer2DOptions = {}): Viewer2D {
if (options.fontUrl) setDefaultFontUrl(options.fontUrl);
return new Viewer2D(container);
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Parse DXF text/buffer into the same CadParseResult shape as DWG.
*/
import DxfParser from 'dxf-parser';
import HatchHandler from './dxfHatchHandler.js';
import { dxfToParseResult } from './dxfAdapter.js';
import { decodeDxf } from './decodeDxf';
import type { CadParseResult } from './dwgParser';
/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */
export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult {
return parseDxfText(decodeDxf(buf));
}
/** Parse DXF text already decoded as a string. */
export function parseDxfText(text: string): CadParseResult {
const parser = new DxfParser();
// dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'.
parser.registerEntityHandler(
HatchHandler as unknown as Parameters<DxfParser['registerEntityHandler']>[0],
);
const dxf = parser.parseSync(text);
return dxfToParseResult(dxf);
}
+20
View File
@@ -0,0 +1,20 @@
declare module 'dxf-parser' {
export default class DxfParser {
parseSync(text: string): unknown;
registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void;
}
}
declare module 'opentype.js' {
export function parse(buffer: ArrayBuffer): unknown;
}
declare module '*.wasm?url' {
const url: string;
export default url;
}
declare module '*.wasm' {
const url: string;
export default url;
}
+400
View File
@@ -0,0 +1,400 @@
/**
* slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's
* Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines",
* JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent
* dedicated to the public domain, attribution retained here).
*
* Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier
* outlines are uploaded once per UNIQUE GLYPH into a float data texture, and
* the fragment shader computes an antialiased winding number per pixel by
* intersecting horizontal + vertical rays with the glyph's curves (curves
* pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays
* razor sharp at any zoom and memory scales with glyph count, not text count.
*
* Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines
* (quadratic only), Latin + full Hangul.
*/
import * as THREE from 'three';
import { parse as parseFont } from 'opentype.js';
const TEX_W = 1024; // data texture width in texels (RGBA32F)
const BANDS = 8; // bands per axis per glyph
const PAD = 0.08; // em padding around glyph quads (AA spill room)
interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number }
interface GlyphRec {
base: number; // texel index of the glyph header (-1 when no ink, e.g. space)
advance: number; // em units
}
/** Default TrueType font URL (host must serve Hangul-capable TTF). */
let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf';
/** Override the default font path before creating CadViewer / Viewer2D. */
export function setDefaultFontUrl(url: string): void {
defaultFontUrl = url;
// reset shared engine so the next shared() uses the new URL
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(SlugTextEngine as any)._shared = null;
}
/** One shared font engine (font fetch + parse happens once). */
export class SlugTextEngine {
private static _shared: Promise<SlugTextEngine> | null = null;
static shared(url: string = defaultFontUrl): Promise<SlugTextEngine> {
if (!this._shared) {
this._shared = (async () => {
const buf = await (await fetch(url)).arrayBuffer();
return new SlugTextEngine(parseFont(buf));
})();
// allow a retry on transient fetch failure instead of caching the rejection
this._shared.catch(() => { (SlugTextEngine as any)._shared = null; });
}
return this._shared;
}
readonly capHeightEm: number;
readonly ascentEm: number;
readonly descentEm: number;
private font: any;
private upem: number;
private glyphs = new Map<number, GlyphRec>();
private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed
private used = 0; // texels consumed
private _texture: THREE.DataTexture | null = null;
private _texelsUploaded = 0;
private constructor(font: any) {
this.font = font;
this.upem = font.unitsPerEm;
this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem;
this.ascentEm = font.ascender / this.upem;
this.descentEm = font.descender / this.upem; // negative
}
/** Layout width of one line, in em units. */
measureEm(line: string): number {
let w = 0;
for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance;
return w;
}
ensureGlyph(cp: number): GlyphRec {
let rec = this.glyphs.get(cp);
if (rec) return rec;
const glyph = this.font.charToGlyph(String.fromCodePoint(cp));
const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem;
// getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y.
const path = glyph.getPath(0, 0, 1);
const curves: Curve[] = [];
let sx = 0, sy = 0, px = 0, py = 0;
for (const c of path.commands) {
switch (c.type) {
case 'M': px = sx = c.x; py = sy = -c.y; break;
case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break;
case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break;
case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively
const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2;
curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y });
px = c.x; py = -c.y; break;
}
case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break;
}
}
if (!curves.length) {
rec = { base: -1, advance };
this.glyphs.set(cp, rec);
return rec;
}
// Conservative bbox from all points (control points included).
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const q of curves) {
x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1);
y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1);
}
const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6);
// Band membership (conservative, via control-point extents).
const hBands: number[][] = Array.from({ length: BANDS }, () => []);
const vBands: number[][] = Array.from({ length: BANDS }, () => []);
curves.forEach((q, i) => {
const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1);
const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1);
const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h);
for (let b = hb0; b <= hb1; b++) hBands[b].push(i);
const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w);
for (let b = vb0; b <= vb1; b++) vBands[b].push(i);
});
// ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ──
const listTexels = (l: number[]) => Math.ceil(l.length / 4);
const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0)
+ vBands.reduce((s, l) => s + listTexels(l), 0);
const total = 2 + BANDS * 2 + idxTexels + curves.length * 2;
const base = this.alloc(total);
const d = this.data;
const put = (t: number, a: number, b: number, c: number, e: number) => {
d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e;
};
const curveBase = base + 2 + BANDS * 2 + idxTexels;
put(base, x0, y0, x1, y1);
put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase
let listCursor = base + 2 + BANDS * 2;
const writeBands = (bands: number[][], texel: number) => {
for (let b = 0; b < BANDS; b++) {
put(texel + b, listCursor, bands[b].length, 0, 0);
const l = bands[b];
for (let j = 0; j < l.length; j += 4) {
put(listCursor++,
curveBase + l[j] * 2,
j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0,
j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0,
j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0);
}
}
};
writeBands(hBands, base + 2);
writeBands(vBands, base + 2 + BANDS);
curves.forEach((q, i) => {
put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy);
put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0);
});
rec = { base, advance };
this.glyphs.set(cp, rec);
return rec;
}
bboxOf(rec: GlyphRec): [number, number, number, number] {
const t = rec.base * 4;
return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]];
}
private alloc(texels: number): number {
const need = (this.used + texels) * 4;
if (need > this.data.length) {
let cap = this.data.length;
while (cap < need) cap *= 2;
const next = new Float32Array(cap);
next.set(this.data);
this.data = next;
}
const at = this.used;
this.used += texels;
return at;
}
/** Data texture with all glyph data uploaded (recreated when it grew). */
texture(): THREE.DataTexture {
const rows = Math.max(1, Math.ceil(this.used / TEX_W));
if (!this._texture || this._texelsUploaded < this.used) {
this._texture?.dispose();
const buf = new Float32Array(TEX_W * rows * 4);
buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4)));
const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType);
tex.minFilter = THREE.NearestFilter;
tex.magFilter = THREE.NearestFilter;
tex.generateMipmaps = false;
tex.needsUpdate = true;
this._texture = tex;
this._texelsUploaded = this.used;
}
return this._texture;
}
}
function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve {
return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 };
}
function clampBand(f: number): number {
return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS)));
}
// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ──
const VERT = /* glsl */`
attribute vec2 emuv;
attribute float gbase;
attribute vec3 tcolor;
varying vec2 vEm;
flat varying int vBase;
varying vec3 vColor;
void main() {
vEm = emuv;
vBase = int(gbase);
vColor = tcolor;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const FRAG = /* glsl */`
precision highp float;
uniform sampler2D dataTex;
varying vec2 vEm;
flat varying int vBase;
varying vec3 vColor;
layout(location = 0) out vec4 fragOut;
vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); }
// Lengyel's banded winding-number coverage for one ray along +x from p.
// Curves are fetched via the band's index list; 'swap' mirrors x/y for the
// vertical (+y) ray, whose winding sign flips (handedness reversal).
float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) {
vec4 band = T(bandTexel);
int count = int(band.y);
float cov = 0.0;
for (int j = 0; j < 256; j++) {
if (j >= count) break;
vec4 idx4 = T(int(band.x) + (j >> 2));
int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w);
vec4 A = T(cb);
vec2 e = T(cb + 1).xy;
// Swap the CURVE points before subtracting: p is already in swapped
// coordinates for the vertical ray ((A.xy - p).yx would be wrong).
vec2 p1 = (swap ? A.yx : A.xy) - p;
vec2 p2 = (swap ? A.wz : A.zw) - p;
vec2 p3 = (swap ? e.yx : e.xy) - p;
uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u;
if (code != 0u) {
vec2 a = p1 - p2 * 2.0 + p3;
vec2 b = p1 - p2;
float t1, t2;
if (abs(a.y) < 1e-6) {
float t = p1.y / (2.0 * b.y);
t1 = t; t2 = t;
} else {
float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0));
t1 = (b.y - d) / a.y;
t2 = (b.y + d) / a.y;
}
if ((code & 1u) != 0u) {
float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
cov += clamp(x * ppem + 0.5, 0.0, 1.0);
}
if (code > 1u) {
float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
cov -= clamp(x * ppem + 0.5, 0.0, 1.0);
}
}
}
return cov;
}
void main() {
vec4 bbox = T(vBase);
vec4 bases = T(vBase + 1);
vec2 fw = fwidth(vEm);
vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6));
int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1});
int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1});
float covH = rayCoverage(vEm, 1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false);
float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true);
float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0);
if (alpha < 0.004) discard;
fragOut = vec4(vColor, alpha);
}
`;
/** Accumulates positioned strings and builds one merged mesh (single draw call). */
export class SlugTextBatch {
private pos: number[] = [];
private emuv: number[] = [];
private gbase: number[] = [];
private color: number[] = [];
private index: number[] = [];
constructor(private engine: SlugTextEngine) {}
/**
* alignH: 0/3/5=left datum, 1/4=center, 2=right.
* alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching
* the previous canvas-sprite behaviour). Multi-line via '\n'.
*/
add(text: string, pos: { x: number; y: number }, height: number, rotation: number,
colorInt: number, alignH: number, alignV: number): void {
const eng = this.engine;
const lines = String(text).split('\n');
const scale = height / eng.capHeightEm;
const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
const blockH = (lines.length - 1) * lineStep + height;
// DXF group 72 = 4 ("Middle") centers both axes on the alignment point;
// group 73 (vertical) is ignored. alignH already centers horizontally (below);
// force vertical to middle here too, else vA=0 text (block-internal plain
// TEXT, e.g. titleblock labels) renders half a line too high.
if (alignH === 4) alignV = 2;
let baseline0: number;
if (alignV === 3) baseline0 = -height; // top datum
else if (alignV === 2) baseline0 = blockH / 2 - height; // middle
else baseline0 = (lines.length - 1) * lineStep; // baseline/bottom
const cos = Math.cos(rotation), sin = Math.sin(rotation);
const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255;
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (!line) continue;
const wEm = eng.measureEm(line);
const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0;
const oy = baseline0 - li * lineStep;
let pen = 0;
for (const ch of line) {
const rec = eng.ensureGlyph(ch.codePointAt(0)!);
if (rec.base >= 0) {
const [x0, y0, x1, y1] = this.engine.bboxOf(rec);
const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD;
const v = this.pos.length / 3;
for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) {
const lx = ox + (pen + ex) * scale;
const ly = oy + ey * scale;
this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1);
this.emuv.push(ex, ey);
this.gbase.push(rec.base);
this.color.push(r, g, b);
}
this.index.push(v, v + 1, v + 2, v, v + 2, v + 3);
}
pen += rec.advance;
}
}
}
/** Build the merged mesh; null when nothing was added. */
build(): THREE.Mesh | null {
if (!this.index.length) return null;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2));
geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1));
geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3));
geo.setIndex(this.index);
const mat = new THREE.ShaderMaterial({
glslVersion: THREE.GLSL3,
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: { dataTex: { value: this.engine.texture() } },
transparent: true,
depthTest: false,
depthWrite: false,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.frustumCulled = true;
return mesh;
}
}