3draft 26.11.11010 → 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.
package/APP.md CHANGED
@@ -218,3 +218,21 @@ Every console group hangs from the same top line; `view` is first, so it stays p
218
218
  `edges` (the E switch) works in the GPU views too: `render({ edges: true })` draws the face edges (`buildEdges` / `sceneEdges` in `src/gpu.ts`, each vertex pair once, cached per part, none above 300 000 faces) as 1-pixel `line-list` / `gl.LINES` after the solid faces, dark at 40% alpha, depth tested with a hair of nudge towards the eye so a line wins against the face it lies on.
219
219
 
220
220
  With the palette open (`body.pal`, desktop) the view, the top line and the console get narrower by the width of the panel (`--palR`) instead of staying behind it; on the phone layout the panel still covers the view.
221
+
222
+ ## Screen material
223
+
224
+ A material with an `url` (the palette's url field; the built-in `monitor` has one) is a screen: on every four-cornered face made of it the page runs in an iframe. The iframe is a DOM layer over the picture (`#screens`), warped onto the face with a CSS `matrix3d` (a perspective map of the face's four corners, up is the world up, the face's own up when it lies flat). Limits: it is not part of the 3D picture (no depth against other objects, so something standing in front does not hide it, and no light), it does not show in a VR view, faces seen from behind or behind the eye are hidden, at most 6 screens at once, the pointer goes through it until you take control of it (below), and sites that forbid framing (X-Frame-Options, CSP) stay blank. Only http(s) addresses are taken. Faces with another number of corners are plain surfaces. **Control:** double-click a screen and the page gets the pointer and the keys; the camera stays where it is. A `network` module appears after `sys` in the console: `exit`, an on / off switch (off blanks that screen, the monitor stays dark) and the page address, a link that opens it in a new tab. A press outside the page, `exit` or Esc (when the focus is not inside the page) gives the control back to the 3D view.
225
+
226
+ ## Saving scenes
227
+
228
+ The `scene` panel has one list for adding a scene: the examples, and under `saved here` what was saved in this browser. Choosing an entry loads it: with the `merge` switch on (the default, remembered) it is added next to what is there, with `merge` off the scene is cleared first and the loaded one stands alone (U twice brings the old one back: the load, then the clear). It holds for the examples and `file ↑` too. Below the list: a name box and **save** (stores the whole scene under that name in this browser's local storage; an empty name gets a dated one), **file ↓** (downloads it as `name.json`), **file ↑** (adds a scene from a `.json` file) and **✕** (forgets the saved scene whose name is in the box). A scene file is `{ format: "3draft-scene", version: 1, name, palette: [the materials its faces use], objects: [{ name, mesh, wire? }] }`, the same shape as an example scene. Materials the palette lacks come along (screens keep their url). Not saved: the camera, the point clouds and flying prisms; instanced objects (the stars) are saved as the meshes they have at that moment. Local storage is per browser and per address, and about 5 MB; for anything bigger use the file.
229
+
230
+ **View in the scene file.** A scene is saved with its view: `view: { edges, light, glow, smooth, studio, cam }` (the switches and the camera). It is applied when the scene stands alone (`merge` off, or nothing was in the scene); a merged scene keeps the view you have. The edges start off.
231
+
232
+ **`?ply=name`** opens the scene saved here under that name at once, alone, with its view, in zen. Without such a saved scene the page starts as usual.
233
+
234
+ ## Text
235
+ Edit mode, a face selected (3), **T**: asks for the words and makes a new model out of them (`text-xxxx`), standing out of the face in the active material. The face gives the lower-left start corner, the reading direction (world up on the face; on a floor: away from the viewer) and the letter height. The font is `src/text.ts`: data, poly-lines on a 2 × 4 grid, every stroke a bar; stroke and depth are 20 % of the letter height. The model carries `text` and `textFrame` (saved in scenes), and T on a text model fixes the words in place. The engine exports `textMesh`, `faceFrame`, `GLYPHS`.
236
+
237
+ ## Lathe
238
+ Edit mode, a face selected, **6**: the face's plane (its `faceFrame`) is the drawing board, its bottom line the axis. V puts contour points, Enter asks for the segments (>= 4, Enter on its own = 7) and `lathe(contour, frame, segments, { mat, color })` (`src/lathe.ts`) turns the `[u, r]` contour round the axis into one closed mesh with outward faces; a tip on the axis is a point, an end off it gets a disc. The result is a new model.
package/dist/index.d.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  export declare const name: "3draft";
2
- export declare const version = "26.11.11010";
2
+ export declare const version = "26.11.11032";
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
7
  export { boolean, union, subtract, intersect, health, splitParts } from './csg.js';
8
+ export { lathe } from './lathe.js';
9
+ export type { LatheFrame } from './lathe.js';
10
+ export { textMesh, faceFrame, GLYPHS, STROKE as TEXT_STROKE } from './text.js';
11
+ export type { TextFrame, TextOptions } from './text.js';
8
12
  export { saw, planeThroughLine, planeThroughTriangle, sectionSegments } from './saw.js';
9
13
  export type { CutPlane, Sawn } from './saw.js';
10
14
  export type { BooleanOp, Health, Part } from './csg.js';
package/dist/index.js CHANGED
@@ -4,10 +4,12 @@
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.11010';
7
+ export const version = '26.11.11032';
8
8
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
9
9
  export { validate } from './check.js';
10
10
  export { boolean, union, subtract, intersect, health, splitParts } from './csg.js';
11
+ export { lathe } from './lathe.js';
12
+ export { textMesh, faceFrame, GLYPHS, STROKE as TEXT_STROKE } from './text.js';
11
13
  export { saw, planeThroughLine, planeThroughTriangle, sectionSegments } from './saw.js';
12
14
  export { extrudeFace, moveVertices } from './edit.js';
13
15
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
@@ -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/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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3draft",
3
- "version": "26.11.11010",
3
+ "version": "26.11.11032",
4
4
  "description": "Minimalist AI first 3D engine: palette-texture, WASM core, WebGPU/WebGL2 renderer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",