@logomarks/logo3d 0.0.0-stage → 0.1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -2
  4. package/assets/hdri/kloofendal_48d_partly_cloudy_puresky_1k.hdr +0 -0
  5. package/assets/hdri/studio_small_09_1k.hdr +0 -0
  6. package/package.json +82 -4
  7. package/src/export/glb.d.ts +5 -0
  8. package/src/export/glb.js +28 -0
  9. package/src/export/still.d.ts +23 -0
  10. package/src/export/still.js +144 -0
  11. package/src/geometry/buffer.js +102 -0
  12. package/src/geometry/caps.js +53 -0
  13. package/src/geometry/check.js +86 -0
  14. package/src/geometry/index.d.ts +114 -0
  15. package/src/geometry/index.js +112 -0
  16. package/src/geometry/inset.js +339 -0
  17. package/src/geometry/profiles.js +126 -0
  18. package/src/geometry/sides.js +86 -0
  19. package/src/index.d.ts +17 -0
  20. package/src/index.js +19 -0
  21. package/src/materials/factory.d.ts +11 -0
  22. package/src/materials/factory.js +112 -0
  23. package/src/materials/presets.d.ts +34 -0
  24. package/src/materials/presets.js +79 -0
  25. package/src/motion/index.d.ts +23 -0
  26. package/src/motion/index.js +56 -0
  27. package/src/scene/backdrop.d.ts +20 -0
  28. package/src/scene/backdrop.js +128 -0
  29. package/src/scene/camera.d.ts +10 -0
  30. package/src/scene/camera.js +51 -0
  31. package/src/scene/environments.d.ts +22 -0
  32. package/src/scene/environments.js +94 -0
  33. package/src/scene/studio.d.ts +12 -0
  34. package/src/scene/studio.js +81 -0
  35. package/src/scene/view.d.ts +69 -0
  36. package/src/scene/view.js +273 -0
  37. package/src/shape/index.d.ts +80 -0
  38. package/src/shape/index.js +32 -0
  39. package/src/shape/layers.js +142 -0
  40. package/src/shape/matrix.js +88 -0
  41. package/src/shape/normalise.js +92 -0
  42. package/src/shape/simplify.js +95 -0
  43. package/src/shape/svg.js +196 -0
  44. package/src/shape/thinnest.js +77 -0
  45. package/src/three/geometry.d.ts +5 -0
  46. package/src/three/geometry.js +25 -0
  47. package/src/three/index.d.ts +11 -0
  48. package/src/three/index.js +15 -0
  49. package/src/worker/build.d.ts +6 -0
  50. package/src/worker/build.js +37 -0
  51. package/src/worker/client.d.ts +29 -0
  52. package/src/worker/client.js +83 -0
  53. package/src/worker/worker.d.ts +2 -0
  54. package/src/worker/worker.js +23 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape/normalise
