@photivra/engine 2.2.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/CITATION.cff +1 -1
  3. package/README.md +6 -6
  4. package/SECURITY.md +1 -1
  5. package/dist/capture/native-environment-raw.d.ts +18 -0
  6. package/dist/capture/native-environment-raw.js +17 -1
  7. package/dist/capture/native-environment-raw.js.map +1 -1
  8. package/dist/capture/native-raw-dng.d.ts +3 -0
  9. package/dist/capture/native-raw-dng.js +23 -3
  10. package/dist/capture/native-raw-dng.js.map +1 -1
  11. package/dist/capture/native-raw.d.ts +3 -0
  12. package/dist/capture/native-raw.js +8 -5
  13. package/dist/capture/native-raw.js.map +1 -1
  14. package/dist/core/version.d.ts +1 -1
  15. package/dist/core/version.js +1 -1
  16. package/dist/core/version.js.map +1 -1
  17. package/dist/equipment/generic-prime-lenses.d.ts +46 -0
  18. package/dist/equipment/generic-prime-lenses.js +59 -0
  19. package/dist/equipment/generic-prime-lenses.js.map +1 -0
  20. package/dist/facade/chart-scenes.d.ts +270 -1
  21. package/dist/facade/chart-scenes.js +443 -17
  22. package/dist/facade/chart-scenes.js.map +1 -1
  23. package/dist/facade/scene-radiance.d.ts +47 -0
  24. package/dist/facade/scene-radiance.js +65 -5
  25. package/dist/facade/scene-radiance.js.map +1 -1
  26. package/dist/facade/simulate-capture.d.ts +158 -4
  27. package/dist/facade/simulate-capture.js +324 -56
  28. package/dist/facade/simulate-capture.js.map +1 -1
  29. package/dist/facade/subject-framing.d.ts +55 -0
  30. package/dist/facade/subject-framing.js +66 -0
  31. package/dist/facade/subject-framing.js.map +1 -0
  32. package/dist/gpu/chart-radiance-gpu.d.ts +8 -0
  33. package/dist/gpu/chart-radiance-gpu.js +20 -1
  34. package/dist/gpu/chart-radiance-gpu.js.map +1 -1
  35. package/dist/index.d.ts +6 -3
  36. package/dist/index.js +6 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/output/text-metadata.d.ts +32 -0
  39. package/dist/output/text-metadata.js +201 -0
  40. package/dist/output/text-metadata.js.map +1 -0
  41. package/docs/API_REFERENCE.md +56 -1
  42. package/docs/QUICK_START.md +325 -4
  43. package/docs/api/capture-native-raw-dng.md +2 -0
  44. package/docs/api/core-version.md +1 -1
  45. package/docs/api/equipment-generic-prime-lenses.md +85 -0
  46. package/docs/api/exports.json +172 -2
  47. package/docs/api/facade-chart-scenes.md +280 -4
  48. package/docs/api/facade-scene-radiance.md +42 -0
  49. package/docs/api/facade-simulate-capture.md +113 -2
  50. package/docs/api/facade-subject-framing.md +72 -0
  51. package/docs/api/output-text-metadata.md +20 -0
  52. package/package.json +1 -1
@@ -8,19 +8,30 @@
8
8
  * Frame coordinates: `u` runs from -1 (left edge of the framed chart) to +1 (right edge); `v` points up
9
9
  * and has the same scale, so it runs from -aspect to +aspect where aspect = height/width of the frame.
10
10
  * The facade places the chart perpendicular to the optical axis at the subject distance and sizes it so
11
- * that this rectangle exactly fills the active capture (see `SimulateCaptureResult.assumptions`).
11
+ * that this rectangle exactly fills the active capture (see `SimulateCaptureResult.assumptions`), or, with
12
+ * `simulateCapture({ chartSize })` (2.3), at its physical size.
12
13
  *
13
14
  * The slanted edge follows the geometry described by P. D. Burns, "Slanted-Edge MTF for Digital Camera
14
15
  * and Scanner Analysis", IS&T PICS 2000 (free from imaging.org; also ISO 12233). It is a near-vertical
15
16
  * edge rotated by a few degrees (5 degrees by default), with a 4:1 light/dark reflectance ratio by
16
17
  * default. This is only a geometric description. No measured chart or ISO test procedure is copied.
18
+ *
19
+ * Chart elements (2.3) are generic, unbranded test-pattern primitives; each element's definition and
20
+ * source is given at its preset below. A `composite` chart lays out any of them in placement rectangles
21
+ * over a background, so an application can build its own layout from engine parts. Continuous patterns
22
+ * (sine star, zone plate, dead-leaves greys) are quantized to {@link CHART_CONTINUOUS_LEVELS} equal neutral
23
+ * levels, like the grey ramp: the step (below 0.1 % of the range) is far below sensor noise.
17
24
  * @see docs/QUICK_START.md for the chart catalogue and their analytic definitions.
18
25
  */
19
26
  import { InvalidConfigurationError } from "../core/configuration-error.js";
20
27
  import { prepareColorimetricSpectrum } from "../color/color-matching-functions.js";
21
28
  import { requireInputObject, requireInputKeys } from "../core/record-validation.js";
29
+ import { createDeterministicUniformStream } from "../sensor/stochastic-seed.js";
22
30
  export const CHART_SCENE_PRESETS = Object.freeze(["uniform", "slanted-edge", "siemens-star",
23
- "step-wedge", "grey-ramp", "checkerboard", "point-grid", "patches"]);
31
+ "step-wedge", "grey-ramp", "checkerboard", "point-grid", "patches", "dead-leaves", "bar-groups", "hyperbolic-wedge", "zone-plate",
32
+ "slanted-squares", "line-grid", "fiducial", "composite"]);
33
+ /** Neutral levels that represent a continuous pattern (grey ramp; 2.3 sine star, zone plate and dead-leaves greys). */
34
+ export const CHART_CONTINUOUS_LEVELS = 1024;
24
35
  /** Engine-chosen chart parameters, each reported when the caller omitted it. */
