Initial public release: DWG/DXF 2D viewer sample
Standalone Vite sample composing acadrust-dwg WASM, dxf-parser, and Viewer2D.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user