@ludic-labs/assembly 0.0.0-stage → 0.4.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 +78 -0
- package/LICENSE +2 -0
- package/README.md +50 -2
- package/dist/bake.d.ts +80 -0
- package/dist/bake.js +214 -0
- package/dist/browser.d.ts +89 -0
- package/dist/browser.js +534 -0
- package/dist/compat.d.ts +17 -0
- package/dist/compat.js +153 -0
- package/dist/compiler.d.ts +18 -0
- package/dist/compiler.js +1264 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +8 -0
- package/dist/export.d.ts +54 -0
- package/dist/export.js +272 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/legacy.d.ts +36 -0
- package/dist/legacy.js +180 -0
- package/dist/math.d.ts +16 -0
- package/dist/math.js +62 -0
- package/dist/mesh.d.ts +11 -0
- package/dist/mesh.js +153 -0
- package/dist/motion.d.ts +63 -0
- package/dist/motion.js +1476 -0
- package/dist/pixel.d.ts +268 -0
- package/dist/pixel.js +942 -0
- package/dist/required.d.ts +2 -0
- package/dist/required.js +6 -0
- package/dist/shapes.d.ts +71 -0
- package/dist/shapes.js +396 -0
- package/dist/sprite-brief.d.ts +5 -0
- package/dist/sprite-brief.js +47 -0
- package/dist/sprite-compiler.d.ts +62 -0
- package/dist/sprite-compiler.js +1745 -0
- package/dist/sprite-decompile.d.ts +68 -0
- package/dist/sprite-decompile.js +786 -0
- package/dist/sprite-format.d.ts +4 -0
- package/dist/sprite-format.js +155 -0
- package/dist/sprite-guide.d.ts +40 -0
- package/dist/sprite-guide.js +330 -0
- package/dist/sprite-scene.d.ts +277 -0
- package/dist/sprite-scene.js +664 -0
- package/dist/sprite-types.d.ts +110 -0
- package/dist/sprite-types.js +1 -0
- package/dist/sprite.d.ts +8 -0
- package/dist/sprite.js +8 -0
- package/dist/svg/schema.d.ts +71 -0
- package/dist/svg/schema.js +217 -0
- package/dist/svg/types.d.ts +148 -0
- package/dist/svg/types.js +1 -0
- package/dist/svg.d.ts +26 -0
- package/dist/svg.js +539 -0
- package/dist/text.d.ts +12 -0
- package/dist/text.js +75 -0
- package/dist/types.d.ts +478 -0
- package/dist/types.js +1 -0
- package/docs/svg/README.md +150 -0
- package/docs/svg/specification.md +108 -0
- package/examples/svg/arcane-shield.json +261 -0
- package/examples/svg/aurora-ribbon.json +158 -0
- package/examples/svg/bouncing-ember.json +76 -0
- package/examples/svg/ember-fountain.json +224 -0
- package/examples/svg/lantern-keeper.json +1159 -0
- package/examples/svg/night-workshop.json +1120 -0
- package/examples/svg/waygate-platform.json +64 -0
- package/examples/svg/waygate-spark.json +92 -0
- package/examples/svg/waygate-wisp.json +105 -0
- package/examples/svg/waygate.json +485 -0
- package/package.json +132 -4
- package/schemas/asset-assembly-0.1.schema.json +354 -0
- package/schemas/asset-assembly-0.2.schema.json +429 -0
- package/schemas/asset-assembly-0.3.schema.json +572 -0
- package/schemas/motion-set-0.1.schema.json +178 -0
- package/schemas/pixel-sprite-0.1.schema.json +892 -0
- package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
- package/schemas/svg-asset-0.1.schema.json +1157 -0
- package/tools/svg-asset.mjs +106 -0
package/dist/required.js
ADDED
package/dist/shapes.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema 0.3 primitives. The legacy MeshBuilder primitives keep their historical output; these
|
|
3
|
+
* generators add sided round shapes, spheres, cones, wedges, lathes and extrusions. Every polygon
|
|
4
|
+
* is emitted through MeshBuilder.face, which owns flat normals and outward winding.
|
|
5
|
+
*/
|
|
6
|
+
import type { MeshData, Transform, Vec3 } from "./types.js";
|
|
7
|
+
import { MeshBuilder } from "./compat.js";
|
|
8
|
+
import { dot } from "./math.js";
|
|
9
|
+
export declare const DEFAULT_SIDES = 16;
|
|
10
|
+
export declare const DEFAULT_SPHERE_SIDES = 12;
|
|
11
|
+
export declare const MIN_SIDES = 3;
|
|
12
|
+
export declare const MAX_SIDES = 32;
|
|
13
|
+
export declare function frustum(m: MeshBuilder, sides: number, bottom: number, top: number, height: number, color: number): void;
|
|
14
|
+
export declare function cone(m: MeshBuilder, sides: number, radius: number, height: number, color: number): void;
|
|
15
|
+
/** Bottom-centered ellipsoid spanning width × height × depth, with `sides` meridians. */
|
|
16
|
+
export declare function ellipsoid(m: MeshBuilder, sides: number, w: number, h: number, d: number, color: number): void;
|
|
17
|
+
/**
|
|
18
|
+
* Right triangular prism. The bottom (y = 0) and back (z = +depth/2) faces are full rectangles; the
|
|
19
|
+
* slope rises from the front-bottom edge to the back-top edge.
|
|
20
|
+
*/
|
|
21
|
+
export declare function wedge(m: MeshBuilder, w: number, h: number, d: number, color: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* A lathe profile oriented so the material lies to the left of its direction (counterclockwise in the
|
|
24
|
+
* radius–height plane once the open ends are joined along the axis). `closed` profiles repeat their
|
|
25
|
+
* first point at the end and have no caps.
|
|
26
|
+
*/
|
|
27
|
+
export declare function orientProfile(profile: [number, number][]): {
|
|
28
|
+
points: [number, number][];
|
|
29
|
+
closed: boolean;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* True when two non-adjacent profile segments touch or cross, or two segments that share a point
|
|
33
|
+
* (consecutive ones, and the closing pair of a ring) fold back over each other.
|
|
34
|
+
*/
|
|
35
|
+
export declare function profileSelfIntersects(profile: [number, number][], closed: boolean): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Surface of revolution about +Y from a profile of [radius, y] points. The profile may rise and fall
|
|
38
|
+
* (bowls, cups, rims); it is oriented so its material lies to the left. An open end away from the axis
|
|
39
|
+
* is capped when the material lies toward the axis there; a closed profile (first point repeated last)
|
|
40
|
+
* is a ring with no caps.
|
|
41
|
+
*/
|
|
42
|
+
export declare function lathe(m: MeshBuilder, sides: number, input: [number, number][], color: number): void;
|
|
43
|
+
/** Signed area in the X-Z plane (positive when the outline turns from +X toward +Z). */
|
|
44
|
+
export declare const outlineArea: (outline: [number, number][]) => number;
|
|
45
|
+
/** True when two non-adjacent outline edges touch or cross, or a vertex repeats. */
|
|
46
|
+
export declare function outlineSelfIntersects(outline: [number, number][]): boolean;
|
|
47
|
+
/** Prism from a simple X-Z outline, from y = 0 to y = height. */
|
|
48
|
+
export declare function extrude(m: MeshBuilder, input: [number, number][], height: number, color: number): void;
|
|
49
|
+
/**
|
|
50
|
+
* Ring in the local Y-Z plane (axis along X) with `sides` segments around the ring and six around its
|
|
51
|
+
* cross-section. Sixteen sides reproduce the legacy coil exactly; the compiler uses the legacy builder then.
|
|
52
|
+
*/
|
|
53
|
+
export declare function coil(m: MeshBuilder, sides: number, radius: number, thickness: number, color: number): void;
|
|
54
|
+
/**
|
|
55
|
+
* The 16-sided coil with the legacy builder's exact vertex arithmetic. Only the interior reference that
|
|
56
|
+
* orients each face differs (the centerline chord midpoint), so coils the legacy builder wound correctly
|
|
57
|
+
* are byte-identical, and coils thinner than about 2% of their radius are no longer partly inside out.
|
|
58
|
+
* The raw compatibility builder keeps its historical output.
|
|
59
|
+
*/
|
|
60
|
+
export declare function legacyCoil(m: MeshBuilder, radius: number, thickness: number, color: number): void;
|
|
61
|
+
/** Orient a +Y-aligned local mesh along a → b with the legacy tube/beam frame. */
|
|
62
|
+
export declare function alongSegment(part: MeshData, a: Vec3, b: Vec3, target: MeshBuilder): void;
|
|
63
|
+
/** cos/sin of degrees: exact at multiples of 90°, correctly rounded constants at multiples of 30° and 45°. */
|
|
64
|
+
export declare function cosSin(degrees: number): [number, number];
|
|
65
|
+
export declare function axisRotation(axis: "x" | "y" | "z", degrees: number): Transform;
|
|
66
|
+
/** Extrinsic X, then Y, then Z rotation about the local origin: R = Rz · Ry · Rx. */
|
|
67
|
+
export declare function eulerRotation(x: number, y: number, z: number): Transform;
|
|
68
|
+
export declare const isIdentity: (m: Transform) => boolean;
|
|
69
|
+
/** Apply a rotation (and translation) to mesh positions and normals in place. */
|
|
70
|
+
export declare function transformMesh(mesh: MeshData, m: Transform): MeshData;
|
|
71
|
+
export { dot };
|
package/dist/shapes.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { requireValue } from "./required.js";
|
|
2
|
+
import { MeshBuilder } from "./compat.js";
|
|
3
|
+
import { add, applyPoint, applyVector, compose, cross, dot, normalize, scale, sub } from "./math.js";
|
|
4
|
+
export const DEFAULT_SIDES = 16;
|
|
5
|
+
export const DEFAULT_SPHERE_SIDES = 12;
|
|
6
|
+
export const MIN_SIDES = 3;
|
|
7
|
+
export const MAX_SIDES = 32;
|
|
8
|
+
/** Angle n of a regular polygon. For 16 sides this equals the legacy n·π/8 exactly. */
|
|
9
|
+
const angle = (n, sides) => (n * 2 * Math.PI) / sides;
|
|
10
|
+
const ring = (sides, radius, y) => {
|
|
11
|
+
const points = [];
|
|
12
|
+
for (let n = 0; n < sides; n++) {
|
|
13
|
+
const a = angle(n, sides);
|
|
14
|
+
points.push([Math.cos(a) * radius, y, Math.sin(a) * radius]);
|
|
15
|
+
}
|
|
16
|
+
return points;
|
|
17
|
+
};
|
|
18
|
+
/** A point just inside a planar face whose outward normal is `outward`. */
|
|
19
|
+
const inside = (points, outward) => {
|
|
20
|
+
let c = [0, 0, 0];
|
|
21
|
+
for (const p of points)
|
|
22
|
+
c = add(c, p);
|
|
23
|
+
return sub(scale(c, 1 / points.length), outward);
|
|
24
|
+
};
|
|
25
|
+
export function frustum(m, sides, bottom, top, height, color) {
|
|
26
|
+
const center = [0, height / 2, 0], a = ring(sides, bottom, 0), b = ring(sides, top, height);
|
|
27
|
+
m.face(a, color, center);
|
|
28
|
+
m.face(b, color, center);
|
|
29
|
+
for (let n = 0; n < sides; n++)
|
|
30
|
+
m.face([
|
|
31
|
+
requireValue(a[n]),
|
|
32
|
+
requireValue(a[(n + 1) % sides]),
|
|
33
|
+
requireValue(b[(n + 1) % sides]),
|
|
34
|
+
requireValue(b[n]),
|
|
35
|
+
], color, center);
|
|
36
|
+
}
|
|
37
|
+
export function cone(m, sides, radius, height, color) {
|
|
38
|
+
const base = ring(sides, radius, 0), apex = [0, height, 0], center = [0, height / 4, 0];
|
|
39
|
+
m.face(base, color, center);
|
|
40
|
+
for (let n = 0; n < sides; n++)
|
|
41
|
+
m.face([requireValue(base[n]), requireValue(base[(n + 1) % sides]), apex], color, center);
|
|
42
|
+
}
|
|
43
|
+
/** Bottom-centered ellipsoid spanning width × height × depth, with `sides` meridians. */
|
|
44
|
+
export function ellipsoid(m, sides, w, h, d, color) {
|
|
45
|
+
const rings = Math.max(2, Math.floor(sides / 2)), center = [0, h / 2, 0];
|
|
46
|
+
const at = (k, n) => {
|
|
47
|
+
const phi = (k * Math.PI) / rings, a = angle(n, sides), r = Math.sin(phi);
|
|
48
|
+
return [Math.cos(a) * r * (w / 2), (1 - Math.cos(phi)) * (h / 2), Math.sin(a) * r * (d / 2)];
|
|
49
|
+
};
|
|
50
|
+
for (let k = 0; k < rings; k++)
|
|
51
|
+
for (let n = 0; n < sides; n++) {
|
|
52
|
+
const next = (n + 1) % sides;
|
|
53
|
+
if (k === 0)
|
|
54
|
+
m.face([[0, 0, 0], at(1, next), at(1, n)], color, center);
|
|
55
|
+
else if (k === rings - 1)
|
|
56
|
+
m.face([at(k, n), at(k, next), [0, h, 0]], color, center);
|
|
57
|
+
else
|
|
58
|
+
m.face([at(k, n), at(k, next), at(k + 1, next), at(k + 1, n)], color, center);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Right triangular prism. The bottom (y = 0) and back (z = +depth/2) faces are full rectangles; the
|
|
63
|
+
* slope rises from the front-bottom edge to the back-top edge.
|
|
64
|
+
*/
|
|
65
|
+
export function wedge(m, w, h, d, color) {
|
|
66
|
+
const x = w / 2, z = d / 2, center = [0, h / 3, z / 3];
|
|
67
|
+
const fl = [-x, 0, -z], fr = [x, 0, -z], bl = [-x, 0, z], br = [x, 0, z], tl = [-x, h, z], tr = [x, h, z];
|
|
68
|
+
m.face([fl, fr, br, bl], color, center);
|
|
69
|
+
m.face([bl, br, tr, tl], color, center);
|
|
70
|
+
m.face([fl, tl, tr, fr], color, center);
|
|
71
|
+
m.face([fl, bl, tl], color, center);
|
|
72
|
+
m.face([fr, tr, br], color, center);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A lathe profile oriented so the material lies to the left of its direction (counterclockwise in the
|
|
76
|
+
* radius–height plane once the open ends are joined along the axis). `closed` profiles repeat their
|
|
77
|
+
* first point at the end and have no caps.
|
|
78
|
+
*/
|
|
79
|
+
export function orientProfile(profile) {
|
|
80
|
+
const n = profile.length, closed = n >= 4 &&
|
|
81
|
+
requireValue(profile[0])[0] === requireValue(profile[n - 1])[0] &&
|
|
82
|
+
requireValue(profile[0])[1] === requireValue(profile[n - 1])[1];
|
|
83
|
+
const loop = closed
|
|
84
|
+
? profile.slice(0, -1)
|
|
85
|
+
: [...profile, [0, requireValue(profile[n - 1])[1]], [0, requireValue(profile[0])[1]]];
|
|
86
|
+
let area = 0;
|
|
87
|
+
for (let i = 0; i < loop.length; i++) {
|
|
88
|
+
const [r0, y0] = requireValue(loop[i]), [r1, y1] = requireValue(loop[(i + 1) % loop.length]);
|
|
89
|
+
area += r0 * y1 - r1 * y0;
|
|
90
|
+
}
|
|
91
|
+
return { points: area < 0 ? [...profile].reverse() : profile, closed };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* True when two non-adjacent profile segments touch or cross, or two segments that share a point
|
|
95
|
+
* (consecutive ones, and the closing pair of a ring) fold back over each other.
|
|
96
|
+
*/
|
|
97
|
+
export function profileSelfIntersects(profile, closed) {
|
|
98
|
+
const segments = profile.length - 1;
|
|
99
|
+
// p → shared → q retraces itself when the three points are collinear and p, q lie on the same side.
|
|
100
|
+
const foldsBack = (p, shared, q) => Math.abs(cross2(shared, p, q)) < 1e-12 &&
|
|
101
|
+
(p[0] - shared[0]) * (q[0] - shared[0]) + (p[1] - shared[1]) * (q[1] - shared[1]) > 0;
|
|
102
|
+
for (let i = 0; i + 1 < segments; i++)
|
|
103
|
+
if (foldsBack(requireValue(profile[i]), requireValue(profile[i + 1]), requireValue(profile[i + 2])))
|
|
104
|
+
return true;
|
|
105
|
+
if (closed &&
|
|
106
|
+
segments >= 2 &&
|
|
107
|
+
foldsBack(requireValue(profile[segments - 1]), requireValue(profile[0]), requireValue(profile[1])))
|
|
108
|
+
return true;
|
|
109
|
+
for (let i = 0; i < segments; i++)
|
|
110
|
+
for (let j = i + 2; j < segments; j++) {
|
|
111
|
+
if (closed && i === 0 && j === segments - 1)
|
|
112
|
+
continue;
|
|
113
|
+
if (segmentsTouch(requireValue(profile[i]), requireValue(profile[i + 1]), requireValue(profile[j]), requireValue(profile[j + 1])))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
function segmentsTouch(a, b, c, d) {
|
|
119
|
+
const d1 = cross2(c, d, a), d2 = cross2(c, d, b), d3 = cross2(a, b, c), d4 = cross2(a, b, d);
|
|
120
|
+
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
|
|
121
|
+
return true;
|
|
122
|
+
const on = (p, q, r) => Math.min(q[0], r[0]) - 1e-12 <= p[0] &&
|
|
123
|
+
p[0] <= Math.max(q[0], r[0]) + 1e-12 &&
|
|
124
|
+
Math.min(q[1], r[1]) - 1e-12 <= p[1] &&
|
|
125
|
+
p[1] <= Math.max(q[1], r[1]) + 1e-12;
|
|
126
|
+
return ((Math.abs(d1) < 1e-12 && on(a, c, d)) ||
|
|
127
|
+
(Math.abs(d2) < 1e-12 && on(b, c, d)) ||
|
|
128
|
+
(Math.abs(d3) < 1e-12 && on(c, a, b)) ||
|
|
129
|
+
(Math.abs(d4) < 1e-12 && on(d, a, b)));
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Surface of revolution about +Y from a profile of [radius, y] points. The profile may rise and fall
|
|
133
|
+
* (bowls, cups, rims); it is oriented so its material lies to the left. An open end away from the axis
|
|
134
|
+
* is capped when the material lies toward the axis there; a closed profile (first point repeated last)
|
|
135
|
+
* is a ring with no caps.
|
|
136
|
+
*/
|
|
137
|
+
export function lathe(m, sides, input, color) {
|
|
138
|
+
const { points: profile, closed } = orientProfile(input);
|
|
139
|
+
const point = (r, y, n) => {
|
|
140
|
+
const a = angle(n, sides);
|
|
141
|
+
return [Math.cos(a) * r, y, Math.sin(a) * r];
|
|
142
|
+
};
|
|
143
|
+
for (let i = 0; i + 1 < profile.length; i++) {
|
|
144
|
+
const [r0, y0] = requireValue(profile[i]), [r1, y1] = requireValue(profile[i + 1]);
|
|
145
|
+
if (r0 === 0 && r1 === 0)
|
|
146
|
+
continue;
|
|
147
|
+
// Outward 2D profile normal: the tangent (dr, dy) turned clockwise.
|
|
148
|
+
const nr = y1 - y0, ny = -(r1 - r0);
|
|
149
|
+
for (let n = 0; n < sides; n++) {
|
|
150
|
+
const mid = angle(n + 0.5, sides), outward = normalize([nr * Math.cos(mid), ny, nr * Math.sin(mid)]);
|
|
151
|
+
const points = r0 === 0
|
|
152
|
+
? [point(0, y0, 0), point(r1, y1, n + 1), point(r1, y1, n)]
|
|
153
|
+
: r1 === 0
|
|
154
|
+
? [point(r0, y0, n), point(r0, y0, n + 1), point(0, y1, 0)]
|
|
155
|
+
: [point(r0, y0, n), point(r0, y0, n + 1), point(r1, y1, n + 1), point(r1, y1, n)];
|
|
156
|
+
m.face(points, color, inside(points, outward));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (closed)
|
|
160
|
+
return;
|
|
161
|
+
const cap = (at, dr, dy, start) => {
|
|
162
|
+
if (at[0] <= 0)
|
|
163
|
+
return;
|
|
164
|
+
// The material lies to the left of the profile direction; cap only where it lies toward the axis.
|
|
165
|
+
const facing = start
|
|
166
|
+
? dy > 0 || (dy === 0 && dr > 0)
|
|
167
|
+
? -1
|
|
168
|
+
: dy === 0
|
|
169
|
+
? 1
|
|
170
|
+
: 0
|
|
171
|
+
: dy > 0 || (dy === 0 && dr < 0)
|
|
172
|
+
? 1
|
|
173
|
+
: dy === 0
|
|
174
|
+
? -1
|
|
175
|
+
: 0;
|
|
176
|
+
if (!facing)
|
|
177
|
+
return;
|
|
178
|
+
const disc = ring(sides, at[0], at[1]);
|
|
179
|
+
m.face(disc, color, inside(disc, [0, facing, 0]));
|
|
180
|
+
};
|
|
181
|
+
const n = profile.length, [r0, y0] = requireValue(profile[0]), [r1, y1] = requireValue(profile[1]), [ra, ya] = requireValue(profile[n - 2]), [rb, yb] = requireValue(profile[n - 1]);
|
|
182
|
+
cap([r0, y0], r1 - r0, y1 - y0, true);
|
|
183
|
+
cap([rb, yb], rb - ra, yb - ya, false);
|
|
184
|
+
}
|
|
185
|
+
const cross2 = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
186
|
+
/** Signed area in the X-Z plane (positive when the outline turns from +X toward +Z). */
|
|
187
|
+
export const outlineArea = (outline) => {
|
|
188
|
+
let area = 0;
|
|
189
|
+
for (let i = 0; i < outline.length; i++) {
|
|
190
|
+
const [x0, z0] = requireValue(outline[i]), [x1, z1] = requireValue(outline[(i + 1) % outline.length]);
|
|
191
|
+
area += x0 * z1 - x1 * z0;
|
|
192
|
+
}
|
|
193
|
+
return area / 2;
|
|
194
|
+
};
|
|
195
|
+
/** True when two non-adjacent outline edges touch or cross, or a vertex repeats. */
|
|
196
|
+
export function outlineSelfIntersects(outline) {
|
|
197
|
+
const n = outline.length, seg = (i) => [requireValue(outline[i]), requireValue(outline[(i + 1) % n])];
|
|
198
|
+
const onSegment = (p, a, b) => Math.min(a[0], b[0]) - 1e-12 <= p[0] &&
|
|
199
|
+
p[0] <= Math.max(a[0], b[0]) + 1e-12 &&
|
|
200
|
+
Math.min(a[1], b[1]) - 1e-12 <= p[1] &&
|
|
201
|
+
p[1] <= Math.max(a[1], b[1]) + 1e-12;
|
|
202
|
+
for (let i = 0; i < n; i++)
|
|
203
|
+
for (let j = i + 1; j < n; j++) {
|
|
204
|
+
const [a, b] = seg(i), [c, d] = seg(j);
|
|
205
|
+
if (j === i + 1 || (i === 0 && j === n - 1)) {
|
|
206
|
+
// Adjacent edges share one vertex; they must not fold back over each other.
|
|
207
|
+
const shared = j === i + 1 ? b : a, p = j === i + 1 ? a : b, q = j === i + 1 ? d : c;
|
|
208
|
+
if (Math.abs(cross2(shared, p, q)) < 1e-12 &&
|
|
209
|
+
(p[0] - shared[0]) * (q[0] - shared[0]) + (p[1] - shared[1]) * (q[1] - shared[1]) > 0)
|
|
210
|
+
return true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const d1 = cross2(c, d, a), d2 = cross2(c, d, b), d3 = cross2(a, b, c), d4 = cross2(a, b, d);
|
|
214
|
+
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
|
|
215
|
+
return true;
|
|
216
|
+
if ((Math.abs(d1) < 1e-12 && onSegment(a, c, d)) ||
|
|
217
|
+
(Math.abs(d2) < 1e-12 && onSegment(b, c, d)) ||
|
|
218
|
+
(Math.abs(d3) < 1e-12 && onSegment(c, a, b)) ||
|
|
219
|
+
(Math.abs(d4) < 1e-12 && onSegment(d, a, b)))
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
/** Ear-clipping triangulation of a simple polygon with positive area (counterclockwise in X-Z). */
|
|
225
|
+
function triangulate(outline) {
|
|
226
|
+
const remaining = outline.map((_, i) => i), triangles = [];
|
|
227
|
+
const contains = (p, a, b, c) => cross2(a, b, p) >= 0 && cross2(b, c, p) >= 0 && cross2(c, a, p) >= 0;
|
|
228
|
+
let guard = outline.length * outline.length;
|
|
229
|
+
while (remaining.length > 3 && guard-- > 0) {
|
|
230
|
+
let clipped = false;
|
|
231
|
+
for (let k = 0; k < remaining.length; k++) {
|
|
232
|
+
const ia = requireValue(remaining[(k + remaining.length - 1) % remaining.length]), ib = requireValue(remaining[k]), ic = requireValue(remaining[(k + 1) % remaining.length]), a = requireValue(outline[ia]), b = requireValue(outline[ib]), c = requireValue(outline[ic]);
|
|
233
|
+
if (cross2(a, b, c) <= 1e-15)
|
|
234
|
+
continue;
|
|
235
|
+
if (remaining.some((i) => i !== ia && i !== ib && i !== ic && contains(requireValue(outline[i]), a, b, c)))
|
|
236
|
+
continue;
|
|
237
|
+
triangles.push([ia, ib, ic]);
|
|
238
|
+
remaining.splice(k, 1);
|
|
239
|
+
clipped = true;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (!clipped)
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (remaining.length === 3)
|
|
246
|
+
triangles.push([requireValue(remaining[0]), requireValue(remaining[1]), requireValue(remaining[2])]);
|
|
247
|
+
else
|
|
248
|
+
throw new RangeError("Outline could not be triangulated");
|
|
249
|
+
return triangles;
|
|
250
|
+
}
|
|
251
|
+
/** Prism from a simple X-Z outline, from y = 0 to y = height. */
|
|
252
|
+
export function extrude(m, input, height, color) {
|
|
253
|
+
const outline = outlineArea(input) > 0 ? input : [...input].reverse(), n = outline.length;
|
|
254
|
+
for (const [a, b, c] of triangulate(outline)) {
|
|
255
|
+
const tri = (y) => [a, b, c].map((i) => [requireValue(outline[i])[0], y, requireValue(outline[i])[1]]);
|
|
256
|
+
const bottom = tri(0), top = tri(height);
|
|
257
|
+
m.face(bottom, color, inside(bottom, [0, -1, 0]));
|
|
258
|
+
m.face(top, color, inside(top, [0, 1, 0]));
|
|
259
|
+
}
|
|
260
|
+
for (let i = 0; i < n; i++) {
|
|
261
|
+
const [x0, z0] = requireValue(outline[i]), [x1, z1] = requireValue(outline[(i + 1) % n]),
|
|
262
|
+
// Positive area turns from +X toward +Z, so the outward side of each edge is its clockwise normal.
|
|
263
|
+
outward = normalize([z1 - z0, 0, -(x1 - x0)]), quad = [
|
|
264
|
+
[x0, 0, z0],
|
|
265
|
+
[x1, 0, z1],
|
|
266
|
+
[x1, height, z1],
|
|
267
|
+
[x0, height, z0],
|
|
268
|
+
];
|
|
269
|
+
m.face(quad, color, inside(quad, outward));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Ring in the local Y-Z plane (axis along X) with `sides` segments around the ring and six around its
|
|
274
|
+
* cross-section. Sixteen sides reproduce the legacy coil exactly; the compiler uses the legacy builder then.
|
|
275
|
+
*/
|
|
276
|
+
export function coil(m, sides, radius, thickness, color) {
|
|
277
|
+
const point = (a, b) => [
|
|
278
|
+
Math.sin(b) * thickness,
|
|
279
|
+
Math.cos(a) * (radius + Math.cos(b) * thickness),
|
|
280
|
+
Math.sin(a) * (radius + Math.cos(b) * thickness),
|
|
281
|
+
];
|
|
282
|
+
for (let n = 0; n < sides; n++)
|
|
283
|
+
for (let k = 0; k < 6; k++) {
|
|
284
|
+
const a = angle(n, sides), b = (k * Math.PI) / 3, nextA = angle(n + 1, sides), nextB = b + Math.PI / 3;
|
|
285
|
+
// Midpoint of the centerline chord: inside the segment at any side count and thickness.
|
|
286
|
+
m.face([point(a, b), point(nextA, b), point(nextA, nextB), point(a, nextB)], color, [
|
|
287
|
+
0,
|
|
288
|
+
((Math.cos(a) + Math.cos(nextA)) / 2) * radius,
|
|
289
|
+
((Math.sin(a) + Math.sin(nextA)) / 2) * radius,
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The 16-sided coil with the legacy builder's exact vertex arithmetic. Only the interior reference that
|
|
295
|
+
* orients each face differs (the centerline chord midpoint), so coils the legacy builder wound correctly
|
|
296
|
+
* are byte-identical, and coils thinner than about 2% of their radius are no longer partly inside out.
|
|
297
|
+
* The raw compatibility builder keeps its historical output.
|
|
298
|
+
*/
|
|
299
|
+
export function legacyCoil(m, radius, thickness, color) {
|
|
300
|
+
const point = (a, b) => [
|
|
301
|
+
0 + Math.sin(b) * thickness,
|
|
302
|
+
0 + Math.cos(a) * (radius + Math.cos(b) * thickness),
|
|
303
|
+
0 + Math.sin(a) * (radius + Math.cos(b) * thickness),
|
|
304
|
+
];
|
|
305
|
+
for (let n = 0; n < 16; n++)
|
|
306
|
+
for (let k = 0; k < 6; k++) {
|
|
307
|
+
const a = (n * Math.PI) / 8, b = (k * Math.PI) / 3, nextA = a + Math.PI / 8, nextB = b + Math.PI / 3;
|
|
308
|
+
m.face([point(a, b), point(nextA, b), point(nextA, nextB), point(a, nextB)], color, [
|
|
309
|
+
0,
|
|
310
|
+
0 + ((Math.cos(a) + Math.cos(nextA)) / 2) * radius,
|
|
311
|
+
0 + ((Math.sin(a) + Math.sin(nextA)) / 2) * radius,
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Orient a +Y-aligned local mesh along a → b with the legacy tube/beam frame. */
|
|
316
|
+
export function alongSegment(part, a, b, target) {
|
|
317
|
+
const up = normalize(sub(b, a)), right = normalize(cross(Math.abs(up[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0], up)), forward = cross(right, up), mesh = target.mesh, first = mesh.positions.length / 3;
|
|
318
|
+
for (let n = 0; n < part.positions.length; n += 3)
|
|
319
|
+
for (const [input, output, translate] of [
|
|
320
|
+
[part.positions, mesh.positions, true],
|
|
321
|
+
[part.normals, mesh.normals, false],
|
|
322
|
+
])
|
|
323
|
+
for (let c = 0; c < 3; c++)
|
|
324
|
+
output.push((translate ? requireValue(a[c]) : 0) +
|
|
325
|
+
requireValue(right[c]) * requireValue(input[n]) +
|
|
326
|
+
requireValue(up[c]) * requireValue(input[n + 1]) +
|
|
327
|
+
requireValue(forward[c]) * requireValue(input[n + 2]));
|
|
328
|
+
mesh.colors.push(...part.colors);
|
|
329
|
+
mesh.indices.push(...part.indices.map((i) => i + first));
|
|
330
|
+
}
|
|
331
|
+
/* ------------------------------------------------------------------ rotations */
|
|
332
|
+
const EXACT = {
|
|
333
|
+
0: [1, 0],
|
|
334
|
+
30: [Math.sqrt(3) / 2, 0.5],
|
|
335
|
+
45: [Math.SQRT1_2, Math.SQRT1_2],
|
|
336
|
+
60: [0.5, Math.sqrt(3) / 2],
|
|
337
|
+
90: [0, 1],
|
|
338
|
+
};
|
|
339
|
+
/** cos/sin of degrees: exact at multiples of 90°, correctly rounded constants at multiples of 30° and 45°. */
|
|
340
|
+
export function cosSin(degrees) {
|
|
341
|
+
let d = degrees % 360;
|
|
342
|
+
if (d < 0)
|
|
343
|
+
d += 360;
|
|
344
|
+
const quadrant = Math.floor(d / 90), rest = d - quadrant * 90;
|
|
345
|
+
// Reduce to the first quadrant so that θ and θ + 90° use the same libm values.
|
|
346
|
+
const [c, s] = EXACT[rest] ?? [Math.cos((rest * Math.PI) / 180), Math.sin((rest * Math.PI) / 180)];
|
|
347
|
+
// Rotate the first-quadrant value by quadrant × 90°.
|
|
348
|
+
const value = [
|
|
349
|
+
[c, s],
|
|
350
|
+
[-s, c],
|
|
351
|
+
[-c, -s],
|
|
352
|
+
[s, -c],
|
|
353
|
+
][quadrant];
|
|
354
|
+
return [value[0] === 0 ? 0 : value[0], value[1] === 0 ? 0 : value[1]];
|
|
355
|
+
}
|
|
356
|
+
export function axisRotation(axis, degrees) {
|
|
357
|
+
const [c, s] = cosSin(degrees), ns = s === 0 ? 0 : -s;
|
|
358
|
+
if (axis === "x")
|
|
359
|
+
return [1, 0, 0, 0, 0, c, ns, 0, 0, s, c, 0];
|
|
360
|
+
if (axis === "y")
|
|
361
|
+
return [c, 0, s, 0, 0, 1, 0, 0, ns, 0, c, 0];
|
|
362
|
+
return [c, ns, 0, 0, s, c, 0, 0, 0, 0, 1, 0];
|
|
363
|
+
}
|
|
364
|
+
/** Extrinsic X, then Y, then Z rotation about the local origin: R = Rz · Ry · Rx. */
|
|
365
|
+
export function eulerRotation(x, y, z) {
|
|
366
|
+
return compose(axisRotation("z", z), compose(axisRotation("y", y), axisRotation("x", x)));
|
|
367
|
+
}
|
|
368
|
+
export const isIdentity = (m) => m[0] === 1 &&
|
|
369
|
+
m[1] === 0 &&
|
|
370
|
+
m[2] === 0 &&
|
|
371
|
+
m[4] === 0 &&
|
|
372
|
+
m[5] === 1 &&
|
|
373
|
+
m[6] === 0 &&
|
|
374
|
+
m[8] === 0 &&
|
|
375
|
+
m[9] === 0 &&
|
|
376
|
+
m[10] === 1;
|
|
377
|
+
/** Apply a rotation (and translation) to mesh positions and normals in place. */
|
|
378
|
+
export function transformMesh(mesh, m) {
|
|
379
|
+
for (let i = 0; i < mesh.positions.length; i += 3) {
|
|
380
|
+
const p = applyPoint(m, [
|
|
381
|
+
requireValue(mesh.positions[i]),
|
|
382
|
+
requireValue(mesh.positions[i + 1]),
|
|
383
|
+
requireValue(mesh.positions[i + 2]),
|
|
384
|
+
]), n = applyVector(m, [
|
|
385
|
+
requireValue(mesh.normals[i]),
|
|
386
|
+
requireValue(mesh.normals[i + 1]),
|
|
387
|
+
requireValue(mesh.normals[i + 2]),
|
|
388
|
+
]);
|
|
389
|
+
for (let c = 0; c < 3; c++) {
|
|
390
|
+
mesh.positions[i + c] = requireValue(p[c]);
|
|
391
|
+
mesh.normals[i + c] = requireValue(n[c]);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return mesh;
|
|
395
|
+
}
|
|
396
|
+
export { dot };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AssetSpec, CompiledAsset } from "./types.js";
|
|
2
|
+
import type { SpriteView } from "./sprite-types.js";
|
|
3
|
+
export declare function spriteBrief(ref: string, spec: AssetSpec, asset: CompiledAsset, views: Record<string, SpriteView>, { motion }?: {
|
|
4
|
+
motion?: unknown;
|
|
5
|
+
}): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { layoutGuide, posedAsset } from "./sprite-guide.js";
|
|
2
|
+
import { requireValue } from "./required.js";
|
|
3
|
+
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"];
|
|
4
|
+
export function spriteBrief(ref, spec, asset, views, { motion = null } = {}) {
|
|
5
|
+
const out = [`# Brief: ${ref}`, ""];
|
|
6
|
+
out.push("Draw this asset in pixel-sprite/0.1. Every view below shares one density, so sizes agree across views.", "Coordinates are [x, y] from the canvas top-left; boxes are [x, y, w, h].", "");
|
|
7
|
+
out.push("## Parts and materials", "", "| part | material | colour |", "| --- | --- | --- |");
|
|
8
|
+
for (const [name, part] of Object.entries(spec.parts ?? {}))
|
|
9
|
+
out.push(`| ${name} | ${part.material ?? "–"} | ${spec.materials?.[part.material]?.color ?? "–"} |`);
|
|
10
|
+
out.push("");
|
|
11
|
+
for (const [name, view] of Object.entries(views)) {
|
|
12
|
+
const g = layoutGuide(posedAsset(asset, motion, view.pose ?? null), view);
|
|
13
|
+
out.push(`## View \`${name}\``, "", `Camera ${JSON.stringify(view.camera)}, ${view.pixelsPerMetre} px/m${view.pose ? `, pose: clip \`${view.pose.clip}\` at phase ${view.pose.phase}` : ""}. Canvas ${g.width}×${g.height} (use \`"view": "${name}"\`; the cel gets this size).`, "");
|
|
14
|
+
if (g.cut.length)
|
|
15
|
+
out.push(`Cut away in this view (do not draw): ${g.cut.join(", ")}.`, "");
|
|
16
|
+
const visible = Object.entries(g.parts).filter(([, p]) => !p.instance && p.area > 0);
|
|
17
|
+
visible.sort((a, b) => (a[1].depth ?? 0) - (b[1].depth ?? 0));
|
|
18
|
+
const letter = new Map(visible.map(([n], i) => [n, LETTERS[i] ?? "?"]));
|
|
19
|
+
out.push("| map | part | box | visible px | depth (0 = nearest) |", "| --- | --- | --- | ---: | ---: |");
|
|
20
|
+
for (const [n, p] of visible)
|
|
21
|
+
out.push(`| ${letter.get(n)} | ${n} | ${JSON.stringify(p.bbox)} | ${p.area} | ${p.depth} |`);
|
|
22
|
+
const small = Object.entries(g.parts).filter(([, p]) => !p.instance && p.subpixel);
|
|
23
|
+
if (small.length)
|
|
24
|
+
out.push("", `Too small to show at this density (draw them anyway if they matter, e.g. eyes): ${small.map(([n, p]) => `${n} near ${JSON.stringify(p.bbox.slice(0, 2))}`).join(", ")}.`);
|
|
25
|
+
const occluded = g.hidden.filter((n) => !g.cut.includes(n));
|
|
26
|
+
if (occluded.length)
|
|
27
|
+
out.push("", `Hidden behind other parts in this view: ${occluded.join(", ")}.`);
|
|
28
|
+
const sided = Object.entries(g.parts).filter(([, p]) => p.instance && p.area > 0);
|
|
29
|
+
if (sided.length)
|
|
30
|
+
out.push("", `Instances: ${sided.map(([n, p]) => `${n} ${JSON.stringify(p.bbox)}`).join(", ")}.`);
|
|
31
|
+
out.push("", "Part map (`.` empty):", "", "```");
|
|
32
|
+
for (let y = 0; y < g.height; y++) {
|
|
33
|
+
let row = "";
|
|
34
|
+
for (let x = 0; x < g.width; x++) {
|
|
35
|
+
const o = requireValue(g.owners[y * g.width + x]);
|
|
36
|
+
row += o < 0 ? "." : (letter.get(partNameOf(g, o) ?? "") ?? "?");
|
|
37
|
+
}
|
|
38
|
+
out.push(row);
|
|
39
|
+
}
|
|
40
|
+
out.push("```", "");
|
|
41
|
+
}
|
|
42
|
+
return out.join("\n");
|
|
43
|
+
}
|
|
44
|
+
function partNameOf(g, o) {
|
|
45
|
+
// Instance ids look like "foot/left" or "bolts/3"; the part is the first segment.
|
|
46
|
+
return requireValue(g.instances[o]).split("/")[0];
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SpriteDocument, SpriteResult, SpritePoint, SpriteColour, CompileSpriteOptions } from "./sprite-types.js";
|
|
2
|
+
export declare const SPRITE_FORMAT = "pixel-sprite/0.1";
|
|
3
|
+
export declare const SPRITE_LIMITS: Readonly<{
|
|
4
|
+
dimension: 1024;
|
|
5
|
+
cels: 4096;
|
|
6
|
+
layers: 512;
|
|
7
|
+
expandedOperations: 8192;
|
|
8
|
+
pixels: 16777216;
|
|
9
|
+
sceneDimension: 2048;
|
|
10
|
+
stamps: 256;
|
|
11
|
+
colors: 4096;
|
|
12
|
+
specks: 4096;
|
|
13
|
+
}>;
|
|
14
|
+
/** Reserved colour reference: paint transparent. */
|
|
15
|
+
export declare const CLEAR = "clear";
|
|
16
|
+
/** Predefined key in `pixels` rows and stamps: leave the pixel unchanged. */
|
|
17
|
+
export declare const SKIP_KEY = ".";
|
|
18
|
+
/** Guide parts smaller than this many pixels are not required in a view. */
|
|
19
|
+
export declare const GUIDE_MIN_AREA = 3;
|
|
20
|
+
/** Bounding-box overlap below which an authored part is reported as drifting from its guide. */
|
|
21
|
+
export declare const GUIDE_MIN_IOU = 0.35;
|
|
22
|
+
/** Escapes a document key for use as a JSON Pointer segment. */
|
|
23
|
+
export declare const ptr: (key: unknown) => string;
|
|
24
|
+
export declare function parseHex(hex: unknown): SpriteColour | null;
|
|
25
|
+
export declare function toHex([r, g, b, a]: readonly [number, number, number, number]): string;
|
|
26
|
+
export declare function rgbToOklab([r, g, b]: readonly [number, number, number, ...number[]]): [
|
|
27
|
+
number,
|
|
28
|
+
number,
|
|
29
|
+
number
|
|
30
|
+
];
|
|
31
|
+
export declare class SpriteCanvas {
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
rgba: Uint8Array;
|
|
35
|
+
owners: Int32Array;
|
|
36
|
+
constructor(width: number, height: number);
|
|
37
|
+
clone(): SpriteCanvas;
|
|
38
|
+
inside(x: number, y: number): boolean;
|
|
39
|
+
opaque(x: number, y: number): boolean;
|
|
40
|
+
owner(x: number, y: number): number;
|
|
41
|
+
color(x: number, y: number): SpriteColour;
|
|
42
|
+
/** Paints one pixel; CLEAR erases it. Returns false when outside the canvas. */
|
|
43
|
+
set(x: number, y: number, c: SpriteColour | typeof CLEAR, owner?: number | null): boolean;
|
|
44
|
+
flip(axis: "x" | "y" | "xy"): SpriteCanvas;
|
|
45
|
+
}
|
|
46
|
+
/** Deterministic 32-bit PRNG (mulberry32). */
|
|
47
|
+
export declare function mulberry32(seed: number): () => number;
|
|
48
|
+
/** Integer points of a Bresenham segment, inclusive. */
|
|
49
|
+
export declare function bresenham([x0, y0]: SpritePoint, [x1, y1]: SpritePoint): SpritePoint[];
|
|
50
|
+
/**
|
|
51
|
+
* Compiles a pixel-sprite/0.1 document.
|
|
52
|
+
* `SPRITE_GUIDE_HIDDEN_REF` is an error for referencing an occluded guide part;
|
|
53
|
+
* `SPRITE_GUIDE_HIDDEN` warns about an authored drawing of such a part.
|
|
54
|
+
* Every user key in diagnostic paths is JSON Pointer escaped. Expanded recipes
|
|
55
|
+
* retain their calling path: /cels/<cel>/layers/<i>/recipe(<name>)/<j>.
|
|
56
|
+
* Resolver failures always emit an error, even if they also carry warnings.
|
|
57
|
+
*/
|
|
58
|
+
export declare function compileSprite(doc: SpriteDocument, options?: CompileSpriteOptions): SpriteResult;
|
|
59
|
+
/** Maps every opaque pixel to the nearest palette colour in OKLab. */
|
|
60
|
+
export declare function snapToPalette(canvas: Pick<SpriteCanvas, "width" | "height" | "rgba" | "owners">, colors: readonly SpriteColour[]): void;
|
|
61
|
+
/** Part under a pixel of a compiled cel, or null. */
|
|
62
|
+
export declare function pickSprite(result: SpriteResult, celId: string, x: number, y: number): string | null;
|