3draft 26.11.5001 → 26.11.11010

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/dist/gpu.js CHANGED
@@ -4,14 +4,148 @@
4
4
  // buildVertices) need no GPU and are unit tested; createGpu() needs a browser.
5
5
  import { dot, norm } from './vec.js';
6
6
  import { bounds, faceNormal } from './mesh.js';
7
+ import { triangulate } from './triangulate.js';
7
8
  import { makeCamera } from './camera.js';
8
9
  import { surfaceOf } from './material.js';
10
+ /** Floats of one mark segment: a (xyz), b (xyz), colour (rgba), width in CSS pixels, one spare. */
11
+ export const MARK_FLOATS = 12;
12
+ const colorOf = (css) => {
13
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((css ?? '#0b7285').trim());
14
+ const h = m ? (m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]) : '0b7285';
15
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
16
+ };
17
+ /** One instance per segment of every polyline of every mark (a zero-length segment is a round point). */
18
+ export const buildMarks = (marks) => {
19
+ let n = 0;
20
+ for (const m of marks)
21
+ for (const l of m.lines)
22
+ n += Math.max(1, l.length - 1);
23
+ const out = new Float32Array(n * MARK_FLOATS);
24
+ let o = 0;
25
+ for (const m of marks) {
26
+ const [r, g, b] = colorOf(m.color);
27
+ for (const l of m.lines) {
28
+ const segs = l.length > 1 ? l.length - 1 : 1;
29
+ for (let i = 0; i < segs; i++) {
30
+ const a = l[i];
31
+ const c = l[Math.min(i + 1, l.length - 1)];
32
+ out.set([a[0], a[1], a[2], c[0], c[1], c[2], r, g, b, m.opacity ?? 1, m.width ?? 2], o);
33
+ o += MARK_FLOATS;
34
+ }
35
+ }
36
+ }
37
+ return out;
38
+ };
39
+ /** Line-list endpoints (x y z, x y z) of every edge of every face, each pair of vertices once. */
40
+ export const buildEdges = (mesh) => {
41
+ const seen = new Set();
42
+ const n = mesh.positions.length;
43
+ const out = [];
44
+ for (const f of mesh.faces)
45
+ f.v.forEach((a, i) => {
46
+ const b = f.v[(i + 1) % f.v.length];
47
+ const k = a < b ? a * n + b : b * n + a;
48
+ if (seen.has(k))
49
+ return;
50
+ seen.add(k);
51
+ out.push(...mesh.positions[a], ...mesh.positions[b]);
52
+ });
53
+ return new Float32Array(out);
54
+ };
55
+ const EDGE_LIMIT = 300000; // faces: a bigger scene (a galaxy of super-stars) is drawn without edge lines
56
+ const partEdges = new WeakMap();
57
+ /** The edge lines of a scene: of its parts (kept while a part stays the same Mesh) or of the whole mesh. Empty when the scene is huge. */
58
+ export const sceneEdges = (mesh, parts) => {
59
+ if (parts && parts.length) {
60
+ if (parts.reduce((n, m) => n + m.faces.length, 0) > EDGE_LIMIT)
61
+ return new Float32Array(0);
62
+ const built = parts.map((m) => {
63
+ let e = partEdges.get(m);
64
+ if (!e)
65
+ partEdges.set(m, (e = buildEdges(m)));
66
+ return e;
67
+ });
68
+ const all = new Float32Array(built.reduce((n, e) => n + e.length, 0));
69
+ let at = 0;
70
+ for (const e of built)
71
+ (all.set(e, at), (at += e.length));
72
+ return all;
73
+ }
74
+ return mesh.faces.length > EDGE_LIMIT ? new Float32Array(0) : buildEdges(mesh);
75
+ };
76
+ /** Floats of one glow light: position (3), reach (1), colour times intensity (3), spare (1). */
77
+ export const LIGHT_FLOATS = 8;
78
+ export const MAX_LIGHTS = 16;
79
+ const emitters = new WeakMap();
80
+ /** Every emitting face of a mesh: centre (3), area times emit (1), colour (3), area (1). */
81
+ const emittersOf = (mesh, palette) => {
82
+ const hit = emitters.get(mesh);
83
+ if (hit && hit.palette === palette)
84
+ return hit.data;
85
+ const rows = [];
86
+ for (const face of mesh.faces) {
87
+ const s = surfaceOf(face, palette);
88
+ if (s.emit < 0.05)
89
+ continue;
90
+ const pts = face.v.map((k) => mesh.positions[k]);
91
+ const area = Math.hypot(...(faceNormal(pts))) || 0;
92
+ if (!area)
93
+ continue;
94
+ const c = [0, 1, 2].map((k) => pts.reduce((a, p) => a + p[k], 0) / pts.length);
95
+ const [r, g, b] = colorOf(s.color);
96
+ rows.push(...c, area * s.emit, r, g, b, area);
97
+ }
98
+ const data = new Float32Array(rows);
99
+ emitters.set(mesh, { palette, data });
100
+ return data;
101
+ };
102
+ /**
103
+ * Glow: the emitting faces of a scene act as local light sources. Faces close to each other (one cell of a grid) merge into
104
+ * one light at their weighted centre; the brightest MAX_LIGHTS stay. Each light has a reach that grows with the emitting area.
105
+ * Float32Array, LIGHT_FLOATS per light.
106
+ */
107
+ export const sceneLights = (mesh, parts, palette) => {
108
+ const list = (parts && parts.length ? parts : [mesh]).map((m) => emittersOf(m, palette)).filter((e) => e.length);
109
+ if (!list.length)
110
+ return new Float32Array(0);
111
+ const lo = [Infinity, Infinity, Infinity];
112
+ const hi = [-Infinity, -Infinity, -Infinity];
113
+ for (const e of list)
114
+ for (let i = 0; i < e.length; i += 8)
115
+ for (let k = 0; k < 3; k++)
116
+ (lo[k] = Math.min(lo[k], e[i + k]), (hi[k] = Math.max(hi[k], e[i + k])));
117
+ const cell = Math.max((Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 6) || 1, 1e-3);
118
+ const cells = new Map();
119
+ for (const e of list) {
120
+ for (let i = 0; i < e.length; i += 8) {
121
+ const key = [0, 1, 2].map((k) => Math.floor((e[i + k] - lo[k]) / cell)).join(',');
122
+ const c = cells.get(key) ?? { p: 0, x: 0, y: 0, z: 0, r: 0, g: 0, b: 0, a: 0 };
123
+ const w = e[i + 3];
124
+ c.p += w;
125
+ c.x += e[i] * w;
126
+ c.y += e[i + 1] * w;
127
+ c.z += e[i + 2] * w;
128
+ c.r += e[i + 4] * w;
129
+ c.g += e[i + 5] * w;
130
+ c.b += e[i + 6] * w;
131
+ c.a += e[i + 7];
132
+ cells.set(key, c);
133
+ }
134
+ }
135
+ const top = [...cells.values()].sort((a, b) => b.p - a.p).slice(0, MAX_LIGHTS);
136
+ const out = new Float32Array(top.length * LIGHT_FLOATS);
137
+ top.forEach((c, i) => {
138
+ const reach = Math.max(2.5 * Math.sqrt(c.p), 0.05); // a 1 m x 1 m lamp at emit 1 reaches 2.5 m
139
+ out.set([c.x / c.p, c.y / c.p, c.z / c.p, reach, c.r / c.p, c.g / c.p, c.b / c.p, 0], i * LIGHT_FLOATS);
140
+ });
141
+ return out;
142
+ };
9
143
  const FLOATS = 14; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit) soft(1)
