@logomarks/logo3d 0.0.0-stage → 0.1.1

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 +39 -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
package/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@logomarks/logo3d` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.1] — 2026-11-15
9
+
10
+ ### Changed
11
+
12
+ - Works with `@logomarks/core` 0.2.0 as well as 0.1 (`^0.1.9 || ^0.2.0`), so a project on the new core keeps one copy
13
+ of it. 0.2.0 only adds subpaths; nothing this package uses changed.
14
+
15
+ ## [0.1.0] — 2026-11-10
16
+
17
+ The first cut.
18
+
19
+ ### Added
20
+
21
+ - **Shape** (`readLogo`): an SVG's filled elements read without a DOM (transforms, fills and fill rules from
22
+ attributes, styles and classes; rounded rects, circles, ellipses, polygons, polylines), painted into colour layers
23
+ that never overlap, a full-viewBox background dropped, normalised (y up, centred, the longest side 1), and the
24
+ bevel's cap measured: the deepest inset that keeps every layer's pieces and holes. Built on @logomarks/core's SVG
25
+ reader, booleans, distance transform and components.
26
+ - **Geometry** (`buildGeometry`): profiles `flat`, `chamfer`, `round` and `pillow`; every level inset point for point
27
+ onto the true round offset; earcut faces; banded sides with smooth or split normals; face, side and back draw
28
+ groups per layer; deterministic.
29
+ - **Materials**: chrome, black chrome, gold, brushed steel (anisotropy), clear and frosted glass (transmission),
30
+ clay, matte, gloss, and the house flat colour (the face exactly the colour, the sides darker); separate face and
31
+ side materials; glTF through KHR_materials_* (unlit for the flat colour).
32
+ - **Scene**: a studio built in code and two self-hosted CC0 Poly Haven HDRIs at 1k (Studio Small 09; Kloofendal 48d
33
+ Partly Cloudy, pure sky); transparent, solid and paper-sweep backgrounds (a contact shadow on the sweep); four views
34
+ fitted to the logo's bounding sphere; a drag to turn; frames only while moving and seen.
35
+ - **Motion**: none, turntable, swing, float and light sweep, as pure functions of time, exactly periodic.
36
+ - **Exports**: PNG stills off screen (multisampled, tiled above 2048 px, un-premultiplied when transparent), GLB, and
37
+ a frame source for a video encoder.
38
+ - **Worker**: geometry in a same-origin module worker (no blob: URL, no WebAssembly); `createBuilder` keeps only the
39
+ latest request, and builds on the main thread without a worker.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ArrayPress Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @logomarks/logo3d
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 3D logos from SVG, in the browser: a mark's exact outlines extruded with a flat, chamfered, round or pillow edge (the bevel capped by the logo's thinnest stroke), in colour layers with their own face, side and back materials, lit only by what they reflect, and exported as PNG, GLB or video frames. Geometry runs in a module worker. No WebAssembly, no server.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @logomarks/logo3d three
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```js
14
+ import { createBuilder } from '@logomarks/logo3d';
15
+ import { createView } from '@logomarks/logo3d/three';
16
+ import LogoWorker from '@logomarks/logo3d/worker?worker'; // Vite; any same-origin module worker will do
17
+ import softbox from '@logomarks/logo3d/assets/hdri/studio_small_09_1k.hdr?url';
18
+
19
+ const view = createView(canvas, { hdri: { 'studio_small_09_1k.hdr': softbox } });
20
+ const builder = createBuilder(() => new LogoWorker());
21
+
22
+ const { geometry, colors } = await builder.build({ svg, options: { profile: 'round', depth: 0.14, bevel: 0.6 } });
23
+ view.setGeometry(geometry, colors);
24
+ await view.setLook({ material: 'chrome', environment: 'softbox', background: 'sweep', motion: 'turntable' });
25
+
26
+ const png = await view.png({ width: 2048, height: 2048, transparent: true });
27
+ const glb = await view.glb();
28
+ ```
29
+
30
+ In Node (no WebGL), the pure half works on its own:
31
+
32
+ ```js
33
+ import { readLogo, buildGeometry, poseAt } from '@logomarks/logo3d';
34
+
35
+ const shape = readLogo(svgText); // colour layers, y up, longest side 1
36
+ const geo = buildGeometry(shape, { profile: 'pillow' }); // typed arrays and draw groups
37
+ ```
38
+
39
+ ## What's in it
40
+
41
+ - [API](docs/api.md): every export, by entry point
42
+ - [Shape and geometry](docs/geometry.md): reading SVGs, colour layers, the profiles, the bevel's cap
43
+ - [Materials, light and backdrops](docs/look.md): the presets, the studio and the HDRIs, the views and motions
44
+ - [Exports](docs/exports.md): PNG stills, GLB, video frames
45
+ - [Credits](docs/credits.md): the CC0 HDRIs
46
+
47
+ ## Links
48
+
49
+ - [Changelog](CHANGELOG.md)
50
+ - [Licence](LICENSE)
51
+ - [`@logomarks/core`](https://github.com/Logomarks/core), the SVG reader, booleans and raster it builds on
52
+ - [logomarks.ai/3d-logo-generator](https://logomarks.ai/3d-logo-generator), where it runs
53
+
54
+ ## Licence
55
+
56
+ MIT © ArrayPress Limited; author David Sherlock. The HDRIs in `assets/hdri/` are CC0 from Poly Haven (see [credits](docs/credits.md)).
package/package.json CHANGED
@@ -1,6 +1,84 @@
1
1
  {
2
2
  "name": "@logomarks/logo3d",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "3D logos from SVG: a mark's exact outlines extruded with a flat, chamfered, round or pillow edge capped by its thinnest stroke, in colour layers with separate face, side and back materials (chrome, black chrome, gold, brushed steel, clear and frosted glass, clay, matte, gloss, and a flat colour with darker sides), lit by a studio or a CC0 HDRI, on a transparent, solid or paper-sweep ground, moved by loop-exact motions, and exported as PNG, GLB or video frames. Geometry runs in a module worker; no WebAssembly.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./three": {
16
+ "types": "./src/three/index.d.ts",
17
+ "import": "./src/three/index.js",
18
+ "default": "./src/three/index.js"
19
+ },
20
+ "./worker": {
21
+ "types": "./src/worker/worker.d.ts",
22
+ "import": "./src/worker/worker.js",
23
+ "default": "./src/worker/worker.js"
24
+ },
25
+ "./assets/*": "./assets/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "assets",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test tests/*.test.js",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "keywords": [
40
+ "3d",
41
+ "3d-logo",
42
+ "bevel",
43
+ "chrome",
44
+ "extrude",
45
+ "glb",
46
+ "gltf",
47
+ "hdri",
48
+ "logo",
49
+ "logomark",
50
+ "logomarks",
51
+ "svg",
52
+ "three",
53
+ "turntable",
54
+ "webgl"
55
+ ],
56
+ "author": "David Sherlock",
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+https://github.com/Logomarks/logo3d.git"
61
+ },
62
+ "homepage": "https://www.npmjs.com/package/@logomarks/logo3d",
63
+ "bugs": "https://github.com/Logomarks/logo3d/issues",
64
+ "engines": {
65
+ "node": ">=20.0.0"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "registry": "https://registry.npmjs.org/"
70
+ },
71
+ "dependencies": {
72
+ "@logomarks/core": "^0.1.9 || ^0.2.0",
73
+ "earcut": "^3.0.2"
74
+ },
75
+ "peerDependencies": {
76
+ "three": ">=0.170.0"
77
+ },
78
+ "devDependencies": {
79
+ "@types/node": "^26.6.6",
80
+ "@types/three": "^0.186.0",
81
+ "three": "^0.186.0",
82
+ "typescript": "^6.0.3"
83
+ }
84
+ }
@@ -0,0 +1,5 @@
1
+ import type * as THREE from 'three';
2
+ import type { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
3
+
4
+ /** A logo's mesh as a GLB: at rest, its materials as glTF carries them. */
5
+ export function glbOf(three: typeof THREE, exporter: typeof GLTFExporter, mesh: THREE.Mesh, o?: { name?: string }): Promise<Blob>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @module @logomarks/logo3d/export/glb
3
+ *
4
+ * The logo as a binary glTF (.glb): its mesh at rest (no motion), its longest side 1 m, y up and facing +z, in its
5
+ * materials. Physical materials carry their KHR_materials_* extensions (clearcoat, transmission, volume, IOR,
6
+ * anisotropy); the flat colour goes as unlit (KHR_materials_unlit), its sides one darker unlit colour.
7
+ */
8
+
9
+ import { exportable } from '../materials/factory.js';
10
+
11
+ /**
12
+ * A logo's mesh as a GLB.
13
+ *
14
+ * @param {typeof import('three')} THREE three.js.
15
+ * @param {typeof import('three/addons/exporters/GLTFExporter.js').GLTFExporter} GLTFExporter three's exporter.
16
+ * @param {import('three').Mesh} mesh The logo's mesh.
17
+ * @param {{ name?: string }} [o] `name`: the node's name ("Logo").
18
+ * @returns {Promise<Blob>} The file.
19
+ */
20
+ export async function glbOf(THREE, GLTFExporter, mesh, { name = 'Logo' } = {}) {
21
+ const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
22
+ const copy = new THREE.Mesh(mesh.geometry, mats.map((m) => exportable(THREE, m)));
23
+ copy.name = name;
24
+ const scene = new THREE.Scene();
25
+ scene.add(copy);
26
+ const out = await new GLTFExporter().parseAsync(scene, { binary: true });
27
+ return new Blob([/** @type {ArrayBuffer} */ (out)], { type: 'model/gltf-binary' });
28
+ }
@@ -0,0 +1,23 @@
1
+ import type * as THREE from 'three';
2
+ import type { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
3
+
4
+ /** RGBA pixels, rows top to bottom. */
5
+ export interface Pixels {
6
+ data: Uint8ClampedArray<ArrayBuffer>;
7
+ width: number;
8
+ height: number;
9
+ }
10
+ /** Turns premultiplied pixels into straight ones, in place. */
11
+ export function unpremultiply(px: Uint8ClampedArray | Uint8Array): void;
12
+ /** Copies a GL tile (rows bottom to top) into an image, clipped to it. */
13
+ export function placeTile(tile: Uint8Array, tw: number, th: number, out: Uint8ClampedArray, width: number, height: number, x: number, y: number): void;
14
+ /** Even tiles over a side: the tile's side. */
15
+ export function tileSide(size: number, most: number): number;
16
+ /** A still renderer: off-screen, multisampled, tone-mapped by OutputPass, tiled for large stills. */
17
+ export function stillRenderer(
18
+ three: typeof THREE,
19
+ pass: typeof OutputPass,
20
+ renderer: THREE.WebGLRenderer,
21
+ ): { render(scene: THREE.Scene, camera: THREE.PerspectiveCamera, o: { width: number; height: number; transparent?: boolean; tile?: number }): Pixels; dispose(): void };
22
+ /** Pixels as a PNG. */
23
+ export function pngOf(px: Pixels): Promise<Blob>;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @module @logomarks/logo3d/export/still
3
+ *
4
+ * Stills drawn off screen, never by resizing the live view: the scene rendered into a multisampled half-float
5
+ * target, tone-mapped and encoded by three's OutputPass into a second target, and read back. Large stills go in even
6
+ * tiles (the camera's view offset), so a 4096 px PNG never needs a 4096 px multisampled target. Transparent stills
7
+ * are un-premultiplied, so their edges have no dark fringe.
8
+ */
9
+
10
+ /**
11
+ * @typedef {object} Pixels
12
+ * @property {Uint8ClampedArray} data RGBA, rows top to bottom.
13
+ * @property {number} width
14
+ * @property {number} height
15
+ */
16
+
17
+ /**
18
+ * Turns premultiplied pixels into straight ones, in place.
19
+ *
20
+ * @param {Uint8ClampedArray | Uint8Array} px RGBA.
21
+ */
22
+ export function unpremultiply(px) {
23
+ for (let i = 0; i < px.length; i += 4) {
24
+ const a = px[i + 3];
25
+ if (a === 0 || a === 255) continue;
26
+ const k = 255 / a;
27
+ px[i] = Math.min(255, Math.round(px[i] * k));
28
+ px[i + 1] = Math.min(255, Math.round(px[i + 1] * k));
29
+ px[i + 2] = Math.min(255, Math.round(px[i + 2] * k));
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Copies a tile read from GL (rows bottom to top) into an image (rows top to bottom), clipped to the image.
35
+ *
36
+ * @param {Uint8Array} tile The tile's pixels.
37
+ * @param {number} tw Its width.
38
+ * @param {number} th Its height.
39
+ * @param {Uint8ClampedArray} out The image.
40
+ * @param {number} width The image's width.
41
+ * @param {number} height The image's height.
42
+ * @param {number} x Where the tile goes.
43
+ * @param {number} y
44
+ */
45
+ export function placeTile(tile, tw, th, out, width, height, x, y) {
46
+ const w = Math.min(tw, width - x), h = Math.min(th, height - y);
47
+ for (let row = 0; row < h; row++) {
48
+ const src = (th - 1 - row) * tw * 4;
49
+ out.set(tile.subarray(src, src + w * 4), ((y + row) * width + x) * 4);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Even tiles over an image: as few as fit under the largest size, all the same size (the last may run past the edge).
55
+ *
56
+ * @param {number} size The image's side.
57
+ * @param {number} most The largest tile.
58
+ * @returns {number} The tile's side.
59
+ */
60
+ export const tileSide = (size, most) => Math.ceil(size / Math.ceil(size / most));
61
+
62
+ /**
63
+ * A still renderer for a WebGL renderer.
64
+ *
65
+ * @param {typeof import('three')} THREE three.js.
66
+ * @param {typeof import('three/addons/postprocessing/OutputPass.js').OutputPass} OutputPass three's output pass.
67
+ * @param {import('three').WebGLRenderer} renderer The renderer (the live view's).
68
+ * @returns {{ render(scene: import('three').Scene, camera: import('three').PerspectiveCamera, o: { width: number, height: number, transparent?: boolean, tile?: number }): Pixels, dispose(): void }}
69
+ */
70
+ export function stillRenderer(THREE, OutputPass, renderer) {
71
+ /** @type {import('three').WebGLRenderTarget | null} */
72
+ let scene3d = null;
73
+ /** @type {import('three').WebGLRenderTarget | null} */
74
+ let encoded = null;
75
+ const pass = new OutputPass();
76
+ const targets = (/** @type {number} */ w, /** @type {number} */ h) => {
77
+ if (scene3d && scene3d.width === w && scene3d.height === h) return;
78
+ scene3d?.dispose();
79
+ encoded?.dispose();
80
+ const samples = Math.min(4, renderer.capabilities.maxSamples ?? 4);
81
+ scene3d = new THREE.WebGLRenderTarget(w, h, { type: THREE.HalfFloatType, samples });
82
+ encoded = new THREE.WebGLRenderTarget(w, h, { type: THREE.UnsignedByteType });
83
+ };
84
+ return {
85
+ render(scene, camera, { width, height, transparent = false, tile = 2048 }) {
86
+ const out = new Uint8ClampedArray(width * height * 4);
87
+ const tw = tileSide(width, tile), th = tileSide(height, tile);
88
+ targets(tw, th);
89
+ const rt = /** @type {import('three').WebGLRenderTarget} */ (scene3d), enc = /** @type {import('three').WebGLRenderTarget} */ (encoded);
90
+ const buf = new Uint8Array(tw * th * 4);
91
+ const before = { target: renderer.getRenderTarget(), alpha: renderer.getClearAlpha(), aspect: camera.aspect };
92
+ const clear = new THREE.Color();
93
+ renderer.getClearColor(clear);
94
+ renderer.setClearColor(0x000000, transparent ? 0 : 1);
95
+ camera.aspect = width / height;
96
+ camera.updateProjectionMatrix();
97
+ try {
98
+ for (let y = 0; y < height; y += th)
99
+ for (let x = 0; x < width; x += tw) {
100
+ camera.setViewOffset(width, height, x, y, tw, th);
101
+ renderer.setRenderTarget(rt);
102
+ renderer.clear();
103
+ renderer.render(scene, camera);
104
+ pass.render(renderer, enc, rt);
105
+ renderer.readRenderTargetPixels(enc, 0, 0, tw, th, buf);
106
+ placeTile(buf, tw, th, out, width, height, x, y);
107
+ }
108
+ } finally {
109
+ camera.clearViewOffset();
110
+ camera.aspect = before.aspect;
111
+ camera.updateProjectionMatrix();
112
+ renderer.setRenderTarget(before.target);
113
+ renderer.setClearColor(clear, before.alpha);
114
+ }
115
+ if (transparent) unpremultiply(out);
116
+ return { data: out, width, height };
117
+ },
118
+ dispose() {
119
+ scene3d?.dispose();
120
+ encoded?.dispose();
121
+ pass.dispose();
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Pixels as a PNG.
128
+ *
129
+ * @param {Pixels} px The pixels.
130
+ * @returns {Promise<Blob>} The PNG.
131
+ */
132
+ export async function pngOf(px) {
133
+ const img = new ImageData(px.data, px.width, px.height);
134
+ if (typeof OffscreenCanvas !== 'undefined') {
135
+ const c = new OffscreenCanvas(px.width, px.height);
136
+ /** @type {OffscreenCanvasRenderingContext2D} */ (c.getContext('2d')).putImageData(img, 0, 0);
137
+ return c.convertToBlob({ type: 'image/png' });
138
+ }
139
+ const c = document.createElement('canvas');
140
+ c.width = px.width;
141
+ c.height = px.height;
142
+ /** @type {CanvasRenderingContext2D} */ (c.getContext('2d')).putImageData(img, 0, 0);
143
+ return new Promise((ok, fail) => c.toBlob((b) => (b ? ok(b) : fail(new Error('logo3d: could not encode the PNG'))), 'image/png'));
144
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @module @logomarks/logo3d/geometry/buffer
3
+ *
4
+ * A growing indexed mesh: positions, normals and UVs pushed vertex by vertex, triangles pushed into the current part,
5
+ * and the parts packed into typed arrays with one draw group each.
6
+ */
7
+
8
+ /**
9
+ * @typedef {object} Group
10
+ * @property {number} start The group's first index.
11
+ * @property {number} count How many indices it draws.
12
+ * @property {number} layer The colour layer it belongs to.
13
+ * @property {'face' | 'side' | 'back'} part Which part of the logo it is.
14
+ * @property {number} materialIndex `layer * 3` plus 0 (face), 1 (side) or 2 (back).
15
+ */
16
+
17
+ /** The parts, in draw order within a layer. */
18
+ export const PARTS = Object.freeze(/** @type {const} */ (['face', 'side', 'back']));
19
+
20
+ /** A mesh being built. */
21
+ export class MeshBuffer {
22
+ constructor() {
23
+ /** @type {number[]} */
24
+ this.position = [];
25
+ /** @type {number[]} */
26
+ this.normal = [];
27
+ /** @type {number[]} */
28
+ this.uv = [];
29
+ /** @type {{ layer: number, part: 'face' | 'side' | 'back', index: number[] }[]} */
30
+ this.parts = [];
31
+ }
32
+
33
+ /**
34
+ * Starts a part: the triangles pushed next belong to it.
35
+ *
36
+ * @param {number} layer The layer.
37
+ * @param {'face' | 'side' | 'back'} part The part.
38
+ */
39
+ begin(layer, part) {
40
+ this.parts.push({ layer, part, index: [] });
41
+ }
42
+
43
+ /**
44
+ * Adds a vertex.
45
+ *
46
+ * @param {number} x
47
+ * @param {number} y
48
+ * @param {number} z
49
+ * @param {number} nx The normal (unit length).
50
+ * @param {number} ny
51
+ * @param {number} nz
52
+ * @param {number} u The texture coordinates.
53
+ * @param {number} v
54
+ * @returns {number} Its index.
55
+ */
56
+ vertex(x, y, z, nx, ny, nz, u, v) {
57
+ this.position.push(x, y, z);
58
+ this.normal.push(nx, ny, nz);
59
+ this.uv.push(u, v);
60
+ return this.position.length / 3 - 1;
61
+ }
62
+
63
+ /**
64
+ * Adds a triangle to the current part, anticlockwise seen from outside.
65
+ *
66
+ * @param {number} a
67
+ * @param {number} b
68
+ * @param {number} c
69
+ */
70
+ triangle(a, b, c) {
71
+ this.parts[this.parts.length - 1].index.push(a, b, c);
72
+ }
73
+
74
+ /**
75
+ * The mesh as typed arrays, each layer's face, side and back parts merged into one group each.
76
+ *
77
+ * @returns {{ position: Float32Array, normal: Float32Array, uv: Float32Array, index: Uint32Array, groups: Group[] }}
78
+ */
79
+ pack() {
80
+ /** @type {Map<string, number[]>} */
81
+ const merged = new Map();
82
+ for (const p of this.parts) {
83
+ const key = `${p.layer}:${p.part}`;
84
+ const list = merged.get(key);
85
+ if (list) for (const i of p.index) list.push(i);
86
+ else merged.set(key, p.index.slice());
87
+ }
88
+ const layers = Math.max(-1, ...this.parts.map((p) => p.layer)) + 1;
89
+ /** @type {Group[]} */
90
+ const groups = [];
91
+ /** @type {number[]} */
92
+ const index = [];
93
+ for (let layer = 0; layer < layers; layer++)
94
+ PARTS.forEach((part, k) => {
95
+ const list = merged.get(`${layer}:${part}`);
96
+ if (!list?.length) return;
97
+ groups.push({ start: index.length, count: list.length, layer, part, materialIndex: layer * 3 + k });
98
+ for (const i of list) index.push(i);
99
+ });
100
+ return { position: new Float32Array(this.position), normal: new Float32Array(this.normal), uv: new Float32Array(this.uv), index: new Uint32Array(index), groups };
101
+ }
102
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @module @logomarks/logo3d/geometry/caps
3
+ *
4
+ * The front and back faces: each polygon's rings at the face's inset, triangulated by earcut, every triangle turned
5
+ * to face out (+z for the front, −z for the back). UVs map the logo's box to the unit square, mirrored on the back so
6
+ * a texture reads the right way round from behind.
7
+ */
8
+
9
+ import earcut from 'earcut';
10
+
11
+ /**
12
+ * Triangles over a polygon's rings.
13
+ *
14
+ * @param {[number, number][][]} rings The outline, then the holes (as inset).
15
+ * @returns {{ flat: number[], tris: number[] }} The points flattened, and the triangles as indices into them.
16
+ */
17
+ export function triangulate(rings) {
18
+ /** @type {number[]} */
19
+ const flat = [];
20
+ /** @type {number[]} */
21
+ const holes = [];
22
+ rings.forEach((r, i) => {
23
+ if (i) holes.push(flat.length / 2);
24
+ for (const p of r) flat.push(p[0], p[1]);
25
+ });
26
+ return { flat, tris: earcut(flat, holes.length ? holes : undefined, 2) };
27
+ }
28
+
29
+ /**
30
+ * Adds a face.
31
+ *
32
+ * @param {import('./buffer.js').MeshBuffer} mesh The mesh.
33
+ * @param {[number, number][][]} rings The polygon's rings at the face's inset.
34
+ * @param {number} z Its height.
35
+ * @param {boolean} front The front (+z) or the back (−z).
36
+ * @param {{ x0: number, y0: number, size: number }} box The logo's box, for the UVs.
37
+ */
38
+ export function addCap(mesh, rings, z, front, box) {
39
+ const { flat, tris } = triangulate(rings);
40
+ const nz = front ? 1 : -1;
41
+ const base = [];
42
+ for (let i = 0; i < flat.length; i += 2) {
43
+ const u = (flat[i] - box.x0) / box.size, v = (flat[i + 1] - box.y0) / box.size;
44
+ base.push(mesh.vertex(flat[i], flat[i + 1], z, 0, 0, nz, front ? u : 1 - u, v));
45
+ }
46
+ for (let t = 0; t < tris.length; t += 3) {
47
+ const [a, b, c] = [tris[t], tris[t + 1], tris[t + 2]];
48
+ const cross = (flat[2 * b] - flat[2 * a]) * (flat[2 * c + 1] - flat[2 * a + 1]) - (flat[2 * b + 1] - flat[2 * a + 1]) * (flat[2 * c] - flat[2 * a]);
49
+ if (cross === 0) continue;
50
+ if (cross > 0 === front) mesh.triangle(base[a], base[b], base[c]);
51
+ else mesh.triangle(base[a], base[c], base[b]);
52
+ }
53
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @module @logomarks/logo3d/geometry/check
3
+ *
4
+ * Whether rings cross: every pair of edges tested through a grid, so a bevel's levels can be checked for
5
+ * self-intersection. Edges that share a point (neighbours, or points a bevel merged) don't count as crossing.
6
+ */
7
+
8
+ /**
9
+ * Whether segments ab and cd cross properly (each strictly through the other).
10
+ *
11
+ * @param {readonly number[]} a
12
+ * @param {readonly number[]} b
13
+ * @param {readonly number[]} c
14
+ * @param {readonly number[]} d
15
+ * @returns {boolean} True when they cross.
16
+ */
17
+ export function segmentsCross(a, b, c, d) {
18
+ const side = (/** @type {readonly number[]} */ p, /** @type {readonly number[]} */ q, /** @type {readonly number[]} */ r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
19
+ const d1 = side(c, d, a), d2 = side(c, d, b), d3 = side(a, b, c), d4 = side(a, b, d);
20
+ const eps = 1e-14;
21
+ return ((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) && ((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps));
22
+ }
23
+
24
+ /**
25
+ * Whether segments ab and cd meet at all: crossing, touching (an end on the other) or overlapping along a line.
26
+ *
27
+ * @param {readonly number[]} a
28
+ * @param {readonly number[]} b
29
+ * @param {readonly number[]} c
30
+ * @param {readonly number[]} d
31
+ * @returns {boolean} True when they share a point.
32
+ */
33
+ export function segmentsMeet(a, b, c, d) {
34
+ const side = (/** @type {readonly number[]} */ p, /** @type {readonly number[]} */ q, /** @type {readonly number[]} */ r) => Math.sign((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]));
35
+ const within = (/** @type {readonly number[]} */ p, /** @type {readonly number[]} */ q, /** @type {readonly number[]} */ r) =>
36
+ Math.min(p[0], q[0]) <= r[0] && r[0] <= Math.max(p[0], q[0]) && Math.min(p[1], q[1]) <= r[1] && r[1] <= Math.max(p[1], q[1]);
37
+ const d1 = side(c, d, a), d2 = side(c, d, b), d3 = side(a, b, c), d4 = side(a, b, d);
38
+ if (d1 * d2 < 0 && d3 * d4 < 0) return true;
39
+ return (d1 === 0 && within(c, d, a)) || (d2 === 0 && within(c, d, b)) || (d3 === 0 && within(a, b, c)) || (d4 === 0 && within(a, b, d));
40
+ }
41
+
42
+ /**
43
+ * The first pair of crossing edges among some rings, or null.
44
+ *
45
+ * @param {readonly (readonly (readonly number[])[])[]} rings The rings (open).
46
+ * @param {{ cells?: number }} [o] `cells`: the grid's cells across (64).
47
+ * @returns {{ a: [number, number], b: [number, number] } | null} The two edges (ring, edge), or null when none cross.
48
+ */
49
+ export function firstCrossing(rings, { cells = 64 } = {}) {
50
+ /** @type {[number, number, readonly number[], readonly number[]][]} */
51
+ const edges = [];
52
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
53
+ rings.forEach((r, ri) =>
54
+ r.forEach((p, i) => {
55
+ const q = r[(i + 1) % r.length];
56
+ if (p[0] === q[0] && p[1] === q[1]) return;
57
+ edges.push([ri, i, p, q]);
58
+ x0 = Math.min(x0, p[0]);
59
+ y0 = Math.min(y0, p[1]);
60
+ x1 = Math.max(x1, p[0]);
61
+ y1 = Math.max(y1, p[1]);
62
+ }),
63
+ );
64
+ const w = (x1 - x0) / cells || 1, h = (y1 - y0) / cells || 1;
65
+ /** @type {Map<number, number[]>} */
66
+ const grid = new Map();
67
+ const cell = (/** @type {number} */ v, /** @type {number} */ o, /** @type {number} */ s) => Math.min(cells - 1, Math.max(0, Math.floor((v - o) / s)));
68
+ edges.forEach(([, , p, q], e) => {
69
+ for (let cx = cell(Math.min(p[0], q[0]), x0, w); cx <= cell(Math.max(p[0], q[0]), x0, w); cx++)
70
+ for (let cy = cell(Math.min(p[1], q[1]), y0, h); cy <= cell(Math.max(p[1], q[1]), y0, h); cy++) {
71
+ const k = cy * cells + cx;
72
+ const list = grid.get(k);
73
+ if (list) list.push(e);
74
+ else grid.set(k, [e]);
75
+ }
76
+ });
77
+ const shares = (/** @type {readonly number[][]} */ [a, b, c, d]) => [c, d].some((s) => (s[0] === a[0] && s[1] === a[1]) || (s[0] === b[0] && s[1] === b[1]));
78
+ for (const list of grid.values())
79
+ for (let i = 0; i < list.length; i++)
80
+ for (let j = i + 1; j < list.length; j++) {
81
+ const A = edges[list[i]], B = edges[list[j]];
82
+ if (shares([A[2], A[3], B[2], B[3]])) continue;
83
+ if (segmentsCross(A[2], A[3], B[2], B[3])) return { a: [A[0], A[1]], b: [B[0], B[1]] };
84
+ }
85
+ return null;
86
+ }