@photivra/engine 2.2.1 → 2.3.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 +16 -1
- package/CITATION.cff +1 -1
- package/README.md +6 -6
- package/SECURITY.md +1 -1
- package/dist/capture/native-environment-raw.d.ts +18 -0
- package/dist/capture/native-environment-raw.js +17 -1
- package/dist/capture/native-environment-raw.js.map +1 -1
- package/dist/capture/native-raw.d.ts +3 -0
- package/dist/capture/native-raw.js +8 -5
- package/dist/capture/native-raw.js.map +1 -1
- package/dist/core/version.d.ts +1 -1
- package/dist/core/version.js +1 -1
- package/dist/core/version.js.map +1 -1
- package/dist/equipment/generic-prime-lenses.d.ts +46 -0
- package/dist/equipment/generic-prime-lenses.js +59 -0
- package/dist/equipment/generic-prime-lenses.js.map +1 -0
- package/dist/facade/chart-scenes.d.ts +270 -1
- package/dist/facade/chart-scenes.js +443 -17
- package/dist/facade/chart-scenes.js.map +1 -1
- package/dist/facade/scene-radiance.d.ts +47 -0
- package/dist/facade/scene-radiance.js +65 -5
- package/dist/facade/scene-radiance.js.map +1 -1
- package/dist/facade/simulate-capture.d.ts +151 -4
- package/dist/facade/simulate-capture.js +296 -55
- package/dist/facade/simulate-capture.js.map +1 -1
- package/dist/facade/subject-framing.d.ts +55 -0
- package/dist/facade/subject-framing.js +66 -0
- package/dist/facade/subject-framing.js.map +1 -0
- package/dist/gpu/chart-radiance-gpu.d.ts +8 -0
- package/dist/gpu/chart-radiance-gpu.js +20 -1
- package/dist/gpu/chart-radiance-gpu.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/docs/API_REFERENCE.md +48 -1
- package/docs/QUICK_START.md +264 -3
- package/docs/api/core-version.md +1 -1
- package/docs/api/equipment-generic-prime-lenses.md +85 -0
- package/docs/api/exports.json +167 -2
- package/docs/api/facade-chart-scenes.md +280 -4
- package/docs/api/facade-scene-radiance.md +42 -0
- package/docs/api/facade-simulate-capture.md +107 -2
- package/docs/api/facade-subject-framing.md +72 -0
- 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
|
|
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) =>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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.
|