redhotmagma-visualization 1.57.6 → 1.57.7
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/Three/Three2D.js +135 -30
- package/Utils/DesignArea/getUvBoundaryPaths.js +125 -0
- package/Utils/DesignArea/sampleBorderRingColor.js +251 -0
- package/Utils/TextureBuilder/createTextureBuilder.js +31 -5
- package/package.json +2 -2
- package/src/Three/Three2D.js +103 -0
- package/src/Utils/DesignArea/__tests__/getUvBoundaryPaths.test.js +191 -0
- package/src/Utils/DesignArea/__tests__/sampleBorderRingColor.test.js +229 -0
- package/src/Utils/DesignArea/getUvBoundaryPaths.js +110 -0
- package/src/Utils/DesignArea/sampleBorderRingColor.js +200 -0
- package/src/Utils/TextureBuilder/__tests__/createTextureBuilder.test.js +104 -0
- package/src/Utils/TextureBuilder/createTextureBuilder.js +24 -3
|
@@ -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
|
-
...
|
|
134
|
-
|
|
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 } = {
|