Files
dwg-dxf-viewer-sample/src/viewer2d/slugText.ts
T

401 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
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;
}
}