@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/pixel.js
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
import { linear } from "./compat.js";
|
|
2
|
+
export const PIXEL_PROFILE_FORMAT = "pixel-profile/0.1";
|
|
3
|
+
export const PIXEL_IMAGE_FORMAT = "pixel-image/0.1";
|
|
4
|
+
/** Upper bound on logical width or height. */
|
|
5
|
+
export const PIXEL_MAX_DIMENSION = 2048;
|
|
6
|
+
/** Upper bound on supersamples (width * height * samples^2) per render. */
|
|
7
|
+
export const PIXEL_MAX_SUBSAMPLES = 16_777_216;
|
|
8
|
+
const steps = (outline, deep, shadow, light) => [
|
|
9
|
+
{ dL: outline, hueShift: -26, chroma: 0.9, minChroma: 0.02 },
|
|
10
|
+
{ dL: deep, hueShift: -18, chroma: 1.12, minChroma: 0.015 },
|
|
11
|
+
{ dL: shadow, hueShift: -10, chroma: 1.08, minChroma: 0.01 },
|
|
12
|
+
{ dL: 0, hueShift: 0, chroma: 1, minChroma: 0 },
|
|
13
|
+
{ dL: light, hueShift: 8, chroma: 0.9, minChroma: 0 },
|
|
14
|
+
];
|
|
15
|
+
const base = {
|
|
16
|
+
format: PIXEL_PROFILE_FORMAT,
|
|
17
|
+
coolHue: 275,
|
|
18
|
+
warmHue: 85,
|
|
19
|
+
light: { right: -0.25, up: 0.6, back: 0.75 },
|
|
20
|
+
topBias: 0.15,
|
|
21
|
+
bands: [0.72, 0.25, -0.2],
|
|
22
|
+
outlineColor: "#1f1b24",
|
|
23
|
+
depthGap: 0.015,
|
|
24
|
+
frontVotes: 0.5,
|
|
25
|
+
sliverThinness: 0.4,
|
|
26
|
+
accentArea: 10,
|
|
27
|
+
featureWeight: 8,
|
|
28
|
+
orphanCleanup: true,
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Built-in profiles, after a survey of itch.io environment packs (see
|
|
32
|
+
* docs/pixel-art.md). Experimental; none has owner visual acceptance.
|
|
33
|
+
*/
|
|
34
|
+
export const PIXEL_PROFILES = Object.freeze({
|
|
35
|
+
/** Dioramas and rooms: coloured 1 px outlines, occlusion lines, four tones, 32 colours. */
|
|
36
|
+
scene: Object.freeze({
|
|
37
|
+
...base,
|
|
38
|
+
name: "scene",
|
|
39
|
+
outline: "coloured",
|
|
40
|
+
innerLines: true,
|
|
41
|
+
steps: steps(-0.36, -0.21, -0.1, 0.085),
|
|
42
|
+
tones: 4,
|
|
43
|
+
paletteCap: 32,
|
|
44
|
+
patternMinPixels: 4,
|
|
45
|
+
}),
|
|
46
|
+
/** Denser towns and interiors: gentler chroma, four tones, 64 colours, finer patterns. */
|
|
47
|
+
fantasy: Object.freeze({
|
|
48
|
+
...base,
|
|
49
|
+
name: "fantasy",
|
|
50
|
+
outline: "coloured",
|
|
51
|
+
innerLines: true,
|
|
52
|
+
steps: steps(-0.32, -0.18, -0.09, 0.075).map((step) => ({
|
|
53
|
+
...step,
|
|
54
|
+
chroma: 1 + (step.chroma - 1) * 0.5,
|
|
55
|
+
})),
|
|
56
|
+
tones: 4,
|
|
57
|
+
paletteCap: 64,
|
|
58
|
+
patternMinPixels: 3,
|
|
59
|
+
}),
|
|
60
|
+
/** Readable shapes: three tones with strong value separation and one dark line colour. */
|
|
61
|
+
arcade: Object.freeze({
|
|
62
|
+
...base,
|
|
63
|
+
name: "arcade",
|
|
64
|
+
outline: "dark",
|
|
65
|
+
innerLines: true,
|
|
66
|
+
steps: steps(-0.4, -0.2, -0.16, 0.11),
|
|
67
|
+
tones: 3,
|
|
68
|
+
paletteCap: 32,
|
|
69
|
+
patternMinPixels: 4,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
/** Profile, camera and density bundles for the built-in profiles. */
|
|
73
|
+
export const PIXEL_PRESETS = Object.freeze({
|
|
74
|
+
scene: Object.freeze({ profile: PIXEL_PROFILES.scene, camera: "dimetric", pixelsPerMetre: 32 }),
|
|
75
|
+
fantasy: Object.freeze({ profile: PIXEL_PROFILES.fantasy, camera: "topDown", pixelsPerMetre: 48 }),
|
|
76
|
+
arcade: Object.freeze({ profile: PIXEL_PROFILES.arcade, camera: "dimetric", pixelsPerMetre: 32 }),
|
|
77
|
+
});
|
|
78
|
+
const cross = (a, b) => [
|
|
79
|
+
a[1] * b[2] - a[2] * b[1],
|
|
80
|
+
a[2] * b[0] - a[0] * b[2],
|
|
81
|
+
a[0] * b[1] - a[1] * b[0],
|
|
82
|
+
];
|
|
83
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
84
|
+
const normalize = (a) => {
|
|
85
|
+
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
86
|
+
return [a[0] / l, a[1] / l, a[2] / l];
|
|
87
|
+
};
|
|
88
|
+
/** Orthographic camera looking from `yaw` (about +Y, from +Z toward +X) and `pitch` above the horizon. */
|
|
89
|
+
export function pixelCamera(yawDegrees, pitchDegrees, name = `yaw${yawDegrees}-pitch${pitchDegrees}`) {
|
|
90
|
+
if (!Number.isFinite(yawDegrees) || !Number.isFinite(pitchDegrees) || Math.abs(pitchDegrees) >= 90)
|
|
91
|
+
throw new RangeError("Camera yaw must be finite and pitch strictly between -90 and 90 degrees");
|
|
92
|
+
const yaw = (yawDegrees * Math.PI) / 180, pitch = (pitchDegrees * Math.PI) / 180;
|
|
93
|
+
const back = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)];
|
|
94
|
+
const right = normalize(cross([0, 1, 0], back));
|
|
95
|
+
return { name, right, up: cross(back, right), back };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Camera presets. The dimetric presets use a 30 degree pitch at a diagonal
|
|
99
|
+
* yaw, which projects the ground axes to exact 2:1 pixel slopes.
|
|
100
|
+
*/
|
|
101
|
+
export const PIXEL_CAMERAS = Object.freeze({
|
|
102
|
+
/** Gallery-facing 2:1 view: the side the library thumbnails show (yaw 135). */
|
|
103
|
+
dimetric: pixelCamera(135, 30, "dimetric"),
|
|
104
|
+
dimetricRight: pixelCamera(225, 30, "dimetricRight"),
|
|
105
|
+
dimetricOpposite: pixelCamera(315, 30, "dimetricOpposite"),
|
|
106
|
+
dimetricLeft: pixelCamera(45, 30, "dimetricLeft"),
|
|
107
|
+
/** Front elevation (looking along +Z) with a slight downward tilt, for side-view scenes. */
|
|
108
|
+
front: pixelCamera(180, 8, "front"),
|
|
109
|
+
/** Top-down three-quarter view (45 degree elevation) showing tops and front faces. */
|
|
110
|
+
topDown: pixelCamera(180, 45, "topDown"),
|
|
111
|
+
});
|
|
112
|
+
/**
|
|
113
|
+
* Rest-pose instances of a compiled asset (instances hidden in rest states are
|
|
114
|
+
* omitted). `tint` (#rrggbb) multiplies tintable materials, as in meshForAsset.
|
|
115
|
+
*/
|
|
116
|
+
export function pixelInstances(asset, tint = null) {
|
|
117
|
+
if (tint !== null && !isHex(tint))
|
|
118
|
+
throw new RangeError("Tint must be #rrggbb");
|
|
119
|
+
const factor = tint ? [1, 3, 5].map((i) => linear(parseInt(tint.slice(i, i + 2), 16))) : null;
|
|
120
|
+
return asset.instances.map((i) => {
|
|
121
|
+
const tinted = factor && asset.materials[i.material]?.tintable;
|
|
122
|
+
const mesh = tinted
|
|
123
|
+
? { ...i.mesh, colors: i.mesh.colors.map((v, n) => v * (factor[n % 3] ?? 1)) }
|
|
124
|
+
: i.mesh;
|
|
125
|
+
return { id: i.id, part: i.part, material: i.material, mesh, restPositions: null };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Places compiled assets in one scene. Each placement rotates about +Y and
|
|
130
|
+
* translates its rest meshes; IDs become `<placement>/<instance>` and patterns
|
|
131
|
+
* stay in each asset's own frame.
|
|
132
|
+
*/
|
|
133
|
+
export function composePixelScene(placements) {
|
|
134
|
+
const ids = new Set();
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const p of placements) {
|
|
137
|
+
if (ids.has(p.id))
|
|
138
|
+
throw new RangeError(`Duplicate placement ID ${p.id}`);
|
|
139
|
+
ids.add(p.id);
|
|
140
|
+
if (!p.position.every(Number.isFinite) || !Number.isFinite(p.yawDegrees ?? 0))
|
|
141
|
+
throw new RangeError(`Placement ${p.id} needs a finite position and yaw`);
|
|
142
|
+
const a = ((p.yawDegrees ?? 0) * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a);
|
|
143
|
+
for (const inst of pixelInstances(p.asset, p.tint ?? null)) {
|
|
144
|
+
const src = inst.mesh, positions = new Array(src.positions.length), normals = new Array(src.normals.length);
|
|
145
|
+
for (let v = 0; v < src.positions.length; v += 3) {
|
|
146
|
+
const x = src.positions[v] ?? 0, y = src.positions[v + 1] ?? 0, z = src.positions[v + 2] ?? 0;
|
|
147
|
+
positions[v] = c * x + s * z + p.position[0];
|
|
148
|
+
positions[v + 1] = y + p.position[1];
|
|
149
|
+
positions[v + 2] = -s * x + c * z + p.position[2];
|
|
150
|
+
const nx = src.normals[v] ?? 0, ny = src.normals[v + 1] ?? 0, nz = src.normals[v + 2] ?? 0;
|
|
151
|
+
normals[v] = c * nx + s * nz;
|
|
152
|
+
normals[v + 1] = ny;
|
|
153
|
+
normals[v + 2] = -s * nx + c * nz;
|
|
154
|
+
}
|
|
155
|
+
out.push({
|
|
156
|
+
id: `${p.id}/${inst.id}`,
|
|
157
|
+
part: inst.part,
|
|
158
|
+
material: inst.material,
|
|
159
|
+
mesh: { ...src, positions, normals },
|
|
160
|
+
restPositions: src.positions,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
const axisIndex = (a) => (a === "x" ? 0 : a === "y" ? 1 : 2);
|
|
167
|
+
const frac = (v) => v - Math.floor(v);
|
|
168
|
+
/**
|
|
169
|
+
* Smallest undrawn gap of a pattern, in metres. Line-like parts (stripes,
|
|
170
|
+
* grid lines, mortar) are widened to at least one pixel when drawn, so the
|
|
171
|
+
* gap between them is what must survive on the grid.
|
|
172
|
+
*/
|
|
173
|
+
function patternGap(p) {
|
|
174
|
+
switch (p.type) {
|
|
175
|
+
case "stripes":
|
|
176
|
+
return (1 - p.width) * p.period;
|
|
177
|
+
case "checker":
|
|
178
|
+
return p.period;
|
|
179
|
+
case "grid":
|
|
180
|
+
return (1 - p.width) * p.period;
|
|
181
|
+
case "bricks":
|
|
182
|
+
return Math.min(p.course, p.length) - p.mortar;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Whether a point in the asset's own frame lies on the drawn part of a
|
|
187
|
+
* pattern. `lineWidth` is the minimum line width in metres (0 for none). The
|
|
188
|
+
* same function can drive an ordinary 3D renderer as a solid texture.
|
|
189
|
+
*/
|
|
190
|
+
export function samplePixelPattern(p, point, lineWidth = 0) {
|
|
191
|
+
return patternHit(p, point, lineWidth);
|
|
192
|
+
}
|
|
193
|
+
function patternHit(p, q, lineFloor) {
|
|
194
|
+
switch (p.type) {
|
|
195
|
+
case "stripes":
|
|
196
|
+
return frac(q[axisIndex(p.axis)] / p.period) < Math.max(p.width * p.period, lineFloor) / p.period;
|
|
197
|
+
case "checker":
|
|
198
|
+
return ((Math.floor(q[0] / p.period) + Math.floor(q[1] / p.period) + Math.floor(q[2] / p.period)) % 2 !== 0);
|
|
199
|
+
case "grid": {
|
|
200
|
+
const w = Math.max(p.width * p.period, lineFloor) / p.period;
|
|
201
|
+
return p.axes.some((a) => frac(q[axisIndex(a)] / p.period) < w);
|
|
202
|
+
}
|
|
203
|
+
case "bricks": {
|
|
204
|
+
const row = Math.floor(q[1] / p.course), along = q[axisIndex(p.axis)] + (row % 2 !== 0 ? p.length / 2 : 0), across = q[p.axis === "x" ? 2 : 0] + (row % 2 !== 0 ? p.length / 2 : 0), mortar = Math.max(p.mortar, lineFloor);
|
|
205
|
+
return (frac(q[1] / p.course) * p.course < mortar ||
|
|
206
|
+
frac(along / p.length) * p.length < mortar ||
|
|
207
|
+
frac(across / p.length) * p.length < mortar);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export class PixelArtError extends Error {
|
|
212
|
+
diagnostics;
|
|
213
|
+
constructor(diagnostics) {
|
|
214
|
+
super(diagnostics.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
|
|
215
|
+
this.diagnostics = diagnostics;
|
|
216
|
+
this.name = "PixelArtError";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const isHex = (v) => typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v);
|
|
220
|
+
const finite = (v) => typeof v === "number" && Number.isFinite(v);
|
|
221
|
+
/** Structural validation of render options. Errors only; render warnings are returned with the image. */
|
|
222
|
+
export function validatePixelOptions(options) {
|
|
223
|
+
const d = [];
|
|
224
|
+
const fail = (code, path, message) => d.push({ code, path, message });
|
|
225
|
+
const ppm = options.pixelsPerMetre ?? 32;
|
|
226
|
+
if (!finite(ppm) || ppm <= 0 || ppm > 512)
|
|
227
|
+
fail("PIXEL_DENSITY", "/pixelsPerMetre", "Expected a density in (0, 512]");
|
|
228
|
+
const samples = options.samples ?? 4;
|
|
229
|
+
if (!Number.isInteger(samples) || samples < 1 || samples > 4)
|
|
230
|
+
fail("PIXEL_SAMPLES", "/samples", "Expected an integer from 1 to 4");
|
|
231
|
+
const margin = options.margin ?? 4;
|
|
232
|
+
if (!Number.isInteger(margin) || margin < 0 || margin > 256)
|
|
233
|
+
fail("PIXEL_MARGIN", "/margin", "Expected an integer from 0 to 256");
|
|
234
|
+
if (options.frame) {
|
|
235
|
+
const f = options.frame;
|
|
236
|
+
if (!f.center.every(finite))
|
|
237
|
+
fail("PIXEL_FRAME", "/frame/center", "Expected a finite point");
|
|
238
|
+
for (const k of ["width", "height"])
|
|
239
|
+
if (!Number.isInteger(f[k]) || f[k] < 1 || f[k] > PIXEL_MAX_DIMENSION)
|
|
240
|
+
fail("PIXEL_FRAME", `/frame/${k}`, `Expected an integer from 1 to ${PIXEL_MAX_DIMENSION}`);
|
|
241
|
+
}
|
|
242
|
+
if (options.camera) {
|
|
243
|
+
const { right, up, back } = options.camera;
|
|
244
|
+
const unit = (v) => v.every(finite) && Math.abs(Math.hypot(v[0], v[1], v[2]) - 1) < 1e-6;
|
|
245
|
+
if (!unit(right) ||
|
|
246
|
+
!unit(up) ||
|
|
247
|
+
!unit(back) ||
|
|
248
|
+
Math.abs(dot(right, up)) > 1e-6 ||
|
|
249
|
+
Math.abs(dot(right, back)) > 1e-6 ||
|
|
250
|
+
Math.abs(dot(up, back)) > 1e-6)
|
|
251
|
+
fail("PIXEL_CAMERA", "/camera", "Camera basis must be finite and orthonormal");
|
|
252
|
+
}
|
|
253
|
+
if (options.background !== undefined && options.background !== null && !isHex(options.background))
|
|
254
|
+
fail("PIXEL_COLOR", "/background", "Expected #rrggbb or null");
|
|
255
|
+
const profile = options.profile;
|
|
256
|
+
if (profile) {
|
|
257
|
+
if (profile.format !== PIXEL_PROFILE_FORMAT)
|
|
258
|
+
fail("PIXEL_PROFILE", "/profile/format", `Expected ${PIXEL_PROFILE_FORMAT}`);
|
|
259
|
+
if (profile.steps.length !== 5)
|
|
260
|
+
fail("PIXEL_PROFILE", "/profile/steps", "Expected five ramp steps");
|
|
261
|
+
const b = profile.bands;
|
|
262
|
+
if (!(b[0] > b[1] && b[1] > b[2]))
|
|
263
|
+
fail("PIXEL_PROFILE", "/profile/bands", "Band thresholds must descend");
|
|
264
|
+
if (!["coloured", "dark", "none"].includes(profile.outline))
|
|
265
|
+
fail("PIXEL_PROFILE", "/profile/outline", "Unknown outline mode");
|
|
266
|
+
if (!isHex(profile.outlineColor))
|
|
267
|
+
fail("PIXEL_COLOR", "/profile/outlineColor", "Expected #rrggbb");
|
|
268
|
+
if (profile.tones !== 3 && profile.tones !== 4)
|
|
269
|
+
fail("PIXEL_PROFILE", "/profile/tones", "Expected 3 or 4");
|
|
270
|
+
if (profile.paletteCap !== null &&
|
|
271
|
+
(!Number.isInteger(profile.paletteCap) || profile.paletteCap < 4 || profile.paletteCap > 256))
|
|
272
|
+
fail("PIXEL_PROFILE", "/profile/paletteCap", "Expected null or an integer from 4 to 256");
|
|
273
|
+
}
|
|
274
|
+
(options.surfaces ?? []).forEach((s, i) => {
|
|
275
|
+
const path = `/surfaces/${i}`;
|
|
276
|
+
if (!isHex(s.color))
|
|
277
|
+
fail("PIXEL_COLOR", `${path}/color`, "Expected #rrggbb");
|
|
278
|
+
if (typeof s.material !== "string" || !s.material)
|
|
279
|
+
fail("PIXEL_PATTERN", `${path}/material`, "Expected a material name");
|
|
280
|
+
const p = s.pattern;
|
|
281
|
+
const positive = (v, name) => {
|
|
282
|
+
if (!finite(v) || v <= 0 || v > 100)
|
|
283
|
+
fail("PIXEL_PATTERN", `${path}/pattern/${name}`, "Expected a positive length up to 100 m");
|
|
284
|
+
};
|
|
285
|
+
const share = (v, name) => {
|
|
286
|
+
if (!finite(v) || v <= 0 || v >= 1)
|
|
287
|
+
fail("PIXEL_PATTERN", `${path}/pattern/${name}`, "Expected a share strictly between 0 and 1");
|
|
288
|
+
};
|
|
289
|
+
if (p.type === "stripes") {
|
|
290
|
+
positive(p.period, "period");
|
|
291
|
+
share(p.width, "width");
|
|
292
|
+
if (!["x", "y", "z"].includes(p.axis))
|
|
293
|
+
fail("PIXEL_PATTERN", `${path}/pattern/axis`, "Expected x, y or z");
|
|
294
|
+
}
|
|
295
|
+
else if (p.type === "checker")
|
|
296
|
+
positive(p.period, "period");
|
|
297
|
+
else if (p.type === "grid") {
|
|
298
|
+
positive(p.period, "period");
|
|
299
|
+
share(p.width, "width");
|
|
300
|
+
if (!p.axes.length || p.axes.some((a) => !["x", "y", "z"].includes(a)))
|
|
301
|
+
fail("PIXEL_PATTERN", `${path}/pattern/axes`, "Expected one or more of x, y, z");
|
|
302
|
+
}
|
|
303
|
+
else if (p.type === "bricks") {
|
|
304
|
+
positive(p.course, "course");
|
|
305
|
+
positive(p.length, "length");
|
|
306
|
+
positive(p.mortar, "mortar");
|
|
307
|
+
if (finite(p.mortar) && (p.mortar >= p.course || p.mortar >= p.length))
|
|
308
|
+
fail("PIXEL_PATTERN", `${path}/pattern/mortar`, "Mortar must be thinner than courses and bricks");
|
|
309
|
+
if (!["x", "z"].includes(p.axis))
|
|
310
|
+
fail("PIXEL_PATTERN", `${path}/pattern/axis`, "Expected x or z");
|
|
311
|
+
}
|
|
312
|
+
else
|
|
313
|
+
fail("PIXEL_PATTERN", `${path}/pattern/type`, "Unknown pattern type");
|
|
314
|
+
});
|
|
315
|
+
return d;
|
|
316
|
+
}
|
|
317
|
+
// ---------- colour ----------
|
|
318
|
+
const toSrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
319
|
+
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
320
|
+
/** Linear sRGB to OKLab (Ottosson 2020). */
|
|
321
|
+
export function linearToOklab(rgb) {
|
|
322
|
+
const [r, g, b] = rgb;
|
|
323
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
324
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
325
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
326
|
+
return [
|
|
327
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
328
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
329
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
330
|
+
];
|
|
331
|
+
}
|
|
332
|
+
/** OKLab to linear sRGB. */
|
|
333
|
+
export function oklabToLinear(lab) {
|
|
334
|
+
const [L, a, b] = lab;
|
|
335
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
336
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
337
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
338
|
+
return [
|
|
339
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
340
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
341
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
342
|
+
];
|
|
343
|
+
}
|
|
344
|
+
function oklchToSrgb8(L, C, h) {
|
|
345
|
+
for (let c = C;; c *= 0.95) {
|
|
346
|
+
const rad = (h * Math.PI) / 180;
|
|
347
|
+
const lin = oklabToLinear([L, c * Math.cos(rad), c * Math.sin(rad)]);
|
|
348
|
+
if (lin.every((v) => v >= -1e-7 && v <= 1 + 1e-7) || c < 1e-4) {
|
|
349
|
+
const [r, g, b] = lin.map((v) => Math.round(255 * toSrgb(Math.min(1, Math.max(0, v)))));
|
|
350
|
+
return [r ?? 0, g ?? 0, b ?? 0];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const hexToLinear = (hex) => [
|
|
355
|
+
toLinear(parseInt(hex.slice(1, 3), 16) / 255),
|
|
356
|
+
toLinear(parseInt(hex.slice(3, 5), 16) / 255),
|
|
357
|
+
toLinear(parseInt(hex.slice(5, 7), 16) / 255),
|
|
358
|
+
];
|
|
359
|
+
const toHex = (c) => "#" + c.map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
360
|
+
const towards = (h, target, amount) => {
|
|
361
|
+
const d = ((target - h + 540) % 360) - 180;
|
|
362
|
+
return (h + Math.sign(d) * Math.min(Math.abs(d), amount) + 360) % 360;
|
|
363
|
+
};
|
|
364
|
+
/** Ramp step indices. */
|
|
365
|
+
export const PIXEL_RAMP = Object.freeze({ outline: 0, deep: 1, shadow: 2, mid: 3, light: 4 });
|
|
366
|
+
/** Five-step hue-shifted ramp (#rrggbb) for a linear-sRGB colour. */
|
|
367
|
+
export function pixelRamp(linearRgb, profile = PIXEL_PROFILES.scene) {
|
|
368
|
+
return rampRgb(linearRgb, profile).map(toHex);
|
|
369
|
+
}
|
|
370
|
+
function rampRgb(linearRgb, profile) {
|
|
371
|
+
const [L0, a, b] = linearToOklab(linearRgb);
|
|
372
|
+
const C0 = Math.hypot(a, b);
|
|
373
|
+
const h0 = C0 < 1e-6 ? profile.coolHue : ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360;
|
|
374
|
+
return profile.steps.map(({ dL, hueShift, chroma, minChroma }) => {
|
|
375
|
+
const h = hueShift === 0 ? h0 : towards(h0, hueShift < 0 ? profile.coolHue : profile.warmHue, Math.abs(hueShift));
|
|
376
|
+
return oklchToSrgb8(Math.min(0.97, Math.max(0.1, L0 + dL)), Math.max(C0 * chroma, minChroma), h);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/** Instance and part under a pixel, or null for background and out-of-range coordinates. */
|
|
380
|
+
export function pickPixel(image, x, y) {
|
|
381
|
+
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= image.width || y >= image.height)
|
|
382
|
+
return null;
|
|
383
|
+
const o = image.owners[y * image.width + x] ?? -1;
|
|
384
|
+
const inst = o >= 0 ? image.instances[o] : null;
|
|
385
|
+
return inst ? { id: inst.id, part: inst.part } : null;
|
|
386
|
+
}
|
|
387
|
+
// ---------- rendering ----------
|
|
388
|
+
const N4 = [
|
|
389
|
+
[-1, 0],
|
|
390
|
+
[1, 0],
|
|
391
|
+
[0, -1],
|
|
392
|
+
[0, 1],
|
|
393
|
+
];
|
|
394
|
+
const isAsset = (s) => !Array.isArray(s);
|
|
395
|
+
/** Renders an asset or a composed scene to pixel art. Throws PixelArtError on invalid options or empty input. */
|
|
396
|
+
export function renderPixelArt(source, options = {}) {
|
|
397
|
+
const errors = validatePixelOptions(options);
|
|
398
|
+
const instances = isAsset(source) ? pixelInstances(source) : source;
|
|
399
|
+
if (!instances.length || instances.every((i) => !i.mesh.indices.length))
|
|
400
|
+
errors.push({ code: "PIXEL_EMPTY", path: "", message: "Nothing to render" });
|
|
401
|
+
for (const [n, inst] of instances.entries()) {
|
|
402
|
+
const m = inst.mesh;
|
|
403
|
+
if (m.positions.length % 3 ||
|
|
404
|
+
m.normals.length !== m.positions.length ||
|
|
405
|
+
m.colors.length !== m.positions.length ||
|
|
406
|
+
m.indices.length % 3 ||
|
|
407
|
+
m.indices.some((i) => !Number.isInteger(i) || i < 0 || i >= m.positions.length / 3) ||
|
|
408
|
+
!m.positions.every(Number.isFinite) ||
|
|
409
|
+
(inst.restPositions && inst.restPositions.length !== m.positions.length))
|
|
410
|
+
errors.push({ code: "PIXEL_MESH", path: `/instances/${n}`, message: `Invalid mesh for ${inst.id}` });
|
|
411
|
+
}
|
|
412
|
+
if (errors.length)
|
|
413
|
+
throw new PixelArtError(errors);
|
|
414
|
+
const profile = options.profile ?? PIXEL_PROFILES.scene, camera = options.camera ?? requireCamera("dimetric"), ppm = options.pixelsPerMetre ?? 32, s = options.samples ?? 4, margin = options.margin ?? 4, features = new Set(options.features ?? []), warnings = [];
|
|
415
|
+
// Surfaces whose smallest feature is below the grid are suppressed, not drawn broken.
|
|
416
|
+
const surfaces = (options.surfaces ?? []).filter((surface, i) => {
|
|
417
|
+
const px = patternGap(surface.pattern) * ppm;
|
|
418
|
+
if (px >= profile.patternMinPixels)
|
|
419
|
+
return true;
|
|
420
|
+
warnings.push({
|
|
421
|
+
code: "PIXEL_PATTERN_BELOW_GRID",
|
|
422
|
+
path: `/surfaces/${i}`,
|
|
423
|
+
message: `Pattern gap is ${px.toFixed(2)} px at ${ppm} px/m; minimum is ${profile.patternMinPixels} px`,
|
|
424
|
+
});
|
|
425
|
+
return false;
|
|
426
|
+
});
|
|
427
|
+
const surfaceOf = instances.map((inst) => surfaces.findIndex((x) => x.material === inst.material &&
|
|
428
|
+
(x.scope === undefined || x.scope === null || inst.id.startsWith(`${x.scope}/`))));
|
|
429
|
+
// Projection and canvas.
|
|
430
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
431
|
+
for (const { mesh } of instances)
|
|
432
|
+
for (let v = 0; v < mesh.positions.length; v += 3) {
|
|
433
|
+
const p = [mesh.positions[v] ?? 0, mesh.positions[v + 1] ?? 0, mesh.positions[v + 2] ?? 0];
|
|
434
|
+
const x = dot(p, camera.right), y = dot(p, camera.up);
|
|
435
|
+
minX = Math.min(minX, x);
|
|
436
|
+
maxX = Math.max(maxX, x);
|
|
437
|
+
minY = Math.min(minY, y);
|
|
438
|
+
maxY = Math.max(maxY, y);
|
|
439
|
+
}
|
|
440
|
+
let width, height, originX, originY;
|
|
441
|
+
if (options.frame) {
|
|
442
|
+
width = options.frame.width;
|
|
443
|
+
height = options.frame.height;
|
|
444
|
+
originX = dot(options.frame.center, camera.right) - width / 2 / ppm;
|
|
445
|
+
originY = dot(options.frame.center, camera.up) + height / 2 / ppm;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
width = Math.ceil((maxX - minX) * ppm) + 2 * margin;
|
|
449
|
+
height = Math.ceil((maxY - minY) * ppm) + 2 * margin;
|
|
450
|
+
// Centre the content; snap the origin to whole pixels so equal inputs align.
|
|
451
|
+
originX = minX - Math.floor((width - (maxX - minX) * ppm) / 2) / ppm;
|
|
452
|
+
originY = maxY + Math.floor((height - (maxY - minY) * ppm) / 2) / ppm;
|
|
453
|
+
}
|
|
454
|
+
if (width > PIXEL_MAX_DIMENSION ||
|
|
455
|
+
height > PIXEL_MAX_DIMENSION ||
|
|
456
|
+
width * height * s * s > PIXEL_MAX_SUBSAMPLES)
|
|
457
|
+
throw new PixelArtError([
|
|
458
|
+
{
|
|
459
|
+
code: "PIXEL_BUDGET",
|
|
460
|
+
path: "/pixelsPerMetre",
|
|
461
|
+
message: `Canvas ${width}x${height} at ${s}x${s} samples exceeds the render budget`,
|
|
462
|
+
},
|
|
463
|
+
]);
|
|
464
|
+
// Pass 1: supersampled owner, depth, normal and pattern buffers.
|
|
465
|
+
const W = width * s, H = height * s, k = ppm * s, lineFloor = 1 / ppm;
|
|
466
|
+
const owner = new Int32Array(W * H).fill(-1), depth = new Float64Array(W * H).fill(-Infinity), normal = new Float32Array(W * H * 3), pattern = new Uint8Array(W * H);
|
|
467
|
+
for (const [id, inst] of instances.entries()) {
|
|
468
|
+
const m = inst.mesh, rest = inst.restPositions ?? m.positions, n = m.positions.length / 3, surface = surfaces[surfaceOf[id] ?? -1] ?? null;
|
|
469
|
+
const sx = new Float64Array(n), sy = new Float64Array(n), sz = new Float64Array(n);
|
|
470
|
+
for (let v = 0; v < n; v++) {
|
|
471
|
+
const p = [m.positions[3 * v] ?? 0, m.positions[3 * v + 1] ?? 0, m.positions[3 * v + 2] ?? 0];
|
|
472
|
+
sx[v] = (dot(p, camera.right) - originX) * k;
|
|
473
|
+
sy[v] = (originY - dot(p, camera.up)) * k;
|
|
474
|
+
sz[v] = dot(p, camera.back);
|
|
475
|
+
}
|
|
476
|
+
for (let t = 0; t < m.indices.length; t += 3) {
|
|
477
|
+
const a = m.indices[t] ?? 0, b = m.indices[t + 1] ?? 0, c = m.indices[t + 2] ?? 0;
|
|
478
|
+
const ax = sx[a] ?? 0, ay = sy[a] ?? 0, bx = sx[b] ?? 0, by = sy[b] ?? 0, cx = sx[c] ?? 0, cy = sy[c] ?? 0;
|
|
479
|
+
const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
|
|
480
|
+
if (Math.abs(area) < 1e-12)
|
|
481
|
+
continue;
|
|
482
|
+
const x0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))), x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx)));
|
|
483
|
+
const y0 = Math.max(0, Math.floor(Math.min(ay, by, cy))), y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by, cy)));
|
|
484
|
+
for (let y = y0; y <= y1; y++)
|
|
485
|
+
for (let x = x0; x <= x1; x++) {
|
|
486
|
+
const px = x + 0.5, py = y + 0.5;
|
|
487
|
+
const wa = ((bx - px) * (cy - py) - (by - py) * (cx - px)) / area;
|
|
488
|
+
const wb = ((cx - px) * (ay - py) - (cy - py) * (ax - px)) / area;
|
|
489
|
+
const wc = 1 - wa - wb;
|
|
490
|
+
if (wa < -1e-9 || wb < -1e-9 || wc < -1e-9)
|
|
491
|
+
continue;
|
|
492
|
+
const d = wa * (sz[a] ?? 0) + wb * (sz[b] ?? 0) + wc * (sz[c] ?? 0), at = y * W + x;
|
|
493
|
+
if (d <= (depth[at] ?? -Infinity))
|
|
494
|
+
continue;
|
|
495
|
+
depth[at] = d;
|
|
496
|
+
owner[at] = id;
|
|
497
|
+
for (let j = 0; j < 3; j++)
|
|
498
|
+
normal[3 * at + j] =
|
|
499
|
+
wa * (m.normals[3 * a + j] ?? 0) +
|
|
500
|
+
wb * (m.normals[3 * b + j] ?? 0) +
|
|
501
|
+
wc * (m.normals[3 * c + j] ?? 0);
|
|
502
|
+
if (surface) {
|
|
503
|
+
const r = (j) => wa * (rest[3 * a + j] ?? 0) + wb * (rest[3 * b + j] ?? 0) + wc * (rest[3 * c + j] ?? 0);
|
|
504
|
+
const q = [r(0), r(1), r(2)];
|
|
505
|
+
pattern[at] = patternHit(surface.pattern, q, lineFloor) ? 1 : 0;
|
|
506
|
+
}
|
|
507
|
+
else
|
|
508
|
+
pattern[at] = 0;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Pass 2: one owner per pixel; declared features win at >= 1/8 coverage.
|
|
513
|
+
const size = width * height, pxOwner = new Int32Array(size).fill(-1), minFeature = Math.ceil((s * s) / 8), count = new Map();
|
|
514
|
+
for (let y = 0; y < height; y++)
|
|
515
|
+
for (let x = 0; x < width; x++) {
|
|
516
|
+
count.clear();
|
|
517
|
+
let background = 0;
|
|
518
|
+
for (let j = 0; j < s; j++)
|
|
519
|
+
for (let i = 0; i < s; i++) {
|
|
520
|
+
const o = owner[(y * s + j) * W + x * s + i] ?? -1;
|
|
521
|
+
if (o < 0)
|
|
522
|
+
background++;
|
|
523
|
+
else
|
|
524
|
+
count.set(o, (count.get(o) ?? 0) + 1);
|
|
525
|
+
}
|
|
526
|
+
let best = -1, score = 0;
|
|
527
|
+
for (const [o, n] of [...count].sort((p, q) => p[0] - q[0])) {
|
|
528
|
+
const feature = features.has(instances[o]?.part ?? "");
|
|
529
|
+
const sc = feature && n >= minFeature ? n * profile.featureWeight : n;
|
|
530
|
+
if (sc > score) {
|
|
531
|
+
score = sc;
|
|
532
|
+
best = o;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (best >= 0 && background <= score)
|
|
536
|
+
pxOwner[y * width + x] = best;
|
|
537
|
+
}
|
|
538
|
+
// Pass 3: faces (owner subsamples grouped by rounded normal); drop slivers; majority face per pixel.
|
|
539
|
+
const faceKey = (q) => Math.round((normal[3 * q] ?? 0) * 10) * 441 +
|
|
540
|
+
Math.round((normal[3 * q + 1] ?? 0) * 10) * 21 +
|
|
541
|
+
Math.round((normal[3 * q + 2] ?? 0) * 10);
|
|
542
|
+
const cellFaces = (x, y, o) => {
|
|
543
|
+
const faces = new Map();
|
|
544
|
+
for (let j = 0; j < s; j++)
|
|
545
|
+
for (let i = 0; i < s; i++) {
|
|
546
|
+
const q = (y * s + j) * W + x * s + i;
|
|
547
|
+
if (owner[q] !== o)
|
|
548
|
+
continue;
|
|
549
|
+
const key = faceKey(q);
|
|
550
|
+
let f = faces.get(key);
|
|
551
|
+
if (!f) {
|
|
552
|
+
f = { n: 0, nx: 0, ny: 0, nz: 0, hits: 0, flat: true, first: q };
|
|
553
|
+
faces.set(key, f);
|
|
554
|
+
}
|
|
555
|
+
f.n++;
|
|
556
|
+
f.nx += normal[3 * q] ?? 0;
|
|
557
|
+
f.ny += normal[3 * q + 1] ?? 0;
|
|
558
|
+
f.nz += normal[3 * q + 2] ?? 0;
|
|
559
|
+
f.hits += pattern[q] ?? 0;
|
|
560
|
+
for (let c = 0; c < 3; c++)
|
|
561
|
+
if (Math.abs((normal[3 * q + c] ?? 0) - (normal[3 * f.first + c] ?? 0)) > 1e-5)
|
|
562
|
+
f.flat = false;
|
|
563
|
+
}
|
|
564
|
+
return [...faces].sort((p, q) => p[0] - q[0]);
|
|
565
|
+
};
|
|
566
|
+
// A face is flat only if every subsample in every pixel shares the first
|
|
567
|
+
// subsample's normal; comparing within one pixel is not enough, because a
|
|
568
|
+
// curved surface can leave a single sample of a normal bin per pixel.
|
|
569
|
+
const faceStats = new Map();
|
|
570
|
+
const sameNormal = (a, b) => Math.abs((normal[3 * a] ?? 0) - (normal[3 * b] ?? 0)) <= 1e-5 &&
|
|
571
|
+
Math.abs((normal[3 * a + 1] ?? 0) - (normal[3 * b + 1] ?? 0)) <= 1e-5 &&
|
|
572
|
+
Math.abs((normal[3 * a + 2] ?? 0) - (normal[3 * b + 2] ?? 0)) <= 1e-5;
|
|
573
|
+
for (let y = 0; y < height; y++)
|
|
574
|
+
for (let x = 0; x < width; x++) {
|
|
575
|
+
const o = pxOwner[y * width + x] ?? -1;
|
|
576
|
+
if (o < 0)
|
|
577
|
+
continue;
|
|
578
|
+
for (const [key, f] of cellFaces(x, y, o)) {
|
|
579
|
+
const id = `${o}|${key}`, st = faceStats.get(id) ?? { samples: 0, touched: 0, flat: true, ref: f.first };
|
|
580
|
+
st.samples += f.n;
|
|
581
|
+
st.touched++;
|
|
582
|
+
st.flat &&= f.flat && sameNormal(st.ref, f.first);
|
|
583
|
+
faceStats.set(id, st);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// Only flat faces can be slivers; curved surfaces are split into normal bins artificially.
|
|
587
|
+
const sliver = new Set();
|
|
588
|
+
for (const [id, st] of faceStats)
|
|
589
|
+
if (st.flat && st.touched >= 3 && st.samples / (s * s) / st.touched < profile.sliverThinness)
|
|
590
|
+
sliver.add(id);
|
|
591
|
+
const pxNormal = new Float64Array(size * 3), pxPattern = new Uint8Array(size);
|
|
592
|
+
for (let y = 0; y < height; y++)
|
|
593
|
+
for (let x = 0; x < width; x++) {
|
|
594
|
+
const at = y * width + x, o = pxOwner[at] ?? -1;
|
|
595
|
+
if (o < 0)
|
|
596
|
+
continue;
|
|
597
|
+
const faces = cellFaces(x, y, o);
|
|
598
|
+
const eligible = faces.filter(([key]) => !sliver.has(`${o}|${key}`));
|
|
599
|
+
let chosen = null;
|
|
600
|
+
for (const [, f] of eligible.length ? eligible : faces)
|
|
601
|
+
if (!chosen || f.n > chosen.n)
|
|
602
|
+
chosen = f;
|
|
603
|
+
if (!chosen)
|
|
604
|
+
continue;
|
|
605
|
+
const len = Math.hypot(chosen.nx, chosen.ny, chosen.nz) || 1;
|
|
606
|
+
pxNormal[3 * at] = chosen.nx / len;
|
|
607
|
+
pxNormal[3 * at + 1] = chosen.ny / len;
|
|
608
|
+
pxNormal[3 * at + 2] = chosen.nz / len;
|
|
609
|
+
pxPattern[at] = 2 * chosen.hits >= chosen.n ? 1 : 0;
|
|
610
|
+
}
|
|
611
|
+
// Pass 4: which part is in front of which, decided once per pair from subsample votes.
|
|
612
|
+
const front = new Set();
|
|
613
|
+
if (profile.innerLines) {
|
|
614
|
+
const votes = new Map();
|
|
615
|
+
const sameFace = (a, b) => (normal[3 * a] ?? 0) * (normal[3 * b] ?? 0) +
|
|
616
|
+
(normal[3 * a + 1] ?? 0) * (normal[3 * b + 1] ?? 0) +
|
|
617
|
+
(normal[3 * a + 2] ?? 0) * (normal[3 * b + 2] ?? 0) >
|
|
618
|
+
0.9;
|
|
619
|
+
const extrapolate = (a, c) => owner[c] === owner[a] && sameFace(a, c)
|
|
620
|
+
? (depth[a] ?? 0) + ((depth[a] ?? 0) - (depth[c] ?? 0)) * 0.5
|
|
621
|
+
: (depth[a] ?? 0);
|
|
622
|
+
const tally = (a, b, aPrev, bNext) => {
|
|
623
|
+
const oa = owner[a] ?? -1, ob = owner[b] ?? -1;
|
|
624
|
+
if (oa < 0 || ob < 0 || oa === ob)
|
|
625
|
+
return;
|
|
626
|
+
const da = extrapolate(a, aPrev), db = extrapolate(b, bNext);
|
|
627
|
+
const lo = Math.min(oa, ob), key = `${lo},${Math.max(oa, ob)}`, v = votes.get(key) ?? { total: 0, ab: 0, ba: 0 };
|
|
628
|
+
v.total++;
|
|
629
|
+
// ab: the lower-indexed owner is in front.
|
|
630
|
+
const lowDepth = oa === lo ? da : db, highDepth = oa === lo ? db : da;
|
|
631
|
+
if (lowDepth - highDepth > profile.depthGap)
|
|
632
|
+
v.ab++;
|
|
633
|
+
else if (highDepth - lowDepth > profile.depthGap)
|
|
634
|
+
v.ba++;
|
|
635
|
+
votes.set(key, v);
|
|
636
|
+
};
|
|
637
|
+
for (let y = 0; y < H; y++)
|
|
638
|
+
for (let x = 0; x < W; x++) {
|
|
639
|
+
const a = y * W + x;
|
|
640
|
+
if (x + 1 < W)
|
|
641
|
+
tally(a, a + 1, x > 0 ? a - 1 : a, x + 2 < W ? a + 2 : a + 1);
|
|
642
|
+
if (y + 1 < H)
|
|
643
|
+
tally(a, a + W, y > 0 ? a - W : a, y + 2 < H ? a + 2 * W : a + W);
|
|
644
|
+
}
|
|
645
|
+
for (const [key, v] of votes) {
|
|
646
|
+
const [lo, hi] = key.split(",");
|
|
647
|
+
if (v.ab >= profile.frontVotes * v.total)
|
|
648
|
+
front.add(`${lo},${hi}`);
|
|
649
|
+
else if (v.ba >= profile.frontVotes * v.total)
|
|
650
|
+
front.add(`${hi},${lo}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// Pass 5: bands, lines, cleanup and colour.
|
|
654
|
+
const ramps = new Map();
|
|
655
|
+
const rampFor = (linear) => {
|
|
656
|
+
const key = linear.join(",");
|
|
657
|
+
let r = ramps.get(key);
|
|
658
|
+
if (!r) {
|
|
659
|
+
r = rampRgb(linear, profile);
|
|
660
|
+
ramps.set(key, r);
|
|
661
|
+
}
|
|
662
|
+
return r;
|
|
663
|
+
};
|
|
664
|
+
const baseOf = instances.map((inst) => [
|
|
665
|
+
inst.mesh.colors[0] ?? 0,
|
|
666
|
+
inst.mesh.colors[1] ?? 0,
|
|
667
|
+
inst.mesh.colors[2] ?? 0,
|
|
668
|
+
]);
|
|
669
|
+
const patternLinear = surfaces.map((x) => hexToLinear(x.color));
|
|
670
|
+
const declaredSurfaces = options.surfaces ?? [];
|
|
671
|
+
const area = new Map();
|
|
672
|
+
for (const o of pxOwner)
|
|
673
|
+
if (o >= 0)
|
|
674
|
+
area.set(o, (area.get(o) ?? 0) + 1);
|
|
675
|
+
const lightAxis = (i) => profile.light.right * camera.right[i] +
|
|
676
|
+
profile.light.up * camera.up[i] +
|
|
677
|
+
profile.light.back * camera.back[i];
|
|
678
|
+
const L = normalize([lightAxis(0), lightAxis(1), lightAxis(2)]);
|
|
679
|
+
const band = new Int8Array(size).fill(-1);
|
|
680
|
+
for (let at = 0; at < size; at++) {
|
|
681
|
+
if ((pxOwner[at] ?? -1) < 0)
|
|
682
|
+
continue;
|
|
683
|
+
const n = [pxNormal[3 * at] ?? 0, pxNormal[3 * at + 1] ?? 0, pxNormal[3 * at + 2] ?? 0];
|
|
684
|
+
const v = dot(n, L) + profile.topBias * n[1];
|
|
685
|
+
band[at] =
|
|
686
|
+
v >= profile.bands[0]
|
|
687
|
+
? 4
|
|
688
|
+
: v >= profile.bands[1]
|
|
689
|
+
? 3
|
|
690
|
+
: v >= profile.bands[2] || profile.tones === 3
|
|
691
|
+
? 2
|
|
692
|
+
: 1;
|
|
693
|
+
}
|
|
694
|
+
// Out-of-canvas neighbours are skipped: a crop edge is not a silhouette.
|
|
695
|
+
const neighbours = (at, visit) => {
|
|
696
|
+
const x = at % width, y = (at - x) / width;
|
|
697
|
+
for (const [dx, dy] of N4) {
|
|
698
|
+
const qx = x + dx, qy = y + dy;
|
|
699
|
+
if (qx >= 0 && qy >= 0 && qx < width && qy < height)
|
|
700
|
+
visit(qy * width + qx);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
// 0 fill, 1 outline, 2 inner line, 3 accent.
|
|
704
|
+
const kind = new Int8Array(size).fill(-1);
|
|
705
|
+
for (let at = 0; at < size; at++) {
|
|
706
|
+
const o = pxOwner[at] ?? -1;
|
|
707
|
+
if (o < 0)
|
|
708
|
+
continue;
|
|
709
|
+
if ((area.get(o) ?? 0) <= profile.accentArea || features.has(instances[o]?.part ?? "")) {
|
|
710
|
+
kind[at] = 3;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
let exterior = false, inner = false;
|
|
714
|
+
neighbours(at, (q) => {
|
|
715
|
+
const b = pxOwner[q] ?? -1;
|
|
716
|
+
if (b < 0)
|
|
717
|
+
exterior = true;
|
|
718
|
+
else if (b !== o && front.has(`${o},${b}`))
|
|
719
|
+
inner = true;
|
|
720
|
+
});
|
|
721
|
+
kind[at] = exterior && profile.outline !== "none" ? 1 : inner ? 2 : 0;
|
|
722
|
+
}
|
|
723
|
+
const cleaned = band.slice();
|
|
724
|
+
let orphansCleaned = 0;
|
|
725
|
+
if (profile.orphanCleanup)
|
|
726
|
+
for (let at = 0; at < size; at++) {
|
|
727
|
+
if (kind[at] !== 0)
|
|
728
|
+
continue;
|
|
729
|
+
let n = 0, agreed = -1, uniform = true;
|
|
730
|
+
neighbours(at, (q) => {
|
|
731
|
+
if (pxOwner[q] !== pxOwner[at] || kind[q] !== 0 || pxPattern[q] !== pxPattern[at])
|
|
732
|
+
return;
|
|
733
|
+
n++;
|
|
734
|
+
if (agreed < 0)
|
|
735
|
+
agreed = band[q] ?? -1;
|
|
736
|
+
else if (band[q] !== agreed)
|
|
737
|
+
uniform = false;
|
|
738
|
+
});
|
|
739
|
+
if (n >= 2 && uniform && agreed !== band[at]) {
|
|
740
|
+
cleaned[at] = agreed;
|
|
741
|
+
orphansCleaned++;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const outlineRgb = [
|
|
745
|
+
parseInt(profile.outlineColor.slice(1, 3), 16),
|
|
746
|
+
parseInt(profile.outlineColor.slice(3, 5), 16),
|
|
747
|
+
parseInt(profile.outlineColor.slice(5, 7), 16),
|
|
748
|
+
];
|
|
749
|
+
const background = options.background ?? null;
|
|
750
|
+
const bg = background
|
|
751
|
+
? [
|
|
752
|
+
parseInt(background.slice(1, 3), 16),
|
|
753
|
+
parseInt(background.slice(3, 5), 16),
|
|
754
|
+
parseInt(background.slice(5, 7), 16),
|
|
755
|
+
]
|
|
756
|
+
: null;
|
|
757
|
+
const rgba = new Uint8Array(size * 4), roles = new Int8Array(size).fill(-1);
|
|
758
|
+
const stats = {
|
|
759
|
+
outline: 0,
|
|
760
|
+
inner: 0,
|
|
761
|
+
accent: 0,
|
|
762
|
+
fill: 0,
|
|
763
|
+
pattern: 0,
|
|
764
|
+
orphansCleaned,
|
|
765
|
+
sliverFaces: sliver.size,
|
|
766
|
+
frontPairs: front.size,
|
|
767
|
+
};
|
|
768
|
+
// Scene palette from every input colour (not visible pixels), so all views agree.
|
|
769
|
+
const candidates = new Map();
|
|
770
|
+
// Typical use of each ramp step: mid tones cover most pixels, outlines the fewest.
|
|
771
|
+
const stepUse = [0.15, 0.4, 0.7, 1, 0.7];
|
|
772
|
+
const addRamp = (ramp, weight) => {
|
|
773
|
+
ramp.forEach((c, step) => {
|
|
774
|
+
const key = toHex(c), w = weight * (ramp.length === stepUse.length ? (stepUse[step] ?? 1) : 1), e = candidates.get(key);
|
|
775
|
+
if (e)
|
|
776
|
+
e.weight += w;
|
|
777
|
+
else
|
|
778
|
+
candidates.set(key, { rgb: c, weight: w });
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
// Weight by surface area, so a large ground keeps its colours against many small parts.
|
|
782
|
+
let totalArea = 0;
|
|
783
|
+
for (const [o, inst] of instances.entries()) {
|
|
784
|
+
const m = inst.mesh;
|
|
785
|
+
let area = 0;
|
|
786
|
+
for (let t = 0; t < m.indices.length; t += 3) {
|
|
787
|
+
const [a, b, c] = [m.indices[t] ?? 0, m.indices[t + 1] ?? 0, m.indices[t + 2] ?? 0];
|
|
788
|
+
const p = (v) => [
|
|
789
|
+
m.positions[3 * v] ?? 0,
|
|
790
|
+
m.positions[3 * v + 1] ?? 0,
|
|
791
|
+
m.positions[3 * v + 2] ?? 0,
|
|
792
|
+
];
|
|
793
|
+
const [pa, pb, pc] = [p(a), p(b), p(c)];
|
|
794
|
+
const n = cross([pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]], [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]]);
|
|
795
|
+
area += Math.hypot(n[0], n[1], n[2]) / 2;
|
|
796
|
+
}
|
|
797
|
+
totalArea += area;
|
|
798
|
+
// Every declared surface counts, including ones suppressed at this density,
|
|
799
|
+
// so the palette does not depend on pixels per metre or framing.
|
|
800
|
+
const declared = declaredSurfaces.findIndex((x) => x.material === inst.material &&
|
|
801
|
+
(x.scope === undefined || x.scope === null || inst.id.startsWith(`${x.scope}/`)));
|
|
802
|
+
addRamp(rampFor(baseOf[o] ?? [0, 0, 0]), declared >= 0 ? area / 2 : area);
|
|
803
|
+
if (declared >= 0)
|
|
804
|
+
addRamp(rampFor(hexToLinear(declaredSurfaces[declared]?.color ?? "#000000")), area / 2);
|
|
805
|
+
}
|
|
806
|
+
if (profile.outline === "dark")
|
|
807
|
+
addRamp([outlineRgb], totalArea);
|
|
808
|
+
const mapped = profile.paletteCap === null ? null : capPalette(candidates, profile.paletteCap);
|
|
809
|
+
const palette = new Set();
|
|
810
|
+
for (let at = 0; at < size; at++) {
|
|
811
|
+
const o = pxOwner[at] ?? -1;
|
|
812
|
+
if (o < 0) {
|
|
813
|
+
if (bg)
|
|
814
|
+
rgba.set([...bg, 255], 4 * at);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const own = rampFor(baseOf[o] ?? [0, 0, 0]);
|
|
818
|
+
const patterned = pxPattern[at] === 1 && (surfaceOf[o] ?? -1) >= 0;
|
|
819
|
+
const fillRamp = patterned ? rampFor(patternLinear[surfaceOf[o] ?? 0] ?? [0, 0, 0]) : own;
|
|
820
|
+
let role, color;
|
|
821
|
+
const k2 = kind[at];
|
|
822
|
+
if (k2 === 3) {
|
|
823
|
+
role = Math.min(4, Math.max(3, (band[at] ?? 3) + 1));
|
|
824
|
+
color = fillRamp[role] ?? [0, 0, 0];
|
|
825
|
+
stats.accent++;
|
|
826
|
+
}
|
|
827
|
+
else if (k2 === 1) {
|
|
828
|
+
role = 0;
|
|
829
|
+
color = profile.outline === "dark" ? outlineRgb : (own[0] ?? [0, 0, 0]);
|
|
830
|
+
stats.outline++;
|
|
831
|
+
}
|
|
832
|
+
else if (k2 === 2) {
|
|
833
|
+
role = 1;
|
|
834
|
+
color = own[1] ?? [0, 0, 0];
|
|
835
|
+
stats.inner++;
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
role = cleaned[at] ?? 3;
|
|
839
|
+
color = fillRamp[role] ?? [0, 0, 0];
|
|
840
|
+
if (patterned)
|
|
841
|
+
stats.pattern++;
|
|
842
|
+
else
|
|
843
|
+
stats.fill++;
|
|
844
|
+
}
|
|
845
|
+
roles[at] = role;
|
|
846
|
+
if (mapped)
|
|
847
|
+
color = mapped.get(toHex(color)) ?? color;
|
|
848
|
+
rgba.set([...color, 255], 4 * at);
|
|
849
|
+
palette.add(toHex(color));
|
|
850
|
+
}
|
|
851
|
+
// Per-pixel depth of the final owner (any covering sample if cleanup reassigned the pixel).
|
|
852
|
+
const pxDepth = new Float32Array(width * height).fill(NaN);
|
|
853
|
+
for (let y = 0; y < height; y++)
|
|
854
|
+
for (let x = 0; x < width; x++) {
|
|
855
|
+
const o = pxOwner[y * width + x] ?? -1;
|
|
856
|
+
if (o < 0)
|
|
857
|
+
continue;
|
|
858
|
+
let own = -Infinity, any = -Infinity;
|
|
859
|
+
for (let j = 0; j < s; j++)
|
|
860
|
+
for (let i = 0; i < s; i++) {
|
|
861
|
+
const q = (y * s + j) * W + x * s + i, d = depth[q] ?? -Infinity;
|
|
862
|
+
if (d > any)
|
|
863
|
+
any = d;
|
|
864
|
+
if (owner[q] === o && d > own)
|
|
865
|
+
own = d;
|
|
866
|
+
}
|
|
867
|
+
const d = own > -Infinity ? own : any;
|
|
868
|
+
if (d > -Infinity)
|
|
869
|
+
pxDepth[y * width + x] = d;
|
|
870
|
+
}
|
|
871
|
+
return {
|
|
872
|
+
format: PIXEL_IMAGE_FORMAT,
|
|
873
|
+
width,
|
|
874
|
+
height,
|
|
875
|
+
pixelsPerMetre: ppm,
|
|
876
|
+
camera: camera.name,
|
|
877
|
+
profile: profile.name,
|
|
878
|
+
rgba,
|
|
879
|
+
owners: pxOwner,
|
|
880
|
+
roles,
|
|
881
|
+
depth: pxDepth,
|
|
882
|
+
instances: instances.map((i) => ({ id: i.id, part: i.part, material: i.material })),
|
|
883
|
+
palette: [...palette].sort(),
|
|
884
|
+
stats,
|
|
885
|
+
warnings,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Deterministic palette reduction by weighted maximin selection: start from
|
|
890
|
+
* the heaviest colour (area times typical step use), then repeatedly add the
|
|
891
|
+
* candidate with the largest distance to every chosen colour times
|
|
892
|
+
* (weight / heaviest)^0.25, in OKLab with hue and chroma weighted double.
|
|
893
|
+
* Chosen colours are real ramp colours, and distinctive hues (a blue roof, a
|
|
894
|
+
* red sign) survive; every candidate maps to its nearest chosen colour.
|
|
895
|
+
*/
|
|
896
|
+
function capPalette(candidates, cap) {
|
|
897
|
+
const keys = [...candidates.keys()].sort();
|
|
898
|
+
const out = new Map();
|
|
899
|
+
if (keys.length <= cap)
|
|
900
|
+
return out;
|
|
901
|
+
const items = keys.map((k) => {
|
|
902
|
+
const e = candidates.get(k) ?? { rgb: [0, 0, 0], weight: 0 };
|
|
903
|
+
const lab = linearToOklab([toLinear(e.rgb[0] / 255), toLinear(e.rgb[1] / 255), toLinear(e.rgb[2] / 255)]);
|
|
904
|
+
return { key: k, rgb: e.rgb, weight: e.weight, lab };
|
|
905
|
+
});
|
|
906
|
+
const d2 = (a, b) => (a[0] - b[0]) ** 2 + 4 * (a[1] - b[1]) ** 2 + 4 * (a[2] - b[2]) ** 2;
|
|
907
|
+
let first = 0;
|
|
908
|
+
items.forEach((item, i) => {
|
|
909
|
+
if (item.weight > (items[first]?.weight ?? 0))
|
|
910
|
+
first = i;
|
|
911
|
+
});
|
|
912
|
+
const chosen = [first];
|
|
913
|
+
const nearest = items.map((item) => d2(item.lab, items[first]?.lab ?? item.lab));
|
|
914
|
+
const heaviest = items[first]?.weight || 1;
|
|
915
|
+
const score = (i) => (nearest[i] ?? 0) * ((items[i]?.weight ?? 0) / heaviest) ** 0.25;
|
|
916
|
+
while (chosen.length < cap) {
|
|
917
|
+
let far = 0;
|
|
918
|
+
nearest.forEach((_, i) => {
|
|
919
|
+
if (score(i) > score(far))
|
|
920
|
+
far = i;
|
|
921
|
+
});
|
|
922
|
+
if (score(far) === 0)
|
|
923
|
+
break;
|
|
924
|
+
chosen.push(far);
|
|
925
|
+
const c = items[far]?.lab ?? [0, 0, 0];
|
|
926
|
+
items.forEach((item, i) => (nearest[i] = Math.min(nearest[i] ?? Infinity, d2(item.lab, c))));
|
|
927
|
+
}
|
|
928
|
+
for (const item of items) {
|
|
929
|
+
let best = chosen[0] ?? 0;
|
|
930
|
+
for (const c of chosen)
|
|
931
|
+
if (d2(item.lab, items[c]?.lab ?? item.lab) < d2(item.lab, items[best]?.lab ?? item.lab))
|
|
932
|
+
best = c;
|
|
933
|
+
out.set(item.key, items[best]?.rgb ?? item.rgb);
|
|
934
|
+
}
|
|
935
|
+
return out;
|
|
936
|
+
}
|
|
937
|
+
function requireCamera(name) {
|
|
938
|
+
const c = PIXEL_CAMERAS[name];
|
|
939
|
+
if (!c)
|
|
940
|
+
throw new Error(`Missing camera preset ${name}`);
|
|
941
|
+
return c;
|
|
942
|
+
}
|