Initial public release: DWG/DXF 2D viewer sample

Standalone Vite sample composing acadrust-dwg WASM, dxf-parser, and Viewer2D.
This commit is contained in:
minsung
2026-07-15 10:58:04 +09:00
commit 95fa6a452b
33 changed files with 6128 additions and 0 deletions
+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;
}
}