@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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +55 -2
- package/assets/hdri/kloofendal_48d_partly_cloudy_puresky_1k.hdr +0 -0
- package/assets/hdri/studio_small_09_1k.hdr +0 -0
- package/package.json +82 -4
- package/src/export/glb.d.ts +5 -0
- package/src/export/glb.js +28 -0
- package/src/export/still.d.ts +23 -0
- package/src/export/still.js +144 -0
- package/src/geometry/buffer.js +102 -0
- package/src/geometry/caps.js +53 -0
- package/src/geometry/check.js +86 -0
- package/src/geometry/index.d.ts +114 -0
- package/src/geometry/index.js +112 -0
- package/src/geometry/inset.js +339 -0
- package/src/geometry/profiles.js +126 -0
- package/src/geometry/sides.js +86 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +19 -0
- package/src/materials/factory.d.ts +11 -0
- package/src/materials/factory.js +112 -0
- package/src/materials/presets.d.ts +34 -0
- package/src/materials/presets.js +79 -0
- package/src/motion/index.d.ts +23 -0
- package/src/motion/index.js +56 -0
- package/src/scene/backdrop.d.ts +20 -0
- package/src/scene/backdrop.js +128 -0
- package/src/scene/camera.d.ts +10 -0
- package/src/scene/camera.js +51 -0
- package/src/scene/environments.d.ts +22 -0
- package/src/scene/environments.js +94 -0
- package/src/scene/studio.d.ts +12 -0
- package/src/scene/studio.js +81 -0
- package/src/scene/view.d.ts +69 -0
- package/src/scene/view.js +273 -0
- package/src/shape/index.d.ts +80 -0
- package/src/shape/index.js +32 -0
- package/src/shape/layers.js +142 -0
- package/src/shape/matrix.js +88 -0
- package/src/shape/normalise.js +92 -0
- package/src/shape/simplify.js +95 -0
- package/src/shape/svg.js +196 -0
- package/src/shape/thinnest.js +77 -0
- package/src/three/geometry.d.ts +5 -0
- package/src/three/geometry.js +25 -0
- package/src/three/index.d.ts +11 -0
- package/src/three/index.js +15 -0
- package/src/worker/build.d.ts +6 -0
- package/src/worker/build.js +37 -0
- package/src/worker/client.d.ts +29 -0
- package/src/worker/client.js +83 -0
- package/src/worker/worker.d.ts +2 -0
- package/src/worker/worker.js +23 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { LogoShape, Ring2, Polygon2 } from '../shape/index.js';
|
|
2
|
+
|
|
3
|
+
/** An edge profile's name. */
|
|
4
|
+
export type ProfileKind = 'flat' | 'chamfer' | 'round' | 'pillow';
|
|
5
|
+
/** Which part of the logo a draw group is. */
|
|
6
|
+
export type Part = 'face' | 'side' | 'back';
|
|
7
|
+
|
|
8
|
+
/** How a logo is extruded. */
|
|
9
|
+
export interface GeometryOptions {
|
|
10
|
+
/** The edge: `'flat'`, `'chamfer'`, `'round'` (the default) or `'pillow'`. */
|
|
11
|
+
profile?: ProfileKind;
|
|
12
|
+
/** The depth, as a share of the longest side (0.14). */
|
|
13
|
+
depth?: number;
|
|
14
|
+
/** The bevel, 0–1: a share of the most the logo takes (0.6). */
|
|
15
|
+
bevel?: number;
|
|
16
|
+
/** Segments in each curve of a round (5) or pillow (8) edge. */
|
|
17
|
+
segments?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One draw group: a layer's face, side or back. */
|
|
21
|
+
export interface Group {
|
|
22
|
+
start: number;
|
|
23
|
+
count: number;
|
|
24
|
+
layer: number;
|
|
25
|
+
part: Part;
|
|
26
|
+
/** `layer * 3` plus 0 (face), 1 (side) or 2 (back). */
|
|
27
|
+
materialIndex: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A built geometry: typed arrays ready to post from a worker and wrap in a BufferGeometry. */
|
|
31
|
+
export interface LogoGeometry {
|
|
32
|
+
position: Float32Array;
|
|
33
|
+
normal: Float32Array;
|
|
34
|
+
uv: Float32Array;
|
|
35
|
+
index: Uint32Array;
|
|
36
|
+
groups: Group[];
|
|
37
|
+
bounds: { min: [number, number, number]; max: [number, number, number] };
|
|
38
|
+
/** The bevel's width as built (capped, and cut back if a level crossed itself). */
|
|
39
|
+
bevel: number;
|
|
40
|
+
depth: number;
|
|
41
|
+
profile: string;
|
|
42
|
+
triangles: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A point of an edge profile. */
|
|
46
|
+
export interface ProfilePoint {
|
|
47
|
+
inset: number;
|
|
48
|
+
z: number;
|
|
49
|
+
}
|
|
50
|
+
/** An edge profile, back to front. */
|
|
51
|
+
export interface Profile {
|
|
52
|
+
points: ProfilePoint[];
|
|
53
|
+
smooth: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The geometry's defaults. */
|
|
57
|
+
export const GEOMETRY_DEFAULTS: Readonly<{ profile: ProfileKind; depth: number; bevel: number }>;
|
|
58
|
+
/** The profiles. */
|
|
59
|
+
export const PROFILES: readonly ProfileKind[];
|
|
60
|
+
/** The parts, in draw order within a layer. */
|
|
61
|
+
export const PARTS: readonly Part[];
|
|
62
|
+
|
|
63
|
+
/** A logo's geometry. Pure and deterministic. */
|
|
64
|
+
export function buildGeometry(shape: LogoShape, o?: GeometryOptions): LogoGeometry;
|
|
65
|
+
/** Every polygon made ready to inset, the bevel cut back while any deepest level crosses itself. */
|
|
66
|
+
export function prepareInsets(shape: LogoShape, bevel: number): { bevel: number; polys: { pg: Polygon2; levels: Map<number, Ring2[]>; inset: (d: number) => Ring2[] }[][] };
|
|
67
|
+
/** A profile's points. */
|
|
68
|
+
export function profile(kind: string, o: { depth: number; bevel: number; segments?: number }): Profile;
|
|
69
|
+
/** Each profile point's shading normal, and each segment's rows. */
|
|
70
|
+
export function profileRows(p: Profile): { rows: { k: number; n: [number, number] }[]; bands: [number, number][] };
|
|
71
|
+
/** The bevel's width for a look. */
|
|
72
|
+
export function bevelWidth(kind: string, o: { depth: number; bevel: number; halfStroke: number }): number;
|
|
73
|
+
/** Prepares a polygon for insetting to any depth up to `reach`. */
|
|
74
|
+
export function insetter(rings: readonly (readonly (readonly number[])[])[], reach: number): (d: number) => Ring2[];
|
|
75
|
+
/** A polygon moved inwards by `d`, point for point. */
|
|
76
|
+
export function insetPolygon(rings: readonly (readonly (readonly number[])[])[], d: number): Ring2[];
|
|
77
|
+
/** One ring moved inwards by `d`, point for point. */
|
|
78
|
+
export function insetRing(r: readonly (readonly number[])[], d: number): Ring2;
|
|
79
|
+
/** Each edge's unit normal into the solid. */
|
|
80
|
+
export function edgeNormals(r: readonly (readonly number[])[]): [number, number][];
|
|
81
|
+
/** Each point's direction inwards: the bisector of the solid's angle, looking `span` along the ring each way. */
|
|
82
|
+
export function pointNormals(r: readonly (readonly number[])[], span?: number): [number, number][];
|
|
83
|
+
/** The distance from a point to a polygon's outline, up to `reach`. */
|
|
84
|
+
export function outlineDistance(rings: readonly (readonly (readonly number[])[])[], reach: number): (x: number, y: number) => number;
|
|
85
|
+
/** How far a point travels along a direction to be `d` from the outline, and whether it stopped short. */
|
|
86
|
+
export function travel(dist: (x: number, y: number) => number, p: readonly number[], m: readonly number[], d: number): { t: number; short: boolean };
|
|
87
|
+
/** Closes each run of points that stopped short to the corner where the level's sides meet. */
|
|
88
|
+
export function closeShort(r: readonly (readonly number[])[], p: [number, number][], short: boolean[], d: number, fits?: (q: [number, number]) => boolean): [number, number][];
|
|
89
|
+
/** Where two lines meet, or null when parallel. */
|
|
90
|
+
export function lineMeet(a: readonly number[], u: readonly number[], b: readonly number[], v: readonly number[]): [number, number] | null;
|
|
91
|
+
/** Whether a point is inside a polygon (even-odd). */
|
|
92
|
+
export function inside(rings: readonly (readonly (readonly number[])[])[], q: readonly number[]): boolean;
|
|
93
|
+
/** Merges points wherever an inset edge turned over. */
|
|
94
|
+
export function mergeTurned(r: readonly (readonly number[])[], p: [number, number][]): [number, number][];
|
|
95
|
+
/** The first pair of crossing edges among some rings, or null. */
|
|
96
|
+
export function firstCrossing(rings: readonly (readonly (readonly number[])[])[], o?: { cells?: number }): { a: [number, number]; b: [number, number] } | null;
|
|
97
|
+
/** Whether two segments cross properly. */
|
|
98
|
+
export function segmentsCross(a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[]): boolean;
|
|
99
|
+
/** Triangles over a polygon's rings (earcut). */
|
|
100
|
+
export function triangulate(rings: [number, number][][]): { flat: number[]; tris: number[] };
|
|
101
|
+
/** A ring's side columns: one per point along curves, two at hard corners. */
|
|
102
|
+
export function columns(r: readonly (readonly number[])[]): { cols: { i: number; n: [number, number]; u: number }[]; edges: [number, number][] };
|
|
103
|
+
/** A growing indexed mesh. */
|
|
104
|
+
export class MeshBuffer {
|
|
105
|
+
position: number[];
|
|
106
|
+
normal: number[];
|
|
107
|
+
uv: number[];
|
|
108
|
+
begin(layer: number, part: Part): void;
|
|
109
|
+
vertex(x: number, y: number, z: number, nx: number, ny: number, nz: number, u: number, v: number): number;
|
|
110
|
+
triangle(a: number, b: number, c: number): void;
|
|
111
|
+
pack(): { position: Float32Array; normal: Float32Array; uv: Float32Array; index: Uint32Array; groups: Group[] };
|
|
112
|
+
}
|
|
113
|
+
/** Whether two segments meet at all (crossing, touching or overlapping). */
|
|
114
|
+
export function segmentsMeet(a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[]): boolean;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @logomarks/logo3d/geometry
|
|
3
|
+
*
|
|
4
|
+
* A logo's shape extruded: for every polygon of every colour layer, its rings inset to each point of the edge
|
|
5
|
+
* profile, the sides banded between those levels, and the front and back faces triangulated at the face's inset.
|
|
6
|
+
* The bevel is capped below half the thinnest stroke, so no level crosses itself. The result is plain typed arrays
|
|
7
|
+
* (positions, normals, UVs, indices) with one draw group per layer and part (face, side, back), centred on the
|
|
8
|
+
* origin in depth: ready to post from a worker and wrap in a three.js BufferGeometry. Pure and deterministic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { MeshBuffer } from './buffer.js';
|
|
12
|
+
import { addCap } from './caps.js';
|
|
13
|
+
import { addSide } from './sides.js';
|
|
14
|
+
import { insetter } from './inset.js';
|
|
15
|
+
import { firstCrossing } from './check.js';
|
|
16
|
+
import { bevelWidth, profile, profileRows } from './profiles.js';
|
|
17
|
+
|
|
18
|
+
export { PROFILES, profile, profileRows, bevelWidth } from './profiles.js';
|
|
19
|
+
export { insetter, insetPolygon, insetRing, edgeNormals, pointNormals, outlineIndex, outlineDistance, travel, closeShort, lineMeet, inside, mergeTurned } from './inset.js';
|
|
20
|
+
export { firstCrossing, segmentsCross, segmentsMeet } from './check.js';
|
|
21
|
+
export { triangulate } from './caps.js';
|
|
22
|
+
export { columns } from './sides.js';
|
|
23
|
+
export { MeshBuffer, PARTS } from './buffer.js';
|
|
24
|
+
|
|
25
|
+
/** The geometry's defaults. */
|
|
26
|
+
export const GEOMETRY_DEFAULTS = Object.freeze({ profile: 'round', depth: 0.14, bevel: 0.6 });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The box round a shape's outlines, for face UVs.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('../shape/index.js').LogoShape} shape The shape.
|
|
32
|
+
* @returns {{ x0: number, y0: number, size: number }} Its corner and longest side.
|
|
33
|
+
*/
|
|
34
|
+
function uvBox(shape) {
|
|
35
|
+
return { x0: -shape.width / 2, y0: -shape.height / 2, size: Math.max(shape.width, shape.height) || 1 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every polygon of every layer made ready to inset up to the bevel, the bevel cut back (by 0.7 a try, up to three tries)
|
|
40
|
+
* while any polygon's deepest level crosses itself: a wall thinner than the cap's raster can see (a hairline between
|
|
41
|
+
* a hole and the outline) is the only way that happens.
|
|
42
|
+
*
|
|
43
|
+
* @param {import('../shape/index.js').LogoShape} shape The shape.
|
|
44
|
+
* @param {number} bevel The bevel wanted.
|
|
45
|
+
* @returns {{ bevel: number, polys: { pg: [number, number][][], levels: Map<number, [number, number][][]>, inset: (d: number) => [number, number][][] }[][] }}
|
|
46
|
+
* The bevel that holds, and each layer's polygons with their deepest level worked out.
|
|
47
|
+
*/
|
|
48
|
+
export function prepareInsets(shape, bevel) {
|
|
49
|
+
for (let tries = 0; ; tries++) {
|
|
50
|
+
const polys = shape.layers.map((l) =>
|
|
51
|
+
l.polygons.map((pg) => {
|
|
52
|
+
const inset = insetter(pg, bevel);
|
|
53
|
+
return { pg, inset, levels: new Map([[bevel, inset(bevel)]]) };
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
const crossed = bevel > 0 && polys.some((ps) => ps.some((p) => firstCrossing(/** @type {[number, number][][]} */ (p.levels.get(bevel)))));
|
|
57
|
+
if (!crossed || tries >= 3) return { bevel, polys };
|
|
58
|
+
bevel *= 0.7;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A logo's geometry.
|
|
64
|
+
*
|
|
65
|
+
* @param {import('../shape/index.js').LogoShape} shape The shape (`readLogo`).
|
|
66
|
+
* @param {import('./index.js').GeometryOptions} [o] The profile, depth and bevel.
|
|
67
|
+
* @returns {import('./index.js').LogoGeometry} The geometry.
|
|
68
|
+
*/
|
|
69
|
+
export function buildGeometry(shape, o = {}) {
|
|
70
|
+
const kind = o.profile ?? GEOMETRY_DEFAULTS.profile;
|
|
71
|
+
const depth = o.depth ?? GEOMETRY_DEFAULTS.depth;
|
|
72
|
+
const prepared = prepareInsets(shape, bevelWidth(kind, { depth, bevel: o.bevel ?? GEOMETRY_DEFAULTS.bevel, halfStroke: shape.halfStroke }));
|
|
73
|
+
const bevel = prepared.bevel;
|
|
74
|
+
const prof = profile(kind, { depth, bevel, segments: o.segments });
|
|
75
|
+
const shading = profileRows(prof);
|
|
76
|
+
const pts = prof.points;
|
|
77
|
+
const z0 = -depth / 2;
|
|
78
|
+
const box = uvBox(shape);
|
|
79
|
+
const mesh = new MeshBuffer();
|
|
80
|
+
const insets = pts.map((p) => p.inset);
|
|
81
|
+
shape.layers.forEach((layer, li) => {
|
|
82
|
+
const polys = prepared.polys[li].map(({ pg, inset, levels }) => {
|
|
83
|
+
for (const d of insets) if (!levels.has(d)) levels.set(d, inset(d));
|
|
84
|
+
return { pg, at: insets.map((d) => /** @type {[number, number][][]} */ (levels.get(d))) };
|
|
85
|
+
});
|
|
86
|
+
mesh.begin(li, 'face');
|
|
87
|
+
for (const { at } of polys) addCap(mesh, at[at.length - 1], z0 + depth, true, box);
|
|
88
|
+
mesh.begin(li, 'side');
|
|
89
|
+
for (const { pg, at } of polys)
|
|
90
|
+
pg.forEach((ring, ri) =>
|
|
91
|
+
addSide(
|
|
92
|
+
mesh,
|
|
93
|
+
ring,
|
|
94
|
+
at.map((rings) => rings[ri]),
|
|
95
|
+
prof,
|
|
96
|
+
shading,
|
|
97
|
+
z0,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
mesh.begin(li, 'back');
|
|
101
|
+
for (const { at } of polys) addCap(mesh, at[0], z0, false, box);
|
|
102
|
+
});
|
|
103
|
+
const packed = mesh.pack();
|
|
104
|
+
return {
|
|
105
|
+
...packed,
|
|
106
|
+
bounds: { min: [-shape.width / 2, -shape.height / 2, z0], max: [shape.width / 2, shape.height / 2, z0 + depth] },
|
|
107
|
+
bevel,
|
|
108
|
+
depth,
|
|
109
|
+
profile: kind,
|
|
110
|
+
triangles: packed.index.length / 3,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @logomarks/logo3d/geometry/inset
|
|
3
|
+
*
|
|
4
|
+
* A polygon moved inwards by a distance, point for point, so every level of a bevel has the same points and the
|
|
5
|
+
* bands between levels are simple quads. Each point travels inwards along its normal (its two edges' normals
|
|
6
|
+
* weighted by their lengths, so a stray short edge doesn't swing it) until it is exactly the distance from the
|
|
7
|
+
* outline: every point lands on the true round offset, a spike's tip as far in as its sides allow, a notch rounded.
|
|
8
|
+
* Where points pass each other (a convex curve tighter than the distance), the edges between them turn over, and
|
|
9
|
+
* those points are merged. The shape's own cap (half its thinnest stroke) keeps the far sides of a stroke apart.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { segDist2 } from '../shape/simplify.js';
|
|
13
|
+
import { segmentsMeet } from './check.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Each edge's unit normal pointing into the solid (to the left of the ring's direction).
|
|
17
|
+
*
|
|
18
|
+
* @param {readonly (readonly number[])[]} r The ring (open, the solid on its left).
|
|
19
|
+
* @returns {[number, number][]} One normal per edge, edge i running from point i to point i + 1.
|
|
20
|
+
*/
|
|
21
|
+
export function edgeNormals(r) {
|
|
22
|
+
const n = r.length;
|
|
23
|
+
/** @type {[number, number][]} */
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let i = 0; i < n; i++) {
|
|
26
|
+
const a = r[i], b = r[(i + 1) % n];
|
|
27
|
+
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
28
|
+
const l = Math.hypot(dx, dy) || 1;
|
|
29
|
+
out.push([-dy / l, dx / l]);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Each point's direction inwards: the bisector of the solid's angle there, the directions in and out taken from
|
|
36
|
+
* points at least `span` away along the ring, so a step shorter than that (a stray notch) doesn't swing it. At a
|
|
37
|
+
* convex point it splits the corner; at a reflex point (a notch, a slit's end) it points away from the notch.
|
|
38
|
+
*
|
|
39
|
+
* @param {readonly (readonly number[])[]} r The ring (open, the solid on its left).
|
|
40
|
+
* @param {number} [span] How far along the ring to look each way (0: the neighbours).
|
|
41
|
+
* @returns {[number, number][]} One unit vector per point.
|
|
42
|
+
*/
|
|
43
|
+
export function pointNormals(r, span = 0) {
|
|
44
|
+
const n = r.length;
|
|
45
|
+
const len = r.map((p, i) => Math.hypot(r[(i + 1) % n][0] - p[0], r[(i + 1) % n][1] - p[1]));
|
|
46
|
+
/** The unit direction from point i to the first point at least `span` along, forwards (+1) or backwards (-1). */
|
|
47
|
+
const towards = (/** @type {number} */ i, /** @type {number} */ step) => {
|
|
48
|
+
let j = i, run = 0;
|
|
49
|
+
for (let c = 0; c < n - 1; c++) {
|
|
50
|
+
run += len[step > 0 ? j : (j + n - 1) % n];
|
|
51
|
+
j = (j + step + n) % n;
|
|
52
|
+
if (run >= span && run > 0) break;
|
|
53
|
+
}
|
|
54
|
+
const dx = r[j][0] - r[i][0], dy = r[j][1] - r[i][1], l = Math.hypot(dx, dy) || 1;
|
|
55
|
+
return [dx / l, dy / l];
|
|
56
|
+
};
|
|
57
|
+
return r.map((_, i) => {
|
|
58
|
+
const back = towards(i, -1), out = towards(i, 1);
|
|
59
|
+
const inn = [-back[0], -back[1]];
|
|
60
|
+
// the bisector of the angle between where it came from and where it goes
|
|
61
|
+
let x = out[0] + back[0], y = out[1] + back[1];
|
|
62
|
+
const turn = inn[0] * out[1] - inn[1] * out[0];
|
|
63
|
+
let l = Math.hypot(x, y);
|
|
64
|
+
if (l < 1e-9) {
|
|
65
|
+
// straight on: the normal
|
|
66
|
+
(x = -out[1]), (y = out[0]), (l = 1);
|
|
67
|
+
} else if (turn < 0) {
|
|
68
|
+
// reflex: the bisector points out of the solid; turn it round
|
|
69
|
+
(x = -x), (y = -y);
|
|
70
|
+
}
|
|
71
|
+
return [x / l, y / l];
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A grid over a polygon's edges: the distance from a point to its outline, and whether a short step crosses it.
|
|
77
|
+
*
|
|
78
|
+
* @param {readonly (readonly (readonly number[])[])[]} rings The rings.
|
|
79
|
+
* @param {number} reach The farthest distance asked about, and the longest step.
|
|
80
|
+
* @returns {{ dist: (x: number, y: number) => number, crosses: (a: readonly number[], b: readonly number[]) => boolean }}
|
|
81
|
+
* The distance to the outline, up to `reach` (anything farther is `reach`); and whether the step from a to b (no
|
|
82
|
+
* longer than `reach`) meets an edge (crossing it, or through one of its ends).
|
|
83
|
+
*/
|
|
84
|
+
export function outlineIndex(rings, reach) {
|
|
85
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
86
|
+
for (const r of rings) for (const p of r) (x0 = Math.min(x0, p[0])), (y0 = Math.min(y0, p[1])), (x1 = Math.max(x1, p[0])), (y1 = Math.max(y1, p[1]));
|
|
87
|
+
const cell = Math.max(reach, (x1 - x0) / 96, (y1 - y0) / 96, 1e-9);
|
|
88
|
+
const cols = Math.max(1, Math.ceil((x1 - x0) / cell) + 1);
|
|
89
|
+
/** @type {Map<number, (readonly number[])[][]>} */
|
|
90
|
+
const grid = new Map();
|
|
91
|
+
for (const r of rings)
|
|
92
|
+
r.forEach((a, i) => {
|
|
93
|
+
const b = r[(i + 1) % r.length];
|
|
94
|
+
for (let cx = Math.floor((Math.min(a[0], b[0]) - x0) / cell); cx <= Math.floor((Math.max(a[0], b[0]) - x0) / cell); cx++)
|
|
95
|
+
for (let cy = Math.floor((Math.min(a[1], b[1]) - y0) / cell); cy <= Math.floor((Math.max(a[1], b[1]) - y0) / cell); cy++) {
|
|
96
|
+
const k = cy * cols + cx;
|
|
97
|
+
const list = grid.get(k);
|
|
98
|
+
if (list) list.push([a, b]);
|
|
99
|
+
else grid.set(k, [[a, b]]);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
/** Every edge in the cells round a point. */
|
|
103
|
+
const near = function* (/** @type {number} */ x, /** @type {number} */ y) {
|
|
104
|
+
const cx = Math.floor((x - x0) / cell), cy = Math.floor((y - y0) / cell);
|
|
105
|
+
for (let j = cy - 1; j <= cy + 1; j++)
|
|
106
|
+
for (let i = cx - 1; i <= cx + 1; i++) {
|
|
107
|
+
if (i < 0 || j < 0 || i >= cols) continue;
|
|
108
|
+
const list = grid.get(j * cols + i);
|
|
109
|
+
if (list) yield* list;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
dist(x, y) {
|
|
114
|
+
let best = reach * reach;
|
|
115
|
+
const p = [x, y];
|
|
116
|
+
for (const [a, b] of near(x, y)) best = Math.min(best, segDist2(p, a, b));
|
|
117
|
+
return Math.sqrt(best);
|
|
118
|
+
},
|
|
119
|
+
crosses(a, b) {
|
|
120
|
+
for (const [c, d] of near(a[0], a[1])) if (segmentsMeet(a, b, c, d)) return true;
|
|
121
|
+
return false;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The distance from a point to a polygon's outline, up to `reach`.
|
|
128
|
+
*
|
|
129
|
+
* @param {readonly (readonly (readonly number[])[])[]} rings The rings.
|
|
130
|
+
* @param {number} reach The farthest distance asked about.
|
|
131
|
+
* @returns {(x: number, y: number) => number} The distance.
|
|
132
|
+
*/
|
|
133
|
+
export const outlineDistance = (rings, reach) => outlineIndex(rings, reach).dist;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How far a point must travel along a direction to be `d` from the outline: the first such distance, by marching
|
|
137
|
+
* then halving. Where the solid is thinner than 2d (towards a tip), the point crosses the middle of the stroke
|
|
138
|
+
* before it gets there, and the distance starts to fall: it stops on the middle, and is marked short.
|
|
139
|
+
*
|
|
140
|
+
* @param {(x: number, y: number) => number} dist The distance to the outline.
|
|
141
|
+
* @param {readonly number[]} p The point.
|
|
142
|
+
* @param {readonly number[]} m The direction (unit).
|
|
143
|
+
* @param {number} d The distance wanted.
|
|
144
|
+
* @param {number} [far] The farthest it may go (12d): a long thin spike's tip draws back a long way.
|
|
145
|
+
* @param {(a: readonly number[], b: readonly number[]) => boolean} [crosses] Whether a step crosses the outline: a
|
|
146
|
+
* step that would leave the solid (through a sliver thinner than a step) stops it short.
|
|
147
|
+
* @returns {{ t: number, short: boolean }} How far it travels, and whether it stopped short.
|
|
148
|
+
*/
|
|
149
|
+
export function travel(dist, p, m, d, far = 12 * d, crosses = () => false) {
|
|
150
|
+
const at = (/** @type {number} */ t) => dist(p[0] + m[0] * t, p[1] + m[1] * t);
|
|
151
|
+
const pt = (/** @type {number} */ t) => [p[0] + m[0] * t, p[1] + m[1] * t];
|
|
152
|
+
const step = d / 8;
|
|
153
|
+
// the march starts a hair inside, off the point's own edges, so meeting an edge means leaving the solid
|
|
154
|
+
const start = d * 1e-3;
|
|
155
|
+
let lo = 0, last = 0;
|
|
156
|
+
for (let hi = step; hi <= far + 1e-12; hi += step) {
|
|
157
|
+
if (crosses(pt(Math.max(lo, start)), pt(hi))) return { t: lo, short: true };
|
|
158
|
+
const v = at(hi);
|
|
159
|
+
if (v >= d * (1 - 1e-9)) {
|
|
160
|
+
let h = hi;
|
|
161
|
+
for (let k = 0; k < 24; k++) {
|
|
162
|
+
const mid = (lo + h) / 2;
|
|
163
|
+
if (at(mid) >= d) h = mid;
|
|
164
|
+
else lo = mid;
|
|
165
|
+
}
|
|
166
|
+
return { t: h, short: false };
|
|
167
|
+
}
|
|
168
|
+
// nearer the outline than a step ago: past the stroke's middle
|
|
169
|
+
if (v < last - 1e-12 * d) return { t: lo, short: true };
|
|
170
|
+
last = v;
|
|
171
|
+
lo = hi;
|
|
172
|
+
}
|
|
173
|
+
return { t: lo, short: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Where two lines meet: through a along u, and through b along v; null when they run (nearly) parallel.
|
|
178
|
+
*
|
|
179
|
+
* @param {readonly number[]} a
|
|
180
|
+
* @param {readonly number[]} u
|
|
181
|
+
* @param {readonly number[]} b
|
|
182
|
+
* @param {readonly number[]} v
|
|
183
|
+
* @returns {[number, number] | null} The point.
|
|
184
|
+
*/
|
|
185
|
+
export function lineMeet(a, u, b, v) {
|
|
186
|
+
const den = u[0] * v[1] - u[1] * v[0];
|
|
187
|
+
if (Math.abs(den) < 1e-6 * Math.hypot(u[0], u[1]) * Math.hypot(v[0], v[1])) return null;
|
|
188
|
+
const k = ((b[0] - a[0]) * v[1] - (b[1] - a[1]) * v[0]) / den;
|
|
189
|
+
return [a[0] + k * u[0], a[1] + k * u[1]];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Closes up each run of points that stopped short (near a convex corner, or a tip thinner than twice the inset):
|
|
194
|
+
* they all go to the corner where the level's two sides meet, the lines on through the points either side of the
|
|
195
|
+
* run (which did reach the inset) along their edges, or to those two points' mean when the lines run parallel or
|
|
196
|
+
* meet far off. So the level turns the corner in one point instead of folding back along the stroke's middle.
|
|
197
|
+
*
|
|
198
|
+
* @param {readonly (readonly number[])[]} r The original ring.
|
|
199
|
+
* @param {[number, number][]} p The inset points (changed in place).
|
|
200
|
+
* @param {boolean[]} short Which stopped short.
|
|
201
|
+
* @param {number} d The inset.
|
|
202
|
+
* @param {(q: [number, number]) => boolean} [fits] Whether a corner is a fair place to close to (inside the solid,
|
|
203
|
+
* about `d` from the outline); the lines' meeting point is tried first, then the two points' mean; when neither fits,
|
|
204
|
+
* the run's points stay where they stopped.
|
|
205
|
+
* @returns {[number, number][]} The same points.
|
|
206
|
+
*/
|
|
207
|
+
export function closeShort(r, p, short, d, fits = () => true) {
|
|
208
|
+
const n = p.length;
|
|
209
|
+
const start = short.findIndex((s) => !s);
|
|
210
|
+
if (start < 0) {
|
|
211
|
+
// all short: the polygon is thinner than the inset everywhere; it closes to its middle
|
|
212
|
+
const c = p.reduce((a, q) => [a[0] + q[0] / n, a[1] + q[1] / n], [0, 0]);
|
|
213
|
+
return p.map(() => /** @type {[number, number]} */ ([c[0], c[1]]));
|
|
214
|
+
}
|
|
215
|
+
const dir = (/** @type {number} */ i, /** @type {number} */ j) => [r[j][0] - r[i][0], r[j][1] - r[i][1]];
|
|
216
|
+
for (let c = 0; c < n; c++) {
|
|
217
|
+
const i = (start + c) % n;
|
|
218
|
+
if (!short[i] || short[(i + n - 1) % n]) continue;
|
|
219
|
+
// a run begins at i: find its end
|
|
220
|
+
let j = i, len = 0;
|
|
221
|
+
while (short[j] && len < n) (j = (j + 1) % n), len++;
|
|
222
|
+
const h = (i + n - 1) % n;
|
|
223
|
+
const a = p[h], b = p[j];
|
|
224
|
+
/** @type {[number, number]} */
|
|
225
|
+
const mean = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
|
226
|
+
const hit = lineMeet(a, dir(h, (h + 1) % n), b, dir((j + n - 1) % n, j));
|
|
227
|
+
const at = [hit, mean].find((q) => q && Math.hypot(q[0] - mean[0], q[1] - mean[1]) < 4 * d && fits(q));
|
|
228
|
+
// neither is a fair corner: the run's points stay where they stopped, inside the solid
|
|
229
|
+
if (at) for (let k = i, q = 0; q < len; k = (k + 1) % n, q++) p[k] = [at[0], at[1]];
|
|
230
|
+
}
|
|
231
|
+
return p;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Merges points wherever an inset edge runs backwards against its original edge, until none does: the run of
|
|
236
|
+
* points either side of a turned edge meet at their mean.
|
|
237
|
+
*
|
|
238
|
+
* @param {readonly (readonly number[])[]} r The original ring.
|
|
239
|
+
* @param {[number, number][]} p The inset points (changed in place).
|
|
240
|
+
* @returns {[number, number][]} The same points.
|
|
241
|
+
*/
|
|
242
|
+
export function mergeTurned(r, p) {
|
|
243
|
+
const n = p.length;
|
|
244
|
+
// each point's run: runs are consecutive points merged into one position
|
|
245
|
+
const run = Int32Array.from({ length: n }, (_, i) => i);
|
|
246
|
+
const same = (/** @type {number} */ i, /** @type {number} */ j) => run[i] === run[j];
|
|
247
|
+
for (let pass = 0; pass < n; pass++) {
|
|
248
|
+
let turned = false;
|
|
249
|
+
for (let i = 0; i < n; i++) {
|
|
250
|
+
const j = (i + 1) % n;
|
|
251
|
+
if (same(i, j)) continue;
|
|
252
|
+
const ex = r[j][0] - r[i][0], ey = r[j][1] - r[i][1];
|
|
253
|
+
if ((p[j][0] - p[i][0]) * ex + (p[j][1] - p[i][1]) * ey >= 0) continue;
|
|
254
|
+
turned = true;
|
|
255
|
+
let first = i, count = 2;
|
|
256
|
+
while (count < n && same((first + n - 1) % n, i)) (first = (first + n - 1) % n), count++;
|
|
257
|
+
for (let last = j; count < n && same((last + 1) % n, j); ) (last = (last + 1) % n), count++;
|
|
258
|
+
let x = 0, y = 0;
|
|
259
|
+
for (let k = first, c = 0; c < count; k = (k + 1) % n, c++) (x += p[k][0]), (y += p[k][1]);
|
|
260
|
+
for (let k = first, c = 0; c < count; k = (k + 1) % n, c++) (p[k] = [x / count, y / count]), (run[k] = run[i]);
|
|
261
|
+
}
|
|
262
|
+
if (!turned) break;
|
|
263
|
+
}
|
|
264
|
+
return p;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether a point is inside a polygon (even-odd over its rings).
|
|
269
|
+
*
|
|
270
|
+
* @param {readonly (readonly (readonly number[])[])[]} rings The rings.
|
|
271
|
+
* @param {readonly number[]} q The point.
|
|
272
|
+
* @returns {boolean} True inside.
|
|
273
|
+
*/
|
|
274
|
+
export function inside(rings, q) {
|
|
275
|
+
let c = false;
|
|
276
|
+
for (const r of rings)
|
|
277
|
+
for (let i = 0, j = r.length - 1; i < r.length; j = i++)
|
|
278
|
+
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;
|
|
279
|
+
return c;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* How far along a ring a stray step can run and still be ignored when working out a point's direction: 1/150 of the
|
|
284
|
+
* polygon's size.
|
|
285
|
+
*
|
|
286
|
+
* @param {readonly (readonly (readonly number[])[])[]} rings The rings.
|
|
287
|
+
* @returns {number} The span.
|
|
288
|
+
*/
|
|
289
|
+
const spanOf = (rings) => {
|
|
290
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
291
|
+
for (const p of rings[0] ?? []) (x0 = Math.min(x0, p[0])), (x1 = Math.max(x1, p[0])), (y0 = Math.min(y0, p[1])), (y1 = Math.max(y1, p[1]));
|
|
292
|
+
return Math.max(x1 - x0, y1 - y0, 0) / 150;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Prepares a polygon for insetting to any depth up to `reach`: its points' directions, and the distance to its
|
|
297
|
+
* outline.
|
|
298
|
+
*
|
|
299
|
+
* @param {readonly (readonly (readonly number[])[])[]} rings The polygon's rings (open, the solid on their left).
|
|
300
|
+
* @param {number} reach The deepest inset that will be asked for.
|
|
301
|
+
* @returns {(d: number) => [number, number][][]} The polygon inset by `d`, ring by ring, point for point.
|
|
302
|
+
*/
|
|
303
|
+
export function insetter(rings, reach) {
|
|
304
|
+
const span = spanOf(rings);
|
|
305
|
+
const dirs = rings.map((r) => pointNormals(r, Math.min(reach / 2, span)));
|
|
306
|
+
const index = outlineIndex(rings, Math.max(reach, 1e-9) * 1.01);
|
|
307
|
+
const dist = index.dist;
|
|
308
|
+
return (d) =>
|
|
309
|
+
rings.map((r, ri) => {
|
|
310
|
+
if (!d) return r.map((q) => /** @type {[number, number]} */ ([q[0], q[1]]));
|
|
311
|
+
const m = dirs[ri];
|
|
312
|
+
/** @type {boolean[]} */
|
|
313
|
+
const short = [];
|
|
314
|
+
const pts = r.map((q, i) => {
|
|
315
|
+
const { t, short: s } = travel(dist, q, m[i], d, Math.max(12 * d, 150 * span), index.crosses);
|
|
316
|
+
short.push(s);
|
|
317
|
+
return /** @type {[number, number]} */ ([q[0] + m[i][0] * t, q[1] + m[i][1] * t]);
|
|
318
|
+
});
|
|
319
|
+
return mergeTurned(r, closeShort(r, pts, short, d, (q) => inside(rings, q) && Math.abs(dist(q[0], q[1]) - d) < 0.4 * d));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A polygon moved inwards by `d`, point for point.
|
|
325
|
+
*
|
|
326
|
+
* @param {readonly (readonly (readonly number[])[])[]} rings The polygon's rings (open, the solid on their left).
|
|
327
|
+
* @param {number} d The distance.
|
|
328
|
+
* @returns {[number, number][][]} The inset rings.
|
|
329
|
+
*/
|
|
330
|
+
export const insetPolygon = (rings, d) => insetter(rings, d)(d);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* One ring (an outline with no holes) moved inwards by `d`, point for point.
|
|
334
|
+
*
|
|
335
|
+
* @param {readonly (readonly number[])[]} r The ring.
|
|
336
|
+
* @param {number} d The distance.
|
|
337
|
+
* @returns {[number, number][]} The inset points.
|
|
338
|
+
*/
|
|
339
|
+
export const insetRing = (r, d) => insetPolygon([r], d)[0];
|