3draft 26.11.5001 → 26.11.11032

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.
@@ -0,0 +1,41 @@
1
+ // Prototypes and instances. A PROTOTYPE is a list of meshes of the same thing in decreasing detail (level of detail:
2
+ // lods[0] is the finest), all of unit size and centred on the origin, with material SLOTS in `Face.mat` ('core', 'halo'...).
3
+ // An INSTANCE is the prototype placed with a position, a size (one number, or one per slot) and its own slot -> palette
4
+ // map, so a scene file stores the shape once and one short record per star. The detail used on screen follows the size
5
+ // the thing has in pixels, so a far star costs a few faces and a near one a few dozen.
6
+ /** A copy of the prototype mesh, scaled, moved and given its materials. Slots must not share vertices when scaled apart. */
7
+ export const instantiate = (proto, p) => {
8
+ const slotOf = [];
9
+ for (const f of proto.faces)
10
+ for (const i of f.v)
11
+ slotOf[i] ??= f.mat ?? '';
12
+ const size = (slot) => (typeof p.scale === 'number' ? p.scale : (p.scale[slot] ?? p.scale['*'] ?? 1));
13
+ const positions = proto.positions.map((q, i) => {
14
+ const k = size(slotOf[i] ?? '');
15
+ let [x, y, z] = [q[0] * k, q[1] * k, q[2] * k];
16
+ if (p.rot) {
17
+ const [a, b] = p.rot;
18
+ [y, z] = [y * Math.cos(a) - z * Math.sin(a), y * Math.sin(a) + z * Math.cos(a)];
19
+ [x, z] = [x * Math.cos(b) + z * Math.sin(b), -x * Math.sin(b) + z * Math.cos(b)];
20
+ }
21
+ return [x + p.at[0], y + p.at[1], z + p.at[2]];
22
+ });
23
+ const faces = proto.faces.map((f) => {
24
+ const out = { v: f.v };
25
+ const mat = f.mat === undefined ? undefined : (p.mats?.[f.mat] ?? f.mat);
26
+ if (mat !== undefined)
27
+ out.mat = mat;
28
+ const color = p.color ?? f.color;
29
+ if (color !== undefined)
30
+ out.color = color;
31
+ return out;
32
+ });
33
+ return { positions, faces };
34
+ };
35
+ /** 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. */
36
+ export const lodLevel = (px, levels, limits = [60, 22, 8, 3, 1]) => {
37
+ let level = 0;
38
+ while (level < levels - 1 && level < limits.length && px < limits[level])
39
+ level++;
40
+ return level;
41
+ };
package/dist/kernel.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // then every pick is one call that walks all triangles. Same answers as pick() in pick.ts.
4
4
  import { dot, norm, plus, times } from './vec.js';
5
5
  import { faceNormal } from './mesh.js';
6
+ import { triangulate } from './triangulate.js';
6
7
  import { ray } from './camera.js';
7
8
  import { KERNEL_BYTES } from './kernel-bytes.js';
8
9
  const POS = 64; // vertices start here, 24 bytes each (f64 x y z)
@@ -38,14 +39,14 @@ export const initKernel = async () => {
38
39
  f64[i * 3 + 1] = p[1];
39
40
  f64[i * 3 + 2] = p[2];
40
41
  }
41
- // faces are fanned into triangles (v0, vj, vj+1), each remembering its face number
42
+ // faces are cut into triangles (a fan, or ear clipping for a concave one), each remembering its face number
42
43
  const i32 = new Int32Array(memory.buffer, triBase, nt * 4);
43
44
  let at = 0;