25
36
  export const CHART_SCENE_DEFAULTS = Object.freeze({
26
37
  uniform: Object.freeze({ reflectance: 0.18 }),
@@ -30,7 +41,16 @@ export const CHART_SCENE_DEFAULTS = Object.freeze({
30
41
  "grey-ramp": Object.freeze({ minimumReflectance: 0.03, maximumReflectance: 0.9 }),
31
42
  checkerboard: Object.freeze({ squaresAcross: 8, darkReflectance: 0.05, lightReflectance: 0.8 }),
32
43
  "point-grid": Object.freeze({ columns: 5, rows: 3, radius: 0.01, pointReflectance: 0.9, backgroundReflectance: 0.02 }),
33
- patches: Object.freeze({ gapFraction: 0.1, backgroundReflectance: 0.18 })
44
+ patches: Object.freeze({ gapFraction: 0.1, backgroundReflectance: 0.18 }),
45
+ "dead-leaves": Object.freeze({ radiusMinimum: 0.01, radiusMaximum: 0.5, minimumReflectance: 0.2, maximumReflectance: 0.6, seed: 1 }),
46
+ "bar-groups": Object.freeze({ lineWidthsPerPictureHeight: Object.freeze([100, 200, 400, 800]), numerals: true, darkReflectance: 0.05, lightReflectance: 0.8 }),
47
+ "hyperbolic-wedge": Object.freeze({ orientation: "horizontal", lineCount: 5, minimumLineWidthsPerPictureHeight: 100,
48
+ maximumLineWidthsPerPictureHeight: 1000, darkReflectance: 0.05, lightReflectance: 0.8 }),
49
+ "zone-plate": Object.freeze({ radiusFraction: 0.9, maximumCyclesPerPictureHeight: 200, darkReflectance: 0.1, lightReflectance: 0.7, backgroundReflectance: 0.18 }),
50
+ "slanted-squares": Object.freeze({ columns: 3, rows: 2, sizeFraction: 0.5, angleDegrees: 5, darkReflectance: 0.15, lightReflectance: 0.6 }),
51
+ "line-grid": Object.freeze({ spacing: 1 / 3, lineWidth: 0.01, lineReflectance: 0.42, backgroundReflectance: 0.6 }),
52
+ fiducial: Object.freeze({ radiusFraction: 0.8, ringFraction: 0.18, inverted: false, darkReflectance: 0.05, lightReflectance: 0.8, backgroundReflectance: 0.18 }),
53
+ composite: Object.freeze({ background: Object.freeze({ preset: "uniform", reflectance: 0.18 }) })
34
54
  });
35
55
  function reflectance(value, name) {
36
56
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1)
@@ -66,6 +86,29 @@ function material(value, name) {
66
86
  reflectance(spectrum.evaluate(nm), name + " values");
67
87
  return { kind: "spectrum", spectrum };
68
88
  }
89
+ /** Raw caller spectrum inputs per material (undefined for neutral materials), for the facade's scene digest. */
90
+ const MATERIAL_INPUTS = new WeakMap();
91
+ /**
92
+ * Internal (not exported from the package): the caller input behind each material of a resolved chart, in
93
+ * material order (`undefined` for an engine neutral). The facade hashes caller spectra from these inputs.
94
+ */
95
+ export function chartMaterialInputs(chart) {
96
+ return MATERIAL_INPUTS.get(chart) ?? chart.materials.map(() => undefined);
97
+ }
98
+ const neutralMaterials = (values) => values.map((reflectanceValue) => ({ kind: "neutral", reflectance: reflectanceValue }));
99
+ /** CHART_CONTINUOUS_LEVELS equal neutral levels between `from` and `to` (level i at from + (to − from)(i + ½)/levels). */
100
+ function continuousMaterials(from, to) {
101
+ return Array.from({ length: CHART_CONTINUOUS_LEVELS }, (_, i) => ({ kind: "neutral", reflectance: from + (to - from) * (i + 0.5) / CHART_CONTINUOUS_LEVELS }));
102
+ }
103
+ /** The level index of a continuous value between `from` and `to`. */
104
+ function continuousLevel(value, from, to) {
105
+ return Math.min(CHART_CONTINUOUS_LEVELS - 1, Math.max(0, Math.floor((value - from) / (to - from) * CHART_CONTINUOUS_LEVELS)));
106
+ }
107
+ function booleanParameter(value, name) {
108
+ if (typeof value !== "boolean")
109
+ throw new InvalidConfigurationError(name + " must be true or false.");
110
+ return value;
111
+ }
69
112
  /**
70
113
  * Validates a chart description and resolves every default. `aspect` is the frame height/width. Unknown
71
114
  * fields are rejected so that a mistyped parameter never silently falls back to a default.
@@ -79,12 +122,19 @@ export function resolveChartScene(input) {
79
122
  requireInputObject(scene, "chart scene");
80
123
  if (typeof aspect !== "number" || !Number.isFinite(aspect) || aspect <= 0)
81
124
  throw new InvalidConfigurationError("Chart aspect must be positive.");
125
+ return resolveScene(scene, aspect, { pictureHeight: 2 * aspect, nested: false });
126
+ }
127
+ function resolveScene(scene, aspect, context) {
128
+ requireInputObject(scene, "chart scene");
82
129
  const preset = scene.preset;
83
130
  if (typeof preset !== "string" || !CHART_SCENE_PRESETS.includes(preset))
84
131
  throw new InvalidConfigurationError("Unknown chart preset.");
132
+ if (preset === "composite" && context.nested)
133
+ throw new InvalidConfigurationError("A composite chart cannot contain another composite.");
85
134
  const record = scene;
86
135
  const defaults = CHART_SCENE_DEFAULTS[preset];
87
- const allowed = ["preset", ...Object.keys(defaults), ...(preset === "patches" ? ["patches", "columns"] : [])];
136
+ const extra = preset === "patches" ? ["patches", "columns"] : preset === "composite" ? ["elements"] : preset === "siemens-star" ? ["profile"] : [];
137
+ const allowed = ["preset", ...Object.keys(defaults), ...extra];
88
138
  for (const key of Object.keys(record))
89
139
  if (!allowed.includes(key))
90
140
  throw new InvalidConfigurationError("Unknown chart parameter: " + key + ".");
@@ -95,20 +145,26 @@ export function resolveChartScene(input) {
95
145
  return defaults[key];
96
146
  } return record[key]; };
97
147
  const neutral = (key) => reflectance(get(key), key);
98
- const done = (parameters, materials, materialAt) => Object.freeze({ preset: preset, parameters: Object.freeze(parameters), defaultedParameters: Object.freeze(defaulted), aspect, materials: Object.freeze(materials), materialAt,
99
- reflectanceAt(u, v, nm) {
100
- const m = materials[materialAt(u, v)];
101
- if (m.kind === "neutral")
102
- return m.reflectance;
103
- const value = m.spectrum.evaluate(nm);
104
- if (!(value >= 0 && value <= 1))
105
- throw new InvalidConfigurationError("Chart reflectance spectrum left [0, 1].");
106
- return value;
107
- } });
148
+ const done = (parameters, materials, materialAt, inputs) => {
149
+ const chart = Object.freeze({ preset: preset, parameters: Object.freeze(parameters), defaultedParameters: Object.freeze(defaulted), aspect,
150
+ materials: Object.freeze(materials), materialAt,
151
+ reflectanceAt(u, v, nm) {
152
+ const m = materials[materialAt(u, v)];
153
+ if (m.kind === "neutral")
154
+ return m.reflectance;
155
+ const value = m.spectrum.evaluate(nm);
156
+ if (!(value >= 0 && value <= 1))
157
+ throw new InvalidConfigurationError("Chart reflectance spectrum left [0, 1].");
158
+ return value;
159
+ } });
160
+ if (inputs)
161
+ MATERIAL_INPUTS.set(chart, Object.freeze([...inputs]));
162
+ return chart;
163
+ };
108
164
  switch (preset) {
109
165
  case "uniform": {
110
166
  const raw = get("reflectance"), m = material(raw, "reflectance");
111
- return done({ reflectance: typeof raw === "number" ? raw : "caller-spectrum" }, [m], () => 0);
167
+ return done({ reflectance: typeof raw === "number" ? raw : "caller-spectrum" }, [m], () => 0, [typeof raw === "number" ? undefined : raw]);
112
168
  }
113
169
  case "slanted-edge": {
114
170
  const angle = finiteIn(get("angleDegrees"), "angleDegrees", -45, 45, true), dark = neutral("darkReflectance"), light = neutral("lightReflectance");
@@ -121,7 +177,26 @@ export function resolveChartScene(input) {
121
177
  if (radius === 0)
122
178
  throw new InvalidConfigurationError("radiusFraction must be positive.");
123
179
  const dark = neutral("darkReflectance"), light = neutral("lightReflectance"), background = neutral("backgroundReflectance"), r = radius * aspect;
124
- return done({ cycles, radiusFraction: radius, darkReflectance: dark, lightReflectance: light, backgroundReflectance: background }, [{ kind: "neutral", reflectance: dark }, { kind: "neutral", reflectance: light }, { kind: "neutral", reflectance: background }], (u, v) => {
180
+ const profile = record.profile;
181
+ if (profile !== undefined && profile !== "bar" && profile !== "sine")
182
+ throw new InvalidConfigurationError("profile must be bar or sine.");
183
+ const parameters = { cycles, radiusFraction: radius, darkReflectance: dark, lightReflectance: light, backgroundReflectance: background };
184
+ if (profile === "sine") {
185
+ // Sinusoidal star (2.3): zero crossings at the bar star's sector edges, light where the bar star is light.
186
+ if (dark === light)
187
+ throw new InvalidConfigurationError("A sine star needs different dark and light reflectances.");
188
+ const mid = (dark + light) / 2, amp = (light - dark) / 2, materials = continuousMaterials(dark, light);
189
+ materials.push({ kind: "neutral", reflectance: background });
190
+ return done({ ...parameters, profile, representation: CHART_CONTINUOUS_LEVELS + " equal neutral levels" }, materials, (u, v) => {
191
+ if (u * u + v * v > r * r)
192
+ return CHART_CONTINUOUS_LEVELS;
193
+ const phase = Math.atan2(v, u) / (2 * Math.PI) + 0.5;
194
+ return continuousLevel(mid + amp * Math.sin(2 * Math.PI * cycles * phase), dark, light);
195
+ });
196
+ }
197
+ if (profile === "bar")
198
+ parameters.profile = profile;
199
+ return done(parameters, [{ kind: "neutral", reflectance: dark }, { kind: "neutral", reflectance: light }, { kind: "neutral", reflectance: background }], (u, v) => {
125
200
  if (u * u + v * v > r * r)
126
201
  return 2;
127
202
  const phase = Math.atan2(v, u) / (2 * Math.PI) + 0.5; // [0, 1]
@@ -165,7 +240,7 @@ export function resolveChartScene(input) {
165
240
  if (!Array.isArray(list) || list.length < 1 || list.length > 1024)
166
241
  throw new InvalidConfigurationError("patches must list 1 to 1024 caller reflectances.");
167
242
  const ids = new Set();
168
- const materials = [];
243
+ const materials = [], inputs = [];
169
244
  for (const [index, patch] of list.entries()) {
170
245
  requireInputObject(patch, "patches[" + index + "]");
171
246
  const p = patch;
@@ -176,6 +251,7 @@ export function resolveChartScene(input) {
176
251
  throw new InvalidConfigurationError("Each patch needs a unique non-empty patchId.");
177
252
  ids.add(p.patchId);
178
253
  materials.push(material(p.reflectance, "patches[" + index + "].reflectance"));
254
+ inputs.push(typeof p.reflectance === "number" ? undefined : p.reflectance);
179
255
  }
180
256
  const columnsRaw = record.columns === undefined ? Math.ceil(Math.sqrt(list.length)) : record.columns;
181
257
  if (record.columns === undefined)
@@ -186,6 +262,7 @@ export function resolveChartScene(input) {
186
262
  throw new InvalidConfigurationError("gapFraction must be below 0.5.");
187
263
  const backgroundIndex = materials.length;
188
264
  materials.push({ kind: "neutral", reflectance: background });
265
+ inputs.push(undefined);
189
266
  const cellW = 2 / columns, cellH = 2 * aspect / rows;
190
267
  return done({ patchIds: list.map((p) => p.patchId), columns, rows, gapFraction: gap, backgroundReflectance: background }, materials, (u, v) => {
191
268
  const fx = (u + 1) / cellW, fy = (aspect - v) / cellH, i = Math.floor(fx), j = Math.floor(fy);
@@ -195,10 +272,359 @@ export function resolveChartScene(input) {
195
272
  if (index >= list.length || x < gap / 2 || x > 1 - gap / 2 || y < gap / 2 || y > 1 - gap / 2)
196
273
  return backgroundIndex;
197
274
  return index;
275
+ }, inputs);
276
+ }
277
+ case "dead-leaves": {
278
+ const rMin = finiteIn(get("radiusMinimum"), "radiusMinimum", DEAD_LEAVES_MINIMUM_RADIUS, 2), rMax = finiteIn(get("radiusMaximum"), "radiusMaximum", 0, 2, true);
279
+ if (!(rMax > rMin))
280
+ throw new InvalidConfigurationError("radiusMaximum must exceed radiusMinimum.");
281
+ const lo = neutral("minimumReflectance"), hi = neutral("maximumReflectance"), seed = integerIn(get("seed"), "seed", 0, 0xffffffff);
282
+ if (hi < lo)
283
+ throw new InvalidConfigurationError("Dead leaves need minimum ≤ maximum reflectance.");
284
+ const leaves = generateDeadLeaves(rMin, rMax, seed, aspect);
285
+ return done({ radiusMinimum: rMin, radiusMaximum: rMax, minimumReflectance: lo, maximumReflectance: hi, seed, diskCount: leaves.diskCount,
286
+ radiusDensity: "proportional-to-r^-3", representation: CHART_CONTINUOUS_LEVELS + " equal neutral levels" }, continuousMaterials(lo, hi), leaves.levelAt);
287
+ }
288
+ case "bar-groups": {
289
+ const lws = get("lineWidthsPerPictureHeight");
290
+ if (!Array.isArray(lws) || lws.length < 1 || lws.length > 32)
291
+ throw new InvalidConfigurationError("lineWidthsPerPictureHeight must list 1 to 32 frequencies.");
292
+ const list = lws.map((lw, i) => integerIn(lw, "lineWidthsPerPictureHeight[" + i + "]", 1, 100_000));
293
+ const numerals = booleanParameter(get("numerals"), "numerals"), dark = neutral("darkReflectance"), light = neutral("lightReflectance");
294
+ const layout = barGroupLayout(list, numerals, aspect, context.pictureHeight);
295
+ return done({ lineWidthsPerPictureHeight: list, numerals, darkReflectance: dark, lightReflectance: light }, neutralMaterials([dark, light]), (u, v) => {
296
+ for (const g of layout) {
297
+ if (u < g.cell.u0 || u > g.cell.u1)
298
+ continue;
299
+ const b = g.verticalBars, h = g.horizontalBars;
300
+ if (u >= b.u0 && u < b.u1 && v >= b.v0 && v <= b.v1)
301
+ return Math.floor((u - b.u0) / g.lineWidth) % 2 === 0 ? 0 : 1;
302
+ if (u >= h.u0 && u <= h.u1 && v > h.v0 && v <= h.v1)
303
+ return Math.floor((h.v1 - v) / g.lineWidth) % 2 === 0 ? 0 : 1;
304
+ if (g.label && numeralInk(g.label, u, v))
305
+ return 0;
306
+ return 1;
307
+ }
308
+ return 1;
309
+ });
310
+ }
311
+ case "hyperbolic-wedge": {
312
+ const orientation = get("orientation");
313
+ if (orientation !== "horizontal" && orientation !== "vertical")
314
+ throw new InvalidConfigurationError("orientation must be horizontal or vertical.");
315
+ const lines = integerIn(get("lineCount"), "lineCount", 1, 50);
316
+ const lw0 = finiteIn(get("minimumLineWidthsPerPictureHeight"), "minimumLineWidthsPerPictureHeight", 0, 1e6, true);
317
+ const lw1 = finiteIn(get("maximumLineWidthsPerPictureHeight"), "maximumLineWidthsPerPictureHeight", 0, 1e6, true);
318
+ if (!(lw1 > lw0))
319
+ throw new InvalidConfigurationError("maximumLineWidthsPerPictureHeight must exceed minimumLineWidthsPerPictureHeight.");
320
+ const dark = neutral("darkReflectance"), light = neutral("lightReflectance"), ph = context.pictureHeight;
321
+ const across = orientation === "horizontal" ? 2 * aspect : 2;
322
+ if ((2 * lines - 1) * ph / lw0 > across)
323
+ throw new InvalidConfigurationError("The wedge's " + (2 * lines - 1) + " line widths at " + lw0 + " LW/PH do not fit across the chart.");
324
+ return done({ orientation, lineCount: lines, minimumLineWidthsPerPictureHeight: lw0, maximumLineWidthsPerPictureHeight: lw1, darkReflectance: dark, lightReflectance: light }, neutralMaterials([dark, light]), (u, v) => {
325
+ const s = orientation === "horizontal" ? (u + 1) / 2 : (v + aspect) / (2 * aspect), y = orientation === "horizontal" ? v : u;
326
+ if (s < 0 || s > 1)
327
+ return 1;
328
+ const w = ph / (lw0 + (lw1 - lw0) * s), half = (2 * lines - 1) * w / 2;
329
+ if (y < -half || y >= half)
330
+ return 1;
331
+ return Math.floor((y + half) / w) % 2 === 0 ? 0 : 1;
332
+ });
333
+ }
334
+ case "zone-plate": {
335
+ const radius = finiteIn(get("radiusFraction"), "radiusFraction", 0, 1, false);
336
+ if (radius === 0)
337
+ throw new InvalidConfigurationError("radiusFraction must be positive.");
338
+ const fMax = finiteIn(get("maximumCyclesPerPictureHeight"), "maximumCyclesPerPictureHeight", 0, 1e6, true);
339
+ const dark = neutral("darkReflectance"), light = neutral("lightReflectance"), background = neutral("backgroundReflectance");
340
+ if (dark === light)
341
+ throw new InvalidConfigurationError("A zone plate needs different dark and light reflectances.");
342
+ // Local frequency of cos(π·k·r²) is k·r cycles per frame unit: k·R = fMax / PH at the rim.
343
+ const r = radius * aspect, k = fMax / context.pictureHeight / r, mid = (dark + light) / 2, amp = (light - dark) / 2;
344
+ const materials = continuousMaterials(dark, light);
345
+ materials.push({ kind: "neutral", reflectance: background });
346
+ return done({ radiusFraction: radius, maximumCyclesPerPictureHeight: fMax, darkReflectance: dark, lightReflectance: light, backgroundReflectance: background,
347
+ representation: CHART_CONTINUOUS_LEVELS + " equal neutral levels" }, materials, (u, v) => {
348
+ const r2 = u * u + v * v;
349
+ if (r2 > r * r)
350
+ return CHART_CONTINUOUS_LEVELS;
351
+ return continuousLevel(mid + amp * Math.cos(Math.PI * k * r2), dark, light);
352
+ });
353
+ }
354
+ case "slanted-squares": {
355
+ const columns = integerIn(get("columns"), "columns", 1, 64), rows = integerIn(get("rows"), "rows", 1, 64);
356
+ const size = finiteIn(get("sizeFraction"), "sizeFraction", 0, 1, true), angle = finiteIn(get("angleDegrees"), "angleDegrees", -45, 45, true);
357
+ const dark = neutral("darkReflectance"), light = neutral("lightReflectance");
358
+ const t = angle * Math.PI / 180, c = Math.cos(t), s = Math.sin(t);
359
+ if (size * (Math.abs(c) + Math.abs(s)) > 1)
360
+ throw new InvalidConfigurationError("The rotated squares must stay inside their cells: sizeFraction·(|cos θ| + |sin θ|) ≤ 1.");
361
+ const cellW = 2 / columns, cellH = 2 * aspect / rows, half = size * Math.min(cellW, cellH) / 2;
362
+ return done({ columns, rows, sizeFraction: size, angleDegrees: angle, darkReflectance: dark, lightReflectance: light }, neutralMaterials([dark, light]), (u, v) => {
363
+ const i = Math.min(columns - 1, Math.max(0, Math.floor((u + 1) / cellW))), j = Math.min(rows - 1, Math.max(0, Math.floor((aspect - v) / cellH)));
364
+ const du = u - (-1 + (i + 0.5) * cellW), dv = v - (aspect - (j + 0.5) * cellH);
365
+ const x = du * c - dv * s, y = du * s + dv * c;
366
+ return Math.abs(x) <= half && Math.abs(y) <= half ? 0 : 1;
367
+ });
368
+ }
369
+ case "line-grid": {
370
+ const spacing = finiteIn(get("spacing"), "spacing", 0, 2, true), width = finiteIn(get("lineWidth"), "lineWidth", 0, spacing, true);
371
+ const line = neutral("lineReflectance"), background = neutral("backgroundReflectance"), half = width / 2;
372
+ return done({ spacing, lineWidth: width, lineReflectance: line, backgroundReflectance: background }, neutralMaterials([background, line]), (u, v) => (Math.abs(u - spacing * Math.round(u / spacing)) <= half || Math.abs(v - spacing * Math.round(v / spacing)) <= half ? 1 : 0));
373
+ }
374
+ case "fiducial": {
375
+ const radius = finiteIn(get("radiusFraction"), "radiusFraction", 0, 1, false), ring = finiteIn(get("ringFraction"), "ringFraction", 0, 1, true);
376
+ if (radius === 0)
377
+ throw new InvalidConfigurationError("radiusFraction must be positive.");
378
+ const inverted = booleanParameter(get("inverted"), "inverted"), dark = neutral("darkReflectance"), light = neutral("lightReflectance"), background = neutral("backgroundReflectance");
379
+ const r = radius * aspect, inner = r * (1 - ring);
380
+ return done({ radiusFraction: radius, ringFraction: ring, inverted, darkReflectance: dark, lightReflectance: light, backgroundReflectance: background }, neutralMaterials([dark, light, background]), (u, v) => {
381
+ const r2 = u * u + v * v;
382
+ if (r2 > r * r)
383
+ return 2;
384
+ if (r2 > inner * inner)
385
+ return 0;
386
+ return ((u >= 0) === (v >= 0)) !== inverted ? 0 : 1;
198
387
  });
199
388
  }
389
+ case "composite": {
390
+ const list = record.elements;
391
+ if (!Array.isArray(list) || list.length < 1 || list.length > 256)
392
+ throw new InvalidConfigurationError("elements must list 1 to 256 chart elements.");
393
+ const nested = { pictureHeight: context.pictureHeight, nested: true };
394
+ const background = resolveScene(get("background"), aspect, nested);
395
+ const materials = [...background.materials], inputs = [...chartMaterialInputs(background)];
396
+ for (const key of background.defaultedParameters)
397
+ defaulted.push("background.parameters." + key);
398
+ const elements = list.map((value, index) => {
399
+ const name = "elements[" + index + "]";
400
+ requireInputObject(value, name);
401
+ const e = value;
402
+ for (const key of Object.keys(e))
403
+ if (!["scene", "centerU", "centerV", "width", "height", "mask"].includes(key))
404
+ throw new InvalidConfigurationError("Unknown " + name + " field: " + key + ".");
405
+ const cu = finiteIn(e.centerU, name + ".centerU", -1e6, 1e6), cv = finiteIn(e.centerV, name + ".centerV", -1e6, 1e6);
406
+ const width = finiteIn(e.width, name + ".width", 0, 1e6, true), height = finiteIn(e.height, name + ".height", 0, 1e6, true);
407
+ const mask = e.mask === undefined ? "rectangle" : e.mask;
408
+ if (mask !== "rectangle" && mask !== "ellipse")
409
+ throw new InvalidConfigurationError(name + ".mask must be rectangle or ellipse.");
410
+ if (e.mask === undefined)
411
+ defaulted.push("elements." + index + ".placement.mask");
412
+ // The element's own frame: u = ±1 across its width; frequencies keep the composite's picture height.
413
+ const chart = resolveScene(e.scene, height / width, { pictureHeight: context.pictureHeight * 2 / width, nested: true });
414
+ for (const key of chart.defaultedParameters)
415
+ defaulted.push("elements." + index + ".parameters." + key);
416
+ const offset = materials.length;
417
+ materials.push(...chart.materials);
418
+ inputs.push(...chartMaterialInputs(chart));
419
+ return { chart, offset, cu, cv, hw: width / 2, hh: height / 2, scale: 2 / width, ellipse: mask === "ellipse",
420
+ report: { preset: chart.preset, placement: { centerU: cu, centerV: cv, width, height, mask }, parameters: chart.parameters } };
421
+ });
422
+ return done({ background: { preset: background.preset, parameters: background.parameters }, elements: elements.map((e) => e.report) }, materials, (u, v) => {
423
+ for (let i = elements.length - 1; i >= 0; i--) {
424
+ const e = elements[i], du = u - e.cu, dv = v - e.cv;
425
+ if (du < -e.hw || du > e.hw || dv < -e.hh || dv > e.hh)
426
+ continue;
427
+ if (e.ellipse && (du / e.hw) ** 2 + (dv / e.hh) ** 2 > 1)
428
+ continue;
429
+ return e.offset + e.chart.materialAt(du * e.scale, dv * e.scale);
430
+ }
431
+ return background.materialAt(u, v);
432
+ }, inputs);
433
+ }
200
434
  }
201
435
  }
436
+ /** Smallest dead-leaves disk radius, frame units: bounds the lookup grid and the disk count. */
437
+ const DEAD_LEAVES_MINIMUM_RADIUS = 0.002;
438
+ /** Dead-leaves work limits: lookup cells and disks. */
439
+ const DEAD_LEAVES_MAXIMUM_CELLS = 4_194_304, DEAD_LEAVES_MAXIMUM_DISKS = 4_000_000;
440
+ /** The covered margin around the chart, frame units; beyond it the texture is its mean grey. */
441
+ const DEAD_LEAVES_MARGIN = 0.1;
442
+ /**
443
+ * Dead-leaves disks, drawn front to back: disk n lies under disks 0..n−1. Each disk is listed in the lookup
444
+ * cells it overlaps that are not yet covered; a cell is closed as soon as one disk covers it entirely, so no
445
+ * later disk can show in it. Drawing stops when every cell is closed (the domain is fully covered), which is
446
+ * the backward perfect simulation of the stationary model (Kendall and Thönnes 1999). Deterministic: the
447
+ * engine's xorshift32 stream, and only correctly rounded operations (+ − × ÷ and √) on the drawn values.
448
+ */
449
+ function generateDeadLeaves(rMin, rMax, seed, aspect) {
450
+ const u0 = -1 - DEAD_LEAVES_MARGIN, v0 = -aspect - DEAD_LEAVES_MARGIN, width = 2 + 2 * DEAD_LEAVES_MARGIN, height = 2 * aspect + 2 * DEAD_LEAVES_MARGIN;
451
+ const cell = Math.max(rMin, Math.sqrt(width * height / DEAD_LEAVES_MAXIMUM_CELLS));
452
+ const nx = Math.ceil(width / cell), ny = Math.ceil(height / cell), cells = nx * ny;
453
+ if (!(cells <= DEAD_LEAVES_MAXIMUM_CELLS))
454
+ throw new InvalidConfigurationError("Dead leaves: the chart aspect is too extreme for the lookup grid.");
455
+ const closed = new Uint8Array(cells), head = new Int32Array(cells).fill(-1), tail = new Int32Array(cells).fill(-1);
456
+ let next = new Int32Array(1 << 16), entryDisk = new Int32Array(1 << 16), entries = 0;
457
+ let X = new Float64Array(1 << 14), Y = new Float64Array(1 << 14), R2 = new Float64Array(1 << 14), level = new Uint16Array(1 << 14);
458
+ const a = 1 / (rMin * rMin), b = 1 / (rMax * rMax), random = createDeterministicUniformStream(seed);
459
+ let open = cells, n = 0;
460
+ while (open > 0) {
461
+ if (n >= DEAD_LEAVES_MAXIMUM_DISKS)
462
+ throw new InvalidConfigurationError("Dead leaves need more than " + DEAD_LEAVES_MAXIMUM_DISKS + " disks; raise radiusMinimum.");
463
+ // Inverse CDF of the density ∝ r⁻³ on [rMin, rMax]: r = (rMin⁻² − U·(rMin⁻² − rMax⁻²))^(−1/2).
464
+ const r = 1 / Math.sqrt(a - random.next() * (a - b));
465
+ const x = u0 - rMax + random.next() * (width + 2 * rMax), y = v0 - rMax + random.next() * (height + 2 * rMax);
466
+ const grey = Math.min(CHART_CONTINUOUS_LEVELS - 1, Math.floor(random.next() * CHART_CONTINUOUS_LEVELS));
467
+ if (n === X.length) {
468
+ const grow = (old, make) => { const out = make(old.length * 2); out.set(old); return out; };
469
+ X = grow(X, (l) => new Float64Array(l));
470
+ Y = grow(Y, (l) => new Float64Array(l));
471
+ R2 = grow(R2, (l) => new Float64Array(l));
472
+ level = grow(level, (l) => new Uint16Array(l));
473
+ }
474
+ X[n] = x;
475
+ Y[n] = y;
476
+ R2[n] = r * r;
477
+ level[n] = grey;
478
+ const i0 = Math.max(0, Math.floor((x - r - u0) / cell)), i1 = Math.min(nx - 1, Math.floor((x + r - u0) / cell));
479
+ const j0 = Math.max(0, Math.floor((y - r - v0) / cell)), j1 = Math.min(ny - 1, Math.floor((y + r - v0) / cell));
480
+ for (let j = j0; j <= j1; j++) {
481
+ const cy0 = v0 + j * cell, cy1 = cy0 + cell, ny0 = Math.max(cy0, Math.min(y, cy1)) - y;
482
+ for (let i = i0; i <= i1; i++) {
483
+ const c = j * nx + i;
484
+ if (closed[c])
485
+ continue;
486
+ const cx0 = u0 + i * cell, cx1 = cx0 + cell, nx0 = Math.max(cx0, Math.min(x, cx1)) - x;
487
+ if (nx0 * nx0 + ny0 * ny0 > r * r)
488
+ continue;
489
+ if (entries === next.length) {
490
+ const grownNext = new Int32Array(entries * 2), grownDisk = new Int32Array(entries * 2);
491
+ grownNext.set(next);
492
+ grownDisk.set(entryDisk);
493
+ next = grownNext;
494
+ entryDisk = grownDisk;
495
+ }
496
+ entryDisk[entries] = n;
497
+ next[entries] = -1;
498
+ if (tail[c] < 0)
499
+ head[c] = entries;
500
+ else
501
+ next[tail[c]] = entries;
502
+ tail[c] = entries++;
503
+ const fx = Math.max(Math.abs(cx0 - x), Math.abs(cx1 - x)), fy = Math.max(Math.abs(cy0 - y), Math.abs(cy1 - y));
504
+ if (fx * fx + fy * fy <= r * r) {
505
+ closed[c] = 1;
506
+ open--;
507
+ }
508
+ }
509
+ }
510
+ n++;
511
+ }
512
+ const mean = CHART_CONTINUOUS_LEVELS / 2;
513
+ return { diskCount: n, levelAt(u, v) {
514
+ const i = Math.floor((u - u0) / cell), j = Math.floor((v - v0) / cell);
515
+ if (i < 0 || j < 0 || i >= nx || j >= ny)
516
+ return mean;
517
+ for (let e = head[j * nx + i]; e >= 0; e = next[e]) {
518
+ const d = entryDisk[e], dx = u - X[d], dy = v - Y[d];
519
+ if (dx * dx + dy * dy <= R2[d])
520
+ return level[d];
521
+ }
522
+ return mean;
523
+ } };
524
+ }
525
+ /**
526
+ * Bar-group layout in the chart's own frame: G equal columns (80 % group, 20 % gap), rows from the top
527
+ * 40 % vertical bars, 40 % horizontal bars, 20 % numerals (50 %/50 % without numerals), each row inset by
528
+ * 5 % of its height. A group is five line widths (three dark bars, two light gaps) centred in its cell.
529
+ */
530
+ function barGroupLayout(list, numerals, aspect, pictureHeight) {
531
+ const H = 2 * aspect, weights = numerals ? [0.4, 0.4, 0.2] : [0.5, 0.5], column = 2 / list.length;
532
+ const rows = [];
533
+ let top = aspect;
534
+ for (const w of weights) {
535
+ const h = H * w;
536
+ rows.push({ v0: top - h + 0.05 * h, v1: top - 0.05 * h });
537
+ top -= h;
538
+ }
539
+ return list.map((lw, i) => {
540
+ const w = pictureHeight / lw, u0 = -1 + i * column + 0.1 * column, u1 = u0 + 0.8 * column, uc = (u0 + u1) / 2;
541
+ const r0 = rows[0], r1 = rows[1], vc = (r1.v0 + r1.v1) / 2;
542
+ if (5 * w > u1 - u0 || 5 * w > r1.v1 - r1.v0)
543
+ throw new InvalidConfigurationError("The bar group at " + lw + " LW/PH (five line widths) does not fit its cell; use fewer groups or higher frequencies.");
544
+ const group = { lineWidthsPerPictureHeight: lw, lineWidth: w, cell: { u0, u1 },
545
+ verticalBars: { u0: uc - 2.5 * w, v0: r0.v0, u1: uc + 2.5 * w, v1: r0.v1 }, horizontalBars: { u0, v0: vc - 2.5 * w, u1, v1: vc + 2.5 * w } };
546
+ if (numerals) {
547
+ const r2 = rows[2], text = String(lw), units = text.length * NUMERAL_ADVANCE - (NUMERAL_ADVANCE - NUMERAL_WIDTH);
548
+ const height = Math.min(r2.v1 - r2.v0, (u1 - u0) / units);
549
+ group.label = { text, u0: uc - units * height / 2, v0: (r2.v0 + r2.v1) / 2 - height / 2, height };
550
+ }
551
+ return group;
552
+ });
553
+ }
554
+ /** Engine-drawn seven-segment numerals: a 0.6 × 1 cell per digit (baseline at 0, y up), stroke 0.16, advance 0.82. */
555
+ const NUMERAL_WIDTH = 0.6, NUMERAL_ADVANCE = 0.82;
556
+ const SEGMENTS = Object.freeze({
557
+ a: [0.08, 0.84, 0.52, 1], b: [0.44, 0.5, 0.6, 1], c: [0.44, 0, 0.6, 0.5], d: [0.08, 0, 0.52, 0.16], e: [0, 0, 0.16, 0.5], f: [0, 0.5, 0.16, 1], g: [0.08, 0.42, 0.52, 0.58]
558
+ });
559
+ const DIGIT_SEGMENTS = ["abcdef", "bc", "abged", "abgcd", "fgbc", "afgcd", "afgedc", "abc", "abcdefg", "abcdfg"];
560
+ function numeralInk(label, u, v) {
561
+ const x = (u - label.u0) / label.height, y = (v - label.v0) / label.height;
562
+ if (y < 0 || y > 1 || x < 0)
563
+ return false;
564
+ const k = Math.floor(x / NUMERAL_ADVANCE);
565
+ if (k >= label.text.length)
566
+ return false;
567
+ const cx = x - k * NUMERAL_ADVANCE;
568
+ for (const s of DIGIT_SEGMENTS[label.text.charCodeAt(k) - 48]) {
569
+ const [x0, y0, x1, y1] = SEGMENTS[s];
570
+ if (cx >= x0 && cx <= x1 && y >= y0 && y <= y1)
571
+ return true;
572
+ }
573
+ return false;
574
+ }
575
+ /** Element placements of a resolved chart with a frame map to the chart's frame units (one identity entry for a plain preset). */
576
+ function chartElements(chart) {
577
+ if (chart.preset !== "composite")
578
+ return [{ index: null, preset: chart.preset, parameters: chart.parameters, aspect: chart.aspect,
579
+ pictureHeight: 2 * chart.aspect, toFrame: (u, v) => ({ u, v }), scale: 1 }];
580
+ const elements = chart.parameters.elements;
581
+ return elements.map((e, index) => {
582
+ const p = e.placement, s = p.width / 2;
583
+ return { index, preset: e.preset, parameters: e.parameters, aspect: p.height / p.width, pictureHeight: 2 * chart.aspect / s,
584
+ toFrame: (u, v) => ({ u: p.centerU + u * s, v: p.centerV + v * s }), scale: s };
585
+ });
586
+ }
587
+ const mapRectangle = (r, toFrame) => {
588
+ const a = toFrame(r.u0, r.v0), b = toFrame(r.u1, r.v1);
589
+ return { u0: a.u, v0: a.v, u1: b.u, v1: b.v };
590
+ };
591
+ /**
592
+ * The bar groups of a `bar-groups` chart, or of every `bar-groups` element of a composite, in the chart's
593
+ * frame units (2.3). `aspect` is the chart's height / width, as for {@link resolveChartScene}.
594
+ */
595
+ export function calculateBarGroupTargets(input) {
596
+ requireInputKeys(input, "calculateBarGroupTargets", ["scene", "aspect"]);
597
+ const chart = resolveChartScene({ scene: input.scene, aspect: input.aspect });
598
+ return chartElements(chart).filter((e) => e.preset === "bar-groups").flatMap((e) => barGroupLayout(e.parameters.lineWidthsPerPictureHeight, e.parameters.numerals, e.aspect, e.pictureHeight).map((g) => ({
599
+ elementIndex: e.index, lineWidthsPerPictureHeight: g.lineWidthsPerPictureHeight, cyclesPerPictureHeight: g.lineWidthsPerPictureHeight / 2,
600
+ lineWidth: g.lineWidth * e.scale, verticalBars: mapRectangle(g.verticalBars, e.toFrame), horizontalBars: mapRectangle(g.horizontalBars, e.toFrame)
601
+ })));
602
+ }
603
+ /**
604
+ * Squares and edges of a `slanted-squares` chart, or of every `slanted-squares` element of a composite, in
605
+ * the chart's frame units (2.3), for slanted-edge regions of interest (Burns, PICS 2000). Directions are unit
606
+ * vectors along each edge (u right, v up).
607
+ */
608
+ export function calculateSlantedSquareEdges(input) {
609
+ requireInputKeys(input, "calculateSlantedSquareEdges", ["scene", "aspect"]);
610
+ const chart = resolveChartScene({ scene: input.scene, aspect: input.aspect });
611
+ return chartElements(chart).filter((e) => e.preset === "slanted-squares").flatMap((e) => {
612
+ const p = e.parameters;
613
+ const t = p.angleDegrees * Math.PI / 180, c = Math.cos(t), s = Math.sin(t), cellW = 2 / p.columns, cellH = 2 * e.aspect / p.rows;
614
+ const half = p.sizeFraction * Math.min(cellW, cellH) / 2, out = [];
615
+ // Local square axes rotated clockwise by t: x̂ = (cos t, −sin t), ŷ = (sin t, cos t) in (u, v).
616
+ const xAxis = { u: c, v: -s }, yAxis = { u: s, v: c };
617
+ for (let j = 0; j < p.rows; j++)
618
+ for (let i = 0; i < p.columns; i++) {
619
+ const cu = -1 + (i + 0.5) * cellW, cv = e.aspect - (j + 0.5) * cellH, at = (x, y) => e.toFrame(cu + x * xAxis.u + y * yAxis.u, cv + x * xAxis.v + y * yAxis.v);
620
+ const length = 2 * half * e.scale;
621
+ out.push({ elementIndex: e.index, center: e.toFrame(cu, cv), side: length, angleDegrees: p.angleDegrees, darkReflectance: p.darkReflectance, lightReflectance: p.lightReflectance,
622
+ edges: [{ side: "left", midpoint: at(-half, 0), direction: yAxis, length }, { side: "right", midpoint: at(half, 0), direction: yAxis, length },
623
+ { side: "top", midpoint: at(0, half), direction: xAxis, length }, { side: "bottom", midpoint: at(0, -half), direction: xAxis, length }] });
624
+ }
625
+ return out;
626
+ });
627
+ }
202
628
  /**
203
629
  * Step reflectances of a step wedge. `equal-density` spaces optical density D = -log10(R) evenly, which
204
630
  * gives geometric reflectance steps, as on a photographic step tablet. `linear` spaces reflectance evenly.