3
+ *
4
+ * Colour layers to the units the geometry works in: y up, centred on the origin, the longest side 1. Every ring is
5
+ * open (no repeated closing point), simplified, and wound so the solid is on its left: outlines anticlockwise, holes
6
+ * clockwise. Slivers are dropped.
7
+ */
8
+
9
+ import { signedArea, simplifyRing } from './simplify.js';
10
+
11
+ /**
12
+ * The box round every point of every layer.
13
+ *
14
+ * @param {{ shape: import('@logomarks/core/geometry').Shape }[]} layers The layers (y down, file units).
15
+ * @returns {[number, number, number, number]} `[x0, y0, x1, y1]`.
16
+ */
17
+ export function boundsOf(layers) {
18
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
19
+ for (const l of layers) for (const pg of l.shape) for (const r of pg) for (const [x, y] of r) {
20
+ if (x < x0) x0 = x;
21
+ if (x > x1) x1 = x;
22
+ if (y < y0) y0 = y;
23
+ if (y > y1) y1 = y;
24
+ }
25
+ return [x0, y0, x1, y1];
26
+ }
27
+
28
+ /**
29
+ * @typedef {[number, number][]} Ring2
30
+ * @typedef {Ring2[]} Polygon2 An outline, then its holes.
31
+ * @typedef {object} NormalLayer
32
+ * @property {string | null} color The layer's colour, or null for the ink.
33
+ * @property {Polygon2[]} polygons Its polygons.
34
+ */
35
+
36
+ /**
37
+ * Layers normalised.
38
+ *
39
+ * @param {import('./layers.js').Layer[]} layers The layers (y down, file units).
40
+ * @param {{ tolerance?: number }} [o] `tolerance`: the simplification, as a share of the longest side (1/1200).
41
+ * @returns {{ layers: NormalLayer[], width: number, height: number, scale: number }} The layers, their size (the
42
+ * longest side 1) and the scale from file units.
43
+ */
44
+ export function normalise(layers, { tolerance = 1 / 1200 } = {}) {
45
+ const [x0, y0, x1, y1] = boundsOf(layers);
46
+ const span = Math.max(x1 - x0, y1 - y0);
47
+ if (!Number.isFinite(span) || span <= 0) return { layers: [], width: 0, height: 0, scale: 1 };
48
+ const s = 1 / span, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
49
+ const minArea = (tolerance * 4) ** 2;
50
+ /** @type {NormalLayer[]} */
51
+ const out = [];
52
+ for (const l of layers) {
53
+ /** @type {Polygon2[]} */
54
+ const polygons = [];
55
+ for (const pg of l.shape) {
56
+ /** @type {Ring2[]} */
57
+ const rings = [];
58
+ pg.forEach((ring, i) => {
59
+ /** @type {Ring2} */
60
+ let r = ring.map(([x, y]) => [(x - cx) * s, (cy - y) * s]);
61
+ r = simplifyRing(r, tolerance);
62
+ if (r.length < 3) return;
63
+ const a = signedArea(r);
64
+ if (Math.abs(a) < minArea) return;
65
+ // the outline (the first ring) anticlockwise, holes clockwise
66
+ if ((i === 0) !== a > 0) r.reverse();
67
+ rings.push(r);
68
+ });
69
+ if (rings.length && signedArea(rings[0]) > 0 && pg.length) polygons.push(rings);
70
+ }
71
+ if (polygons.length) out.push({ color: l.color, polygons });
72
+ }
73
+ return recentre(out, s);
74
+ }
75
+
76
+ /**
77
+ * Layers moved and scaled once more after simplifying (which can move an extreme point a hair), so they are exactly
78
+ * centred with the longest side exactly 1.
79
+ *
80
+ * @param {NormalLayer[]} layers The layers (changed in place).
81
+ * @param {number} s The scale so far.
82
+ * @returns {{ layers: NormalLayer[], width: number, height: number, scale: number }} The layers and their size.
83
+ */
84
+ function recentre(layers, s) {
85
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
86
+ for (const l of layers) for (const pg of l.polygons) for (const [x, y] of pg[0]) (x0 = Math.min(x0, x)), (x1 = Math.max(x1, x)), (y0 = Math.min(y0, y)), (y1 = Math.max(y1, y));
87
+ const span = Math.max(x1 - x0, y1 - y0);
88
+ if (!(span > 0)) return { layers: [], width: 0, height: 0, scale: s };
89
+ const k = 1 / span, cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
90
+ for (const l of layers) for (const pg of l.polygons) for (const r of pg) for (const p of r) (p[0] = (p[0] - cx) * k), (p[1] = (p[1] - cy) * k);
91
+ return { layers, width: (x1 - x0) * k, height: (y1 - y0) * k, scale: s * k };
92
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape/simplify
3
+ *
4
+ * Ring clean-up before extrusion: the shoelace area, and Douglas–Peucker simplification of a closed ring, so long
5
+ * straight runs become single edges while curves keep their sampling.
6
+ */
7
+
8
+ /**
9
+ * A ring's signed area, positive anticlockwise with y up.
10
+ *
11
+ * @param {readonly (readonly number[])[]} r The ring (open: the last point is not the first).
12
+ * @returns {number} The area.
13
+ */
14
+ export function signedArea(r) {
15
+ let s = 0;
16
+ for (let i = 0, j = r.length - 1; i < r.length; j = i++) s += (r[j][0] - r[i][0]) * (r[j][1] + r[i][1]);
17
+ return s / 2;
18
+ }
19
+
20
+ /**
21
+ * The squared distance from p to the segment ab.
22
+ *
23
+ * @param {readonly number[]} p The point.
24
+ * @param {readonly number[]} a The segment's start.
25
+ * @param {readonly number[]} b Its end.
26
+ * @returns {number} The squared distance.
27
+ */
28
+ export function segDist2(p, a, b) {
29
+ const dx = b[0] - a[0], dy = b[1] - a[1];
30
+ const l2 = dx * dx + dy * dy;
31
+ const t = l2 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2)) : 0;
32
+ const x = a[0] + t * dx - p[0], y = a[1] + t * dy - p[1];
33
+ return x * x + y * y;
34
+ }
35
+
36
+ /**
37
+ * Douglas–Peucker on an open polyline (its ends kept).
38
+ *
39
+ * @param {[number, number][]} pts The polyline.
40
+ * @param {number} tol The tolerance.
41
+ * @returns {[number, number][]} The kept points.
42
+ */
43
+ function dp(pts, tol) {
44
+ if (pts.length < 3) return pts.slice();
45
+ const keep = new Uint8Array(pts.length);
46
+ keep[0] = keep[pts.length - 1] = 1;
47
+ const stack = [[0, pts.length - 1]];
48
+ const t2 = tol * tol;
49
+ while (stack.length) {
50
+ const [a, b] = /** @type {number[]} */ (stack.pop());
51
+ let far = -1, fd = t2;
52
+ for (let i = a + 1; i < b; i++) {
53
+ const d = segDist2(pts[i], pts[a], pts[b]);
54
+ if (d > fd) (fd = d), (far = i);
55
+ }
56
+ if (far > 0) {
57
+ keep[far] = 1;
58
+ stack.push([a, far], [far, b]);
59
+ }
60
+ }
61
+ return pts.filter((_, i) => keep[i]);
62
+ }
63
+
64
+ /**
65
+ * A closed ring simplified to a tolerance: Douglas–Peucker, then any step shorter than the tolerance dropped.
66
+ *
67
+ * @param {[number, number][]} ring The ring (open).
68
+ * @param {number} tol The tolerance, in the ring's units.
69
+ * @returns {[number, number][]} The simplified ring (open).
70
+ */
71
+ export function simplifyRing(ring, tol) {
72
+ /** @type {[number, number][]} */
73
+ const pts = [];
74
+ for (const p of ring) {
75
+ const q = pts[pts.length - 1];
76
+ if (!q || Math.hypot(p[0] - q[0], p[1] - q[1]) > tol * 1e-3) pts.push(p);
77
+ }
78
+ while (pts.length > 1 && Math.hypot(pts[0][0] - pts[pts.length - 1][0], pts[0][1] - pts[pts.length - 1][1]) <= tol * 1e-3) pts.pop();
79
+ if (pts.length < 4 || !tol) return pts;
80
+ let far = 0, fd = -1;
81
+ pts.forEach((p, i) => {
82
+ const d = Math.hypot(p[0] - pts[0][0], p[1] - pts[0][1]);
83
+ if (d > fd) (fd = d), (far = i);
84
+ });
85
+ const kept = [...dp(pts.slice(0, far + 1), tol).slice(0, -1), ...dp([...pts.slice(far), pts[0]], tol).slice(0, -1)];
86
+ // steps shorter than the tolerance (a stray notch) go: each point that close to the last kept one is dropped
87
+ /** @type {[number, number][]} */
88
+ const out = [];
89
+ for (const p of kept) {
90
+ const q = out[out.length - 1];
91
+ if (!q || Math.hypot(p[0] - q[0], p[1] - q[1]) >= tol) out.push(p);
92
+ }
93
+ while (out.length > 3 && Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) < tol) out.pop();
94
+ return out.length >= 3 ? out : kept;
95
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape/svg
3
+ *
4
+ * Reads an SVG file's filled elements without a DOM (it runs in a worker and in Node): a small tag walker that keeps
5
+ * the element tree's transforms, fills and fill rules (attributes, inline `style`, and simple `.class` rules in a
6
+ * `<style>`), skips what never paints (`<defs>`, clip paths, masks, `display: none`, `fill: none`), and returns each
7
+ * filled `<path>`, `<rect>` (rounded too), `<circle>`, `<ellipse>`, `<polygon>` and `<polyline>` as rings in the
8
+ * file's user space, curves sampled by @logomarks/core.
9
+ */
10
+
11
+ import { parsePath, rings } from '@logomarks/core/svg';
12
+ import { IDENTITY, apply, multiply, parseTransform, scaleOf } from './matrix.js';
13
+
14
+ /** Elements whose content never paints. */
15
+ const SKIP = new Set(['defs', 'clippath', 'mask', 'symbol', 'pattern', 'marker', 'lineargradient', 'radialgradient', 'filter', 'title', 'desc', 'metadata', 'style', 'text', 'script', 'foreignobject']);
16
+ /** The filled elements read. */
17
+ const SHAPES = new Set(['path', 'rect', 'circle', 'ellipse', 'polygon', 'polyline']);
18
+
19
+ /**
20
+ * A tag's attributes.
21
+ *
22
+ * @param {string} text Everything between the tag's name and its `>`.
23
+ * @returns {Record<string, string>} The attributes by lower-case name.
24
+ */
25
+ export function attributes(text) {
26
+ /** @type {Record<string, string>} */
27
+ const out = {};
28
+ for (const [, name, dq, sq] of text.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[name.toLowerCase()] = dq ?? sq ?? '';
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * CSS declarations (`fill: #f00; fill-rule: evenodd`) as properties.
34
+ *
35
+ * @param {string | undefined} text The declarations.
36
+ * @returns {Record<string, string>} The properties by lower-case name.
37
+ */
38
+ export function declarations(text) {
39
+ /** @type {Record<string, string>} */
40
+ const out = {};
41
+ for (const part of (text ?? '').split(';')) {
42
+ const i = part.indexOf(':');
43
+ if (i > 0) out[part.slice(0, i).trim().toLowerCase()] = part.slice(i + 1).replace(/!important/i, '').trim();
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * The class rules in a file's `<style>` elements (`.a, .b { fill: … }`); other selectors are ignored.
50
+ *
51
+ * @param {string} svg The file.
52
+ * @returns {Map<string, Record<string, string>>} Each class's properties.
53
+ */
54
+ export function classRules(svg) {
55
+ /** @type {Map<string, Record<string, string>>} */
56
+ const out = new Map();
57
+ for (const [, css] of svg.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
58
+ for (const [, selectors, body] of css.replace(/<!\[CDATA\[|\]\]>/g, '').matchAll(/([^{}]+)\{([^}]*)\}/g)) {
59
+ const props = declarations(body);
60
+ for (const sel of selectors.split(',').map((s) => s.trim())) {
61
+ const m = /^\.([\w-]+)$/.exec(sel);
62
+ if (m) out.set(m[1], { ...out.get(m[1]), ...props });
63
+ }
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * A number attribute (lengths in user units; a `%` or unit suffix is dropped).
71
+ *
72
+ * @param {Record<string, string>} a The attributes.
73
+ * @param {string} name The attribute.
74
+ * @param {number} [fallback] When it is missing.
75
+ * @returns {number} Its value.
76
+ */
77
+ const num = (a, name, fallback = 0) => {
78
+ const v = parseFloat(a[name] ?? '');
79
+ return Number.isFinite(v) ? v : fallback;
80
+ };
81
+
82
+ /**
83
+ * A filled element's outline as path data.
84
+ *
85
+ * @param {string} name The element.
86
+ * @param {Record<string, string>} a Its attributes.
87
+ * @returns {string | undefined} Its path data.
88
+ */
89
+ export function outline(name, a) {
90
+ if (name === 'path') return a.d;
91
+ if (name === 'circle' || name === 'ellipse') {
92
+ const cx = num(a, 'cx'), cy = num(a, 'cy');
93
+ const rx = num(a, name === 'circle' ? 'r' : 'rx'), ry = num(a, name === 'circle' ? 'r' : 'ry');
94
+ if (rx <= 0 || ry <= 0) return undefined;
95
+ return `M${cx - rx} ${cy}A${rx} ${ry} 0 1 0 ${cx + rx} ${cy}A${rx} ${ry} 0 1 0 ${cx - rx} ${cy}Z`;
96
+ }
97
+ if (name === 'rect') {
98
+ const x = num(a, 'x'), y = num(a, 'y'), w = num(a, 'width'), h = num(a, 'height');
99
+ if (w <= 0 || h <= 0) return undefined;
100
+ let rx = num(a, 'rx', NaN), ry = num(a, 'ry', NaN);
101
+ if (Number.isNaN(rx)) rx = Number.isNaN(ry) ? 0 : ry;
102
+ if (Number.isNaN(ry)) ry = rx;
103
+ rx = Math.min(Math.max(rx, 0), w / 2);
104
+ ry = Math.min(Math.max(ry, 0), h / 2);
105
+ if (!rx || !ry) return `M${x} ${y}H${x + w}V${y + h}H${x}Z`;
106
+ const arc = (/** @type {number} */ ex, /** @type {number} */ ey) => `A${rx} ${ry} 0 0 1 ${ex} ${ey}`;
107
+ return `M${x + rx} ${y}H${x + w - rx}${arc(x + w, y + ry)}V${y + h - ry}${arc(x + w - rx, y + h)}H${x + rx}${arc(x, y + h - ry)}V${y + ry}${arc(x + rx, y)}Z`;
108
+ }
109
+ const points = a.points?.trim();
110
+ return points ? `M${points.replace(/\s+/g, ' ')}Z` : undefined;
111
+ }
112
+
113
+ /**
114
+ * @typedef {object} Paint
115
+ * @property {number[]} matrix The transform to the file's user space.
116
+ * @property {string | undefined} fill The fill, as written (`undefined`: inherited black).
117
+ * @property {string | undefined} rule The fill rule.
118
+ * @property {boolean} hidden Whether it is `display: none` or `visibility: hidden`.
119
+ * @property {boolean} skip Whether it is inside something that never paints.
120
+ */
121
+
122
+ /**
123
+ * One element's paint, from its parent's.
124
+ *
125
+ * @param {Paint} parent The parent's paint.
126
+ * @param {Record<string, string>} a The element's attributes.
127
+ * @param {Map<string, Record<string, string>>} classes The file's class rules.
128
+ * @param {string} name The element.
129
+ * @returns {Paint} Its own.
130
+ */
131
+ function paintOf(parent, a, classes, name) {
132
+ const props = { ...a };
133
+ for (const c of (a.class ?? '').split(/\s+/)) Object.assign(props, classes.get(c));
134
+ Object.assign(props, declarations(a.style));
135
+ return {
136
+ matrix: a.transform ? multiply(parent.matrix, parseTransform(a.transform)) : parent.matrix,
137
+ fill: props.fill && props.fill !== 'inherit' ? props.fill : parent.fill,
138
+ rule: props['fill-rule'] && props['fill-rule'] !== 'inherit' ? props['fill-rule'] : parent.rule,
139
+ hidden: parent.hidden || props.display === 'none' || props.visibility === 'hidden' || props.opacity === '0' || props['fill-opacity'] === '0',
140
+ skip: parent.skip || SKIP.has(name),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * @typedef {object} Element
146
+ * @property {import('@logomarks/core/geometry').Ring[]} rings Its rings in the file's user space.
147
+ * @property {boolean} evenodd Whether it fills even-odd.
148
+ * @property {string | undefined} fill Its fill as written (`currentColor`, a colour, a `url(#…)`), or undefined.
149
+ * @property {boolean} first Whether it is the first filled element (for dropping a background).
150
+ */
151
+
152
+ /**
153
+ * Every filled element of a file, in paint order, as rings in its user space.
154
+ *
155
+ * @param {string} svg The file's text.
156
+ * @param {{ step?: number }} [o] `step`: the longest chord along a curve, as a share of the file's size (1/340).
157
+ * @returns {{ elements: Element[], box: [number, number, number, number] | undefined }} The elements, and the viewBox.
158
+ */
159
+ export function readElements(svg, { step = 1 / 340 } = {}) {
160
+ const text = svg.replace(/<!--[\s\S]*?-->/g, '').replace(/<\?[\s\S]*?\?>/g, '').replace(/<!DOCTYPE[^>]*>/gi, '');
161
+ const classes = classRules(text);
162
+ const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']([^"']+)["']/i.exec(text)?.[1].trim().split(/[\s,]+/).map(Number);
163
+ const root = /<svg\b([^>]*)>/i.exec(text);
164
+ const ra = root ? attributes(root[1]) : {};
165
+ /** @type {[number, number, number, number] | undefined} */
166
+ const box = vb?.length === 4 && vb.every(Number.isFinite) ? [vb[0], vb[1], vb[2], vb[3]] : num(ra, 'width') && num(ra, 'height') ? [0, 0, num(ra, 'width'), num(ra, 'height')] : undefined;
167
+ const size = box ? Math.max(box[2], box[3]) : 512;
168
+ /** @type {Paint[]} */
169
+ const stack = [{ matrix: [...IDENTITY], fill: undefined, rule: undefined, hidden: false, skip: false }];
170
+ /** @type {Element[]} */
171
+ const elements = [];
172
+ for (const [, close, rawName, rest, selfClose] of text.matchAll(/<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g)) {
173
+ const name = rawName.toLowerCase().replace(/^svg:/, '');
174
+ if (close) {
175
+ if (stack.length > 1) stack.pop();
176
+ continue;
177
+ }
178
+ const a = attributes(rest);
179
+ const paint = paintOf(stack[stack.length - 1], a, classes, name);
180
+ // every open tag (a shape's too: <path …></path>) holds its paint until it closes
181
+ if (!selfClose) stack.push(paint);
182
+ if (!SHAPES.has(name) || paint.skip || paint.hidden || paint.fill === 'none' || paint.fill === 'transparent') continue;
183
+ const d = outline(name, a);
184
+ if (!d) continue;
185
+ let segs;
186
+ try {
187
+ segs = parsePath(d);
188
+ } catch {
189
+ continue;
190
+ }
191
+ const local = (size * step) / scaleOf(paint.matrix);
192
+ const rs = rings(segs, local).map((r) => r.map((p) => apply(paint.matrix, /** @type {[number, number]} */ (p))));
193
+ if (rs.length) elements.push({ rings: rs, evenodd: paint.rule === 'evenodd', fill: paint.fill, first: elements.length === 0 });
194
+ }
195
+ return { elements, box };
196
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape/thinnest
3
+ *
4
+ * How far a logo can be inset before it changes: the cap on its bevel, which is about half its thinnest stroke. Each
5
+ * layer is rasterised (@logomarks/core's scanline), every ink pixel's distance to the nearest empty one measured
6
+ * (core's distance transform), and the deepest inset found (a binary search) that keeps as many pieces and holes as
7
+ * the layer has (core's components): no stroke is cut through, no dot vanishes, no hole opens into another. Corners
8
+ * don't count against it, as they would for a measure of stroke widths.
9
+ */
10
+
11
+ import { components, distance, fill } from '@logomarks/core/raster';
12
+
13
+ /**
14
+ * A raster frame tight round a layer, three pixels of margin.
15
+ *
16
+ * @param {import('./normalise.js').NormalLayer} l The layer.
17
+ * @param {number} scale Pixels a unit.
18
+ * @returns {import('@logomarks/core/raster').RasterFrame} The frame.
19
+ */
20
+ function frameAround(l, scale) {
21
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
22
+ for (const pg of l.polygons) for (const [x, y] of pg[0]) (x0 = Math.min(x0, x)), (y0 = Math.min(y0, y)), (x1 = Math.max(x1, x)), (y1 = Math.max(y1, y));
23
+ const pad = 3 / scale;
24
+ return { x0: x0 - pad, y0: y0 - pad, scale, width: Math.ceil((x1 - x0 + 2 * pad) * scale), height: Math.ceil((y1 - y0 + 2 * pad) * scale) };
25
+ }
26
+
27
+ /**
28
+ * A raster's topology: its ink pieces and empty regions, counted.
29
+ *
30
+ * @param {import('@logomarks/core/raster').Bitmap} bm The raster.
31
+ * @returns {string} `"<ink>/<empty>"`.
32
+ */
33
+ export function topology(bm) {
34
+ let ink = 0, empty = 0;
35
+ for (const c of components(bm)) c.ink ? ink++ : empty++;
36
+ return `${ink}/${empty}`;
37
+ }
38
+
39
+ /**
40
+ * The deepest inset (in pixels) that keeps a raster's topology.
41
+ *
42
+ * @param {import('@logomarks/core/raster').Bitmap} bm The raster.
43
+ * @param {{ steps?: number }} [o] `steps`: the binary search's steps (12).
44
+ * @returns {number} The inset.
45
+ */
46
+ export function deepestInset(bm, { steps = 12 } = {}) {
47
+ const d = distance(bm, 1);
48
+ const same = topology(bm);
49
+ let lo = 0, hi = 0;
50
+ for (const v of d) if (v > hi) hi = v;
51
+ const eroded = { ...bm, data: new Uint8Array(bm.data.length) };
52
+ for (let s = 0; s < steps; s++) {
53
+ const t = (lo + hi) / 2;
54
+ for (let i = 0; i < d.length; i++) eroded.data[i] = d[i] > t ? 1 : 0;
55
+ if (topology(eroded) === same) lo = t;
56
+ else hi = t;
57
+ }
58
+ return lo;
59
+ }
60
+
61
+ /**
62
+ * About half the thinnest stroke of normalised layers (in their units: the longest side is 1): the deepest inset
63
+ * that keeps every layer's pieces and holes.
64
+ *
65
+ * @param {import('./normalise.js').NormalLayer[]} layers The layers.
66
+ * @param {{ resolution?: number }} [o] `resolution`: pixels a unit (448).
67
+ * @returns {number} The half-width.
68
+ */
69
+ export function halfThinnest(layers, { resolution = 448 } = {}) {
70
+ let best = Infinity;
71
+ for (const l of layers) {
72
+ const paths = l.polygons.map((pg) => ({ rings: pg, evenodd: true }));
73
+ const bm = fill(paths, frameAround(l, resolution));
74
+ best = Math.min(best, Math.max(0.5, deepestInset(bm)) / resolution);
75
+ }
76
+ return Number.isFinite(best) ? best : 0;
77
+ }
@@ -0,0 +1,5 @@
1
+ import type * as THREE from 'three';
2
+ import type { LogoGeometry } from '../geometry/index.js';
3
+
4
+ /** A BufferGeometry from a built geometry, its groups' material indices matching `logoMaterials`. */
5
+ export function toBufferGeometry(three: typeof THREE, g: LogoGeometry): THREE.BufferGeometry;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @module @logomarks/logo3d/three/geometry
3
+ *
4
+ * The geometry's typed arrays as a three.js BufferGeometry: positions, normals, UVs and indices as attributes, each
5
+ * layer's face, side and back as a draw group whose material index matches `logoMaterials`.
6
+ */
7
+
8
+ /**
9
+ * A BufferGeometry from a built geometry.
10
+ *
11
+ * @param {typeof import('three')} THREE three.js.
12
+ * @param {import('../geometry/index.js').LogoGeometry} g The geometry (`buildGeometry`, or a worker's reply).
13
+ * @returns {import('three').BufferGeometry} The BufferGeometry.
14
+ */
15
+ export function toBufferGeometry(THREE, g) {
16
+ const geo = new THREE.BufferGeometry();
17
+ geo.setAttribute('position', new THREE.BufferAttribute(g.position, 3));
18
+ geo.setAttribute('normal', new THREE.BufferAttribute(g.normal, 3));
19
+ geo.setAttribute('uv', new THREE.BufferAttribute(g.uv, 2));
20
+ geo.setIndex(new THREE.BufferAttribute(g.index, 1));
21
+ for (const grp of g.groups) geo.addGroup(grp.start, grp.count, grp.materialIndex);
22
+ geo.boundingBox = new THREE.Box3(new THREE.Vector3(...g.bounds.min), new THREE.Vector3(...g.bounds.max));
23
+ geo.computeBoundingSphere();
24
+ return geo;
25
+ }
@@ -0,0 +1,11 @@
1
+ export { createView, DEFAULT_LOOK, colorsFor } from '../scene/view.js';
2
+ export type { Look, LogoView, ViewOptions } from '../scene/view.js';
3
+ export { toBufferGeometry } from './geometry.js';
4
+ export { logoMaterials, presetMaterial, flatSides, exportable } from '../materials/factory.js';
5
+ export { environmentCache } from '../scene/environments.js';
6
+ export { studioScene } from '../scene/studio.js';
7
+ export { makeBackdrop, sweepMesh } from '../scene/backdrop.js';
8
+ export type { Backdrop } from '../scene/backdrop.js';
9
+ export { stillRenderer, pngOf, unpremultiply, placeTile, tileSide } from '../export/still.js';
10
+ export type { Pixels } from '../export/still.js';
11
+ export { glbOf } from '../export/glb.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @module @logomarks/logo3d/three
3
+ *
4
+ * The three.js side: the live view (`createView`), the geometry as a BufferGeometry, the materials, the environments
5
+ * and backdrops, and the exports (off-screen stills as PNG, GLB, video frames). three.js is a peer dependency.
6
+ */
7
+
8
+ export { createView, DEFAULT_LOOK, colorsFor } from '../scene/view.js';
9
+ export { toBufferGeometry } from './geometry.js';
10
+ export { logoMaterials, presetMaterial, flatSides, exportable } from '../materials/factory.js';
11
+ export { environmentCache } from '../scene/environments.js';
12
+ export { studioScene } from '../scene/studio.js';
13
+ export { makeBackdrop, sweepMesh } from '../scene/backdrop.js';
14
+ export { stillRenderer, pngOf, unpremultiply, placeTile, tileSide } from '../export/still.js';
15
+ export { glbOf } from '../export/glb.js';
@@ -0,0 +1,6 @@
1
+ import type { BuildRequest, BuildResult } from './client.js';
2
+
3
+ /** A builder with a one-file memory (the shape is kept while the same SVG comes again). */
4
+ export function makeBuild(): (req: BuildRequest) => BuildResult;
5
+ /** The typed arrays a result can hand over without copying. */
6
+ export function transferables(r: BuildResult): ArrayBuffer[];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @module @logomarks/logo3d/worker/build
3
+ *
4
+ * One build, as the worker runs it: the SVG read into a shape (kept while the same SVG comes again, so changing the
5
+ * depth or profile doesn't read the file again) and extruded. Pure; the worker and the main-thread fallback share it.
6
+ */
7
+
8
+ import { readLogo } from '../shape/index.js';
9
+ import { buildGeometry } from '../geometry/index.js';
10
+
11
+ /**
12
+ * A builder with a one-file memory.
13
+ *
14
+ * @returns {(req: import('./client.js').BuildRequest) => import('./client.js').BuildResult} Builds a request.
15
+ */
16
+ export function makeBuild() {
17
+ /** @type {{ svg: string, shape: import('../shape/index.js').LogoShape } | null} */
18
+ let last = null;
19
+ return (req) => {
20
+ if (!last || last.svg !== req.svg) last = { svg: req.svg, shape: readLogo(req.svg) };
21
+ const { shape } = last;
22
+ if (!shape.layers.length) throw new Error('This SVG has no filled shapes to extrude.');
23
+ return {
24
+ geometry: buildGeometry(shape, req.options ?? {}),
25
+ colors: shape.layers.map((l) => l.color),
26
+ halfStroke: shape.halfStroke,
27
+ };
28
+ };
29
+ }
30
+
31
+ /**
32
+ * The typed arrays a result can hand over without copying.
33
+ *
34
+ * @param {import('./client.js').BuildResult} r The result.
35
+ * @returns {ArrayBuffer[]} Their buffers.
36
+ */
37
+ export const transferables = (r) => [r.geometry.position.buffer, r.geometry.normal.buffer, r.geometry.uv.buffer, r.geometry.index.buffer].map((b) => /** @type {ArrayBuffer} */ (b));
@@ -0,0 +1,29 @@
1
+ import type { GeometryOptions, LogoGeometry } from '../geometry/index.js';
2
+
3
+ /** One build: an SVG and how to extrude it. */
4
+ export interface BuildRequest {
5
+ svg: string;
6
+ options?: GeometryOptions;
7
+ }
8
+ /** What a build gives back. */
9
+ export interface BuildResult {
10
+ geometry: LogoGeometry;
11
+ /** Each layer's own colour (null for the ink). */
12
+ colors: (string | null)[];
13
+ /** Half the logo's thinnest stroke. */
14
+ halfStroke: number;
15
+ }
16
+ /** What the client needs of a worker. */
17
+ export interface WorkerLike {
18
+ postMessage(message: unknown): void;
19
+ onmessage: ((e: MessageEvent) => void) | null;
20
+ terminate(): void;
21
+ }
22
+ /** A builder: latest request wins. */
23
+ export interface Builder {
24
+ /** Builds; rejects with an `AbortError` when a newer request replaces it before it starts. */
25
+ build(req: BuildRequest): Promise<BuildResult>;
26
+ dispose(): void;
27
+ }
28
+ /** A builder over a worker (or on the main thread when `makeWorker` is null). */
29
+ export function createBuilder(makeWorker?: (() => WorkerLike) | null): Builder;