44
45
  mesh.faces.forEach((f, face) => {
45
- for (let j = 1; j < f.v.length - 1; j++) {
46
- i32[at] = f.v[0];
47
- i32[at + 1] = f.v[j];
48
- i32[at + 2] = f.v[j + 1];
46
+ for (const [a, b, c] of triangulate(f.v.map((k) => mesh.positions[k]))) {
47
+ i32[at] = f.v[a];
48
+ i32[at + 1] = f.v[b];
49
+ i32[at + 2] = f.v[c];
49
50
  i32[at + 3] = face;
50
51
  at += 4;
51
52
  }
@@ -0,0 +1,13 @@
1
+ import type { Mesh, Vec3 } from './types.js';
2
+ export interface LatheFrame {
3
+ /** A point of the axis. */
4
+ origin: Vec3;
5
+ /** The axis direction, the direction of r = 1 in the plane, and the plane's normal (right × up = normal). */
6
+ right: Vec3;
7
+ up: Vec3;
8
+ normal: Vec3;
9
+ }
10
+ export declare const lathe: (contour: [number, number][], frame: LatheFrame, segments?: number, { mat, color }?: {
11
+ mat?: string;
12
+ color?: string;
13
+ }) => Mesh;
package/dist/lathe.js ADDED
@@ -0,0 +1,87 @@
1
+ // The lathe: a contour drawn on a plane is turned round an axis into a solid. Pure geometry, one closed mesh.
2
+ // The axis is a line in the plane (for the app: the bottom line of the face the contour was drawn on); the contour is a list of
3
+ // [u, r] points: u along the axis, r the distance from it (0 = on the axis). Both ends are closed: an end off the axis gets a flat disc,
4
+ // an end on the axis is a point (a tip).
5
+ import { add, scale as mul, dot } from './vec.js';
6
+ export const lathe = (contour, frame, segments = 7, { mat, color = '#c8c8c8' } = {}) => {
7
+ if (!Number.isInteger(segments) || segments < 3)
8
+ throw new Error('a lathe needs 3 or more segments');
9
+ const span = Math.max(...contour.map(([, r]) => Math.abs(r)), 1e-9);
10
+ const eps = span * 1e-6;
11
+ // the same point twice in a row is one point
12
+ const pts = [];
13
+ for (const q of contour) {
14
+ const last = pts[pts.length - 1];
15
+ if (!last || Math.hypot(q[0] - last[0], q[1] - last[1]) > eps)
16
+ pts.push([q[0], q[1]]);
17
+ }
18
+ if (pts.length < 2)
19
+ throw new Error('the contour needs 2 different points');
20
+ if (pts.some(([, r]) => r < -eps))
21
+ throw new Error('the contour must stay on one side of the axis (r >= 0)');
22
+ const on = pts.map(([, r]) => r <= eps);
23
+ // which way is the inside: the sign of the area between the contour and the axis
24
+ const loop = [...pts, [pts[pts.length - 1][0], 0], [pts[0][0], 0]];
25
+ let area = 0;
26
+ for (let i = 0; i < loop.length; i++) {
27
+ const a = loop[i];
28
+ const b = loop[(i + 1) % loop.length];
29
+ area += a[0] * b[1] - b[0] * a[1];
30
+ }
31
+ if (Math.abs(area) < eps * span)
32
+ throw new Error('the contour and the axis enclose nothing');
33
+ const sgn = area > 0 ? 1 : -1;
34
+ const ang = (j) => (2 * Math.PI * j) / segments;
35
+ const radial = (t) => add(mul(frame.up, Math.cos(t)), mul(frame.normal, Math.sin(t)));
36
+ const positions = [];
37
+ const ring = pts.map(([u, r], i) => {
38
+ if (on[i]) {
39
+ positions.push(add(frame.origin, mul(frame.right, u)));
40
+ return [positions.length - 1];
41
+ }
42
+ return Array.from({ length: segments }, (_, j) => {
43
+ positions.push(add(add(frame.origin, mul(frame.right, u)), mul(radial(ang(j)), r)));
44
+ return positions.length - 1;
45
+ });
46
+ });
47
+ const faces = [];
48
+ const put = (v, want) => {
49
+ // wound so that the face looks the way it should (its Newell normal agrees with `want`)
50
+ const n = [0, 0, 0];
51
+ for (let k = 0; k < v.length; k++) {
52
+ const a = positions[v[k]];
53
+ const b = positions[v[(k + 1) % v.length]];
54
+ n[0] += (a[1] - b[1]) * (a[2] + b[2]);
55
+ n[1] += (a[2] - b[2]) * (a[0] + b[0]);
56
+ n[2] += (a[0] - b[0]) * (a[1] + b[1]);
57
+ }
58
+ faces.push({ v: dot(n, want) < 0 ? [...v].reverse() : v, color, ...(mat ? { mat } : {}) });
59
+ };
60
+ for (let i = 0; i + 1 < pts.length; i++) {
61
+ if (on[i] && on[i + 1])
62
+ continue; // a piece of the axis itself: no surface
63
+ const du = pts[i + 1][0] - pts[i][0];
64
+ const dr = pts[i + 1][1] - pts[i][1];
65
+ const n2 = [sgn * dr, -sgn * du]; // outward, in the plane of the contour
66
+ for (let j = 0; j < segments; j++) {
67
+ const want = add(mul(frame.right, n2[0]), mul(radial(ang(j + 0.5)), n2[1]));
68
+ const a = ring[i];
69
+ const b = ring[i + 1];
70
+ const j2 = (j + 1) % segments;
71
+ if (on[i])
72
+ put([a[0], b[j], b[j2]], want);
73
+ else if (on[i + 1])
74
+ put([a[j], a[j2], b[0]], want);
75
+ else
76
+ put([a[j], a[j2], b[j2], b[j]], want);
77
+ }
78
+ }
79
+ // the two ends: a disc where the contour does not end on the axis
80
+ const first = pts[0];
81
+ const last = pts[pts.length - 1];
82
+ if (!on[0])
83
+ put(ring[0], mul(frame.right, sgn * first[1]));
84
+ if (!on[pts.length - 1])
85
+ put(ring[pts.length - 1], mul(frame.right, -sgn * last[1]));
86
+ return { positions, faces };
87
+ };
@@ -12,6 +12,8 @@ export interface Material {
12
12
  emit?: number;
13
13
  /** 0..1: soft, skin-like light. The light wraps round the form and a warm, scattered band shows where light turns to shade. */
14
14
  soft?: number;
15
+ /** A screen: a page shown on every four-cornered face of this material (the viewer puts an iframe over the picture, warped to the face). The renderers ignore it. */
16
+ url?: string;
15
17
  }
16
18
  export type Palette = Record<string, Material>;
17
19
  /** What a face is made of, all numbers filled in. */
package/dist/pick.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // the painter-sorted drawing is not.
4
4
  import { cross, dot, norm, plus, minus, times, scale } from './vec.js';
5
5
  import { faceNormal } from './mesh.js';
6
+ import { triangulate } from './triangulate.js';
6
7
  import { ray } from './camera.js';
7
8
  const EPS = 1e-9;
8
9
  // Moller-Trumbore ray/triangle test. Distance along the ray, or null.
@@ -41,8 +42,8 @@ export const pick = (mesh, cam, x, y, { cull = true } = {}) => {
41
42
  const front = dot(outward, dir) < 0;
42
43
  if (cull && !front)
43
44
  return;
44
- for (let j = 1; j < pts.length - 1; j++) {
45
- const t = hitTriangle(origin, dir, pts[0], pts[j], pts[j + 1]);
45
+ for (const [a, b, c] of triangulate(pts)) {
46
+ const t = hitTriangle(origin, dir, pts[a], pts[b], pts[c]);
46
47
  if (t !== null && (!best || t < best.distance)) {
47
48
  best = {
48
49
  face: i,
package/dist/saw.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Mesh, Vec3 } from './types.js';
2
+ /** A plane: a point on it and the unit normal. `front` is the side the normal points to. */
3
+ export interface CutPlane {
4
+ point: Vec3;
5
+ n: Vec3;
6
+ }
7
+ export interface Sawn {
8
+ /** Closed pieces on the normal's side of the plane. */
9
+ front: Mesh[];
10
+ /** Closed pieces on the other side. */
11
+ back: Mesh[];
12
+ }
13
+ /**
14
+ * The plane a drawn line gives: it contains the line and stands square on the face the line was drawn on
15
+ * (`faceNormal`), so it runs straight into the material and out beyond the ends of the line.
16
+ */
17
+ export declare const planeThroughLine: (a: Vec3, b: Vec3, faceNormal: Vec3) => CutPlane;
18
+ /** The plane through three points. Throws when they lie on one line. */
19
+ export declare const planeThroughTriangle: (a: Vec3, b: Vec3, c: Vec3) => CutPlane;
20
+ /**
21
+ * Saw `mesh` along `plane`. `mat` / `color` is the material of the two new faces (the cut surface).
22
+ * Throws when the mesh is not a closed solid, or when the plane does not go through it.
23
+ */
24
+ export declare const saw: (mesh: Mesh, plane: CutPlane, opts?: {
25
+ color?: string;
26
+ mat?: string;
27
+ }) => Sawn;
28
+ /** Where the plane meets the faces of the mesh: line segments for the preview of a cut. */
29
+ export declare const sectionSegments: (mesh: Mesh, plane: CutPlane) => [Vec3, Vec3][];
package/dist/saw.js ADDED
@@ -0,0 +1,121 @@
1
+ // The saw: one plane cuts a closed solid into closed solids, like a board on the bench.
2
+ // The cut is a half-space (a huge box with one face on the plane) run through the boolean, so both sides come out
3
+ // closed, every face stays planar, and the new face lies exactly in the saw plane (the "kerf" has no width).
4
+ // A solid that the plane splits into several pieces (a U shape cut across its arms) comes out as several meshes.
5
+ import { sub, cross, dot, len, norm, add, scale as mul } from './vec.js';
6
+ import { box, bounds } from './mesh.js';
7
+ import { subtract, intersect, splitParts } from './csg.js';
8
+ import { validate } from './check.js';
9
+ const MIN = 1e-9;
10
+ /**
11
+ * The plane a drawn line gives: it contains the line and stands square on the face the line was drawn on
12
+ * (`faceNormal`), so it runs straight into the material and out beyond the ends of the line.
13
+ */
14
+ export const planeThroughLine = (a, b, faceNormal) => {
15
+ const c = cross(sub(b, a), faceNormal);
16
+ if (len(c) < MIN * Math.max(1, len(sub(b, a))))
17
+ throw new Error('the line has no length, or it points along the face normal: it cannot give a plane');
18
+ return { point: a, n: norm(c) };
19
+ };
20
+ /** The plane through three points. Throws when they lie on one line. */
21
+ export const planeThroughTriangle = (a, b, c) => {
22
+ const x = cross(sub(b, a), sub(c, a));
23
+ const scale = Math.max(len(sub(b, a)), len(sub(c, a)), MIN);
24
+ if (len(x) < 1e-9 * scale * scale)
25
+ throw new Error('the three points lie on one line: they do not give a plane');
26
+ return { point: a, n: norm(x) };
27
+ };
28
+ const frame = (n) => {
29
+ const helper = Math.abs(n[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0];
30
+ const u = norm(cross(helper, n));
31
+ const v = cross(n, u);
32
+ return [u, v, n]; // right-handed: u x v = n
33
+ };
34
+ /**
35
+ * Saw `mesh` along `plane`. `mat` / `color` is the material of the two new faces (the cut surface).
36
+ * Throws when the mesh is not a closed solid, or when the plane does not go through it.
37
+ */
38
+ export const saw = (mesh, plane, opts = {}) => {
39
+ // A model of several shells (a cube with a drawn-and-pulled-out V standing on it, a loose open surface): every closed shell is cut by
40
+ // itself, a shell the plane misses, or one that is not closed, goes whole to the side its middle is on.
41
+ const parts = splitParts(mesh);
42
+ if (parts.length > 1) {
43
+ const n = norm(plane.n);
44
+ const front = [];
45
+ const back = [];
46
+ let solids = 0;
47
+ let cut = 0;
48
+ for (const part of parts) {
49
+ const pv = validate(part);
50
+ if (pv.closed && !pv.inside_out) {
51
+ solids++;
52
+ try {
53
+ const s = sawSolid(part, plane, opts);
54
+ front.push(...s.front);
55
+ back.push(...s.back);
56
+ cut++;
57
+ continue;
58
+ }
59
+ catch (err) {
60
+ if (!/does not go through/.test(String(err.message)))
61
+ throw err;
62
+ }
63
+ }
64
+ (dot(sub(bounds(part).center, plane.point), n) >= 0 ? front : back).push(part);
65
+ }
66
+ if (!solids)
67
+ throw new Error('saw: only a closed solid can be cut');
68
+ if (!cut)
69
+ throw new Error('the plane does not go through the object');
70
+ return { front, back };
71
+ }
72
+ return sawSolid(mesh, plane, opts);
73
+ };
74
+ const sawSolid = (mesh, plane, { color = '#c8c8c8', mat } = {}) => {
75
+ const v = validate(mesh);
76
+ if (!v.closed || v.inside_out)
77
+ throw new Error('saw: only a closed solid can be cut');
78
+ const n = norm(plane.n);
79
+ const { center, radius } = bounds(mesh);
80
+ const size = Math.max(radius, MIN) * 8;
81
+ const axes = frame(n);
82
+ // the half-space: a box whose bottom face lies in the plane and which reaches far past the solid on every side
83
+ const along = dot(sub(center, plane.point), n);
84
+ const origin = add(sub(center, mul(n, along)), mul(n, size / 2)); // centre of the box: over the foot of the solid's centre
85
+ const slab = box({ size: [size, size, size], at: origin, color, axes });
86
+ if (mat)
87
+ slab.faces.forEach((f) => (f.mat = mat));
88
+ const front = intersect(mesh, slab);
89
+ const back = subtract(mesh, slab);
90
+ if (!front.faces.length || !back.faces.length)
91
+ throw new Error('the plane does not go through the object');
92
+ return { front: splitParts(front), back: splitParts(back) };
93
+ };
94
+ /** Where the plane meets the faces of the mesh: line segments for the preview of a cut. */
95
+ export const sectionSegments = (mesh, plane) => {
96
+ const n = norm(plane.n);
97
+ const out = [];
98
+ const eps = 1e-9 * Math.max(bounds(mesh).radius, 1);
99
+ const d = mesh.positions.map((p) => dot(sub(p, plane.point), n));
100
+ for (const f of mesh.faces) {
101
+ const hits = [];
102
+ f.v.forEach((i, k) => {
103
+ const j = f.v[(k + 1) % f.v.length];
104
+ const a = d[i];
105
+ const b = d[j];
106
+ if (Math.abs(a) <= eps && Math.abs(b) <= eps)
107
+ return; // an edge lying in the plane is the cut's boundary: shown from the other face
108
+ if ((a < -eps && b > eps) || (a > eps && b < -eps)) {
109
+ const t = a / (a - b);
110
+ hits.push(add(mesh.positions[i], mul(sub(mesh.positions[j], mesh.positions[i]), t)));
111
+ }
112
+ else if (Math.abs(a) <= eps)
113
+ hits.push(mesh.positions[i]);
114
+ });
115
+ // a convex face meets a plane in at most two points
116
+ const uniq = hits.filter((p, k) => hits.findIndex((q) => len(sub(p, q)) <= eps * 10) === k);
117
+ if (uniq.length === 2)
118
+ out.push([uniq[0], uniq[1]]);
119
+ }
120
+ return out;
121
+ };
package/dist/svg.js CHANGED
@@ -208,7 +208,7 @@ const panel = (mesh, view, o, x = 0, y = 0) => {
208
208
  return [
209
209
  `<polyline data-mark="${esc(m.name ?? 'mark')}" points="${pts.map((p) => `${num(p.x)},${num(p.y)}`).join(' ')}" ` +
210
210
  `fill="none" stroke="${m.color ?? '#0b7285'}" stroke-width="${m.width ?? 2}" ` +
211
- `stroke-linecap="round" stroke-linejoin="round"/>`,
211
+ `stroke-linecap="round" stroke-linejoin="round"${m.opacity !== undefined && m.opacity < 1 ? ` stroke-opacity="${m.opacity}"` : ''}/>`,
212
212
  ];
213
213
  }));
214
214
  return [
package/dist/text.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { Mesh, Vec3 } from './types.js';
2
+ type Pt = [number, number];
3
+ /** The font: x 0..2, y 0..4 (the centre line of the strokes); accents rise above 4. */
4
+ export declare const GLYPHS: Record<string, Pt[][]>;
5
+ /** Grid units: the centre line is 4 high, the stroke 1: the letter is 5 high, so the stroke is 20 % of it. */
6
+ export declare const STROKE = 0.2;
7
+ export interface TextFrame {
8
+ /** The lower left corner of the first letter. */
9
+ origin: Vec3;
10
+ /** The reading direction, the up direction and the outward normal of the face (a right-handed frame: right × up = normal). */
11
+ right: Vec3;
12
+ up: Vec3;
13
+ normal: Vec3;
14
+ /** The letter height: also the face height it was taken from. */
15
+ height: number;
16
+ }
17
+ /** The frame of a face: it starts in the lower left corner (up = world up on the face; on a floor or ceiling: away from the viewer), and its height is the face's height. */
18
+ export declare const faceFrame: (mesh: Mesh, face: number) => TextFrame;
19
+ export interface TextOptions {
20
+ mat?: string;
21
+ color?: string;
22
+ }
23
+ /** The text as one mesh: every stroke a bar of stroke × stroke section standing out of the face by the stroke width. */
24
+ export declare const textMesh: (text: string, frame: TextFrame, { mat, color }?: TextOptions) => Mesh;
25
+ export {};
package/dist/text.js ADDED
@@ -0,0 +1,225 @@
1
+ // Text as a model: a minimalist, blocky stroke font drawn on a 2 x 4 grid, every stroke a straight wooden wedge-simple bar.
2
+ // The stroke is 20 % of the letter height (and so is the depth the letters stand out from the face).
3
+ // Glyphs are data: lists of poly-lines; a poly-line of one point is a dot. Unknown characters are skipped, lower case is upper case.
4
+ import { add, cross, dot, norm, scale, sub } from './vec.js';
5
+ import { box, faceNormal, merge } from './mesh.js';
6
+ import { health, union } from './csg.js';
7
+ const G = (...lines) => lines;
8
+ const O_ = [[0, 0], [0, 4], [2, 4], [2, 0], [0, 0]];
9
+ /** The font: x 0..2, y 0..4 (the centre line of the strokes); accents rise above 4. */
10
+ export const GLYPHS = {
11
+ A: G([[0, 0], [0, 4], [2, 4], [2, 0]], [[0, 2], [2, 2]]),
12
+ B: G(O_, [[0, 2], [2, 2]]),
13
+ C: G([[2, 4], [0, 4], [0, 0], [2, 0]]),
14
+ D: G([[0, 0], [0, 4], [1, 4], [2, 3], [2, 1], [1, 0], [0, 0]]),
15
+ E: G([[2, 4], [0, 4], [0, 0], [2, 0]], [[0, 2], [1.5, 2]]),
16
+ F: G([[2, 4], [0, 4], [0, 0]], [[0, 2], [1.5, 2]]),
17
+ G: G([[2, 4], [0, 4], [0, 0], [2, 0], [2, 2], [1, 2]]),
18
+ H: G([[0, 0], [0, 4]], [[2, 0], [2, 4]], [[0, 2], [2, 2]]),
19
+ I: G([[0, 4], [2, 4]], [[1, 4], [1, 0]], [[0, 0], [2, 0]]),
20
+ J: G([[2, 4], [2, 0], [0, 0], [0, 1]]),
21
+ K: G([[0, 0], [0, 4]], [[2, 4], [0, 2], [2, 0]]),
22
+ L: G([[0, 4], [0, 0], [2, 0]]),
23
+ M: G([[0, 0], [0, 4], [1, 2], [2, 4], [2, 0]]),
24
+ N: G([[0, 0], [0, 4], [2, 0], [2, 4]]),
25
+ O: G(O_),
26
+ P: G([[0, 0], [0, 4], [2, 4], [2, 2], [0, 2]]),
27
+ Q: G(O_, [[1, 1], [2, 0]]),
28
+ R: G([[0, 0], [0, 4], [2, 4], [2, 2], [0, 2]], [[1, 2], [2, 0]]),
29
+ S: G([[2, 4], [0, 4], [0, 2], [2, 2], [2, 0], [0, 0]]),
30
+ T: G([[0, 4], [2, 4]], [[1, 4], [1, 0]]),
31
+ U: G([[0, 4], [0, 0], [2, 0], [2, 4]]),
32
+ V: G([[0, 4], [1, 0], [2, 4]]),
33
+ W: G([[0, 4], [0.5, 0], [1, 2], [1.5, 0], [2, 4]]),
34
+ X: G([[0, 4], [2, 0]], [[0, 0], [2, 4]]),
35
+ Y: G([[0, 4], [1, 2], [2, 4]], [[1, 2], [1, 0]]),
36
+ Z: G([[0, 4], [2, 4], [0, 0], [2, 0]]),
37
+ '0': G(O_, [[0, 0], [2, 4]]),
38
+ '1': G([[0, 3], [1, 4], [1, 0]]),
39
+ '2': G([[0, 4], [2, 4], [2, 2], [0, 2], [0, 0], [2, 0]]),
40
+ '3': G([[0, 4], [2, 4], [2, 0], [0, 0]], [[0, 2], [2, 2]]),
41
+ '4': G([[0, 4], [0, 2], [2, 2]], [[2, 4], [2, 0]]),
42
+ '5': G([[2, 4], [0, 4], [0, 2], [2, 2], [2, 0], [0, 0]]),
43
+ '6': G([[2, 4], [0, 4], [0, 0], [2, 0], [2, 2], [0, 2]]),
44
+ '7': G([[0, 4], [2, 4], [0, 0]]),
45
+ '8': G(O_, [[0, 2], [2, 2]]),
46
+ '9': G([[2, 2], [0, 2], [0, 4], [2, 4], [2, 0], [0, 0]]),
47
+ '.': G([[1, 0]]),
48
+ ',': G([[1, 0], [0.5, -1]]),
49
+ ':': G([[1, 0]], [[1, 2.5]]),
50
+ '!': G([[1, 4], [1, 1.5]], [[1, 0]]),
51
+ '?': G([[0, 4], [2, 4], [2, 2], [1, 2], [1, 1]], [[1, 0]]),
52
+ '-': G([[0, 2], [2, 2]]),
53
+ _: G([[0, 0], [2, 0]]),
54
+ '+': G([[0, 2], [2, 2]], [[1, 3], [1, 1]]),
55
+ '/': G([[0, 0], [2, 4]]),
56
+ "'": G([[1, 4], [1, 3]]),
57
+ ' ': [],
58
+ };
59
+ // Hungarian (and other) accents: a letter plus a mark above it
60
+ const ACUTE = G([[0.7, 5.4], [1.3, 6.4]]);
61
+ const DOUBLE = G([[0, 5.4], [0.6, 6.4]], [[1.4, 5.4], [2, 6.4]]);
62
+ const DOTS = G([[0, 5.6]], [[2, 5.6]]);
63
+ const ACCENTS = {
64
+ Á: ['A', ACUTE], É: ['E', ACUTE], Í: ['I', ACUTE], Ó: ['O', ACUTE], Ú: ['U', ACUTE],
65
+ Ö: ['O', DOTS], Ü: ['U', DOTS], Ő: ['O', DOUBLE], Ű: ['U', DOUBLE],
66
+ };
67
+ /** Grid units: the centre line is 4 high, the stroke 1: the letter is 5 high, so the stroke is 20 % of it. */
68
+ export const STROKE = 0.2;
69
+ const CELL_H = 5;
70
+ const ADVANCE = 4; // 3 wide letter + 1 gap
71
+ const SPACE = 3;
72
+ /** The frame of a face: it starts in the lower left corner (up = world up on the face; on a floor or ceiling: away from the viewer), and its height is the face's height. */
73
+ export const faceFrame = (mesh, face) => {
74
+ const f = mesh.faces[face];
75
+ const n = norm(faceNormal(f.v.map((i) => mesh.positions[i])));
76
+ let up = sub([0, 1, 0], scale(n, dot([0, 1, 0], n)));
77
+ if (Math.hypot(...up) < 1e-3)
78
+ up = sub([0, 0, -1], scale(n, dot([0, 0, -1], n)));
79
+ up = norm(up);
80
+ const right = norm(cross(up, n));
81
+ let lo = Infinity, hi = -Infinity, left = Infinity;
82
+ for (const i of f.v) {
83
+ const p = mesh.positions[i];
84
+ const u = dot(p, up);
85
+ lo = Math.min(lo, u);
86
+ hi = Math.max(hi, u);
87
+ left = Math.min(left, dot(p, right));
88
+ }
89
+ const p0 = mesh.positions[f.v[0]];
90
+ // the point of the face plane with the lowest up and the lowest right
91
+ const origin = add(add(scale(right, left), scale(up, lo)), scale(n, dot(p0, n)));
92
+ return { origin, right, up, normal: n, height: Math.max(1e-6, hi - lo) };
93
+ };
94
+ const strokesOf = (ch) => {
95
+ const up = ch.toUpperCase();
96
+ const acc = ACCENTS[up];
97
+ if (acc)
98
+ return [{ at: 0, lines: GLYPHS[acc[0]] }, { at: 0, lines: acc[1] }];
99
+ const g = GLYPHS[up];
100
+ return g ? [{ at: 0, lines: g }] : [];
101
+ };
102
+ /** Faces that lie in one plane and touch along an edge become one face (the boolean cuts the big sides into pieces; a letter wants plain sides). */
103
+ const mergeFlat = (mesh) => {
104
+ const P = mesh.positions;
105
+ let faces = mesh.faces.map((f) => ({ ...f, n: norm(faceNormal(f.v.map((i) => P[i]))) }));
106
+ const tryMerge = (a, b) => {
107
+ if (a.mat !== b.mat || a.color !== b.color || dot(a.n, b.n) < 1 - 1e-9)
108
+ return null;
109
+ const bAt = new Map();
110
+ b.v.forEach((v, i) => bAt.set(`${v},${b.v[(i + 1) % b.v.length]}`, i));
111
+ let shared = 0;
112
+ let start = -1;
113
+ a.v.forEach((u, i) => {
114
+ const w = a.v[(i + 1) % a.v.length];
115
+ if (bAt.has(`${w},${u}`)) {
116
+ shared++;
117
+ start = i;
118
+ }
119
+ });
120
+ if (!shared)
121
+ return null;
122
+ // the shared edge u -> v of a (v -> u in b): a from v round to u, then b from u round to v
123
+ const u = a.v[start];
124
+ const v = a.v[(start + 1) % a.v.length];
125
+ const ai = (start + 1) % a.v.length;
126
+ const A = a.v.map((_, k) => a.v[(ai + k) % a.v.length]); // v ... u
127
+ const bi = bAt.get(`${v},${u}`) + 1; // u in b
128
+ const B = b.v.map((_, k) => b.v[(bi + k) % b.v.length]); // u ... v
129
+ const out = [...A, ...B.slice(1, -1)];
130
+ // spikes: a vertex between two copies of the same neighbour (several shared edges in a row)
131
+ for (let again = true; again && out.length > 2;) {
132
+ again = false;
133
+ for (let i = 0; i < out.length; i++) {
134
+ if (out[(i + out.length - 1) % out.length] === out[(i + 1) % out.length]) {
135
+ out.splice(i, 1);
136
+ out.splice(i % out.length, 1);
137
+ again = true;
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ if (out.length !== a.v.length + b.v.length - 2 * shared || new Set(out).size !== out.length)
143
+ return null;
144
+ // the engine's faces are convex (picking and painting assume it): a merge that would bend a face is not made
145
+ for (let i = 0; i < out.length; i++) {
146
+ const p0 = P[out[i]];
147
+ const p1 = P[out[(i + 1) % out.length]];
148
+ const p2 = P[out[(i + 2) % out.length]];
149
+ if (dot(cross(sub(p1, p0), sub(p2, p1)), a.n) < -1e-9)
150
+ return null;
151
+ }
152
+ return out;
153
+ };
154
+ for (let again = true; again;) {
155
+ again = false;
156
+ outer: for (let i = 0; i < faces.length; i++) {
157
+ for (let j = i + 1; j < faces.length; j++) {
158
+ const m = tryMerge(faces[i], faces[j]);
159
+ if (!m)
160
+ continue;
161
+ faces = faces.filter((_, k) => k !== j);
162
+ faces[i] = { ...faces[i], v: m };
163
+ again = true;
164
+ break outer;
165
+ }
166
+ }
167
+ }
168
+ return { positions: P, faces: faces.map(({ n: _n, ...f }) => f) };
169
+ };
170
+ /** The bars of one letter fused into plain closed solids (no bar showing through another); if the fusing fails, the loose bars. */
171
+ const solid = (bars) => {
172
+ if (bars.length < 2)
173
+ return merge(...bars);
174
+ try {
175
+ let m = bars[0];
176
+ for (const b of bars.slice(1))
177
+ m = union(m, b);
178
+ const h = health(m);
179
+ if (h.allSolid && h.skippedFaces === 0)
180
+ return mergeFlat(m);
181
+ }
182
+ catch {
183
+ /* fall through */
184
+ }
185
+ return merge(...bars);
186
+ };
187
+ /** The text as one mesh: every stroke a bar of stroke × stroke section standing out of the face by the stroke width. */
188
+ export const textMesh = (text, frame, { mat, color = '#c8c8c8' } = {}) => {
189
+ const s = frame.height / CELL_H; // world units per grid unit
190
+ const letters = [];
191
+ let x = 0;
192
+ const half = 0.5; // the outer edge of the letter starts half a stroke before the centre line
193
+ for (const ch of text) {
194
+ if (ch === ' ') {
195
+ x += SPACE;
196
+ continue;
197
+ }
198
+ const bars = [];
199
+ for (const part of strokesOf(ch)) {
200
+ for (const line of part.lines) {
201
+ const pts = line.length === 1 ? [line[0], line[0]] : line;
202
+ for (let i = 0; i + 1 < pts.length; i++) {
203
+ const [ax, ay] = pts[i];
204
+ const [bx, by] = pts[i + 1];
205
+ const dx = bx - ax;
206
+ const dy = by - ay;
207
+ const len = Math.hypot(dx, dy);
208
+ const dir = len ? add(scale(frame.right, dx / len), scale(frame.up, dy / len)) : frame.right;
209
+ const ez = frame.normal;
210
+ const ey = cross(ez, dir);
211
+ const cx = x + half + (ax + bx) / 2;
212
+ const cy = half + (ay + by) / 2;
213
+ const at = add(add(add(frame.origin, scale(frame.right, cx * s)), scale(frame.up, cy * s)), scale(ez, (1 * s) / 2));
214
+ const bar = box({ size: [(len + 1) * s, s, s], at, axes: [dir, ey, ez], color });
215
+ if (mat)
216
+ bar.faces.forEach((f) => (f.mat = mat));
217
+ bars.push(bar);
218
+ }
219
+ }
220
+ }
221
+ letters.push(solid(bars));
222
+ x += ADVANCE;
223
+ }
224
+ return merge(...letters);
225
+ };
@@ -0,0 +1,4 @@
1
+ import type { Vec3 } from './types.js';
2
+ export type Tri = [number, number, number];
3
+ /** Indices into `pts`, three per triangle, `pts.length - 2` triangles. */
4
+ export declare const triangulate: (pts: Vec3[]) => Tri[];