10
144
  /**
11
145
  * Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
12
146
  * like camera.project(): the same pixels, plus a depth between near and far.
13
147
  */
14
- export const viewProjection = (cam, near = 0.03, far = 1000) => {
148
+ export const viewProjection = (cam, near = 0.03, far = 1e9) => {
15
149
  const kk = cam.k / (cam.size / 2);
16
150
  const row = (v, s) => [v[0] * s, v[1] * s, v[2] * s, -dot(v, cam.eye) * s];
17
151
  const a = cam.ortho ? 1 / (far - near) : far / (far - near);
@@ -22,6 +156,21 @@ export const viewProjection = (cam, near = 0.03, far = 1000) => {
22
156
  rows.forEach((r, i) => r.forEach((v, j) => (m[j * 4 + i] = v)));
23
157
  return m;
24
158
  };
159
+ /**
160
+ * Near plane for a scene seen from `cam`: the usual 0.03, unless everything is very far (a galaxy seen from outside, the
161
+ * nearest point thousands of units away), where a near plane that close would leave no depth precision at all.
162
+ */
163
+ export const nearPlane = (cam, mesh) => {
164
+ const [ex, ey, ez] = cam.eye;
165
+ const [fx, fy, fz] = cam.f;
166
+ let nearest = Infinity;
167
+ for (const p of mesh.positions) {
168
+ const d = (p[0] - ex) * fx + (p[1] - ey) * fy + (p[2] - ez) * fz;
169
+ if (d > 1e-6 && d < nearest)
170
+ nearest = d;
171
+ }
172
+ return nearest > 1000 ? nearest * 0.25 : 0.03;
173
+ };
25
174
  const rgb = (color) => {
26
175
  const h = String(color ?? '').replace('#', '');
27
176
  const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
@@ -34,8 +183,13 @@ const rgb = (color) => {
34
183
  * crack in the indices does not break it) are averaged, weighted by area, but only those within `crease` degrees of
35
184
  * this face: a 90 degree box corner stays sharp, a faceted fuselage becomes round.
36
185
  */
37
- export const cornerNormals = (mesh, crease) => {
38
- const cosC = Math.cos((crease * Math.PI) / 180);
186
+ export const cornerNormals = (mesh, crease, soft) => {
187
+ // the material's soft decides how far its edges may be smoothed: 0 keeps every edge hard, from 0.5 up the whole crease angle is allowed;
188
+ // between two faces the harder one rules
189
+ const cosAt = (i, j) => {
190
+ const k = soft ? Math.min(1, 2 * Math.min(soft[i], soft[j])) : 1;
191
+ return Math.cos((crease * k * Math.PI) / 180);
192
+ };
39
193
  const area = mesh.faces.map((f) => faceNormal(f.v.map((k) => mesh.positions[k]))); // length = area
40
194
  const unit = area.map((n) => (Math.hypot(...n) > 0 ? norm(n) : [0, 1, 0]));
41
195
  const q = (bounds(mesh).radius || 1) * 1e-6;
@@ -51,7 +205,9 @@ export const cornerNormals = (mesh, crease) => {
51
205
  return mesh.faces.map((f, i) => f.v.map((k) => {
52
206
  const sum = [0, 0, 0];
53
207
  for (const j of at.get(key(mesh.positions[k])) ?? []) {
54
- if (dot(unit[i], unit[j]) < cosC)
208
+ if (i !== j && (soft ? Math.min(soft[i], soft[j]) <= 0 : false))
209
+ continue;
210
+ if (dot(unit[i], unit[j]) < cosAt(i, j) - 1e-12)
55
211
  continue;
56
212
  for (let c = 0; c < 3; c++)
57
213
  sum[c] += area[j][c];
@@ -68,7 +224,7 @@ export const buildVertices = (mesh, palette, { smooth = 0 } = {}) => {
68
224
  for (const f of mesh.faces)
69
225
  tris += Math.max(0, f.v.length - 2);
70
226
  const out = new Float32Array(tris * 3 * FLOATS);
71
- const corners = smooth > 0 ? cornerNormals(mesh, smooth) : null;
227
+ const corners = smooth > 0 ? cornerNormals(mesh, smooth, mesh.faces.map((f) => surfaceOf(f, palette).soft)) : null;
72
228
  let o = 0;
73
229
  mesh.faces.forEach((face, fi) => {
74
230
  const pts = face.v.map((k) => mesh.positions[k]);
@@ -76,8 +232,8 @@ export const buildVertices = (mesh, palette, { smooth = 0 } = {}) => {
76
232
  const s = surfaceOf(face, palette);
77
233
  const c = rgb(s.color);
78
234
  const mat = [s.metal, s.rough, s.alpha, s.emit];
79
- for (let j = 1; j < pts.length - 1; j++) {
80
- for (const k of [0, j, j + 1]) {
235
+ for (const tri of triangulate(pts)) {
236
+ for (const k of tri) {
81
237
  out.set([...pts[k], ...(corners ? corners[fi][k] : flat), ...c, ...mat, s.soft], o);
82
238
  o += FLOATS;
83
239
  }
@@ -108,6 +264,9 @@ struct U {
108
264
  fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain, studio (0 or 1)
109
265
  fill : vec4<f32>, // studio: xyz direction towards the fill light, w intensity
110
266
  rim : vec4<f32>, // studio: xyz direction towards the rim light, w intensity
267
+ res : vec4<f32>, // marks: canvas pixels (x, y), device pixels per CSS pixel (z); w: number of glow lights
268
+ lpos : array<vec4<f32>, 16>, // glow lights: xyz position, w reach
269
+ lcol : array<vec4<f32>, 16>, // rgb colour times intensity
111
270
  };
112
271
  @group(0) @binding(0) var<uniform> u : U;
113
272
 
@@ -152,6 +311,58 @@ struct IIn {
152
311
  return o;
153
312
  }
154
313
 
314
+ // the edge lines: 1 pixel, nudged a hair towards the eye so they win against the faces they lie on
315
+ @vertex fn vsl(@location(0) pos : vec3<f32>) -> @builtin(position) vec4<f32> {
316
+ var p = u.vp * vec4<f32>(pos, 1.0);
317
+ p.z = p.z - 0.0002 * p.w;
318
+ return p;
319
+ }
320
+ @fragment fn fsl() -> @location(0) vec4<f32> {
321
+ // dark lines on the lit picture; with no light the picture is dark, so the lines turn light
322
+ let g = select(0.0, 0.85, u.light.w > 0.5);
323
+ return vec4<f32>(g, g, g, select(0.4, 0.55, u.light.w > 0.5));
324
+ }
325
+
326
+ // the marks: thick round-capped segments, expanded to screen-space quads, drawn over everything (no depth test)
327
+ struct MOut { @builtin(position) p : vec4<f32>, @location(0) q : vec2<f32>, @location(1) c : vec4<f32>, @location(2) h : vec3<f32> };
328
+ @vertex fn vsm(@builtin(vertex_index) vi : u32, @location(0) a : vec3<f32>, @location(1) b : vec3<f32>, @location(2) col : vec4<f32>, @location(3) wd : f32) -> MOut {
329
+ var o : MOut;
330
+ var ca = u.vp * vec4<f32>(a, 1.0);
331
+ var cb = u.vp * vec4<f32>(b, 1.0);
332
+ let e = 0.001;
333
+ o.p = vec4<f32>(2.0, 2.0, 2.0, 1.0);
334
+ if (ca.w < e && cb.w < e) { return o; }
335
+ if (ca.w < e) { ca = mix(ca, cb, (e - ca.w) / (cb.w - ca.w)); }
336
+ if (cb.w < e) { cb = mix(cb, ca, (e - cb.w) / (ca.w - cb.w)); }
337
+ let half = vec2<f32>(u.res.x, u.res.y) * 0.5;
338
+ let pa = ca.xy / ca.w * half;
339
+ let pb = cb.xy / cb.w * half;
340
+ let len = length(pb - pa);
341
+ var d = vec2<f32>(1.0, 0.0);
342
+ if (len > 0.0001) { d = (pb - pa) / len; }
343
+ let hw = wd * u.res.z * 0.5;
344
+ let n = vec2<f32>(-d.y, d.x);
345
+ let cx = array<f32, 6>(0.0, 1.0, 0.0, 1.0, 1.0, 0.0);
346
+ let cy = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
347
+ let t = cx[vi];
348
+ let s = cy[vi];
349
+ let ext = hw + 1.0;
350
+ let along = mix(-ext, len + ext, t);
351
+ let across = s * ext;
352
+ let pos = pa + d * along + n * across;
353
+ o.p = vec4<f32>(pos / half, 0.0, 1.0);
354
+ o.q = vec2<f32>(along, across);
355
+ o.c = col;
356
+ o.h = vec3<f32>(len, hw, 0.0);
357
+ return o;
358
+ }
359
+ @fragment fn fsm(i : MOut) -> @location(0) vec4<f32> {
360
+ let dx = max(max(-i.q.x, i.q.x - i.h.x), 0.0);
361
+ let dist = length(vec2<f32>(dx, i.q.y));
362
+ let cov = clamp(i.h.y + 0.5 - dist, 0.0, 1.0);
363
+ return vec4<f32>(i.c.rgb, i.c.a * cov);
364
+ }
365
+
155
366
  fn toLinear(c : vec3<f32>) -> vec3<f32> {
156
367
  return select(pow((c + 0.055) / 1.055, vec3<f32>(2.4)), c / 12.92, c <= vec3<f32>(0.04045));
157
368
  }
@@ -186,6 +397,24 @@ fn lights(n : vec3<f32>, v : vec3<f32>, shin : f32, soft : f32) -> vec3<f32> {
186
397
  return vec3<f32>(diff, spec, scat);
187
398
  }
188
399
 
400
+ // Glow: every emitting cluster is a local light with a smooth falloff to zero at its reach, a little wrap so shade edges soften
401
+ fn glowAt(p : vec3<f32>, n : vec3<f32>) -> vec3<f32> {
402
+ var sum = vec3<f32>(0.0);
403
+ let cnt = u32(u.res.w);
404
+ for (var k = 0u; k < cnt; k = k + 1u) {
405
+ let d = u.lpos[k].xyz - p;
406
+ let dist = max(length(d), 0.001);
407
+ let reach = u.lpos[k].w;
408
+ let x = dist / reach;
409
+ var win = clamp(1.0 - x * x * x * x, 0.0, 1.0);
410
+ win = win * win;
411
+ let s = dist / (reach * 0.25);
412
+ let wrap = max((dot(n, d / dist) + 0.3) / 1.3, 0.0);
413
+ sum = sum + u.lcol[k].rgb * (win / (1.0 + s * s)) * wrap * 1.6;
414
+ }
415
+ return sum;
416
+ }
417
+
189
418
  // The filmic path: the same light model, but in linear colour with HDR light and emission, then tone mapped.
190
419
  fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
191
420
  let metal = i.m.x;
@@ -207,6 +436,7 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
207
436
  + vec3<f32>(spec) * mix(vec3<f32>(1.0), albedo, metal);
208
437
  // emission adds light, so a lamp can go above 1.0 and still keep its colour after tone mapping
209
438
  lit = lit + albedo * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.5 * (1.0 - metal); // soft: the warm scattered band
439
+ lit = lit + albedo * toLinear(vec3<f32>(1.0)) * glowAt(i.w, n) * (1.0 - metal);
210
440
  lit = lit + albedo * i.m.w * u.fx.z;
211
441
  return vec4<f32>(toSrgb(aces(lit * u.fx.y)), i.m.z);
212
442
  }
@@ -216,6 +446,12 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
216
446
  if (!front) { n = -n; }
217
447
  let l = normalize(u.light.xyz);
218
448
  let v = normalize(u.eye.xyz - i.w);
449
+ if (u.light.w > 0.5) {
450
+ // unlit: no light in the scene, only what glows
451
+ let g = glowAt(i.w, n) * (1.0 - i.m.x);
452
+ if (u.fx.x > 0.5) { return vec4<f32>(toSrgb(aces((toLinear(i.c) * (i.m.w * u.fx.z + g)) * u.fx.y)), i.m.z); }
453
+ return vec4<f32>(clamp(i.c * (min(i.m.w, 1.0) + g), vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
454
+ }
219
455
  if (u.fx.x > 0.5) { return filmic(i, n, l, v); }
220
456
  let metal = i.m.x;
221
457
  let rough = i.m.y;
@@ -232,6 +468,7 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
232
468
  + i.c * env * metal * (1.0 - 0.6 * rough)
233
469
  + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal)
234
470
  + i.c * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.25 * (1.0 - metal);
471
+ lit = lit + i.c * glowAt(i.w, n) * (1.0 - metal);
235
472
  lit = mix(lit, i.c * 1.15, min(i.m.w, 1.0)); // the plain look: emit 0..1 fades to flat colour, more does not add
236
473
  return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
237
474
  }
@@ -316,13 +553,55 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
316
553
  const glassPipeline = make('vs', [meshLayout], true);
317
554
  // the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
318
555
  const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
319
- const uniforms = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
556
+ const linePipeline = device.createRenderPipeline({
557
+ layout: pipelineLayout,
558
+ vertex: { module, entryPoint: 'vsl', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }] },
559
+ fragment: {
560
+ module,
561
+ entryPoint: 'fsl',
562
+ targets: [{ format, blend: { color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' } } }],
563
+ },
564
+ primitive: { topology: 'line-list' },
565
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'less-equal' },
566
+ multisample: { count: sampleCount },
567
+ });
568
+ const markPipeline = device.createRenderPipeline({
569
+ layout: pipelineLayout,
570
+ vertex: {
571
+ module,
572
+ entryPoint: 'vsm',
573
+ buffers: [
574
+ {
575
+ arrayStride: MARK_FLOATS * 4,
576
+ stepMode: 'instance',
577
+ attributes: [
578
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
579
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
580
+ { shaderLocation: 2, offset: 24, format: 'float32x4' },
581
+ { shaderLocation: 3, offset: 40, format: 'float32' },
582
+ ],
583
+ },
584
+ ],
585
+ },
586
+ fragment: {
587
+ module,
588
+ entryPoint: 'fsm',
589
+ targets: [{ format, blend: { color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' } } }],
590
+ },
591
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
592
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'always' },
593
+ multisample: { count: sampleCount },
594
+ });
595
+ const uniforms = device.createBuffer({ size: 720, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
320
596
  const bind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
321
597
  const instBind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
322
598
  // Geometry that did not change since the last frame is not rebuilt or uploaded again.
323
599
  let uploaded = null; // the mesh now in vertexBuffer
324
600
  let uploadedPalette; // and the palette it was built with
325
601
  let uploadedSmooth = 0; // and the smoothing
602
+ let partCache = new WeakMap(); // vertices per part mesh (see `parts`)
603
+ let partPalette;
604
+ let partSmooth = 0;
326
605
  let vertexCount = 0;
327
606
  let protoMesh = null;
328
607
  let protoBuffer = null;
@@ -333,6 +612,15 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
333
612
  let instCount = 0;
334
613
  let vertexBuffer = null;
335
614
  let capacity = 0;
615
+ let markBuffer = null;
616
+ let markCapacity = 0;
617
+ let markCount = 0;
618
+ let edgeBuffer = null;
619
+ let edgeCapacity = 0;
620
+ let edgeCount = 0; // vertices (two per line)
621
+ let edgeMesh = null;
622
+ let edgeParts;
623
+ let edgeOn = false;
336
624
  let glassBuffer = null; // the see-through triangles, sorted far to near for the eye
337
625
  let glassCapacity = 0;
338
626
  let glassTris = []; // centroid and offset (in floats) in glassSource
@@ -353,7 +641,7 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
353
641
  depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
354
642
  color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
355
643
  };
356
- const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, tonemap = false, smooth = false, studio = false } = {}, readback = false) => {
644
+ const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, parts, tonemap = false, smooth = false, studio = false, edges = false, marks, unlit = false, glow = false } = {}, readback = false) => {
357
645
  const L = { ...defaultLight, ...light };
358
646
  const filmic = tonemap ? { exposure: 1, emit: 2, ...(tonemap === true ? {} : tonemap) } : null;
359
647
  const M = { ...defaultMaterial, ...material };
@@ -362,23 +650,46 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
362
650
  const crease = smooth === true ? 40 : smooth || 0;
363
651
  if (mesh !== uploaded || palette !== uploadedPalette || crease !== uploadedSmooth) {
364
652
  uploadedSmooth = crease;
365
- const all = buildVertices(mesh, palette, { smooth: crease });
653
+ let all;
654
+ if (parts && parts.length) {
655
+ if (palette !== partPalette || crease !== partSmooth) {
656
+ partCache = new WeakMap();
657
+ partPalette = palette;
658
+ partSmooth = crease;
659
+ }
660
+ const built = parts.map((m) => {
661
+ let v = partCache.get(m);
662
+ if (!v)
663
+ partCache.set(m, (v = buildVertices(m, palette, { smooth: crease })));
664
+ return v;
665
+ });
666
+ all = new Float32Array(built.reduce((n, v) => n + v.length, 0));
667
+ let at = 0;
668
+ for (const v of built) {
669
+ all.set(v, at);
670
+ at += v.length;
671
+ }
672
+ }
673
+ else
674
+ all = buildVertices(mesh, palette, { smooth: crease });
366
675
  // split: solid triangles are drawn first; triangles with alpha < 1 are kept apart and blended afterwards
367
676
  const tri = 3 * FLOATS;
368
- const solid = [];
677
+ const solid = new Float32Array(all.length);
678
+ let solidLen = 0;
369
679
  glassTris = [];
370
680
  for (let o = 0; o < all.length; o += tri) {
371
681
  if (all[o + 11] < 1) {
372
682
  const c = [0, 1, 2].map((k) => (all[o + k] + all[o + FLOATS + k] + all[o + 2 * FLOATS + k]) / 3);
373
683
  glassTris.push({ c, at: o });
374
684
  }
375
- else
376
- for (let k = 0; k < tri; k++)
377
- solid.push(all[o + k]);
685
+ else {
686
+ solid.set(all.subarray(o, o + tri), solidLen);
687
+ solidLen += tri;
688
+ }
378
689
  }
379
690
  glassSource = all;
380
691
  glassEye = '';
381
- const verts = Float32Array.from(solid);
692
+ const verts = solid.subarray(0, solidLen);
382
693
  if (verts.byteLength > capacity) {
383
694
  vertexBuffer?.destroy();
384
695
  capacity = Math.max(verts.byteLength, 4096);
@@ -390,6 +701,22 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
390
701
  uploaded = mesh;
391
702
  uploadedPalette = palette;
392
703
  }
704
+ if (!edges)
705
+ edgeCount = 0;
706
+ else if (mesh !== edgeMesh || parts !== edgeParts || !edgeOn) {
707
+ const e = sceneEdges(mesh, parts);
708
+ if (e.byteLength > edgeCapacity) {
709
+ edgeBuffer?.destroy();
710
+ edgeCapacity = Math.max(e.byteLength, 4096);
711
+ edgeBuffer = device.createBuffer({ size: edgeCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
712
+ }
713
+ if (e.byteLength)
714
+ device.queue.writeBuffer(edgeBuffer, 0, e);
715
+ edgeCount = e.length / 3;
716
+ edgeMesh = mesh;
717
+ edgeParts = parts;
718
+ }
719
+ edgeOn = edges;
393
720
  if (instances?.mesh && instances.mesh !== protoMesh) {
394
721
  const v = buildVertices(instances.mesh);
395
722
  protoBuffer?.destroy();
@@ -433,9 +760,9 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
433
760
  // studio rig, relative to the camera: key upper left in front, fill from the right, rim from behind and above
434
761
  const rig = (f, r, up) => norm([0, 1, 2].map((k) => -cam.f[k] * f + cam.r[k] * r + cam.u[k] * up));
435
762
  const S = studio ? { fill: 0.4, rim: 1, ...(studio === true ? {} : studio) } : null;
436
- const u = new Float32Array(48);
437
- u.set(viewProjection(cam), 0);
438
- u.set([...(S ? rig(0.7, -0.5, 0.6) : norm(L.direction)), 0], 16);
763
+ const u = new Float32Array(52 + MAX_LIGHTS * 8);
764
+ u.set(viewProjection(cam, nearPlane(cam, mesh)), 0);
765
+ u.set([...(S ? rig(0.7, -0.5, 0.6) : norm(L.direction)), unlit ? 1 : 0], 16);
439
766
  u.set([...L.sky, 0], 20);
440
767
  u.set([...L.ground, 0], 24);
441
768
  u.set([...cam.eye, 0], 28);
@@ -443,7 +770,23 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
443
770
  u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, S ? 1 : 0], 36);
444
771
  u.set([...rig(0.6, 0.8, 0.1), S ? S.fill : 0], 40);
445
772
  u.set([...rig(-0.8, 0.3, 0.5), S ? S.rim : 0], 44);
773
+ const lights = glow ? sceneLights(mesh, parts, palette) : new Float32Array(0);
774
+ u.set([px, px, px / size, lights.length / LIGHT_FLOATS], 48);
775
+ for (let k = 0; k < lights.length / LIGHT_FLOATS; k++) {
776
+ u.set(lights.subarray(k * LIGHT_FLOATS, k * LIGHT_FLOATS + 4), 52 + k * 4);
777
+ u.set([...lights.subarray(k * LIGHT_FLOATS + 4, k * LIGHT_FLOATS + 7), 0], 52 + MAX_LIGHTS * 4 + k * 4);
778
+ }
446
779
  device.queue.writeBuffer(uniforms, 0, u);
780
+ const markData = marks?.length ? buildMarks(marks) : null;
781
+ markCount = markData ? markData.length / MARK_FLOATS : 0;
782
+ if (markData && markData.byteLength) {
783
+ if (markData.byteLength > markCapacity) {
784
+ markBuffer?.destroy();
785
+ markCapacity = Math.ceil(markData.byteLength * 1.5);
786
+ markBuffer = device.createBuffer({ size: markCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
787
+ }
788
+ device.queue.writeBuffer(markBuffer, 0, markData);
789
+ }
447
790
  const encoder = device.createCommandEncoder();
448
791
  const offscreen = readback
449
792
  ? device.createTexture({ size: [px, px], format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC })
@@ -474,6 +817,12 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
474
817
  pass.setVertexBuffer(1, instBuffer);
475
818
  pass.draw(protoCount, instCount);
476
819
  }
820
+ if (edgeCount) {
821
+ pass.setPipeline(linePipeline);
822
+ pass.setBindGroup(0, bind);
823
+ pass.setVertexBuffer(0, edgeBuffer);
824
+ pass.draw(edgeCount);
825
+ }
477
826
  if (glassCount) {
478
827
  // after everything solid (and the instances): see-through faces, far to near
479
828
  pass.setPipeline(glassPipeline);
@@ -481,6 +830,12 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
481
830
  pass.setVertexBuffer(0, glassBuffer);
482
831
  pass.draw(glassCount);
483
832
  }
833
+ if (markCount) {
834
+ pass.setPipeline(markPipeline);
835
+ pass.setBindGroup(0, bind);
836
+ pass.setVertexBuffer(0, markBuffer);
837
+ pass.draw(6, markCount);
838
+ }
484
839
  pass.end();
485
840
  if (!offscreen) {
486
841
  device.queue.submit([encoder.finish()]);
@@ -519,6 +874,7 @@ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
519
874
  const destroy = () => {
520
875
  vertexBuffer?.destroy();
521
876
  glassBuffer?.destroy();
877
+ markBuffer?.destroy();
522
878
  protoBuffer?.destroy();
523
879
  instBuffer?.destroy();
524
880
  depth?.destroy();
package/dist/index.d.ts CHANGED
@@ -1,20 +1,25 @@
1
1
  export declare const name: "3draft";
2
- export declare const version = "26.11.5001";
2
+ export declare const version = "26.11.11010";
3
3
  export type { Vec3, Face, Mesh, Axes, ViewName, ViewSpec, Fit, Camera, Hit, Mark } from './types.js';
4
4
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
5
5
  export type { BoxOptions } from './mesh.js';
6
6
  export { validate } from './check.js';
7
- export { boolean, union, subtract, intersect, health } from './csg.js';
7
+ export { boolean, union, subtract, intersect, health, splitParts } from './csg.js';
8
+ export { saw, planeThroughLine, planeThroughTriangle, sectionSegments } from './saw.js';
9
+ export type { CutPlane, Sawn } from './saw.js';
8
10
  export type { BooleanOp, Health, Part } from './csg.js';
9
11
  export type { Validation } from './check.js';
10
12
  export { extrudeFace, moveVertices } from './edit.js';
11
13
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
12
14
  export { pick, crosshair } from './pick.js';
15
+ export { instantiate, lodLevel } from './instances.js';
16
+ export type { Prototype, Placement } from './instances.js';
13
17
  export { standOn } from './place.js';
14
18
  export type { StandOptions, Frame } from './place.js';
15
19
  export { paintOrder } from './order.js';
16
20
  export type { PaintItem } from './order.js';
17
21
  export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
22
+ export { createGl } from './gl.js';
18
23
  export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
19
24
  export { renderView, renderViews, renderSheet } from './svg.js';
20
25
  export type { RenderOptions } from './svg.js';
@@ -31,5 +36,8 @@ export interface Info {
31
36
  export declare const info: () => Info;
32
37
  export { initKernel } from './kernel.js';
33
38
  export type { Kernel } from './kernel.js';
34
- export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
39
+ export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet, reshape, outlineOf } from './wire.js';
40
+ export { triangulate } from './triangulate.js';
41
+ export { buildEdges, buildMarks, MARK_FLOATS, sceneEdges } from './gpu.js';
42
+ export type { Tri } from './triangulate.js';
35
43
  export type { Wire } from './wire.js';
package/dist/index.js CHANGED
@@ -4,16 +4,19 @@
4
4
  // a target cross that lies on the surface under the cursor. No textures, no
5
5
  // shading yet.
6
6
  export const name = '3draft';
7
- export const version = '26.11.5001';
7
+ export const version = '26.11.11010';
8
8
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
9
9
  export { validate } from './check.js';
10
- export { boolean, union, subtract, intersect, health } from './csg.js';
10
+ export { boolean, union, subtract, intersect, health, splitParts } from './csg.js';
11
+ export { saw, planeThroughLine, planeThroughTriangle, sectionSegments } from './saw.js';
11
12
  export { extrudeFace, moveVertices } from './edit.js';
12
13
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
13
14
  export { pick, crosshair } from './pick.js';
15
+ export { instantiate, lodLevel } from './instances.js';
14
16
  export { standOn } from './place.js';
15
17
  export { paintOrder } from './order.js';
16
18
  export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
19
+ export { createGl } from './gl.js';
17
20
  export { renderView, renderViews, renderSheet } from './svg.js';
18
21
  export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
19
22
  export { displayVersion } from './version.js';
@@ -30,4 +33,6 @@ export const info = () => ({
30
33
  ],
31
34
  });
32
35
  export { initKernel } from './kernel.js';
33
- export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
36
+ export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet, reshape, outlineOf } from './wire.js';
37
+ export { triangulate } from './triangulate.js';
38
+ export { buildEdges, buildMarks, MARK_FLOATS, sceneEdges } from './gpu.js';
@@ -0,0 +1,19 @@
1
+ import type { Mesh, Vec3 } from './types.js';
2
+ export interface Prototype {
3
+ lods: Mesh[];
4
+ }
5
+ export interface Placement {
6
+ at: Vec3;
7
+ /** Size of the instance; a record scales each slot on its own ('*' is the default), e.g. a star core and its halo. */
8
+ scale: number | Record<string, number>;
9
+ /** Slot -> palette name. */
10
+ mats?: Record<string, string>;
11
+ /** Turn of the instance about its own centre: [tilt about x, turn about y] in radians (a galaxy disc seen from any side). */
12
+ rot?: [number, number];
13
+ /** Colour of every face (a tint on top of the material). */
14
+ color?: string;
15
+ }
16
+ /** A copy of the prototype mesh, scaled, moved and given its materials. Slots must not share vertices when scaled apart. */
17
+ export declare const instantiate: (proto: Mesh, p: Placement) => Mesh;
18
+ /** Which level of detail to draw for something that is `px` pixels across (its radius); `limits` are the pixel sizes where the next finer level starts. */
19
+ export declare const lodLevel: (px: number, levels: number, limits?: readonly number[]) => number;