@feelgxxd/textel 0.0.0-stage → 0.3.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 +224 -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/browser/viewer-core.d.ts +42 -0
- package/dist/browser/viewer-core.js +67 -0
- package/dist/browser/viewer.d.ts +29 -0
- package/dist/browser/viewer.js +338 -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 +18 -0
- package/dist/loaders/index.js +58 -0
- package/dist/loaders/obj.d.ts +8 -0
- package/dist/loaders/obj.js +69 -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 +88 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.js +309 -0
- package/dist/sdf.d.ts +36 -0
- package/dist/sdf.js +151 -0
- package/dist/style.d.ts +138 -0
- package/dist/style.js +132 -0
- package/dist/terminal.d.ts +89 -0
- package/dist/terminal.js +197 -0
- package/dist/textel-player.js +7 -0
- package/dist/textel-viewer.js +10 -0
- package/package.json +90 -4
package/dist/render.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { computeSmoothNormals } from "./mesh.js";
|
|
2
|
+
import { mergeStyles, resolveMaterial, resolveStyle } from "./style.js";
|
|
3
|
+
const BG = -1;
|
|
4
|
+
/**
|
|
5
|
+
* A small software rasterizer that draws into a character grid.
|
|
6
|
+
* The model is centered and scaled to a unit sphere; the camera sits on +z looking at the origin.
|
|
7
|
+
* Each cell is sampled `supersample`² times; the cell takes the mean brightness of the samples that hit.
|
|
8
|
+
*/
|
|
9
|
+
export function createRenderer(mesh, input = {}) {
|
|
10
|
+
const nV = mesh.positions.length / 3, nT = mesh.indices.length / 3;
|
|
11
|
+
if (!nT)
|
|
12
|
+
throw new Error('the model has no triangles');
|
|
13
|
+
const I = mesh.indices, TM = mesh.triMaterial;
|
|
14
|
+
// ---- normalize to a unit sphere around the bounding box center ----
|
|
15
|
+
const P = new Float32Array(nV * 3);
|
|
16
|
+
{
|
|
17
|
+
const src = mesh.positions;
|
|
18
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
19
|
+
for (let v = 0; v < nV; v++)
|
|
20
|
+
for (let a = 0; a < 3; a++) {
|
|
21
|
+
const x = src[v * 3 + a];
|
|
22
|
+
if (x < lo[a])
|
|
23
|
+
lo[a] = x;
|
|
24
|
+
if (x > hi[a])
|
|
25
|
+
hi[a] = x;
|
|
26
|
+
}
|
|
27
|
+
const c = [0, 1, 2].map((a) => (lo[a] + hi[a]) / 2);
|
|
28
|
+
let r = 0;
|
|
29
|
+
for (let v = 0; v < nV; v++)
|
|
30
|
+
r = Math.max(r, Math.hypot(src[v * 3] - c[0], src[v * 3 + 1] - c[1], src[v * 3 + 2] - c[2]));
|
|
31
|
+
const k = 1 / (r || 1);
|
|
32
|
+
for (let v = 0; v < nV; v++)
|
|
33
|
+
for (let a = 0; a < 3; a++)
|
|
34
|
+
P[v * 3 + a] = (src[v * 3 + a] - c[a]) * k;
|
|
35
|
+
}
|
|
36
|
+
// extents used to fit the model in the grid while it spins around y
|
|
37
|
+
let rxz = 0, ry = 0;
|
|
38
|
+
for (let v = 0; v < nV; v++) {
|
|
39
|
+
rxz = Math.max(rxz, Math.hypot(P[v * 3], P[v * 3 + 2]));
|
|
40
|
+
ry = Math.max(ry, Math.abs(P[v * 3 + 1]));
|
|
41
|
+
}
|
|
42
|
+
let smoothN = null;
|
|
43
|
+
const VP = new Float32Array(nV * 3); // view space positions
|
|
44
|
+
const SX = new Float32Array(nV), SY = new Float32Array(nV), OZ = new Float32Array(nV);
|
|
45
|
+
let VN = null;
|
|
46
|
+
let styleInput = input;
|
|
47
|
+
let style = resolveStyle(styleInput);
|
|
48
|
+
let materials = [];
|
|
49
|
+
let sw = 0, sh = 0;
|
|
50
|
+
let sOoz = new Float32Array(0), sLum = new Float32Array(0), sMat = new Int16Array(0);
|
|
51
|
+
let cOoz = new Float32Array(0), cLum = new Float32Array(0), cMat = new Int16Array(0);
|
|
52
|
+
function configure() {
|
|
53
|
+
materials = mesh.materials.map((name) => resolveMaterial(name, style.materials[name]));
|
|
54
|
+
const ss = style.supersample;
|
|
55
|
+
if (sw !== style.width * ss || sh !== style.height * ss) {
|
|
56
|
+
sw = style.width * ss;
|
|
57
|
+
sh = style.height * ss;
|
|
58
|
+
sOoz = new Float32Array(sw * sh);
|
|
59
|
+
sLum = new Float32Array(sw * sh);
|
|
60
|
+
sMat = new Int16Array(sw * sh);
|
|
61
|
+
}
|
|
62
|
+
const n = style.width * style.height;
|
|
63
|
+
if (cOoz.length !== n) {
|
|
64
|
+
cOoz = new Float32Array(n);
|
|
65
|
+
cLum = new Float32Array(n);
|
|
66
|
+
cMat = new Int16Array(n);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
configure();
|
|
70
|
+
function normals() {
|
|
71
|
+
if (style.shading === 'flat')
|
|
72
|
+
return null;
|
|
73
|
+
if (mesh.normals)
|
|
74
|
+
return mesh.normals;
|
|
75
|
+
if (style.shading === 'smooth')
|
|
76
|
+
return (smoothN ??= computeSmoothNormals(mesh));
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** focal length in samples: the model fits the grid at any yaw, at the resting pitch */
|
|
80
|
+
function focal() {
|
|
81
|
+
const D = style.distance;
|
|
82
|
+
// vertical half extent ry·cos p + rxz·sin p, at its largest over the pitches that must fit;
|
|
83
|
+
// it peaks at p = atan(rxz / ry), so check that point when it is inside the range
|
|
84
|
+
const lo = Math.abs(style.pitch), hi = Math.max(lo, style.fitPitch), peak = Math.atan2(rxz, ry);
|
|
85
|
+
const extent = (p) => ry * Math.cos(p) + rxz * Math.sin(p);
|
|
86
|
+
const hx = Math.max(rxz, 1e-3), hy = Math.max(peak > lo && peak < hi ? Math.hypot(ry, rxz) : Math.max(extent(lo), extent(hi)), 1e-3);
|
|
87
|
+
// largest x/z a circle of radius r at distance D reaches, with perspective
|
|
88
|
+
const tx = hx / Math.sqrt(D * D - Math.min(hx * hx, D * D * 0.99));
|
|
89
|
+
const ty = hy / Math.sqrt(D * D - 1);
|
|
90
|
+
return style.zoom * Math.min((sw / 2) / tx, ((sh / 2) * style.aspect) / ty) * 0.98;
|
|
91
|
+
}
|
|
92
|
+
function render(view = {}) {
|
|
93
|
+
const ss = style.supersample, W = style.width, H = style.height, D = style.distance, aspect = style.aspect;
|
|
94
|
+
const yaw = view.yaw ?? 0, pitch = view.pitch ?? style.pitch, roll = view.roll ?? 0;
|
|
95
|
+
// R = Rz(roll) * Rx(pitch) * Ry(yaw): spin on a turntable, then tilt toward the camera
|
|
96
|
+
const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch), cr = Math.cos(roll), sr = Math.sin(roll);
|
|
97
|
+
// Rx*Ry
|
|
98
|
+
const a00 = cy, a01 = 0, a02 = sy;
|
|
99
|
+
const a10 = sp * sy, a11 = cp, a12 = -sp * cy;
|
|
100
|
+
const a20 = -cp * sy, a21 = sp, a22 = cp * cy;
|
|
101
|
+
const m00 = cr * a00 - sr * a10, m01 = cr * a01 - sr * a11, m02 = cr * a02 - sr * a12;
|
|
102
|
+
const m10 = sr * a00 + cr * a10, m11 = sr * a01 + cr * a11, m12 = sr * a02 + cr * a12;
|
|
103
|
+
const m20 = a20, m21 = a21, m22 = a22;
|
|
104
|
+
const f = focal(), fy = f / aspect, hw = sw / 2, hh = sh / 2;
|
|
105
|
+
for (let v = 0, o = 0; v < nV; v++, o += 3) {
|
|
106
|
+
const x = P[o], y = P[o + 1], z = P[o + 2];
|
|
107
|
+
const X = m00 * x + m01 * y + m02 * z, Y = m10 * x + m11 * y + m12 * z, Z = m20 * x + m21 * y + m22 * z;
|
|
108
|
+
VP[o] = X;
|
|
109
|
+
VP[o + 1] = Y;
|
|
110
|
+
VP[o + 2] = Z;
|
|
111
|
+
const ooz = 1 / (D - Z);
|
|
112
|
+
OZ[v] = ooz;
|
|
113
|
+
SX[v] = hw + f * X * ooz;
|
|
114
|
+
SY[v] = hh - fy * Y * ooz;
|
|
115
|
+
}
|
|
116
|
+
const N = normals();
|
|
117
|
+
if (N) {
|
|
118
|
+
if (!VN || VN.length !== N.length)
|
|
119
|
+
VN = new Float32Array(N.length);
|
|
120
|
+
for (let o = 0; o < N.length; o += 3) {
|
|
121
|
+
const x = N[o], y = N[o + 1], z = N[o + 2];
|
|
122
|
+
VN[o] = m00 * x + m01 * y + m02 * z;
|
|
123
|
+
VN[o + 1] = m10 * x + m11 * y + m12 * z;
|
|
124
|
+
VN[o + 2] = m20 * x + m21 * y + m22 * z;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
sOoz.fill(0);
|
|
128
|
+
sMat.fill(BG);
|
|
129
|
+
const [lx, ly, lz] = style.light, amb = style.ambient, dif = 1 - amb;
|
|
130
|
+
for (let t = 0; t < nT; t++) {
|
|
131
|
+
const ia = I[t * 3], ib = I[t * 3 + 1], ic = I[t * 3 + 2];
|
|
132
|
+
const ax = SX[ia], ay = SY[ia], bx = SX[ib], by = SY[ib], cx = SX[ic], cyy = SY[ic];
|
|
133
|
+
const area = (bx - ax) * (cyy - ay) - (by - ay) * (cx - ax);
|
|
134
|
+
if (area === 0 || !Number.isFinite(area))
|
|
135
|
+
continue;
|
|
136
|
+
// sample centers sit at +0.5
|
|
137
|
+
const x0 = Math.max(0, Math.ceil(Math.min(ax, bx, cx) - 0.5)), x1 = Math.min(sw - 1, Math.floor(Math.max(ax, bx, cx) - 0.5));
|
|
138
|
+
const y0 = Math.max(0, Math.ceil(Math.min(ay, by, cyy) - 0.5)), y1 = Math.min(sh - 1, Math.floor(Math.max(ay, by, cyy) - 0.5));
|
|
139
|
+
if (x0 > x1 || y0 > y1)
|
|
140
|
+
continue;
|
|
141
|
+
// geometric normal in view space, oriented toward the camera
|
|
142
|
+
const pa = ia * 3, pb = ib * 3, pc = ic * 3;
|
|
143
|
+
const e1x = VP[pb] - VP[pa], e1y = VP[pb + 1] - VP[pa + 1], e1z = VP[pb + 2] - VP[pa + 2];
|
|
144
|
+
const e2x = VP[pc] - VP[pa], e2y = VP[pc + 1] - VP[pa + 1], e2z = VP[pc + 2] - VP[pa + 2];
|
|
145
|
+
let fnx = e1y * e2z - e1z * e2y, fny = e1z * e2x - e1x * e2z, fnz = e1x * e2y - e1y * e2x;
|
|
146
|
+
const gx = (VP[pa] + VP[pb] + VP[pc]) / 3, gy = (VP[pa + 1] + VP[pb + 1] + VP[pc + 1]) / 3, gz = (VP[pa + 2] + VP[pb + 2] + VP[pc + 2]) / 3;
|
|
147
|
+
const vx = -gx, vy = -gy, vz = D - gz;
|
|
148
|
+
let flip = 1, flatLum = 0;
|
|
149
|
+
if (VN) {
|
|
150
|
+
// trust the vertex normals for "outside"; flip them when we see the inside of a face
|
|
151
|
+
const nsx = VN[pa] + VN[pb] + VN[pc], nsy = VN[pa + 1] + VN[pb + 1] + VN[pc + 1], nsz = VN[pa + 2] + VN[pb + 2] + VN[pc + 2];
|
|
152
|
+
if (fnx * nsx + fny * nsy + fnz * nsz < 0) {
|
|
153
|
+
fnx = -fnx;
|
|
154
|
+
fny = -fny;
|
|
155
|
+
fnz = -fnz;
|
|
156
|
+
}
|
|
157
|
+
if (fnx * vx + fny * vy + fnz * vz < 0)
|
|
158
|
+
flip = -1;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
if (fnx * vx + fny * vy + fnz * vz < 0) {
|
|
162
|
+
fnx = -fnx;
|
|
163
|
+
fny = -fny;
|
|
164
|
+
fnz = -fnz;
|
|
165
|
+
}
|
|
166
|
+
const l = Math.hypot(fnx, fny, fnz) || 1;
|
|
167
|
+
flatLum = amb + dif * Math.max(0, (fnx * lx + fny * ly + fnz * lz) / l);
|
|
168
|
+
}
|
|
169
|
+
const inv = 1 / area, mat = TM[t], oa = OZ[ia], ob = OZ[ib], oc = OZ[ic];
|
|
170
|
+
for (let y = y0; y <= y1; y++) {
|
|
171
|
+
const py = y + 0.5;
|
|
172
|
+
for (let x = x0; x <= x1; x++) {
|
|
173
|
+
const px = x + 0.5;
|
|
174
|
+
const w0 = ((bx - px) * (cyy - py) - (by - py) * (cx - px)) * inv;
|
|
175
|
+
const w1 = ((cx - px) * (ay - py) - (cyy - py) * (ax - px)) * inv;
|
|
176
|
+
const w2 = 1 - w0 - w1;
|
|
177
|
+
if (w0 < -1e-7 || w1 < -1e-7 || w2 < -1e-7)
|
|
178
|
+
continue;
|
|
179
|
+
const ooz = w0 * oa + w1 * ob + w2 * oc, i = y * sw + x;
|
|
180
|
+
if (ooz <= sOoz[i])
|
|
181
|
+
continue;
|
|
182
|
+
sOoz[i] = ooz;
|
|
183
|
+
sMat[i] = mat;
|
|
184
|
+
if (VN) {
|
|
185
|
+
const nx = (w0 * VN[pa] + w1 * VN[pb] + w2 * VN[pc]) * flip;
|
|
186
|
+
const ny = (w0 * VN[pa + 1] + w1 * VN[pb + 1] + w2 * VN[pc + 1]) * flip;
|
|
187
|
+
const nz = (w0 * VN[pa + 2] + w1 * VN[pb + 2] + w2 * VN[pc + 2]) * flip;
|
|
188
|
+
const l = Math.hypot(nx, ny, nz) || 1;
|
|
189
|
+
sLum[i] = amb + dif * Math.max(0, (nx * lx + ny * ly + nz * lz) / l);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
sLum[i] = flatLum;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// ---- resolve samples into cells ----
|
|
198
|
+
const n = ss * ss, need = Math.max(1, Math.ceil(style.coverage * n - 1e-9));
|
|
199
|
+
const votes = new Int16Array(n);
|
|
200
|
+
for (let r = 0; r < H; r++)
|
|
201
|
+
for (let c = 0; c < W; c++) {
|
|
202
|
+
let hit = 0, lum = 0, ooz = 0;
|
|
203
|
+
for (let sy2 = 0; sy2 < ss; sy2++)
|
|
204
|
+
for (let sx2 = 0; sx2 < ss; sx2++) {
|
|
205
|
+
const i = (r * ss + sy2) * sw + c * ss + sx2;
|
|
206
|
+
if (sMat[i] === BG)
|
|
207
|
+
continue;
|
|
208
|
+
votes[hit++] = sMat[i];
|
|
209
|
+
lum += sLum[i];
|
|
210
|
+
if (sOoz[i] > ooz)
|
|
211
|
+
ooz = sOoz[i];
|
|
212
|
+
}
|
|
213
|
+
const ci = r * W + c;
|
|
214
|
+
if (hit < need) {
|
|
215
|
+
cMat[ci] = BG;
|
|
216
|
+
cLum[ci] = 0;
|
|
217
|
+
cOoz[ci] = 0;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
cMat[ci] = hit === 1 ? votes[0] : majority(votes, hit);
|
|
221
|
+
cLum[ci] = lum / hit;
|
|
222
|
+
cOoz[ci] = ooz;
|
|
223
|
+
}
|
|
224
|
+
// ---- glyphs ----
|
|
225
|
+
const chars = new Array(W * H);
|
|
226
|
+
for (let i = 0; i < W * H; i++) {
|
|
227
|
+
const m = cMat[i];
|
|
228
|
+
if (m === BG) {
|
|
229
|
+
chars[i] = ' ';
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const ms = materials[m];
|
|
233
|
+
if (ms.carved) {
|
|
234
|
+
chars[i] = ' ';
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const ramp = ms.chars ?? style.chars;
|
|
238
|
+
chars[i] = ramp[Math.min(ramp.length - 1, Math.floor(cLum[i] * ramp.length))];
|
|
239
|
+
}
|
|
240
|
+
if (style.edges)
|
|
241
|
+
drawEdges(chars, W, H);
|
|
242
|
+
return { width: W, height: H, chars, lum: cLum.slice(), mat: cMat.slice(), materials, style };
|
|
243
|
+
}
|
|
244
|
+
function drawEdges(chars, W, H) {
|
|
245
|
+
const depth = style.edges === 'depth', th = style.edgeThreshold, aspect = style.aspect;
|
|
246
|
+
const solid = (c, r) => c >= 0 && r >= 0 && c < W && r < H && cMat[r * W + c] !== BG;
|
|
247
|
+
const F = (c, r) => (solid(c, r) ? cOoz[r * W + c] : 0);
|
|
248
|
+
// the angles '/' and '\' actually sit at in a cell this tall
|
|
249
|
+
const diag = Math.atan(aspect), b1 = diag / 2, b2 = (diag + Math.PI / 2) / 2, b3 = Math.PI - b2, b4 = Math.PI - b1;
|
|
250
|
+
const [hz, rise, vert, fall] = style.edgeChars;
|
|
251
|
+
for (let r = 0; r < H; r++)
|
|
252
|
+
for (let c = 0; c < W; c++) {
|
|
253
|
+
const i = r * W + c;
|
|
254
|
+
if (cMat[i] === BG || materials[cMat[i]].carved)
|
|
255
|
+
continue;
|
|
256
|
+
let edge = false;
|
|
257
|
+
for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
258
|
+
if (!solid(c + dc, r + dr)) {
|
|
259
|
+
edge = true;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
// only the nearer side of a depth step gets the line
|
|
263
|
+
if (depth && cOoz[i] - F(c + dc, r + dr) > th * cOoz[i]) {
|
|
264
|
+
edge = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!edge)
|
|
269
|
+
continue;
|
|
270
|
+
// Sobel on inverse depth (background = 0); screen y points down
|
|
271
|
+
const gx = F(c + 1, r - 1) + 2 * F(c + 1, r) + F(c + 1, r + 1) - F(c - 1, r - 1) - 2 * F(c - 1, r) - F(c - 1, r + 1);
|
|
272
|
+
const gyDown = F(c - 1, r + 1) + 2 * F(c, r + 1) + F(c + 1, r + 1) - F(c - 1, r - 1) - 2 * F(c, r - 1) - F(c + 1, r - 1);
|
|
273
|
+
const gy = -gyDown / aspect;
|
|
274
|
+
if (gx === 0 && gy === 0)
|
|
275
|
+
continue;
|
|
276
|
+
// the line runs perpendicular to the gradient
|
|
277
|
+
let a = Math.atan2(gx, -gy);
|
|
278
|
+
if (a < 0)
|
|
279
|
+
a += Math.PI;
|
|
280
|
+
if (a >= Math.PI)
|
|
281
|
+
a -= Math.PI;
|
|
282
|
+
chars[i] = a < b1 || a >= b4 ? hz : a < b2 ? rise : a < b3 ? vert : fall;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
mesh,
|
|
287
|
+
get style() { return style; },
|
|
288
|
+
setStyle(next) {
|
|
289
|
+
styleInput = mergeStyles(styleInput, next);
|
|
290
|
+
style = resolveStyle(styleInput);
|
|
291
|
+
configure();
|
|
292
|
+
},
|
|
293
|
+
render,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function majority(v, n) {
|
|
297
|
+
let best = v[0], bestN = 0;
|
|
298
|
+
for (let i = 0; i < n; i++) {
|
|
299
|
+
let k = 0;
|
|
300
|
+
for (let j = 0; j < n; j++)
|
|
301
|
+
if (v[j] === v[i])
|
|
302
|
+
k++;
|
|
303
|
+
if (k > bestN) {
|
|
304
|
+
bestN = k;
|
|
305
|
+
best = v[i];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return best;
|
|
309
|
+
}
|
package/dist/sdf.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Mesh } from './mesh.ts';
|
|
2
|
+
import type { Vec3 } from './style.ts';
|
|
3
|
+
export type Field = (x: number, y: number, z: number) => number;
|
|
4
|
+
export declare const clamp: (v: number, a: number, b: number) => number;
|
|
5
|
+
export declare const fract: (v: number) => number;
|
|
6
|
+
export declare const sdSphere: (x: number, y: number, z: number, r: number) => number;
|
|
7
|
+
/** axis-aligned box with half sizes hx hy hz, edges rounded by r */
|
|
8
|
+
export declare const sdBox: (x: number, y: number, z: number, hx: number, hy: number, hz: number, r?: number) => number;
|
|
9
|
+
/** capped cylinder along y: radius r, half height h */
|
|
10
|
+
export declare const sdCyl: (x: number, y: number, z: number, r: number, h: number) => number;
|
|
11
|
+
/** torus in the xz plane: ring radius R, tube radius r */
|
|
12
|
+
export declare const sdTorus: (x: number, y: number, z: number, R: number, r: number) => number;
|
|
13
|
+
/** 2D polygon in the xy plane */
|
|
14
|
+
export declare const sdPoly: (v: number[][], px: number, py: number) => number;
|
|
15
|
+
/** a 2D distance pushed out along z to half thickness h, edges rounded by r */
|
|
16
|
+
export declare const extrude: (d2: number, z: number, h: number, r: number) => number;
|
|
17
|
+
/** smooth union with blend radius k */
|
|
18
|
+
export declare const smin: (a: number, b: number, k: number) => number;
|
|
19
|
+
export interface SdfOptions {
|
|
20
|
+
/** bounds of the sampling grid */
|
|
21
|
+
min: Vec3;
|
|
22
|
+
max: Vec3;
|
|
23
|
+
/** grid spacing; the mesh has roughly one vertex per cell on the surface */
|
|
24
|
+
step: number;
|
|
25
|
+
/** material names, indexed by what `material` returns */
|
|
26
|
+
materials?: string[];
|
|
27
|
+
/** material index at a point on the surface */
|
|
28
|
+
material?: (x: number, y: number, z: number) => number;
|
|
29
|
+
}
|
|
30
|
+
/** Meshes a field with surface nets. Synchronous; see sdfToMeshSteps to spread the work out. */
|
|
31
|
+
export declare function sdfToMesh(field: Field, opts: SdfOptions): Mesh;
|
|
32
|
+
/**
|
|
33
|
+
* The same as sdfToMesh, as a generator that yields after every grid layer, so a browser can
|
|
34
|
+
* build a mesh in idle slices without blocking input.
|
|
35
|
+
*/
|
|
36
|
+
export declare function sdfToMeshSteps(field: Field, opts: SdfOptions): Generator<void, Mesh>;
|
package/dist/sdf.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Signed distance fields: build shapes in code and mesh them.
|
|
2
|
+
// Convention: negative inside, positive outside, roughly the distance to the surface.
|
|
3
|
+
import { MeshBuilder } from "./mesh.js";
|
|
4
|
+
export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
5
|
+
export const fract = (v) => v - Math.floor(v);
|
|
6
|
+
export const sdSphere = (x, y, z, r) => Math.hypot(x, y, z) - r;
|
|
7
|
+
/** axis-aligned box with half sizes hx hy hz, edges rounded by r */
|
|
8
|
+
export const sdBox = (x, y, z, hx, hy, hz, r = 0) => {
|
|
9
|
+
const qx = Math.abs(x) - hx + r, qy = Math.abs(y) - hy + r, qz = Math.abs(z) - hz + r;
|
|
10
|
+
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qy, qz), 0) - r;
|
|
11
|
+
};
|
|
12
|
+
/** capped cylinder along y: radius r, half height h */
|
|
13
|
+
export const sdCyl = (x, y, z, r, h) => {
|
|
14
|
+
const dx = Math.hypot(x, z) - r, dy = Math.abs(y) - h;
|
|
15
|
+
return Math.min(Math.max(dx, dy), 0) + Math.hypot(Math.max(dx, 0), Math.max(dy, 0));
|
|
16
|
+
};
|
|
17
|
+
/** torus in the xz plane: ring radius R, tube radius r */
|
|
18
|
+
export const sdTorus = (x, y, z, R, r) => Math.hypot(Math.hypot(x, z) - R, y) - r;
|
|
19
|
+
/** 2D polygon in the xy plane */
|
|
20
|
+
export const sdPoly = (v, px, py) => {
|
|
21
|
+
let d = (px - v[0][0]) ** 2 + (py - v[0][1]) ** 2, s = 1;
|
|
22
|
+
for (let i = 0, j = v.length - 1; i < v.length; j = i, i++) {
|
|
23
|
+
const ex = v[j][0] - v[i][0], ey = v[j][1] - v[i][1], wx = px - v[i][0], wy = py - v[i][1];
|
|
24
|
+
const t = clamp((wx * ex + wy * ey) / (ex * ex + ey * ey), 0, 1);
|
|
25
|
+
d = Math.min(d, (wx - ex * t) ** 2 + (wy - ey * t) ** 2);
|
|
26
|
+
const c1 = py >= v[i][1], c2 = py < v[j][1], c3 = ex * wy > ey * wx;
|
|
27
|
+
if ((c1 && c2 && c3) || (!c1 && !c2 && !c3))
|
|
28
|
+
s = -s;
|
|
29
|
+
}
|
|
30
|
+
return s * Math.sqrt(d);
|
|
31
|
+
};
|
|
32
|
+
/** a 2D distance pushed out along z to half thickness h, edges rounded by r */
|
|
33
|
+
export const extrude = (d2, z, h, r) => {
|
|
34
|
+
const a = d2 + r, b = Math.abs(z) - h + r;
|
|
35
|
+
return Math.min(Math.max(a, b), 0) + Math.hypot(Math.max(a, 0), Math.max(b, 0)) - r;
|
|
36
|
+
};
|
|
37
|
+
/** smooth union with blend radius k */
|
|
38
|
+
export const smin = (a, b, k) => {
|
|
39
|
+
const h = clamp(0.5 + (0.5 * (b - a)) / k, 0, 1);
|
|
40
|
+
return b + (a - b) * h - k * h * (1 - h);
|
|
41
|
+
};
|
|
42
|
+
/** Meshes a field with surface nets. Synchronous; see sdfToMeshSteps to spread the work out. */
|
|
43
|
+
export function sdfToMesh(field, opts) {
|
|
44
|
+
const g = sdfToMeshSteps(field, opts);
|
|
45
|
+
let r = g.next();
|
|
46
|
+
while (!r.done)
|
|
47
|
+
r = g.next();
|
|
48
|
+
return r.value;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The same as sdfToMesh, as a generator that yields after every grid layer, so a browser can
|
|
52
|
+
* build a mesh in idle slices without blocking input.
|
|
53
|
+
*/
|
|
54
|
+
export function* sdfToMeshSteps(field, opts) {
|
|
55
|
+
const { step } = opts;
|
|
56
|
+
if (!(step > 0))
|
|
57
|
+
throw new Error('step must be > 0');
|
|
58
|
+
// one cell of padding so the surface is closed even if the bounds are tight
|
|
59
|
+
const min = opts.min.map((v) => v - step), max = opts.max.map((v) => v + step);
|
|
60
|
+
const NX = Math.ceil((max[0] - min[0]) / step) + 1, NY = Math.ceil((max[1] - min[1]) / step) + 1, NZ = Math.ceil((max[2] - min[2]) / step) + 1;
|
|
61
|
+
if (NX * NY * NZ > 64e6)
|
|
62
|
+
throw new Error('grid too fine: raise step or shrink the bounds');
|
|
63
|
+
const val = new Float32Array(NX * NY * NZ);
|
|
64
|
+
const at = (i, j, k) => (k * NY + j) * NX + i;
|
|
65
|
+
for (let k = 0; k < NZ; k++) {
|
|
66
|
+
const z = min[2] + k * step;
|
|
67
|
+
for (let j = 0; j < NY; j++) {
|
|
68
|
+
const y = min[1] + j * step;
|
|
69
|
+
for (let i = 0; i < NX; i++)
|
|
70
|
+
val[at(i, j, k)] = field(min[0] + i * step, y, z);
|
|
71
|
+
}
|
|
72
|
+
yield;
|
|
73
|
+
}
|
|
74
|
+
// the padding layer must be outside
|
|
75
|
+
for (let k = 0; k < NZ; k++)
|
|
76
|
+
for (let j = 0; j < NY; j++)
|
|
77
|
+
for (let i = 0; i < NX; i++)
|
|
78
|
+
if (i === 0 || j === 0 || k === 0 || i === NX - 1 || j === NY - 1 || k === NZ - 1)
|
|
79
|
+
val[at(i, j, k)] = Math.max(val[at(i, j, k)], 1e-6);
|
|
80
|
+
const CX = NX - 1, CY = NY - 1;
|
|
81
|
+
const cell = new Int32Array(CX * CY * (NZ - 1)).fill(-1);
|
|
82
|
+
const cid = (i, j, k) => (k * CY + j) * CX + i;
|
|
83
|
+
const b = new MeshBuilder();
|
|
84
|
+
for (const name of opts.materials ?? ['default'])
|
|
85
|
+
b.material(name);
|
|
86
|
+
// the 12 cube edges as corner pairs; corner bits are x=1 y=2 z=4
|
|
87
|
+
const EDGES = [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]];
|
|
88
|
+
const cv = new Float64Array(8);
|
|
89
|
+
const h = step * 0.5;
|
|
90
|
+
for (let k = 0; k < NZ - 1; k++) {
|
|
91
|
+
for (let j = 0; j < CY; j++)
|
|
92
|
+
for (let i = 0; i < CX; i++) {
|
|
93
|
+
let mask = 0;
|
|
94
|
+
for (let c = 0; c < 8; c++) {
|
|
95
|
+
cv[c] = val[at(i + (c & 1), j + ((c >> 1) & 1), k + ((c >> 2) & 1))];
|
|
96
|
+
if (cv[c] < 0)
|
|
97
|
+
mask |= 1 << c;
|
|
98
|
+
}
|
|
99
|
+
if (mask === 0 || mask === 255)
|
|
100
|
+
continue;
|
|
101
|
+
// the vertex sits at the mean of the edge crossings
|
|
102
|
+
let sx = 0, sy = 0, sz = 0, n = 0;
|
|
103
|
+
for (const [a, bb] of EDGES) {
|
|
104
|
+
if ((cv[a] < 0) === (cv[bb] < 0))
|
|
105
|
+
continue;
|
|
106
|
+
const t = cv[a] / (cv[a] - cv[bb]);
|
|
107
|
+
sx += (a & 1) + t * ((bb & 1) - (a & 1));
|
|
108
|
+
sy += ((a >> 1) & 1) + t * (((bb >> 1) & 1) - ((a >> 1) & 1));
|
|
109
|
+
sz += ((a >> 2) & 1) + t * (((bb >> 2) & 1) - ((a >> 2) & 1));
|
|
110
|
+
n++;
|
|
111
|
+
}
|
|
112
|
+
const x = min[0] + (i + sx / n) * step, y = min[1] + (j + sy / n) * step, z = min[2] + (k + sz / n) * step;
|
|
113
|
+
// outward normal = gradient of the field
|
|
114
|
+
const nx = field(x + h, y, z) - field(x - h, y, z), ny = field(x, y + h, z) - field(x, y - h, z), nz = field(x, y, z + h) - field(x, y, z - h);
|
|
115
|
+
const l = Math.hypot(nx, ny, nz) || 1;
|
|
116
|
+
cell[cid(i, j, k)] = b.vertex(x, y, z, nx / l, ny / l, nz / l);
|
|
117
|
+
}
|
|
118
|
+
yield;
|
|
119
|
+
}
|
|
120
|
+
const P = b.positions, mat = opts.material;
|
|
121
|
+
const quad = (q0, q1, q2, q3, inside) => {
|
|
122
|
+
const v = inside ? [q0, q1, q2, q3] : [q3, q2, q1, q0];
|
|
123
|
+
let m = 0;
|
|
124
|
+
if (mat) {
|
|
125
|
+
let x = 0, y = 0, z = 0;
|
|
126
|
+
for (const vi of v) {
|
|
127
|
+
x += P[vi * 3];
|
|
128
|
+
y += P[vi * 3 + 1];
|
|
129
|
+
z += P[vi * 3 + 2];
|
|
130
|
+
}
|
|
131
|
+
m = mat(x / 4, y / 4, z / 4) | 0;
|
|
132
|
+
}
|
|
133
|
+
b.triangle(v[0], v[1], v[2], m);
|
|
134
|
+
b.triangle(v[0], v[2], v[3], m);
|
|
135
|
+
};
|
|
136
|
+
for (let k = 0; k < NZ; k++) {
|
|
137
|
+
for (let j = 0; j < NY; j++)
|
|
138
|
+
for (let i = 0; i < NX; i++) {
|
|
139
|
+
const v0 = val[at(i, j, k)], in0 = v0 < 0;
|
|
140
|
+
// each grid edge that crosses the surface becomes a quad joining the 4 cells around it
|
|
141
|
+
if (i < NX - 1 && j > 0 && k > 0 && j < NY - 1 && k < NZ - 1 && in0 !== val[at(i + 1, j, k)] < 0)
|
|
142
|
+
quad(cell[cid(i, j - 1, k - 1)], cell[cid(i, j, k - 1)], cell[cid(i, j, k)], cell[cid(i, j - 1, k)], in0);
|
|
143
|
+
if (j < NY - 1 && i > 0 && k > 0 && i < NX - 1 && k < NZ - 1 && in0 !== val[at(i, j + 1, k)] < 0)
|
|
144
|
+
quad(cell[cid(i - 1, j, k - 1)], cell[cid(i - 1, j, k)], cell[cid(i, j, k)], cell[cid(i, j, k - 1)], in0);
|
|
145
|
+
if (k < NZ - 1 && i > 0 && j > 0 && i < NX - 1 && j < NY - 1 && in0 !== val[at(i, j, k + 1)] < 0)
|
|
146
|
+
quad(cell[cid(i - 1, j - 1, k)], cell[cid(i, j - 1, k)], cell[cid(i, j, k)], cell[cid(i - 1, j, k)], in0);
|
|
147
|
+
}
|
|
148
|
+
yield;
|
|
149
|
+
}
|
|
150
|
+
return b.build();
|
|
151
|
+
}
|
package/dist/style.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export type Vec3 = [number, number, number];
|
|
2
|
+
export type RGB = [number, number, number];
|
|
3
|
+
/**
|
|
4
|
+
* Character ramps, darkest first. A ramp that starts with a space lets shadows go empty
|
|
5
|
+
* (crisp on flat faces); one that does not keeps the whole silhouette visible.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CHARSETS: Record<string, string>;
|
|
8
|
+
/**
|
|
9
|
+
* How a material is drawn.
|
|
10
|
+
* - `solid`: the normal ramp
|
|
11
|
+
* - `carved`: blank, but still hides what is behind it (holes, eye sockets, engravings)
|
|
12
|
+
* - `dim`: the normal ramp at lower intensity
|
|
13
|
+
*/
|
|
14
|
+
export type MaterialInput = 'solid' | 'carved' | 'dim' | {
|
|
15
|
+
chars?: string;
|
|
16
|
+
charset?: string;
|
|
17
|
+
color?: string;
|
|
18
|
+
opacity?: number;
|
|
19
|
+
carved?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export interface ResolvedMaterial {
|
|
22
|
+
name: string;
|
|
23
|
+
/** own ramp, or null for the style's */
|
|
24
|
+
chars: string[] | null;
|
|
25
|
+
color: RGB | null;
|
|
26
|
+
opacity: number;
|
|
27
|
+
carved: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** `'none'`, a single `'#hex'` (mono), a list of hex stops (gradient by brightness) or the full object. */
|
|
30
|
+
export type ColorInput = string | string[] | {
|
|
31
|
+
mode?: 'none' | 'mono' | 'gradient';
|
|
32
|
+
fg?: string;
|
|
33
|
+
stops?: string[];
|
|
34
|
+
bg?: string;
|
|
35
|
+
};
|
|
36
|
+
export interface ResolvedColor {
|
|
37
|
+
mode: 'none' | 'mono' | 'gradient';
|
|
38
|
+
fg: RGB;
|
|
39
|
+
stops: RGB[];
|
|
40
|
+
bg: RGB | null;
|
|
41
|
+
}
|
|
42
|
+
export type EdgeMode = false | 'outline' | 'depth';
|
|
43
|
+
/** Everything about the look. Every field is optional; see DEFAULTS. This is what a preset file holds. */
|
|
44
|
+
export interface StyleInput {
|
|
45
|
+
/** grid size in characters */
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
/** character cell height / width. ~2 in most terminals. */
|
|
49
|
+
aspect?: number;
|
|
50
|
+
/** a name from CHARSETS */
|
|
51
|
+
charset?: string;
|
|
52
|
+
/** a custom ramp, darkest first; overrides `charset` */
|
|
53
|
+
chars?: string;
|
|
54
|
+
/** resting tilt toward the viewer, radians. Positive shows the top. */
|
|
55
|
+
pitch?: number;
|
|
56
|
+
/**
|
|
57
|
+
* the model is fitted so it stays inside the grid for any pitch up to this (radians, either way);
|
|
58
|
+
* default: just the resting pitch. Viewers that let people tilt the model set it higher.
|
|
59
|
+
*/
|
|
60
|
+
fitPitch?: number;
|
|
61
|
+
/** auto-rotation speed around the vertical axis, radians per second (players use it, the renderer does not) */
|
|
62
|
+
spin?: number;
|
|
63
|
+
/** camera distance in model radii; smaller is more perspective */
|
|
64
|
+
distance?: number;
|
|
65
|
+
/** 1 fills the grid */
|
|
66
|
+
zoom?: number;
|
|
67
|
+
/** direction toward the light in view space: x right, y up, z toward the viewer */
|
|
68
|
+
light?: Vec3;
|
|
69
|
+
/** brightness floor, 0..1 */
|
|
70
|
+
ambient?: number;
|
|
71
|
+
/** auto: smooth if the model has normals, flat otherwise */
|
|
72
|
+
shading?: 'auto' | 'flat' | 'smooth';
|
|
73
|
+
/** samples per cell along each axis (2 = 4 samples). Higher is steadier and slower. */
|
|
74
|
+
supersample?: number;
|
|
75
|
+
/** share of a cell's samples that must hit the model for the cell to be drawn */
|
|
76
|
+
coverage?: number;
|
|
77
|
+
/** draw outlines with directional glyphs */
|
|
78
|
+
edges?: EdgeMode | boolean;
|
|
79
|
+
/** four glyphs: horizontal, rising, vertical, falling */
|
|
80
|
+
edgeChars?: string;
|
|
81
|
+
/** depth jump (relative) that counts as an edge in `depth` mode */
|
|
82
|
+
edgeThreshold?: number;
|
|
83
|
+
color?: ColorInput;
|
|
84
|
+
/**
|
|
85
|
+
* how many distinct colors a gradient uses. Default: one per glyph of the ramp, so a color
|
|
86
|
+
* changes only where the glyph does (far fewer color changes to draw). 0 = smooth.
|
|
87
|
+
*/
|
|
88
|
+
colorSteps?: number;
|
|
89
|
+
/** per material name */
|
|
90
|
+
materials?: Record<string, MaterialInput>;
|
|
91
|
+
}
|
|
92
|
+
export interface Style {
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
aspect: number;
|
|
96
|
+
chars: string[];
|
|
97
|
+
pitch: number;
|
|
98
|
+
fitPitch: number;
|
|
99
|
+
spin: number;
|
|
100
|
+
distance: number;
|
|
101
|
+
zoom: number;
|
|
102
|
+
light: Vec3;
|
|
103
|
+
ambient: number;
|
|
104
|
+
shading: 'auto' | 'flat' | 'smooth';
|
|
105
|
+
supersample: number;
|
|
106
|
+
coverage: number;
|
|
107
|
+
edges: EdgeMode;
|
|
108
|
+
edgeChars: string[];
|
|
109
|
+
edgeThreshold: number;
|
|
110
|
+
color: ResolvedColor;
|
|
111
|
+
/** 0 = smooth; otherwise the number of gradient colors (default: the ramp length) */
|
|
112
|
+
colorSteps: number;
|
|
113
|
+
materials: Record<string, MaterialInput>;
|
|
114
|
+
}
|
|
115
|
+
export declare const DEFAULTS: {
|
|
116
|
+
width: number;
|
|
117
|
+
height: number;
|
|
118
|
+
aspect: number;
|
|
119
|
+
charset: string;
|
|
120
|
+
pitch: number;
|
|
121
|
+
spin: number;
|
|
122
|
+
distance: number;
|
|
123
|
+
zoom: number;
|
|
124
|
+
light: Vec3;
|
|
125
|
+
ambient: number;
|
|
126
|
+
shading: "auto";
|
|
127
|
+
supersample: number;
|
|
128
|
+
coverage: number;
|
|
129
|
+
edgeChars: string;
|
|
130
|
+
edgeThreshold: number;
|
|
131
|
+
};
|
|
132
|
+
/** Later inputs win; materials are merged by name. */
|
|
133
|
+
export declare function mergeStyles(...inputs: (StyleInput | undefined)[]): StyleInput;
|
|
134
|
+
export declare function resolveRamp(chars: string | undefined, charset: string | undefined): string[];
|
|
135
|
+
export declare function resolveStyle(input?: StyleInput): Style;
|
|
136
|
+
export declare function parseHex(hex: string): RGB;
|
|
137
|
+
export declare function resolveColor(c: ColorInput | undefined): ResolvedColor;
|
|
138
|
+
export declare function resolveMaterial(name: string, input: MaterialInput | undefined): ResolvedMaterial;
|