@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,273 @@
1
+ /**
2
+ * @module @logomarks/logo3d/scene/view
3
+ *
4
+ * The live view: one WebGL renderer on the page's canvas, the logo's mesh in its materials, lit only by the
5
+ * environment it reflects, on its backdrop, seen from a named view, moved by its motion. It draws on demand (a change,
6
+ * a drag) and runs frames only while it moves, is on screen and the page is shown; with reduced motion it holds still
7
+ * (a drag still turns it). Stills, GLBs and video frames are drawn off screen from the same scene.
8
+ */
9
+
10
+ import * as THREE from 'three';
11
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
12
+ import { toBufferGeometry } from '../three/geometry.js';
13
+ import { logoMaterials } from '../materials/factory.js';
14
+ import { materialById, DEFAULT_MATERIAL } from '../materials/presets.js';
15
+ import { environmentById, environmentCache, DEFAULT_ENVIRONMENT } from './environments.js';
16
+ import { makeBackdrop } from './backdrop.js';
17
+ import { viewPosition, DEFAULT_VIEW } from './camera.js';
18
+ import { poseAt, PERIODS } from '../motion/index.js';
19
+ import { stillRenderer, pngOf } from '../export/still.js';
20
+ import { glbOf } from '../export/glb.js';
21
+
22
+ /** The look a view starts with. */
23
+ export const DEFAULT_LOOK = Object.freeze({
24
+ material: DEFAULT_MATERIAL,
25
+ side: /** @type {string | null} */ (null),
26
+ color: '#c8ff00',
27
+ environment: DEFAULT_ENVIRONMENT,
28
+ background: 'transparent',
29
+ backgroundColor: '#f1efea',
30
+ view: DEFAULT_VIEW,
31
+ motion: 'turntable',
32
+ });
33
+
34
+ /** The lens's vertical field of view, degrees: long enough to keep a logo's faces flat. */
35
+ const FOV = 26;
36
+
37
+ /**
38
+ * Each layer's colour: a one-colour logo takes the look's colour; a logo of several keeps its own, its ink layers
39
+ * taking the look's.
40
+ *
41
+ * @param {(string | null)[]} layerColors The layers' own colours (null for the ink).
42
+ * @param {string} color The look's colour.
43
+ * @returns {string[]} The colours.
44
+ */
45
+ export const colorsFor = (layerColors, color) => (layerColors.length <= 1 ? [color] : layerColors.map((c) => c ?? color));
46
+
47
+ /**
48
+ * A live view on a canvas.
49
+ *
50
+ * @param {HTMLCanvasElement} canvas The canvas (sized by CSS; the view follows it).
51
+ * @param {import('./view.js').ViewOptions} [o] The HDRIs' URLs, the pixel ratio, and whether a drag turns the logo.
52
+ * @returns {import('./view.js').LogoView} The view.
53
+ */
54
+ export function createView(canvas, o = {}) {
55
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: 'high-performance' });
56
+ renderer.setPixelRatio(o.pixelRatio ?? Math.min(globalThis.devicePixelRatio || 1, 2));
57
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
58
+ renderer.toneMapping = THREE.NeutralToneMapping;
59
+ renderer.setClearColor(0x000000, 0);
60
+
61
+ const scene = new THREE.Scene();
62
+ const camera = new THREE.PerspectiveCamera(FOV, 1, 0.01, 200);
63
+ const pivot = new THREE.Group();
64
+ scene.add(pivot);
65
+ const mesh = new THREE.Mesh(new THREE.BufferGeometry(), []);
66
+ mesh.castShadow = true;
67
+ pivot.add(mesh);
68
+
69
+ const envs = environmentCache(THREE, renderer, o.hdri ?? {});
70
+ const stills = stillRenderer(THREE, OutputPass, renderer);
71
+ /** @type {import('./view.js').Look} */
72
+ let look = { ...DEFAULT_LOOK };
73
+ /** @type {(string | null)[]} */
74
+ let layerColors = [null];
75
+ let depth = 0.14;
76
+ let radius = 0.75;
77
+ let height = 1;
78
+ /** @type {import('./backdrop.js').Backdrop | null} */
79
+ let backdrop = null;
80
+ let envId = '';
81
+ const drag = { yaw: 0, pitch: 0 };
82
+
83
+ // ---- what the scene is made of ----
84
+ function rebuildMaterials() {
85
+ const old = /** @type {import('three').Material[]} */ (mesh.material);
86
+ mesh.material = logoMaterials(THREE, { face: look.material, side: look.side ?? undefined, colors: colorsFor(layerColors, look.color), depth });
87
+ new Set(old).forEach((m) => m.dispose());
88
+ renderer.toneMapping = materialById(look.material).kind === 'flat' && (!look.side || materialById(look.side).kind === 'flat') ? THREE.NoToneMapping : THREE.NeutralToneMapping;
89
+ }
90
+ function rebuildBackdrop() {
91
+ if (backdrop) {
92
+ scene.remove(backdrop.group);
93
+ backdrop.dispose();
94
+ }
95
+ backdrop = makeBackdrop(THREE, { kind: look.background, color: look.backgroundColor, size: radius });
96
+ scene.add(backdrop.group);
97
+ backdrop.apply(scene, renderer);
98
+ place();
99
+ }
100
+ /** The camera for the view and the frame's aspect; the logo standing on the floor when there is one. */
101
+ function place(aspect = camera.aspect) {
102
+ const standing = backdrop?.standing ?? false;
103
+ const target = /** @type {[number, number, number]} */ ([0, standing ? height / 2 : 0, 0]);
104
+ const name = standing && look.view === 'front' ? 'angle' : look.view;
105
+ camera.position.set(...viewPosition(name, { radius, aspect, fov: FOV, target }));
106
+ camera.lookAt(...target);
107
+ camera.updateProjectionMatrix();
108
+ }
109
+ async function applyEnvironment() {
110
+ const e = environmentById(look.environment);
111
+ if (envId === e.id) return;
112
+ envId = e.id;
113
+ const tex = await envs.get(e.id);
114
+ if (envId !== e.id) return;
115
+ scene.environment = tex;
116
+ scene.environmentIntensity = e.intensity;
117
+ draw();
118
+ }
119
+
120
+ // ---- drawing ----
121
+ let start = performance.now();
122
+ let time = 0;
123
+ /** The scene posed at `t` seconds into the motion. */
124
+ function pose(t) {
125
+ const p = poseAt(look.motion, t);
126
+ const base = backdrop?.standing ? height / 2 : 0;
127
+ pivot.position.y = base + p.lift;
128
+ pivot.rotation.set(p.tilt + drag.pitch, p.turn + drag.yaw, 0, 'YXZ');
129
+ scene.environmentRotation.set(0, environmentById(look.environment).rotation + p.light, 0);
130
+ }
131
+ function draw() {
132
+ pose(time);
133
+ renderer.render(scene, camera);
134
+ }
135
+
136
+ // ---- the loop: frames only while it moves, is seen, and the page is shown ----
137
+ const still = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
138
+ let frame = 0;
139
+ let seen = true;
140
+ const moving = () => look.motion !== 'none' && !still && seen && !document.hidden;
141
+ function tick() {
142
+ frame = 0;
143
+ if (!moving()) return;
144
+ time = (performance.now() - start) / 1000;
145
+ draw();
146
+ frame = requestAnimationFrame(tick);
147
+ }
148
+ function run() {
149
+ if (!frame && moving()) frame = requestAnimationFrame(tick);
150
+ }
151
+ const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver(([e]) => ((seen = e.isIntersecting), run())) : null;
152
+ io?.observe(canvas);
153
+ const onVisibility = () => run();
154
+ document.addEventListener('visibilitychange', onVisibility);
155
+
156
+ // ---- size ----
157
+ function resize() {
158
+ const w = canvas.clientWidth, h = canvas.clientHeight;
159
+ if (!w || !h) return;
160
+ renderer.setSize(w, h, false);
161
+ camera.aspect = w / h;
162
+ place();
163
+ draw();
164
+ }
165
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null;
166
+ ro?.observe(canvas);
167
+
168
+ // ---- a drag turns the logo ----
169
+ if (o.drag !== false) {
170
+ /** @type {[number, number] | null} */
171
+ let last = null;
172
+ canvas.addEventListener('pointerdown', (e) => {
173
+ last = [e.clientX, e.clientY];
174
+ canvas.setPointerCapture(e.pointerId);
175
+ });
176
+ canvas.addEventListener('pointermove', (e) => {
177
+ if (!last) return;
178
+ drag.yaw += (e.clientX - last[0]) * 0.008;
179
+ drag.pitch = Math.max(-0.7, Math.min(0.7, drag.pitch + (e.clientY - last[1]) * 0.006));
180
+ last = [e.clientX, e.clientY];
181
+ if (!frame) draw();
182
+ });
183
+ const up = () => (last = null);
184
+ canvas.addEventListener('pointerup', up);
185
+ canvas.addEventListener('pointercancel', up);
186
+ }
187
+
188
+ return {
189
+ canvas,
190
+ renderer,
191
+ scene,
192
+ camera,
193
+ mesh,
194
+ get look() {
195
+ return { ...look };
196
+ },
197
+ setGeometry(g, colors = [null]) {
198
+ const old = mesh.geometry;
199
+ mesh.geometry = toBufferGeometry(THREE, g);
200
+ old.dispose();
201
+ layerColors = colors.length ? colors : [null];
202
+ depth = g.depth;
203
+ height = g.bounds.max[1] - g.bounds.min[1];
204
+ radius = /** @type {import('three').Sphere} */ (mesh.geometry.boundingSphere).radius;
205
+ rebuildMaterials();
206
+ rebuildBackdrop();
207
+ draw();
208
+ },
209
+ async setLook(next) {
210
+ const before = look;
211
+ look = { ...look, ...next };
212
+ if (before.material !== look.material || before.side !== look.side || before.color !== look.color) rebuildMaterials();
213
+ if (before.background !== look.background || before.backgroundColor !== look.backgroundColor || !backdrop) rebuildBackdrop();
214
+ if (before.view !== look.view) place();
215
+ if (before.motion !== look.motion) {
216
+ start = performance.now();
217
+ time = 0;
218
+ }
219
+ draw();
220
+ run();
221
+ await applyEnvironment();
222
+ },
223
+ resetDrag() {
224
+ drag.yaw = drag.pitch = 0;
225
+ draw();
226
+ },
227
+ render(t) {
228
+ if (t !== undefined) time = t;
229
+ draw();
230
+ },
231
+ period: () => PERIODS[/** @type {keyof typeof PERIODS} */ (look.motion)] ?? 6,
232
+ still({ width, height: h, transparent = false, t = 0 }) {
233
+ const keep = time;
234
+ time = t;
235
+ pose(t);
236
+ const prev = camera.aspect;
237
+ place(width / h);
238
+ const px = stills.render(scene, camera, { width, height: h, transparent: transparent && look.background === 'transparent' });
239
+ camera.aspect = prev;
240
+ place();
241
+ time = keep;
242
+ draw();
243
+ return px;
244
+ },
245
+ async png(opts) {
246
+ return pngOf(this.still(opts));
247
+ },
248
+ async glb() {
249
+ const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
250
+ return glbOf(THREE, GLTFExporter, mesh);
251
+ },
252
+ frames({ width, height: h }) {
253
+ return (t, target) => {
254
+ const px = this.still({ width, height: h, t, transparent: false });
255
+ if (target.width !== width) target.width = width;
256
+ if (target.height !== h) target.height = h;
257
+ /** @type {CanvasRenderingContext2D} */ (target.getContext('2d')).putImageData(new ImageData(px.data, width, h), 0, 0);
258
+ };
259
+ },
260
+ dispose() {
261
+ cancelAnimationFrame(frame);
262
+ io?.disconnect();
263
+ ro?.disconnect();
264
+ document.removeEventListener('visibilitychange', onVisibility);
265
+ mesh.geometry.dispose();
266
+ new Set(/** @type {import('three').Material[]} */ (mesh.material)).forEach((m) => m.dispose());
267
+ backdrop?.dispose();
268
+ envs.dispose();
269
+ stills.dispose();
270
+ renderer.dispose();
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,80 @@
1
+ import type { Ring, Shape } from '@logomarks/core/geometry';
2
+ import type { Bitmap } from '@logomarks/core/raster';
3
+
4
+ /** A ring of points, open (the last point is not the first). */
5
+ export type Ring2 = [number, number][];
6
+ /** An outline (anticlockwise, y up), then its holes (clockwise). */
7
+ export type Polygon2 = Ring2[];
8
+
9
+ /** One colour of a logo, normalised. */
10
+ export interface NormalLayer {
11
+ /** Its colour (`#rrggbb`), or null for the ink (unset, `currentColor`, a gradient). */
12
+ color: string | null;
13
+ polygons: Polygon2[];
14
+ }
15
+
16
+ /** A logo ready to extrude: y up, centred on the origin, its longest side 1. */
17
+ export interface LogoShape {
18
+ /** Its colour layers, in the order their colours first appear; they never overlap. */
19
+ layers: NormalLayer[];
20
+ width: number;
21
+ height: number;
22
+ /** Half its thinnest stroke: the bevel's cap. */
23
+ halfStroke: number;
24
+ }
25
+
26
+ /** One filled element of a file, in its user space. */
27
+ export interface SvgElement {
28
+ rings: Ring[];
29
+ evenodd: boolean;
30
+ /** Its fill as written, or undefined (black). */
31
+ fill: string | undefined;
32
+ first: boolean;
33
+ }
34
+
35
+ /** One colour of a file, in its user space (y down). */
36
+ export interface Layer {
37
+ color: string | null;
38
+ shape: Shape;
39
+ }
40
+
41
+ /** A logo's shape from its SVG text. Pure; runs in Node, a worker or a page. */
42
+ export function readLogo(svg: string, o?: { seed?: string; tolerance?: number }): LogoShape;
43
+ /** Every filled element of a file, in paint order (transforms applied, curves sampled every `step` of its size). */
44
+ export function readElements(svg: string, o?: { step?: number }): { elements: SvgElement[]; box: [number, number, number, number] | undefined };
45
+ /** A tag's attributes by lower-case name. */
46
+ export function attributes(text: string): Record<string, string>;
47
+ /** CSS declarations as properties. */
48
+ export function declarations(text: string | undefined): Record<string, string>;
49
+ /** The `.class` rules of a file's `<style>` elements. */
50
+ export function classRules(svg: string): Map<string, Record<string, string>>;
51
+ /** A filled element's outline as path data. */
52
+ export function outline(name: string, a: Record<string, string>): string | undefined;
53
+ /** Elements painted in order into colour layers (a full-viewBox background dropped). */
54
+ export function paintLayers(elements: SvgElement[], box: [number, number, number, number] | undefined, o?: { seed?: string }): Layer[];
55
+ /** A fill as `#rrggbb`, or null for the ink. */
56
+ export function colorOf(fill: string | undefined): string | null;
57
+ /** Layers normalised: y up, centred, the longest side 1, rings wound and simplified. */
58
+ export function normalise(layers: Layer[], o?: { tolerance?: number }): { layers: NormalLayer[]; width: number; height: number; scale: number };
59
+ /** The box round every point of every layer, `[x0, y0, x1, y1]`. */
60
+ export function boundsOf(layers: { shape: Shape }[]): [number, number, number, number];
61
+ /** About half the thinnest stroke of normalised layers: the deepest inset that keeps every layer's pieces and holes. */
62
+ export function halfThinnest(layers: NormalLayer[], o?: { resolution?: number }): number;
63
+ /** The deepest inset, in pixels, that keeps a raster's pieces and holes (a binary search over its distance field). */
64
+ export function deepestInset(bm: Bitmap, o?: { steps?: number }): number;
65
+ /** A raster's ink pieces and empty regions, counted: `"<ink>/<empty>"`. */
66
+ export function topology(bm: Bitmap): string;
67
+ /** A ring's signed area, positive anticlockwise with y up. */
68
+ export function signedArea(r: readonly (readonly number[])[]): number;
69
+ /** A closed ring simplified (Douglas–Peucker), repeated points dropped. */
70
+ export function simplifyRing(ring: [number, number][], tol: number): [number, number][];
71
+ /** A `transform` attribute as a matrix `[a, b, c, d, e, f]`. */
72
+ export function parseTransform(text: string | undefined): number[];
73
+ /** The product of two matrices (`n` applied first). */
74
+ export function multiply(m: readonly number[], n: readonly number[]): number[];
75
+ /** A point through a matrix. */
76
+ export function apply(m: readonly number[], p: readonly [number, number]): [number, number];
77
+ /** The identity matrix. */
78
+ export const IDENTITY: readonly number[];
79
+ /** Rings as polygons by nesting alone (no booleans): the fallback when the booleans can't take a file. */
80
+ export function nestRings(rings: Ring[]): Shape;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape
3
+ *
4
+ * A logo's SVG as the shape the geometry extrudes: its filled elements read (transforms, fills and fill rules kept),
5
+ * painted into colour layers, and normalised (y up, centred, the longest side 1), with half its thinnest stroke
6
+ * measured for the bevel's cap. Pure: it runs in Node, a worker or a page.
7
+ */
8
+
9
+ import { readElements } from './svg.js';
10
+ import { paintLayers } from './layers.js';
11
+ import { normalise } from './normalise.js';
12
+ import { halfThinnest } from './thinnest.js';
13
+
14
+ export { readElements, attributes, declarations, classRules, outline } from './svg.js';
15
+ export { paintLayers, colorOf, nestRings } from './layers.js';
16
+ export { normalise, boundsOf } from './normalise.js';
17
+ export { halfThinnest, deepestInset, topology } from './thinnest.js';
18
+ export { signedArea, simplifyRing } from './simplify.js';
19
+ export { parseTransform, multiply, apply, IDENTITY } from './matrix.js';
20
+
21
+ /**
22
+ * A logo's shape from its SVG.
23
+ *
24
+ * @param {string} svg The file's text (a mark's file, or any SVG with filled shapes).
25
+ * @param {{ seed?: string, tolerance?: number }} [o] `seed`: the booleans' retry jitter; `tolerance`: the simplification.
26
+ * @returns {import('./index.js').LogoShape} The shape. It has no layers when the file draws nothing filled.
27
+ */
28
+ export function readLogo(svg, o = {}) {
29
+ const { elements, box } = readElements(svg);
30
+ const n = normalise(paintLayers(elements, box, o), o);
31
+ return { layers: n.layers, width: n.width, height: n.height, halfStroke: halfThinnest(n.layers) };
32
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape/layers
3
+ *
4
+ * A file's filled elements as colour layers: each element filled by its own rule, then painted in order, so a later
5
+ * element covers what is under it (it is cut out of every other colour). A first element that fills the whole
6
+ * viewBox is a background, and is dropped.
7
+ */
8
+
9
+ import { nonzeroFill } from '@logomarks/core/svg';
10
+ import { robustOps, seeded, area } from '@logomarks/core/geometry';
11
+
12
+ /** CSS colour names an exported SVG commonly uses. */
13
+ const NAMED = /** @type {Record<string, string>} */ ({ black: '#000000', white: '#ffffff', red: '#ff0000', lime: '#00ff00', green: '#008000', blue: '#0000ff', yellow: '#ffff00', orange: '#ffa500', gray: '#808080', grey: '#808080', silver: '#c0c0c0', navy: '#000080', purple: '#800080', teal: '#008080', maroon: '#800000' });
14
+
15
+ /**
16
+ * A fill as a layer's colour: `#rrggbb`, or null for the ink (unset, `currentColor`, a gradient or pattern).
17
+ *
18
+ * @param {string | undefined} fill The fill as written.
19
+ * @returns {string | null} The colour.
20
+ */
21
+ export function colorOf(fill) {
22
+ if (!fill) return null;
23
+ const f = fill.trim().toLowerCase();
24
+ if (f === 'currentcolor' || f.startsWith('url(') || f === 'inherit') return null;
25
+ if (NAMED[f]) return NAMED[f];
26
+ let m = /^#([0-9a-f]{3,4})$/.exec(f);
27
+ if (m) return `#${[...m[1].slice(0, 3)].map((c) => c + c).join('')}`;
28
+ m = /^#([0-9a-f]{6})(?:[0-9a-f]{2})?$/.exec(f);
29
+ if (m) return `#${m[1]}`;
30
+ m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/.exec(f);
31
+ if (m) {
32
+ const ch = (/** @type {string} */ v) => Math.round(Math.min(255, Math.max(0, v.endsWith('%') ? (parseFloat(v) * 255) / 100 : parseFloat(v))));
33
+ return `#${[m[1], m[2], m[3]].map((v) => ch(v).toString(16).padStart(2, '0')).join('')}`;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Whether an element is a background: a box over at least 97% of the viewBox.
40
+ *
41
+ * @param {import('./svg.js').Element} el The element.
42
+ * @param {[number, number, number, number] | undefined} box The viewBox.
43
+ * @returns {boolean} True for a background.
44
+ */
45
+ function isBackground(el, box) {
46
+ if (!box || el.rings.length !== 1) return false;
47
+ const covered = area(el.rings[0]);
48
+ return covered >= 0.97 * box[2] * box[3];
49
+ }
50
+
51
+ /**
52
+ * Rings as polygons by nesting alone (no booleans): a ring inside an even number of others is an outline, inside an
53
+ * odd number a hole of the smallest ring round it. The fallback when the booleans can't take a file's geometry.
54
+ *
55
+ * @param {import('@logomarks/core/geometry').Ring[]} rings The rings.
56
+ * @returns {import('@logomarks/core/geometry').Shape} The polygons.
57
+ */
58
+ export function nestRings(rings) {
59
+ const rs = rings.filter((r) => r.length > 2 && area(r) > 0).sort((a, b) => area(b) - area(a));
60
+ const inside = (/** @type {import('@logomarks/core/geometry').Point} */ q, /** @type {import('@logomarks/core/geometry').Ring} */ r) => {
61
+ let c = false;
62
+ for (let i = 0, j = r.length - 1; i < r.length; j = i++) if (r[i][1] > q[1] !== r[j][1] > q[1] && q[0] < ((r[j][0] - r[i][0]) * (q[1] - r[i][1])) / (r[j][1] - r[i][1]) + r[i][0]) c = !c;
63
+ return c;
64
+ };
65
+ /** @type {import('@logomarks/core/geometry').Shape} */
66
+ const out = [];
67
+ /** @type {Map<import('@logomarks/core/geometry').Ring, import('@logomarks/core/geometry').Polygon>} */
68
+ const polyOf = new Map();
69
+ rs.forEach((r, i) => {
70
+ const parents = rs.slice(0, i).filter((o) => inside(r[0], o));
71
+ if (parents.length % 2 === 0) {
72
+ const pg = [r];
73
+ out.push(pg);
74
+ polyOf.set(r, pg);
75
+ } else polyOf.get(parents[parents.length - 1])?.push(r);
76
+ });
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * One element's fill, by its own rule; by nesting when the booleans fail on it.
82
+ *
83
+ * @param {import('./svg.js').Element} el The element.
84
+ * @param {import('@logomarks/core/geometry').BooleanOps} ops The booleans.
85
+ * @returns {import('@logomarks/core/geometry').Shape} Its shape.
86
+ */
87
+ function fillOf(el, ops) {
88
+ try {
89
+ return el.evenodd ? ops.xor(...el.rings.map((r) => [r])) : nonzeroFill(el.rings, ops);
90
+ } catch {
91
+ return nestRings(el.rings);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A boolean, or a fallback when it fails.
97
+ *
98
+ * @template T
99
+ * @param {() => T} op The boolean.
100
+ * @param {() => T} fallback What to use instead.
101
+ * @returns {T} The result.
102
+ */
103
+ const tryOp = (op, fallback) => {
104
+ try {
105
+ return op();
106
+ } catch {
107
+ return fallback();
108
+ }
109
+ };
110
+
111
+ /**
112
+ * @typedef {object} Layer
113
+ * @property {string | null} color Its colour (`#rrggbb`), or null for the ink.
114
+ * @property {import('@logomarks/core/geometry').Shape} shape What it covers.
115
+ */
116
+
117
+ /**
118
+ * Elements painted in order into colour layers.
119
+ *
120
+ * @param {import('./svg.js').Element[]} elements The elements, in paint order.
121
+ * @param {[number, number, number, number] | undefined} box The viewBox.
122
+ * @param {{ seed?: string }} [o] `seed`: the booleans' retry jitter, so a file reads the same every time.
123
+ * @returns {Layer[]} The layers, in the order their colours first appear; empty ones dropped.
124
+ */
125
+ export function paintLayers(elements, box, { seed = 'logo3d' } = {}) {
126
+ const ops = robustOps(seeded(seed));
127
+ const els = elements.length > 1 && isBackground(elements[0], box) ? elements.slice(1) : elements;
128
+ /** @type {Map<string, import('@logomarks/core/geometry').Shape>} */
129
+ const byColor = new Map();
130
+ const keyOf = (/** @type {string | null} */ c) => c ?? 'ink';
131
+ const colors = new Set(els.map((e) => keyOf(colorOf(e.fill))));
132
+ for (const el of els) {
133
+ const shape = fillOf(el, ops);
134
+ if (!shape.length) continue;
135
+ const key = keyOf(colorOf(el.fill));
136
+ if (colors.size > 1)
137
+ for (const [k, s] of byColor) if (k !== key) byColor.set(k, tryOp(() => /** @type {import('@logomarks/core/geometry').Shape} */ (ops.difference(s, shape)), () => s));
138
+ const had = byColor.get(key);
139
+ byColor.set(key, had ? tryOp(() => ops.union(had, shape), () => [...had, ...shape]) : shape);
140
+ }
141
+ return [...byColor].filter(([, s]) => s.length).map(([k, shape]) => ({ color: k === 'ink' ? null : k, shape }));
142
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module @logomarks/logo3d/shape/matrix
3
+ *
4
+ * SVG transforms as 2D affine matrices `[a, b, c, d, e, f]` (x' = a·x + c·y + e, y' = b·x + d·y + f): parsed from a
5
+ * `transform` attribute, multiplied down the element tree, and applied to points.
6
+ */
7
+
8
+ /** The identity. */
9
+ export const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
10
+
11
+ /**
12
+ * `m` then `n`: the matrix that applies `n` first and `m` after (a parent's matrix times its child's).
13
+ *
14
+ * @param {readonly number[]} m The outer matrix.
15
+ * @param {readonly number[]} n The inner matrix.
16
+ * @returns {number[]} Their product.
17
+ */
18
+ export const multiply = (m, n) => [
19
+ m[0] * n[0] + m[2] * n[1],
20
+ m[1] * n[0] + m[3] * n[1],
21
+ m[0] * n[2] + m[2] * n[3],
22
+ m[1] * n[2] + m[3] * n[3],
23
+ m[0] * n[4] + m[2] * n[5] + m[4],
24
+ m[1] * n[4] + m[3] * n[5] + m[5],
25
+ ];
26
+
27
+ /**
28
+ * One transform function as a matrix.
29
+ *
30
+ * @param {string} name `matrix`, `translate`, `scale`, `rotate`, `skewX` or `skewY`.
31
+ * @param {number[]} v Its numbers.
32
+ * @returns {number[]} The matrix (the identity for an unknown name).
33
+ */
34
+ function one(name, v) {
35
+ const rad = ((v[0] ?? 0) * Math.PI) / 180;
36
+ switch (name) {
37
+ case 'matrix':
38
+ return v.length === 6 ? v : [...IDENTITY];
39
+ case 'translate':
40
+ return [1, 0, 0, 1, v[0] ?? 0, v[1] ?? 0];
41
+ case 'scale':
42
+ return [v[0] ?? 1, 0, 0, v[1] ?? v[0] ?? 1, 0, 0];
43
+ case 'rotate': {
44
+ const [cx = 0, cy = 0] = v.slice(1);
45
+ const r = [Math.cos(rad), Math.sin(rad), -Math.sin(rad), Math.cos(rad), 0, 0];
46
+ return multiply(multiply([1, 0, 0, 1, cx, cy], r), [1, 0, 0, 1, -cx, -cy]);
47
+ }
48
+ case 'skewX':
49
+ return [1, 0, Math.tan(rad), 1, 0, 0];
50
+ case 'skewY':
51
+ return [1, Math.tan(rad), 0, 1, 0, 0];
52
+ default:
53
+ return [...IDENTITY];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * A `transform` attribute as one matrix (its functions applied right to left, as SVG does).
59
+ *
60
+ * @param {string | undefined} text The attribute.
61
+ * @returns {number[]} The matrix.
62
+ */
63
+ export function parseTransform(text) {
64
+ let m = [...IDENTITY];
65
+ if (!text) return m;
66
+ for (const [, name, args] of text.matchAll(/([a-zA-Z]+)\s*\(([^)]*)\)/g)) {
67
+ const v = (args.match(/[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/gi) ?? []).map(Number);
68
+ m = multiply(m, one(name, v));
69
+ }
70
+ return m;
71
+ }
72
+
73
+ /**
74
+ * A point through a matrix.
75
+ *
76
+ * @param {readonly number[]} m The matrix.
77
+ * @param {readonly [number, number]} p The point.
78
+ * @returns {[number, number]} Where it goes.
79
+ */
80
+ export const apply = (m, p) => [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
81
+
82
+ /**
83
+ * How much a matrix scales lengths, on average (the square root of its determinant's size).
84
+ *
85
+ * @param {readonly number[]} m The matrix.
86
+ * @returns {number} The scale.
87
+ */
88
+ export const scaleOf = (m) => Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1;