@feelgxxd/textel 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +180 -2
- package/dist/ansi.d.ts +13 -0
- package/dist/ansi.js +46 -0
- package/dist/browser/player-core.d.ts +23 -0
- package/dist/browser/player-core.js +68 -0
- package/dist/browser/player.d.ts +25 -0
- package/dist/browser/player.js +198 -0
- package/dist/cli/commands/list.d.ts +3 -0
- package/dist/cli/commands/list.js +21 -0
- package/dist/cli/commands/play.d.ts +3 -0
- package/dist/cli/commands/play.js +55 -0
- package/dist/cli/commands/preview.d.ts +3 -0
- package/dist/cli/commands/preview.js +57 -0
- package/dist/cli/commands/render.d.ts +3 -0
- package/dist/cli/commands/render.js +94 -0
- package/dist/cli/live.d.ts +13 -0
- package/dist/cli/live.js +116 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +53 -0
- package/dist/cli/shared.d.ts +103 -0
- package/dist/cli/shared.js +178 -0
- package/dist/examples.d.ts +13 -0
- package/dist/examples.js +164 -0
- package/dist/format.d.ts +57 -0
- package/dist/format.js +262 -0
- package/dist/frames.d.ts +34 -0
- package/dist/frames.js +88 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/loaders/gltf.d.ts +7 -0
- package/dist/loaders/gltf.js +187 -0
- package/dist/loaders/index.d.ts +8 -0
- package/dist/loaders/index.js +24 -0
- package/dist/loaders/obj.d.ts +3 -0
- package/dist/loaders/obj.js +52 -0
- package/dist/loaders/stl.d.ts +3 -0
- package/dist/loaders/stl.js +29 -0
- package/dist/mesh.d.ts +35 -0
- package/dist/mesh.js +106 -0
- package/dist/output.d.ts +18 -0
- package/dist/output.js +76 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.js +305 -0
- package/dist/sdf.d.ts +36 -0
- package/dist/sdf.js +151 -0
- package/dist/style.d.ts +125 -0
- package/dist/style.js +130 -0
- package/dist/terminal.d.ts +89 -0
- package/dist/terminal.js +197 -0
- package/dist/textel-player.js +7 -0
- package/package.json +84 -4
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { MeshBuilder } from "../mesh.js";
|
|
2
|
+
const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
|
|
3
|
+
/** `.glb` (binary) or `.gltf` (JSON) bytes. Applies node transforms; all triangle primitives go into one mesh. */
|
|
4
|
+
export function parseGLTF(bytes, opts = {}) {
|
|
5
|
+
let json, bin = null;
|
|
6
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
7
|
+
if (bytes.length >= 12 && dv.getUint32(0, true) === 0x46546c67) {
|
|
8
|
+
// GLB: header, then a JSON chunk and an optional BIN chunk
|
|
9
|
+
let o = 12;
|
|
10
|
+
json = {};
|
|
11
|
+
while (o + 8 <= bytes.length) {
|
|
12
|
+
const len = dv.getUint32(o, true), type = dv.getUint32(o + 4, true);
|
|
13
|
+
const body = bytes.subarray(o + 8, o + 8 + len);
|
|
14
|
+
if (type === 0x4e4f534a)
|
|
15
|
+
json = JSON.parse(new TextDecoder().decode(body));
|
|
16
|
+
else if (type === 0x004e4942)
|
|
17
|
+
bin = body;
|
|
18
|
+
o += 8 + len;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
json = JSON.parse(new TextDecoder().decode(bytes));
|
|
23
|
+
}
|
|
24
|
+
// quantized attributes (KHR_mesh_quantization) are fine: `read` handles every component type
|
|
25
|
+
const unsupported = (json.extensionsRequired ?? []).filter((e) => e === 'KHR_draco_mesh_compression' || e === 'EXT_meshopt_compression');
|
|
26
|
+
if (unsupported.length)
|
|
27
|
+
throw new Error(`compressed glTF (${unsupported.join(', ')}) is not supported yet; export it without compression`);
|
|
28
|
+
const buffers = (json.buffers ?? []).map((buf, i) => {
|
|
29
|
+
if (buf.uri === undefined) {
|
|
30
|
+
if (!bin)
|
|
31
|
+
throw new Error(`buffer ${i} has no data`);
|
|
32
|
+
return bin;
|
|
33
|
+
}
|
|
34
|
+
const m = /^data:[^,]*;base64,(.*)$/.exec(buf.uri);
|
|
35
|
+
if (m)
|
|
36
|
+
return base64(m[1]);
|
|
37
|
+
if (!opts.resolve)
|
|
38
|
+
throw new Error(`external buffer "${buf.uri}" needs a resolver`);
|
|
39
|
+
return opts.resolve(decodeURIComponent(buf.uri));
|
|
40
|
+
});
|
|
41
|
+
const read = (index) => {
|
|
42
|
+
const acc = json.accessors?.[index];
|
|
43
|
+
if (!acc)
|
|
44
|
+
throw new Error(`missing accessor ${index}`);
|
|
45
|
+
const size = COMPONENTS[acc.type] ?? 1, out = new Float32Array(acc.count * size);
|
|
46
|
+
if (acc.bufferView === undefined)
|
|
47
|
+
return { data: out, size, count: acc.count };
|
|
48
|
+
const bv = json.bufferViews[acc.bufferView], buf = buffers[bv.buffer];
|
|
49
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
50
|
+
const ct = acc.componentType;
|
|
51
|
+
const csize = ct === 5126 || ct === 5125 ? 4 : ct === 5123 || ct === 5122 ? 2 : 1;
|
|
52
|
+
const stride = bv.byteStride || csize * size, base = (bv.byteOffset ?? 0) + (acc.byteOffset ?? 0);
|
|
53
|
+
const norm = !!acc.normalized;
|
|
54
|
+
for (let e = 0; e < acc.count; e++)
|
|
55
|
+
for (let c = 0; c < size; c++) {
|
|
56
|
+
const o = base + e * stride + c * csize;
|
|
57
|
+
let x;
|
|
58
|
+
switch (ct) {
|
|
59
|
+
case 5126:
|
|
60
|
+
x = view.getFloat32(o, true);
|
|
61
|
+
break;
|
|
62
|
+
case 5125:
|
|
63
|
+
x = view.getUint32(o, true);
|
|
64
|
+
break;
|
|
65
|
+
case 5123:
|
|
66
|
+
x = view.getUint16(o, true);
|
|
67
|
+
if (norm)
|
|
68
|
+
x /= 65535;
|
|
69
|
+
break;
|
|
70
|
+
case 5122:
|
|
71
|
+
x = view.getInt16(o, true);
|
|
72
|
+
if (norm)
|
|
73
|
+
x = Math.max(x / 32767, -1);
|
|
74
|
+
break;
|
|
75
|
+
case 5121:
|
|
76
|
+
x = view.getUint8(o);
|
|
77
|
+
if (norm)
|
|
78
|
+
x /= 255;
|
|
79
|
+
break;
|
|
80
|
+
case 5120:
|
|
81
|
+
x = view.getInt8(o);
|
|
82
|
+
if (norm)
|
|
83
|
+
x = Math.max(x / 127, -1);
|
|
84
|
+
break;
|
|
85
|
+
default: throw new Error(`unknown component type ${ct}`);
|
|
86
|
+
}
|
|
87
|
+
out[e * size + c] = x;
|
|
88
|
+
}
|
|
89
|
+
return { data: out, size, count: acc.count };
|
|
90
|
+
};
|
|
91
|
+
const b = new MeshBuilder();
|
|
92
|
+
const matName = (i) => (i === undefined ? 'default' : json.materials?.[i]?.name || `material${i}`);
|
|
93
|
+
const visit = (ni, parent, depth) => {
|
|
94
|
+
if (depth > 64)
|
|
95
|
+
throw new Error('node hierarchy too deep');
|
|
96
|
+
const node = json.nodes?.[ni];
|
|
97
|
+
if (!node)
|
|
98
|
+
return;
|
|
99
|
+
const world = mul(parent, local(node));
|
|
100
|
+
if (node.mesh !== undefined)
|
|
101
|
+
addMesh(node.mesh, world);
|
|
102
|
+
for (const c of node.children ?? [])
|
|
103
|
+
visit(c, world, depth + 1);
|
|
104
|
+
};
|
|
105
|
+
const addMesh = (mi, M) => {
|
|
106
|
+
const nm = normalMatrix(M);
|
|
107
|
+
for (const prim of json.meshes?.[mi]?.primitives ?? []) {
|
|
108
|
+
const mode = prim.mode ?? 4;
|
|
109
|
+
if (mode !== 4 && mode !== 5 && mode !== 6)
|
|
110
|
+
continue; // points and lines
|
|
111
|
+
if (prim.attributes.POSITION === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
const pos = read(prim.attributes.POSITION);
|
|
114
|
+
const nor = prim.attributes.NORMAL !== undefined ? read(prim.attributes.NORMAL) : null;
|
|
115
|
+
const mat = b.material(matName(prim.material));
|
|
116
|
+
const first = b.positions.length / 3;
|
|
117
|
+
for (let v = 0; v < pos.count; v++) {
|
|
118
|
+
const x = pos.data[v * 3], y = pos.data[v * 3 + 1], z = pos.data[v * 3 + 2];
|
|
119
|
+
const X = M[0] * x + M[4] * y + M[8] * z + M[12], Y = M[1] * x + M[5] * y + M[9] * z + M[13], Z = M[2] * x + M[6] * y + M[10] * z + M[14];
|
|
120
|
+
if (nor) {
|
|
121
|
+
const a = nor.data[v * 3], bb = nor.data[v * 3 + 1], c = nor.data[v * 3 + 2];
|
|
122
|
+
b.vertex(X, Y, Z, nm[0] * a + nm[3] * bb + nm[6] * c, nm[1] * a + nm[4] * bb + nm[7] * c, nm[2] * a + nm[5] * bb + nm[8] * c);
|
|
123
|
+
}
|
|
124
|
+
else
|
|
125
|
+
b.vertex(X, Y, Z);
|
|
126
|
+
}
|
|
127
|
+
const idx = prim.indices !== undefined ? read(prim.indices).data : null;
|
|
128
|
+
const n = idx ? idx.length : pos.count;
|
|
129
|
+
const get = (k) => first + (idx ? idx[k] : k);
|
|
130
|
+
if (mode === 4)
|
|
131
|
+
for (let k = 0; k + 2 < n; k += 3)
|
|
132
|
+
b.triangle(get(k), get(k + 1), get(k + 2), mat);
|
|
133
|
+
else if (mode === 5)
|
|
134
|
+
for (let k = 0; k + 2 < n; k++)
|
|
135
|
+
k % 2 ? b.triangle(get(k + 1), get(k), get(k + 2), mat) : b.triangle(get(k), get(k + 1), get(k + 2), mat);
|
|
136
|
+
else
|
|
137
|
+
for (let k = 1; k + 1 < n; k++)
|
|
138
|
+
b.triangle(get(0), get(k), get(k + 1), mat);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const I = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
142
|
+
const scene = json.scenes?.[json.scene ?? 0];
|
|
143
|
+
let roots = scene?.nodes;
|
|
144
|
+
if (!roots) {
|
|
145
|
+
// no scene: every node that is nobody's child
|
|
146
|
+
const kids = new Set((json.nodes ?? []).flatMap((n) => n.children ?? []));
|
|
147
|
+
roots = (json.nodes ?? []).map((_, i) => i).filter((i) => !kids.has(i));
|
|
148
|
+
}
|
|
149
|
+
for (const r of roots)
|
|
150
|
+
visit(r, I, 0);
|
|
151
|
+
if (!b.indices.length)
|
|
152
|
+
throw new Error('no triangle meshes found in the glTF');
|
|
153
|
+
return b.build();
|
|
154
|
+
}
|
|
155
|
+
/** column-major 4x4 */
|
|
156
|
+
function mul(a, b) {
|
|
157
|
+
const o = new Array(16).fill(0);
|
|
158
|
+
for (let c = 0; c < 4; c++)
|
|
159
|
+
for (let r = 0; r < 4; r++)
|
|
160
|
+
for (let k = 0; k < 4; k++)
|
|
161
|
+
o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
|
|
162
|
+
return o;
|
|
163
|
+
}
|
|
164
|
+
function local(n) {
|
|
165
|
+
if (n.matrix)
|
|
166
|
+
return n.matrix;
|
|
167
|
+
const [tx, ty, tz] = n.translation ?? [0, 0, 0], [x, y, z, w] = n.rotation ?? [0, 0, 0, 1], [sx, sy, sz] = n.scale ?? [1, 1, 1];
|
|
168
|
+
return [
|
|
169
|
+
(1 - 2 * (y * y + z * z)) * sx, 2 * (x * y + z * w) * sx, 2 * (x * z - y * w) * sx, 0,
|
|
170
|
+
2 * (x * y - z * w) * sy, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z + x * w) * sy, 0,
|
|
171
|
+
2 * (x * z + y * w) * sz, 2 * (y * z - x * w) * sz, (1 - 2 * (x * x + y * y)) * sz, 0,
|
|
172
|
+
tx, ty, tz, 1,
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
/** inverse transpose of the upper 3x3, column-major 3x3 */
|
|
176
|
+
function normalMatrix(m) {
|
|
177
|
+
const a = m[0], b = m[1], c = m[2], d = m[4], e = m[5], f = m[6], g = m[8], h = m[9], i = m[10];
|
|
178
|
+
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
|
|
179
|
+
const det = a * A + b * B + c * C || 1;
|
|
180
|
+
// cofactor matrix / det = inverse transpose
|
|
181
|
+
return [A / det, B / det, C / det, -(b * i - c * h) / det, (a * i - c * g) / det, -(a * h - b * g) / det, (b * f - c * e) / det, -(a * f - c * d) / det, (a * e - b * d) / det];
|
|
182
|
+
}
|
|
183
|
+
function base64(s) {
|
|
184
|
+
if (typeof atob === 'function')
|
|
185
|
+
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
186
|
+
throw new Error('base64 decoding is not available');
|
|
187
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Mesh } from '../mesh.ts';
|
|
2
|
+
import { parseGLTF, type GltfOptions } from './gltf.ts';
|
|
3
|
+
import { parseOBJ } from './obj.ts';
|
|
4
|
+
import { parseSTL } from './stl.ts';
|
|
5
|
+
export { parseGLTF, parseOBJ, parseSTL };
|
|
6
|
+
export declare const MODEL_EXTENSIONS: string[];
|
|
7
|
+
/** Picks a parser from the file name, falling back to sniffing the bytes. */
|
|
8
|
+
export declare function parseModel(bytes: Uint8Array, filename?: string, opts?: GltfOptions): Mesh;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parseGLTF } from "./gltf.js";
|
|
2
|
+
import { parseOBJ } from "./obj.js";
|
|
3
|
+
import { parseSTL } from "./stl.js";
|
|
4
|
+
export { parseGLTF, parseOBJ, parseSTL };
|
|
5
|
+
export const MODEL_EXTENSIONS = ['.glb', '.gltf', '.obj', '.stl'];
|
|
6
|
+
/** Picks a parser from the file name, falling back to sniffing the bytes. */
|
|
7
|
+
export function parseModel(bytes, filename = '', opts = {}) {
|
|
8
|
+
const ext = filename.toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? '';
|
|
9
|
+
if (ext === '.glb' || ext === '.gltf')
|
|
10
|
+
return parseGLTF(bytes, opts);
|
|
11
|
+
if (ext === '.obj')
|
|
12
|
+
return parseOBJ(new TextDecoder().decode(bytes));
|
|
13
|
+
if (ext === '.stl')
|
|
14
|
+
return parseSTL(bytes);
|
|
15
|
+
// no or unknown extension
|
|
16
|
+
if (bytes.length >= 4 && bytes[0] === 0x67 && bytes[1] === 0x6c && bytes[2] === 0x54 && bytes[3] === 0x46)
|
|
17
|
+
return parseGLTF(bytes, opts);
|
|
18
|
+
const head = new TextDecoder().decode(bytes.subarray(0, 512)).trimStart();
|
|
19
|
+
if (head.startsWith('{'))
|
|
20
|
+
return parseGLTF(bytes, opts);
|
|
21
|
+
if (/^(v|vn|vt|o|g|mtllib|#)\s/m.test(head))
|
|
22
|
+
return parseOBJ(new TextDecoder().decode(bytes));
|
|
23
|
+
return parseSTL(bytes);
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { MeshBuilder } from "../mesh.js";
|
|
2
|
+
/** Wavefront OBJ: v, vn, f (any polygon, fan-triangulated), usemtl. Texture coordinates are ignored. */
|
|
3
|
+
export function parseOBJ(text) {
|
|
4
|
+
const v = [], vn = [];
|
|
5
|
+
const b = new MeshBuilder();
|
|
6
|
+
const seen = new Map();
|
|
7
|
+
let mat = -1;
|
|
8
|
+
const lines = text.split(/\r?\n/);
|
|
9
|
+
for (let ln = 0; ln < lines.length; ln++) {
|
|
10
|
+
const line = lines[ln].trim();
|
|
11
|
+
if (!line || line[0] === '#')
|
|
12
|
+
continue;
|
|
13
|
+
const parts = line.split(/\s+/), tag = parts[0];
|
|
14
|
+
if (tag === 'v')
|
|
15
|
+
v.push(+parts[1], +parts[2], +parts[3]);
|
|
16
|
+
else if (tag === 'vn')
|
|
17
|
+
vn.push(+parts[1], +parts[2], +parts[3]);
|
|
18
|
+
else if (tag === 'usemtl') {
|
|
19
|
+
mat = b.material(parts.slice(1).join(' ') || 'default');
|
|
20
|
+
seen.clear();
|
|
21
|
+
}
|
|
22
|
+
else if (tag === 'f') {
|
|
23
|
+
if (mat < 0)
|
|
24
|
+
mat = b.material('default');
|
|
25
|
+
const poly = [];
|
|
26
|
+
for (let p = 1; p < parts.length; p++) {
|
|
27
|
+
const key = parts[p];
|
|
28
|
+
let idx = seen.get(key);
|
|
29
|
+
if (idx === undefined) {
|
|
30
|
+
const [vs, , ns] = key.split('/');
|
|
31
|
+
let vi = parseInt(vs, 10);
|
|
32
|
+
vi = vi < 0 ? v.length / 3 + vi : vi - 1;
|
|
33
|
+
if (!(vi >= 0 && vi < v.length / 3))
|
|
34
|
+
throw new Error(`obj line ${ln + 1}: bad vertex index "${key}"`);
|
|
35
|
+
if (ns) {
|
|
36
|
+
let ni = parseInt(ns, 10);
|
|
37
|
+
ni = ni < 0 ? vn.length / 3 + ni : ni - 1;
|
|
38
|
+
idx = b.vertex(v[vi * 3], v[vi * 3 + 1], v[vi * 3 + 2], vn[ni * 3], vn[ni * 3 + 1], vn[ni * 3 + 2]);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
idx = b.vertex(v[vi * 3], v[vi * 3 + 1], v[vi * 3 + 2]);
|
|
42
|
+
}
|
|
43
|
+
seen.set(key, idx);
|
|
44
|
+
}
|
|
45
|
+
poly.push(idx);
|
|
46
|
+
}
|
|
47
|
+
for (let k = 1; k + 1 < poly.length; k++)
|
|
48
|
+
b.triangle(poly[0], poly[k], poly[k + 1], mat);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return b.build();
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** STL, binary or ASCII. STL has no shared vertices or usable normals, so the mesh is flat shaded by default. */
|
|
2
|
+
export function parseSTL(bytes) {
|
|
3
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
4
|
+
if (bytes.length >= 84) {
|
|
5
|
+
const n = dv.getUint32(80, true);
|
|
6
|
+
if (84 + n * 50 === bytes.length) {
|
|
7
|
+
const pos = new Float32Array(n * 9);
|
|
8
|
+
for (let t = 0; t < n; t++) {
|
|
9
|
+
const o = 84 + t * 50 + 12;
|
|
10
|
+
for (let k = 0; k < 9; k++)
|
|
11
|
+
pos[t * 9 + k] = dv.getFloat32(o + k * 4, true);
|
|
12
|
+
}
|
|
13
|
+
return soup(pos);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const text = new TextDecoder().decode(bytes);
|
|
17
|
+
const nums = [];
|
|
18
|
+
for (const m of text.matchAll(/vertex\s+(\S+)\s+(\S+)\s+(\S+)/g))
|
|
19
|
+
nums.push(+m[1], +m[2], +m[3]);
|
|
20
|
+
if (!nums.length || nums.length % 9)
|
|
21
|
+
throw new Error('not a valid STL file');
|
|
22
|
+
return soup(new Float32Array(nums));
|
|
23
|
+
}
|
|
24
|
+
function soup(pos) {
|
|
25
|
+
const n = pos.length / 3, idx = new Uint32Array(n);
|
|
26
|
+
for (let i = 0; i < n; i++)
|
|
27
|
+
idx[i] = i;
|
|
28
|
+
return { positions: pos, normals: null, indices: idx, triMaterial: new Uint16Array(n / 3), materials: ['default'] };
|
|
29
|
+
}
|
package/dist/mesh.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** An indexed triangle mesh. Every loader and the SDF mesher produce this. */
|
|
2
|
+
export interface Mesh {
|
|
3
|
+
/** xyz per vertex */
|
|
4
|
+
positions: Float32Array;
|
|
5
|
+
/** xyz per vertex, unit length; null means "flat shade from the triangles" */
|
|
6
|
+
normals: Float32Array | null;
|
|
7
|
+
/** three vertex indices per triangle */
|
|
8
|
+
indices: Uint32Array;
|
|
9
|
+
/** material index per triangle, into `materials` */
|
|
10
|
+
triMaterial: Uint16Array;
|
|
11
|
+
/** material names; styles are looked up by these names */
|
|
12
|
+
materials: string[];
|
|
13
|
+
}
|
|
14
|
+
export declare const vertexCount: (m: Mesh) => number;
|
|
15
|
+
export declare const triangleCount: (m: Mesh) => number;
|
|
16
|
+
/** Collects triangles into a Mesh. Loaders push into it and call `build()` once. */
|
|
17
|
+
export declare class MeshBuilder {
|
|
18
|
+
positions: number[];
|
|
19
|
+
normals: number[];
|
|
20
|
+
indices: number[];
|
|
21
|
+
triMaterial: number[];
|
|
22
|
+
materials: string[];
|
|
23
|
+
/** false as soon as one vertex arrives without a normal */
|
|
24
|
+
hasNormals: boolean;
|
|
25
|
+
private matIndex;
|
|
26
|
+
material(name: string): number;
|
|
27
|
+
vertex(x: number, y: number, z: number, nx?: number, ny?: number, nz?: number): number;
|
|
28
|
+
triangle(a: number, b: number, c: number, material: number): void;
|
|
29
|
+
build(): Mesh;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Smooth vertex normals for a mesh that has none. Vertices at the same position are welded first,
|
|
33
|
+
* so a triangle soup (STL) still shades smoothly. Each face contributes its area-weighted normal.
|
|
34
|
+
*/
|
|
35
|
+
export declare function computeSmoothNormals(m: Mesh): Float32Array;
|
package/dist/mesh.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const vertexCount = (m) => m.positions.length / 3;
|
|
2
|
+
export const triangleCount = (m) => m.indices.length / 3;
|
|
3
|
+
/** Collects triangles into a Mesh. Loaders push into it and call `build()` once. */
|
|
4
|
+
export class MeshBuilder {
|
|
5
|
+
positions = [];
|
|
6
|
+
normals = [];
|
|
7
|
+
indices = [];
|
|
8
|
+
triMaterial = [];
|
|
9
|
+
materials = [];
|
|
10
|
+
/** false as soon as one vertex arrives without a normal */
|
|
11
|
+
hasNormals = true;
|
|
12
|
+
matIndex = new Map();
|
|
13
|
+
material(name) {
|
|
14
|
+
let i = this.matIndex.get(name);
|
|
15
|
+
if (i === undefined) {
|
|
16
|
+
i = this.materials.length;
|
|
17
|
+
this.materials.push(name);
|
|
18
|
+
this.matIndex.set(name, i);
|
|
19
|
+
}
|
|
20
|
+
return i;
|
|
21
|
+
}
|
|
22
|
+
vertex(x, y, z, nx, ny, nz) {
|
|
23
|
+
const i = this.positions.length / 3;
|
|
24
|
+
this.positions.push(x, y, z);
|
|
25
|
+
if (nx === undefined || ny === undefined || nz === undefined) {
|
|
26
|
+
this.hasNormals = false;
|
|
27
|
+
this.normals.push(0, 0, 0);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
this.normals.push(nx, ny, nz);
|
|
31
|
+
}
|
|
32
|
+
return i;
|
|
33
|
+
}
|
|
34
|
+
triangle(a, b, c, material) {
|
|
35
|
+
this.indices.push(a, b, c);
|
|
36
|
+
this.triMaterial.push(material);
|
|
37
|
+
}
|
|
38
|
+
build() {
|
|
39
|
+
if (this.materials.length === 0)
|
|
40
|
+
this.material('default');
|
|
41
|
+
return {
|
|
42
|
+
positions: new Float32Array(this.positions),
|
|
43
|
+
normals: this.hasNormals && this.positions.length ? normalizeAll(new Float32Array(this.normals)) : null,
|
|
44
|
+
indices: new Uint32Array(this.indices),
|
|
45
|
+
triMaterial: new Uint16Array(this.triMaterial),
|
|
46
|
+
materials: this.materials,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function normalizeAll(n) {
|
|
51
|
+
for (let i = 0; i < n.length; i += 3) {
|
|
52
|
+
const l = Math.hypot(n[i], n[i + 1], n[i + 2]) || 1;
|
|
53
|
+
n[i] /= l;
|
|
54
|
+
n[i + 1] /= l;
|
|
55
|
+
n[i + 2] /= l;
|
|
56
|
+
}
|
|
57
|
+
return n;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Smooth vertex normals for a mesh that has none. Vertices at the same position are welded first,
|
|
61
|
+
* so a triangle soup (STL) still shades smoothly. Each face contributes its area-weighted normal.
|
|
62
|
+
*/
|
|
63
|
+
export function computeSmoothNormals(m) {
|
|
64
|
+
const P = m.positions, I = m.indices, nV = P.length / 3;
|
|
65
|
+
// weld by quantized position
|
|
66
|
+
const ids = new Uint32Array(nV);
|
|
67
|
+
const keys = new Map();
|
|
68
|
+
let b = [Infinity, -Infinity];
|
|
69
|
+
for (let i = 0; i < P.length; i++) {
|
|
70
|
+
b[0] = Math.min(b[0], P[i]);
|
|
71
|
+
b[1] = Math.max(b[1], P[i]);
|
|
72
|
+
}
|
|
73
|
+
const q = 1e5 / Math.max(b[1] - b[0], 1e-9);
|
|
74
|
+
for (let v = 0; v < nV; v++) {
|
|
75
|
+
const k = `${Math.round(P[v * 3] * q)},${Math.round(P[v * 3 + 1] * q)},${Math.round(P[v * 3 + 2] * q)}`;
|
|
76
|
+
let id = keys.get(k);
|
|
77
|
+
if (id === undefined) {
|
|
78
|
+
id = keys.size;
|
|
79
|
+
keys.set(k, id);
|
|
80
|
+
}
|
|
81
|
+
ids[v] = id;
|
|
82
|
+
}
|
|
83
|
+
const acc = new Float64Array(keys.size * 3);
|
|
84
|
+
for (let t = 0; t < I.length; t += 3) {
|
|
85
|
+
const a = I[t] * 3, bb = I[t + 1] * 3, c = I[t + 2] * 3;
|
|
86
|
+
const e1x = P[bb] - P[a], e1y = P[bb + 1] - P[a + 1], e1z = P[bb + 2] - P[a + 2];
|
|
87
|
+
const e2x = P[c] - P[a], e2y = P[c + 1] - P[a + 1], e2z = P[c + 2] - P[a + 2];
|
|
88
|
+
// cross product length is twice the area, which is the weight we want
|
|
89
|
+
const nx = e1y * e2z - e1z * e2y, ny = e1z * e2x - e1x * e2z, nz = e1x * e2y - e1y * e2x;
|
|
90
|
+
for (const v of [I[t], I[t + 1], I[t + 2]]) {
|
|
91
|
+
const w = ids[v] * 3;
|
|
92
|
+
acc[w] += nx;
|
|
93
|
+
acc[w + 1] += ny;
|
|
94
|
+
acc[w + 2] += nz;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const out = new Float32Array(nV * 3);
|
|
98
|
+
for (let v = 0; v < nV; v++) {
|
|
99
|
+
const w = ids[v] * 3;
|
|
100
|
+
const l = Math.hypot(acc[w], acc[w + 1], acc[w + 2]) || 1;
|
|
101
|
+
out[v * 3] = acc[w] / l;
|
|
102
|
+
out[v * 3 + 1] = acc[w + 1] / l;
|
|
103
|
+
out[v * 3 + 2] = acc[w + 2] / l;
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Frame } from './render.ts';
|
|
2
|
+
import { type ColorDepth } from './ansi.ts';
|
|
3
|
+
import type { RGB } from './style.ts';
|
|
4
|
+
export type { ColorDepth };
|
|
5
|
+
/** Plain text, one line per row. */
|
|
6
|
+
export declare function toText(frame: Frame): string;
|
|
7
|
+
/**
|
|
8
|
+
* Color of one cell, or null when the style has no color for it (use the default color).
|
|
9
|
+
* `faint` asks for a dimmed default color, for dim materials in colorless styles.
|
|
10
|
+
*/
|
|
11
|
+
export declare function cellColor(frame: Frame, i: number): {
|
|
12
|
+
rgb: RGB | null;
|
|
13
|
+
faint: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** ANSI-colored lines for a terminal. Color codes are only written when they change. */
|
|
16
|
+
export declare function toANSI(frame: Frame, depth?: ColorDepth): string[];
|
|
17
|
+
/** HTML for a `<pre>`: colored runs become spans. Without colors, dim materials get reduced opacity. */
|
|
18
|
+
export declare function toHTML(frame: Frame): string;
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { bg, FAINT, fg, line } from "./ansi.js";
|
|
2
|
+
/** Plain text, one line per row. */
|
|
3
|
+
export function toText(frame) {
|
|
4
|
+
const { width: W, height: H, chars } = frame;
|
|
5
|
+
const rows = [];
|
|
6
|
+
for (let r = 0; r < H; r++)
|
|
7
|
+
rows.push(chars.slice(r * W, r * W + W).join(''));
|
|
8
|
+
return rows.join('\n');
|
|
9
|
+
}
|
|
10
|
+
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
11
|
+
function gradient(stops, t) {
|
|
12
|
+
if (stops.length === 1)
|
|
13
|
+
return stops[0];
|
|
14
|
+
const x = Math.min(1, Math.max(0, t)) * (stops.length - 1), i = Math.min(stops.length - 2, Math.floor(x));
|
|
15
|
+
return lerp(stops[i], stops[i + 1], x - i);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Color of one cell, or null when the style has no color for it (use the default color).
|
|
19
|
+
* `faint` asks for a dimmed default color, for dim materials in colorless styles.
|
|
20
|
+
*/
|
|
21
|
+
export function cellColor(frame, i) {
|
|
22
|
+
const m = frame.mat[i];
|
|
23
|
+
if (m < 0)
|
|
24
|
+
return { rgb: null, faint: false };
|
|
25
|
+
const ms = frame.materials[m], cs = frame.style.color;
|
|
26
|
+
let rgb = ms.color ?? (cs.mode === 'gradient' ? gradient(cs.stops, frame.lum[i]) : cs.mode === 'mono' ? cs.fg : null);
|
|
27
|
+
if (!rgb)
|
|
28
|
+
return { rgb: null, faint: ms.opacity < 1 };
|
|
29
|
+
if (ms.opacity < 1)
|
|
30
|
+
rgb = lerp(cs.bg ?? [0, 0, 0], rgb, ms.opacity);
|
|
31
|
+
return { rgb: [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])], faint: false };
|
|
32
|
+
}
|
|
33
|
+
/** ANSI-colored lines for a terminal. Color codes are only written when they change. */
|
|
34
|
+
export function toANSI(frame, depth = 'truecolor') {
|
|
35
|
+
const { width: W, height: H, chars } = frame;
|
|
36
|
+
const background = bg(frame.style.color.bg, depth);
|
|
37
|
+
const code = (i) => {
|
|
38
|
+
const { rgb, faint } = cellColor(frame, i);
|
|
39
|
+
return rgb && depth !== 'none' ? fg(rgb, depth) : faint ? FAINT : '';
|
|
40
|
+
};
|
|
41
|
+
const lines = [];
|
|
42
|
+
for (let r = 0; r < H; r++)
|
|
43
|
+
lines.push(line((i) => chars[i], code, r * W, W, background));
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
const esc = (ch) => (ch === '<' ? '<' : ch === '>' ? '>' : ch === '&' ? '&' : ch);
|
|
47
|
+
const hex = (c) => '#' + c.map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
48
|
+
/** HTML for a `<pre>`: colored runs become spans. Without colors, dim materials get reduced opacity. */
|
|
49
|
+
export function toHTML(frame) {
|
|
50
|
+
const { width: W, height: H, chars } = frame;
|
|
51
|
+
let s = '';
|
|
52
|
+
for (let r = 0; r < H; r++) {
|
|
53
|
+
let open = '';
|
|
54
|
+
for (let c = 0; c < W; c++) {
|
|
55
|
+
const i = r * W + c, ch = chars[i];
|
|
56
|
+
if (ch !== ' ') {
|
|
57
|
+
const { rgb, faint } = cellColor(frame, i);
|
|
58
|
+
const m = frame.mat[i], op = m >= 0 ? frame.materials[m].opacity : 1;
|
|
59
|
+
const attr = rgb ? `color:${hex(rgb)}` : faint ? `opacity:${op}` : '';
|
|
60
|
+
if (attr !== open) {
|
|
61
|
+
if (open)
|
|
62
|
+
s += '</span>';
|
|
63
|
+
if (attr)
|
|
64
|
+
s += `<span style="${attr}">`;
|
|
65
|
+
open = attr;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
s += esc(ch);
|
|
69
|
+
}
|
|
70
|
+
if (open)
|
|
71
|
+
s += '</span>';
|
|
72
|
+
if (r < H - 1)
|
|
73
|
+
s += '\n';
|
|
74
|
+
}
|
|
75
|
+
return s;
|
|
76
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Mesh } from './mesh.ts';
|
|
2
|
+
import { type ResolvedMaterial, type Style, type StyleInput } from './style.ts';
|
|
3
|
+
export interface View {
|
|
4
|
+
/** rotation around the vertical axis, radians */
|
|
5
|
+
yaw?: number;
|
|
6
|
+
/** tilt toward the viewer, radians; defaults to the style's pitch */
|
|
7
|
+
pitch?: number;
|
|
8
|
+
/** rotation around the view axis, radians */
|
|
9
|
+
roll?: number;
|
|
10
|
+
}
|
|
11
|
+
/** One rendered frame. Output adapters (text, ANSI, HTML) only read this. */
|
|
12
|
+
export interface Frame {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
/** one glyph per cell, row-major */
|
|
16
|
+
chars: string[];
|
|
17
|
+
/** surface brightness per cell, 0..1 (0 for background) */
|
|
18
|
+
lum: Float32Array;
|
|
19
|
+
/** material index per cell, -1 for background */
|
|
20
|
+
mat: Int16Array;
|
|
21
|
+
materials: ResolvedMaterial[];
|
|
22
|
+
style: Style;
|
|
23
|
+
}
|
|
24
|
+
export interface Renderer {
|
|
25
|
+
readonly mesh: Mesh;
|
|
26
|
+
readonly style: Style;
|
|
27
|
+
/** merge into the current style input */
|
|
28
|
+
setStyle(input: StyleInput): void;
|
|
29
|
+
render(view?: View): Frame;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A small software rasterizer that draws into a character grid.
|
|
33
|
+
* The model is centered and scaled to a unit sphere; the camera sits on +z looking at the origin.
|
|
34
|
+
* Each cell is sampled `supersample`² times; the cell takes the mean brightness of the samples that hit.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createRenderer(mesh: Mesh, input?: StyleInput): Renderer;
|