/** * 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 | null = null; static shared(url: string = defaultFontUrl): Promise { 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(); 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'); // Glyph outlines are em-scaled (getPath(0,0,1)), so raw cap ink height is // capHeightEm em, not 1em. Divide by it so the rendered cap height equals // the DWG `height` field, matching the canvas-sprite fallback's ascent fit. 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; } }