redhotmagma-visualization 1.57.6 → 1.57.8

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.
@@ -0,0 +1,110 @@
1
+ const EPSILON = 1e-4;
2
+
3
+ /**
4
+ * Collects the vertices on the outer edge of a uv mapped geometry, in perimeter order.
5
+ * The design area border is stroked along the edge of the texture, so these are the vertices the
6
+ * border runs through. It works for any geometry whose uv attribute spans the full 0..1 range,
7
+ * which covers the plane, the control point grid and the open ended cylinder.
8
+ * @param {import("three").BufferGeometry} geometry
9
+ * @returns {number[]} Vertex indices, without a repeated closing vertex
10
+ */
11
+ export const getUvBoundaryIndices = geometry => {
12
+ const uv = geometry?.attributes?.uv;
13
+ if (!uv || !uv.count) {
14
+ return [];
15
+ }
16
+
17
+ const us = [];
18
+ const vs = [];
19
+ let uMin = Infinity;
20
+ let uMax = -Infinity;
21
+ let vMin = Infinity;
22
+ let vMax = -Infinity;
23
+ // a cylinder gets one segment per pixel of arc length, so this can be a few thousand vertices,
24
+ // which is why the extremes are collected in a loop rather than by spreading into Math.min
25
+ for (let i = 0; i < uv.count; i++) {
26
+ const u = uv.getX(i);
27
+ const v = uv.getY(i);
28
+ us.push(u);
29
+ vs.push(v);
30
+ uMin = Math.min(uMin, u);
31
+ uMax = Math.max(uMax, u);
32
+ vMin = Math.min(vMin, v);
33
+ vMax = Math.max(vMax, v);
34
+ }
35
+
36
+ // a geometry without an extent in one direction has no perimeter to walk
37
+ if (uMax - uMin < EPSILON || vMax - vMin < EPSILON) {
38
+ return [];
39
+ }
40
+
41
+ const on = (value, target) => Math.abs(value - target) < EPSILON;
42
+ const collect = (isOnEdge, sortBy, descending) =>
43
+ us
44
+ .map((u, i) => i)
45
+ .filter(i => isOnEdge(us[i], vs[i]))
46
+ .sort((a, b) => (descending ? sortBy[b] - sortBy[a] : sortBy[a] - sortBy[b]));
47
+
48
+ // walk the four edges so that the result is a continuous ring
49
+ const ring = [
50
+ ...collect((u, v) => on(v, vMin), us, false),
51
+ ...collect(u => on(u, uMax), vs, false),
52
+ ...collect((u, v) => on(v, vMax), us, true),
53
+ ...collect(u => on(u, uMin), vs, true),
54
+ ];
55
+
56
+ // the corners are part of two edges each, and the last vertex closes onto the first one
57
+ const deduped = ring.filter((index, i) => index !== ring[i - 1]);
58
+ while (deduped.length > 1 && deduped[deduped.length - 1] === deduped[0]) {
59
+ deduped.pop();
60
+ }
61
+
62
+ return deduped;
63
+ };
64
+
65
+ /**
66
+ * Turns the boundary ring into the paths that are actually drawn on screen.
67
+ * Vertices facing away from the camera are culled by the renderer, so the border is not visible
68
+ * there and those parts must not be sampled either. The ring is treated as cyclic, so a visible
69
+ * run may wrap around its end.
70
+ * @param {number[]} indices The boundary ring, as returned by getUvBoundaryIndices
71
+ * @param {function(number): boolean} isVisible
72
+ * @param {function(number): {x: number, y: number}} project Maps a vertex index to a 2D point
73
+ * @returns {Array<Array<{x: number, y: number}>>} Open polylines, or one closed loop
74
+ */
75
+ export const buildVisiblePaths = (indices, isVisible, project) => {
76
+ if (indices.length < 2) {
77
+ return [];
78
+ }
79
+
80
+ const visible = indices.map(isVisible);
81
+
82
+ if (visible.every(Boolean)) {
83
+ // nothing is culled, so the border is a closed loop
84
+ const loop = indices.map(project);
85
+ return [[...loop, loop[0]]];
86
+ }
87
+ if (!visible.some(Boolean)) {
88
+ return [];
89
+ }
90
+
91
+ // start right after a culled vertex, so that no visible run is split across the array end
92
+ const offset = visible.indexOf(false);
93
+ const paths = [];
94
+ let current = null;
95
+ for (let i = 1; i <= indices.length; i++) {
96
+ const at = (offset + i) % indices.length;
97
+ if (!visible[at]) {
98
+ current = null;
99
+ continue;
100
+ }
101
+ if (!current) {
102
+ current = [];
103
+ paths.push(current);
104
+ }
105
+ current.push(project(indices[at]));
106
+ }
107
+
108
+ // a single point has no segment to sample along
109
+ return paths.filter(path => path.length > 1);
110
+ };
@@ -0,0 +1,200 @@
1
+ import { createWorkingCanvas } from "../createCanvas";
2
+
3
+ /**
4
+ * Default size of the sampled band, relative to the shorter side of the sampled paths.
5
+ * @type {number}
6
+ */
7
+ const RING_WIDTH_RATIO = 0.03;
8
+ const RING_WIDTH_MIN = 4;
9
+ const SAMPLE_STEP = 2;
10
+ /**
11
+ * If the sampled band is mostly transparent there is no meaningful "underlying color",
12
+ * so the caller should keep its static color instead.
13
+ * @type {number}
14
+ */
15
+ const MIN_MEAN_ALPHA = 0.1;
16
+
17
+ /**
18
+ * @param {Array<Array<{x: number, y: number}>>} paths
19
+ * @returns {{x: number, y: number, width: number, height: number}|null}
20
+ */
21
+ const getPathsBounds = paths => {
22
+ let minX = Infinity;
23
+ let minY = Infinity;
24
+ let maxX = -Infinity;
25
+ let maxY = -Infinity;
26
+
27
+ for (const path of paths) {
28
+ for (const point of path) {
29
+ minX = Math.min(minX, point.x);
30
+ minY = Math.min(minY, point.y);
31
+ maxX = Math.max(maxX, point.x);
32
+ maxY = Math.max(maxY, point.y);
33
+ }
34
+ }
35
+
36
+ if (minX > maxX) {
37
+ return null;
38
+ }
39
+
40
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
41
+ };
42
+
43
+ const getRingWidth = (paths, ringWidth) => {
44
+ if (ringWidth) {
45
+ return ringWidth;
46
+ }
47
+ const bounds = getPathsBounds(paths);
48
+ const shorterSide = bounds ? Math.min(bounds.width, bounds.height) : 0;
49
+ return Math.max(RING_WIDTH_MIN, RING_WIDTH_RATIO * shorterSide);
50
+ };
51
+
52
+ /**
53
+ * Walks every segment of the given paths and calls back with the sample points of the band that is
54
+ * centered on it, so the callback sees the pixels just inside and just outside the border.
55
+ * The band is symmetric around the segment, which is why the winding direction does not matter.
56
+ * @param {Array<Array<{x: number, y: number}>>} paths
57
+ * @param {number} ringWidth
58
+ * @param {number} sampleStep
59
+ * @param {function(number, number): void} cb Called with the x/y of every sample point
60
+ */
61
+ const forEachRingPoint = (paths, ringWidth, sampleStep, cb) => {
62
+ const half = ringWidth / 2;
63
+ // the offsets across the band, from the inner to the outer edge
64
+ const offsets = [];
65
+ for (let o = -half; o <= half; o += sampleStep) {
66
+ offsets.push(o);
67
+ }
68
+ if (!offsets.length) {
69
+ offsets.push(0);
70
+ }
71
+
72
+ for (const path of paths) {
73
+ for (let i = 1; i < path.length; i++) {
74
+ const from = path[i - 1];
75
+ const to = path[i];
76
+ const dx = to.x - from.x;
77
+ const dy = to.y - from.y;
78
+ const length = Math.sqrt(dx * dx + dy * dy);
79
+ if (!length) {
80
+ continue;
81
+ }
82
+ // the unit normal of the segment, the direction the band extends into
83
+ const nx = -dy / length;
84
+ const ny = dx / length;
85
+ const steps = Math.max(1, Math.ceil(length / sampleStep));
86
+
87
+ for (let s = 0; s <= steps; s++) {
88
+ const t = s / steps;
89
+ const x = from.x + dx * t;
90
+ const y = from.y + dy * t;
91
+ for (const o of offsets) {
92
+ cb(x + nx * o, y + ny * o);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ };
98
+
99
+ /**
100
+ * Averages the pixels of the border band over the given image data.
101
+ * The channels are weighted by alpha, so semi transparent pixels contribute proportionally.
102
+ * This is kept free of any canvas access so it can be tested without a DOM canvas implementation.
103
+ * @param {{width: number, height: number, data: Uint8ClampedArray|number[]}} imageData
104
+ * @param {{x: number, y: number}} origin Position of the image data within the source image
105
+ * @param {Array<Array<{x: number, y: number}>>} paths The border, in source image pixels
106
+ * @param {{[ringWidth]: number, [sampleStep]: number, [minAlpha]: number}} [options]
107
+ * @returns {{r: number, g: number, b: number}|null} null if there is nothing meaningful to sample
108
+ */
109
+ export const averageRingColorFromImageData = (imageData, origin, paths, options = {}) => {
110
+ const { sampleStep = SAMPLE_STEP, minAlpha = MIN_MEAN_ALPHA } = options;
111
+ const ringWidth = getRingWidth(paths, options.ringWidth);
112
+ const { width, height, data } = imageData;
113
+
114
+ let wr = 0;
115
+ let wg = 0;
116
+ let wb = 0;
117
+ let wa = 0;
118
+ let count = 0;
119
+
120
+ forEachRingPoint(paths, ringWidth, sampleStep, (px, py) => {
121
+ const x = Math.round(px - origin.x);
122
+ const y = Math.round(py - origin.y);
123
+ if (x < 0 || y < 0 || x >= width || y >= height) {
124
+ return;
125
+ }
126
+ const i = (y * width + x) * 4;
127
+ const a = data[i + 3];
128
+ wr += data[i] * a;
129
+ wg += data[i + 1] * a;
130
+ wb += data[i + 2] * a;
131
+ wa += a;
132
+ count++;
133
+ });
134
+
135
+ if (!count || wa / (count * 255) < minAlpha) {
136
+ return null;
137
+ }
138
+
139
+ return {
140
+ r: Math.round(wr / wa),
141
+ g: Math.round(wg / wa),
142
+ b: Math.round(wb / wa),
143
+ };
144
+ };
145
+
146
+ /**
147
+ * Bounding box of the given paths, grown by half the band width and clamped to the source.
148
+ * @param {{width: number, height: number}} source
149
+ * @param {Array<Array<{x: number, y: number}>>} paths
150
+ * @param {number} ringWidth
151
+ * @returns {{x: number, y: number, width: number, height: number}}
152
+ */
153
+ export const getSampleBounds = (source, paths, ringWidth) => {
154
+ const bounds = getPathsBounds(paths);
155
+ if (!bounds) {
156
+ return { x: 0, y: 0, width: 0, height: 0 };
157
+ }
158
+ const half = ringWidth / 2;
159
+
160
+ const x = Math.max(0, Math.floor(bounds.x - half));
161
+ const y = Math.max(0, Math.floor(bounds.y - half));
162
+ const right = Math.min(source.width, Math.ceil(bounds.x + bounds.width + half));
163
+ const bottom = Math.min(source.height, Math.ceil(bounds.y + bounds.height + half));
164
+
165
+ return { x, y, width: right - x, height: bottom - y };
166
+ };
167
+
168
+ /**
169
+ * Reads the average color of the band that the design area border is drawn on.
170
+ * @param {HTMLImageElement|HTMLCanvasElement} source The product image, in its own pixel space
171
+ * @param {Array<Array<{x: number, y: number}>>} paths The border, in the pixel space of the source.
172
+ * Every path is a polyline, a closed one repeats its first point at the end.
173
+ * @param {{[ringWidth]: number, [sampleStep]: number, [minAlpha]: number}} [options]
174
+ * @returns {{r: number, g: number, b: number}|null} null if the color could not be determined
175
+ */
176
+ export const sampleRingAverage = (source, paths, options = {}) => {
177
+ if (!source || !source.width || !source.height || !paths || !paths.length) {
178
+ return null;
179
+ }
180
+
181
+ const bounds = getSampleBounds(source, paths, getRingWidth(paths, options.ringWidth));
182
+ if (bounds.width <= 0 || bounds.height <= 0) {
183
+ return null;
184
+ }
185
+
186
+ const [, ctx] = createWorkingCanvas(source, true);
187
+
188
+ let imageData;
189
+ try {
190
+ imageData = ctx.getImageData(bounds.x, bounds.y, bounds.width, bounds.height);
191
+ } catch (e) {
192
+ // a cross origin image without the proper headers taints the canvas, in that case the
193
+ // caller has to fall back to its static color
194
+ // eslint-disable-next-line no-console
195
+ console.warn("Could not read the product image pixels to derive the border color.", e);
196
+ return null;
197
+ }
198
+
199
+ return averageRingColorFromImageData(imageData, bounds, paths, options);
200
+ };
@@ -0,0 +1,104 @@
1
+ import { getHighlightLineWidth, highlightArea } from "../createTextureBuilder";
2
+
3
+ /**
4
+ * The power of two texture scales that optimizeSizeToTexture produces for design areas of
5
+ * different sizes, see optimizeSizeToTexture with pixelRatio 2 and fitPOT.
6
+ */
7
+ const TEXTURE_SCALE_SMALL_AREA = 3.94; // e.g. a 130 unit wide area, texture 512
8
+ const TEXTURE_SCALE_LARGE_AREA = 1.86; // e.g. a 1100 unit wide area, texture 2048
9
+
10
+ describe("getHighlightLineWidth", () => {
11
+ test("scales the configured width into texture pixels", () => {
12
+ expect(getHighlightLineWidth(1, 4, 0.5)).toBe(8);
13
+ expect(getHighlightLineWidth(2, 4, 0.5)).toBe(16);
14
+ });
15
+
16
+ test("grows with the texture scale and shrinks with the zoom", () => {
17
+ expect(getHighlightLineWidth(1, 4, 0.5)).toBeGreaterThan(getHighlightLineWidth(1, 2, 0.5));
18
+ expect(getHighlightLineWidth(1, 4, 1)).toBeLessThan(getHighlightLineWidth(1, 4, 0.5));
19
+ });
20
+
21
+ test("results in the same on screen width for differently sized design areas", () => {
22
+ const zoom = 0.375;
23
+ const configured = 1;
24
+ // one texture pixel covers zoom / scaling screen pixels, so this is the width on screen
25
+ const onScreen = scaling => (getHighlightLineWidth(configured, scaling, zoom) * zoom) / scaling;
26
+
27
+ expect(onScreen(TEXTURE_SCALE_SMALL_AREA)).toBeCloseTo(configured);
28
+ expect(onScreen(TEXTURE_SCALE_LARGE_AREA)).toBeCloseTo(configured);
29
+ // this is what used to differ: 0.76 px against 1.61 px for the same configured value
30
+ expect(onScreen(TEXTURE_SCALE_SMALL_AREA)).toBeCloseTo(onScreen(TEXTURE_SCALE_LARGE_AREA));
31
+ });
32
+
33
+ test("results in the same on screen width at any zoom", () => {
34
+ const onScreen = zoom => (getHighlightLineWidth(2, 3.41, zoom) * zoom) / 3.41;
35
+
36
+ expect(onScreen(0.3)).toBeCloseTo(2);
37
+ expect(onScreen(0.375)).toBeCloseTo(2);
38
+ expect(onScreen(0.51)).toBeCloseTo(2);
39
+ });
40
+
41
+ test("leaves the 3D visualization untouched", () => {
42
+ // Three3D passes neither useTextureSize nor currentZoom, so both default to 1 and the
43
+ // configured width has to come out unchanged
44
+ expect(getHighlightLineWidth(2, 1, 1)).toBe(2);
45
+ expect(getHighlightLineWidth(2)).toBe(2);
46
+ });
47
+
48
+ test("never drops below one texture pixel", () => {
49
+ expect(getHighlightLineWidth(1, 1, 4)).toBe(1);
50
+ expect(getHighlightLineWidth(0, 1, 1)).toBe(1);
51
+ });
52
+ });
53
+
54
+ describe("highlightArea", () => {
55
+ const createMockContext = () => ({
56
+ fillStyle: null,
57
+ strokeStyle: null,
58
+ lineWidth: null,
59
+ fillRect: jest.fn(),
60
+ strokeRect: jest.fn(),
61
+ });
62
+
63
+ test("keeps the outer edge of the border on the design area bounds", () => {
64
+ const size = { width: 200, height: 100 };
65
+
66
+ for (const lineWidth of [1, 2, 8, 30]) {
67
+ const ctx = createMockContext();
68
+ highlightArea(ctx, size, { borderColor: "#ffffff", lineWidth });
69
+
70
+ // the stroke is centered on the inset rect, so it spans lineWidth inwards from 0
71
+ const [x, y, width, height] = ctx.strokeRect.mock.calls[0];
72
+ expect(x - lineWidth / 2).toBe(0);
73
+ expect(y - lineWidth / 2).toBe(0);
74
+ expect(x + width + lineWidth / 2).toBe(size.width);
75
+ expect(y + height + lineWidth / 2).toBe(size.height);
76
+ expect(ctx.lineWidth).toBe(lineWidth);
77
+ }
78
+ });
79
+
80
+ test("applies the given colors and fills the whole area", () => {
81
+ const ctx = createMockContext();
82
+ highlightArea(
83
+ ctx,
84
+ { width: 200, height: 100 },
85
+ {
86
+ borderColor: "#eeeeee",
87
+ backgroundColor: "rgba(0,0,0,0.1)",
88
+ }
89
+ );
90
+
91
+ expect(ctx.strokeStyle).toBe("#eeeeee");
92
+ expect(ctx.fillStyle).toBe("rgba(0,0,0,0.1)");
93
+ expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, 200, 100);
94
+ });
95
+
96
+ test("falls back to its own defaults", () => {
97
+ const ctx = createMockContext();
98
+ highlightArea(ctx, { width: 50, height: 50 });
99
+
100
+ expect(ctx.strokeStyle).toBe("rgba(0,0,0,0.9)");
101
+ expect(ctx.fillStyle).toBe("transparent");
102
+ expect(ctx.lineWidth).toBe(2);
103
+ });
104
+ });
@@ -127,11 +127,18 @@ export const createTextureBuilder = (textureImage, params = {}) => {
127
127
  transformCanvas(ctx, getTransformProps(baseShape, position), scaling);
128
128
 
129
129
  const { highlight, highlightFunction = highlightArea } = configObject;
130
- const { lineWidth = 2 } = highlight;
130
+ const { lineWidth = 2, borderColor, ...restHighlight } = highlight;
131
+
132
+ // the border color may be given as a function to resolve it per design area,
133
+ // e.g. to keep the border visible on the product image underneath
134
+ const resolvedBorderColor =
135
+ typeof borderColor === "function" ? borderColor(designArea) : borderColor;
131
136
 
132
137
  highlightFunction(ctx, size, {
133
- ...highlight,
134
- lineWidth: Math.ceil(lineWidth / currentZoom),
138
+ ...restHighlight,
139
+ // leave the key out when unresolved, so the default of the highlight function applies
140
+ ...(resolvedBorderColor === undefined ? {} : { borderColor: resolvedBorderColor }),
141
+ lineWidth: getHighlightLineWidth(lineWidth, scaling, currentZoom),
135
142
  });
136
143
 
137
144
  ctx.restore();
@@ -212,6 +219,20 @@ export const getTransformProps = (baseShape = {}, position = {}) => {
212
219
  };
213
220
  };
214
221
 
222
+ /**
223
+ * Converts the configured border width into the texture pixels the border is stroked with.
224
+ * The design area covers scaling texture pixels per unit, and one unit is drawn as currentZoom
225
+ * screen pixels, so one texture pixel ends up as currentZoom / scaling screen pixels. Without this
226
+ * conversion the border on screen would come out thinner or thicker depending on the power of two
227
+ * texture size that the design area happens to get.
228
+ * @param {number} lineWidth The configured width, in screen pixels
229
+ * @param {number} [scaling] Texture pixels per design area unit
230
+ * @param {number} [currentZoom]
231
+ * @returns {number} The width in texture pixels, at least one so the border stays visible
232
+ */
233
+ export const getHighlightLineWidth = (lineWidth, scaling = 1, currentZoom = 1) =>
234
+ Math.max(1, (lineWidth * scaling) / currentZoom);
235
+
215
236
  export const highlightArea = (ctx, size, options = {}) => {
216
237
  const { width, height } = size;
217
238
  const { borderColor, backgroundColor, lineWidth } = {