redhotmagma-visualization 1.57.5 → 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.
@@ -1,6 +1,7 @@
1
1
  function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
2
2
  var _excluded = ["scaling"],
3
- _excluded2 = ["x", "y", "width", "height"];
3
+ _excluded2 = ["lineWidth", "borderColor"],
4
+ _excluded3 = ["x", "y", "width", "height"];
4
5
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
5
6
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
6
7
  function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
@@ -194,9 +195,17 @@ export var createTextureBuilder = function createTextureBuilder(textureImage) {
194
195
  _configObject$highlig = configObject.highlightFunction,
195
196
  highlightFunction = _configObject$highlig === void 0 ? highlightArea : _configObject$highlig;
196
197
  var _highlight$lineWidth = highlight.lineWidth,
197
- lineWidth = _highlight$lineWidth === void 0 ? 2 : _highlight$lineWidth;
198
- highlightFunction(ctx, size, _objectSpread(_objectSpread({}, highlight), {}, {
199
- lineWidth: Math.ceil(lineWidth / currentZoom)
198
+ lineWidth = _highlight$lineWidth === void 0 ? 2 : _highlight$lineWidth,
199
+ borderColor = highlight.borderColor,
200
+ restHighlight = _objectWithoutProperties(highlight, _excluded2);
201
+
202
+ // the border color may be given as a function to resolve it per design area,
203
+ // e.g. to keep the border visible on the product image underneath
204
+ var resolvedBorderColor = typeof borderColor === "function" ? borderColor(designArea) : borderColor;
205
+ highlightFunction(ctx, size, _objectSpread(_objectSpread(_objectSpread({}, restHighlight), resolvedBorderColor === undefined ? {} : {
206
+ borderColor: resolvedBorderColor
207
+ }), {}, {
208
+ lineWidth: getHighlightLineWidth(lineWidth, scaling, currentZoom)
200
209
  }));
201
210
  ctx.restore();
202
211
  };
@@ -289,6 +298,23 @@ export var getTransformProps = function getTransformProps() {
289
298
  inside: inside
290
299
  });
291
300
  };
301
+
302
+ /**
303
+ * Converts the configured border width into the texture pixels the border is stroked with.
304
+ * The design area covers scaling texture pixels per unit, and one unit is drawn as currentZoom
305
+ * screen pixels, so one texture pixel ends up as currentZoom / scaling screen pixels. Without this
306
+ * conversion the border on screen would come out thinner or thicker depending on the power of two
307
+ * texture size that the design area happens to get.
308
+ * @param {number} lineWidth The configured width, in screen pixels
309
+ * @param {number} [scaling] Texture pixels per design area unit
310
+ * @param {number} [currentZoom]
311
+ * @returns {number} The width in texture pixels, at least one so the border stays visible
312
+ */
313
+ export var getHighlightLineWidth = function getHighlightLineWidth(lineWidth) {
314
+ var scaling = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
315
+ var currentZoom = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 1;
316
+ return Math.max(1, lineWidth * scaling / currentZoom);
317
+ };
292
318
  export var highlightArea = function highlightArea(ctx, size) {
293
319
  var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
294
320
  var width = size.width,
@@ -314,7 +340,7 @@ export var getPositionWithPadding = function getPositionWithPadding(_ref5, paddi
314
340
  y = _ref5.y,
315
341
  width = _ref5.width,
316
342
  height = _ref5.height,
317
- otherProps = _objectWithoutProperties(_ref5, _excluded2);
343
+ otherProps = _objectWithoutProperties(_ref5, _excluded3);
318
344
  return _objectSpread(_objectSpread({}, otherProps), {}, {
319
345
  x: x - padding,
320
346
  y: y - padding,
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "redhotmagma-visualization",
3
- "version": "1.57.5",
3
+ "version": "1.57.7",
4
4
  "license": "UNLICENSED",
5
5
  "private": false,
6
6
  "main": "./index.js",
7
7
  "dependencies": {
8
8
  "@babel/polyfill": "^7.12.1",
9
- "@configuratorware/scripts": "1.57.5",
9
+ "@configuratorware/scripts": "1.57.7",
10
10
  "@tweenjs/tween.js": "^17.6.0",
11
11
  "console-polyfill": "^0.3.0",
12
12
  "css-element-queries": "^1.2.3",
@@ -4,6 +4,7 @@ import {
4
4
  CylinderGeometry,
5
5
  Group,
6
6
  ImageLoader,
7
+ Matrix3,
7
8
  Mesh,
8
9
  MeshBasicMaterial,
9
10
  NearestFilter,
@@ -41,6 +42,8 @@ import {
41
42
  optimizeSizeToTexture,
42
43
  } from "../Utils/TextureBuilder/createTextureBuilder";
43
44
  import { getDesignAreaCanvasSize } from "../Utils/DesignArea/getDesignAreaCanvasSize";
45
+ import { sampleRingAverage } from "../Utils/DesignArea/sampleBorderRingColor";
46
+ import { buildVisiblePaths, getUvBoundaryIndices } from "../Utils/DesignArea/getUvBoundaryPaths";
44
47
  import { disposeMaterial } from "../Utils/disposeMaterial";
45
48
  import { createImageRepository } from "../Utils/createImageRepository";
46
49
  import { createCache } from "../Utils/createCache";
@@ -61,6 +64,7 @@ export const allowZooming = (zoom, originalZoom, minZoom, maxZoom) =>
61
64
 
62
65
  export class Three2D extends ThreeBase {
63
66
  _materialMap = new Map();
67
+ _underlyingColorCache = new Map();
64
68
  _designAreaList = [];
65
69
  _currentDesignView = null;
66
70
  _currentDesignArea = null;
@@ -522,6 +526,102 @@ export class Three2D extends ThreeBase {
522
526
  width,
523
527
  height,
524
528
  };
529
+ // the sampled colors belong to the previous product image
530
+ this._underlyingColorCache.clear();
531
+ }
532
+
533
+ /**
534
+ * Reads the average color of the product image underneath the border of the given design area.
535
+ * It is used to derive a border color that stays visible on the current product image.
536
+ * Returns null when it cannot be determined, so the caller can fall back to a static color.
537
+ * @param {string} designAreaIdentifier
538
+ * @returns {{r: number, g: number, b: number}|null}
539
+ */
540
+ getDesignAreaUnderlyingColor(designAreaIdentifier) {
541
+ const image = this._model?.material?.map?.image;
542
+ if (!image || !this._size.width || !this._size.height) {
543
+ return null;
544
+ }
545
+
546
+ try {
547
+ // getModelPath() covers the view and the material variant, so every product image
548
+ // that can be shown gets its own entry
549
+ const cacheKey = `${this.getModelPath()}|${designAreaIdentifier}`;
550
+ if (this._underlyingColorCache.has(cacheKey)) {
551
+ return this._underlyingColorCache.get(cacheKey);
552
+ }
553
+
554
+ const color = this._sampleDesignAreaUnderlyingColor(designAreaIdentifier, image);
555
+ this._underlyingColorCache.set(cacheKey, color);
556
+ return color;
557
+ } catch (e) {
558
+ // this runs while drawing, so never let it break the render
559
+ // eslint-disable-next-line no-console
560
+ console.warn("Could not determine the color underneath the design area border.", e);
561
+ return null;
562
+ }
563
+ }
564
+
565
+ _sampleDesignAreaUnderlyingColor(designAreaIdentifier, image) {
566
+ const mesh = this.findAreaObjectByName(designAreaIdentifier);
567
+ const geometry = mesh?.geometry;
568
+ if (!geometry?.attributes?.position) {
569
+ return null;
570
+ }
571
+
572
+ const indices = getUvBoundaryIndices(geometry);
573
+ if (!indices.length) {
574
+ return null;
575
+ }
576
+
577
+ mesh.updateWorldMatrix(true, false);
578
+ const position = geometry.attributes.position;
579
+ const normal = geometry.attributes.normal;
580
+
581
+ // with optimizeProductImage the texture holds a resized copy, while the scene still uses the
582
+ // original image size, so the mesh has to be projected into the pixel space of the source
583
+ const scaling = image.width / this._size.width;
584
+ const point = new Vector3();
585
+
586
+ const project = index => {
587
+ point.fromBufferAttribute(position, index).applyMatrix4(mesh.matrixWorld);
588
+ return {
589
+ // the product plane is centered in the scene and three.js has the y axis pointing up,
590
+ // while the image pixels are counted downwards from the top left corner
591
+ x: (this._size.width / 2 + point.x) * scaling,
592
+ y: (this._size.height / 2 - point.y) * scaling,
593
+ };
594
+ };
595
+
596
+ const paths = buildVisiblePaths(indices, this._createVisibilityCheck(mesh, normal), project);
597
+
598
+ return sampleRingAverage(image, paths);
599
+ }
600
+
601
+ /**
602
+ * Back facing parts of a design area are culled by the renderer, so the border is not drawn there
603
+ * and must not be sampled either. A rotated cylinder that spans a wide angle turns part of its
604
+ * own border away from the camera.
605
+ * @param {import("three").Mesh} mesh
606
+ * @param {import("three").BufferAttribute} normal
607
+ * @returns {function(number): boolean}
608
+ */
609
+ _createVisibilityCheck(mesh, normal) {
610
+ // planes are rendered with DoubleSide, so both of their faces are visible
611
+ if (!normal || mesh.material?.side === DoubleSide) {
612
+ return () => true;
613
+ }
614
+
615
+ // the camera looks along -z, so a face pointing towards +z faces the camera,
616
+ // unless the material was flipped to the back side by the "inside" flag
617
+ const towardsCamera = mesh.material?.side === BackSide ? -1 : 1;
618
+ const normalMatrix = new Matrix3().getNormalMatrix(mesh.matrixWorld);
619
+ const direction = new Vector3();
620
+
621
+ return index => {
622
+ direction.fromBufferAttribute(normal, index).applyMatrix3(normalMatrix);
623
+ return direction.z * towardsCamera > 0;
624
+ };
525
625
  }
526
626
 
527
627
  getOffset() {
@@ -625,6 +725,9 @@ export class Three2D extends ThreeBase {
625
725
  createObjectsForDesignAreas(designAreas) {
626
726
  const containerGroup = new Group();
627
727
 
728
+ // the design area geometry is rebuilt here, so previously sampled colors may be outdated
729
+ this._underlyingColorCache.clear();
730
+
628
731
  const EPS = 10;
629
732
  let index = 1;
630
733
 
@@ -0,0 +1,191 @@
1
+ import {
2
+ BufferGeometry,
3
+ CylinderGeometry,
4
+ Float32BufferAttribute,
5
+ Matrix3,
6
+ Mesh,
7
+ MeshBasicMaterial,
8
+ PlaneGeometry,
9
+ Vector3,
10
+ } from "three";
11
+ import { buildVisiblePaths, getUvBoundaryIndices } from "../getUvBoundaryPaths";
12
+
13
+ /**
14
+ * Builds the same kind of uv grid the control point design areas use: rows of gridSize + 1
15
+ * vertices, with uv going from 0 to 1 in both directions.
16
+ * @param {number} gridSize
17
+ */
18
+ const createUvGrid = gridSize => {
19
+ const uvs = [];
20
+ const vertices = [];
21
+ for (let i = 0; i <= gridSize; i++) {
22
+ for (let j = 0; j <= gridSize; j++) {
23
+ uvs.push(j / gridSize, i / gridSize);
24
+ vertices.push(j, i, 0);
25
+ }
26
+ }
27
+ const geometry = new BufferGeometry();
28
+ geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
29
+ geometry.setAttribute("uv", new Float32BufferAttribute(uvs, 2));
30
+ return geometry;
31
+ };
32
+
33
+ const byIndex = index => ({ x: index, y: 0 });
34
+
35
+ describe("getUvBoundaryIndices", () => {
36
+ test("returns the four corners of a plane in perimeter order", () => {
37
+ const geometry = new PlaneGeometry(10, 20, 1, 1);
38
+
39
+ expect(getUvBoundaryIndices(geometry)).toEqual([2, 3, 1, 0]);
40
+ });
41
+
42
+ test("the plane perimeter walks the corners without jumping across the shape", () => {
43
+ const geometry = new PlaneGeometry(10, 20, 1, 1);
44
+ const position = geometry.attributes.position;
45
+
46
+ const points = getUvBoundaryIndices(geometry).map(i => [position.getX(i), position.getY(i)]);
47
+
48
+ expect(points).toEqual([
49
+ [-5, -10],
50
+ [5, -10],
51
+ [5, 10],
52
+ [-5, 10],
53
+ ]);
54
+ });
55
+
56
+ test("returns only the outer vertices of a uv grid, in ring order", () => {
57
+ // a 3 x 3 grid, so index 4 is the interior vertex
58
+ expect(getUvBoundaryIndices(createUvGrid(2))).toEqual([0, 1, 2, 5, 8, 7, 6, 3]);
59
+ });
60
+
61
+ test("returns the full ring for a grid that has no interior vertices", () => {
62
+ const indices = getUvBoundaryIndices(createUvGrid(1));
63
+
64
+ expect(indices).toHaveLength(4);
65
+ expect(new Set(indices)).toEqual(new Set([0, 1, 2, 3]));
66
+ });
67
+
68
+ test("returns nothing without a uv attribute", () => {
69
+ const geometry = new BufferGeometry();
70
+ geometry.setAttribute("position", new Float32BufferAttribute([0, 0, 0, 1, 1, 0], 3));
71
+
72
+ expect(getUvBoundaryIndices(geometry)).toEqual([]);
73
+ });
74
+
75
+ test("returns nothing for a geometry without an extent in one direction", () => {
76
+ const geometry = new BufferGeometry();
77
+ geometry.setAttribute("position", new Float32BufferAttribute([0, 0, 0, 1, 0, 0], 3));
78
+ geometry.setAttribute("uv", new Float32BufferAttribute([0, 0, 1, 0], 2));
79
+
80
+ expect(getUvBoundaryIndices(geometry)).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe("buildVisiblePaths", () => {
85
+ const ring = [0, 1, 2, 5, 8, 7, 6, 3];
86
+
87
+ test("returns a single closed loop when everything is visible", () => {
88
+ const paths = buildVisiblePaths(ring, () => true, byIndex);
89
+
90
+ expect(paths).toHaveLength(1);
91
+ // the loop repeats its first point so the last segment is sampled too
92
+ expect(paths[0]).toHaveLength(ring.length + 1);
93
+ expect(paths[0][0]).toEqual(paths[0][paths[0].length - 1]);
94
+ expect(paths[0].slice(0, -1).map(p => p.x)).toEqual(ring);
95
+ });
96
+
97
+ test("returns nothing when everything is culled", () => {
98
+ expect(buildVisiblePaths(ring, () => false, byIndex)).toEqual([]);
99
+ });
100
+
101
+ test("returns one open polyline for a single culled run", () => {
102
+ // 2 and 5 are neighbours in the ring, so one continuous part stays visible
103
+ const culled = [2, 5];
104
+ const paths = buildVisiblePaths(ring, i => !culled.includes(i), byIndex);
105
+
106
+ expect(paths).toHaveLength(1);
107
+ expect(paths[0].map(p => p.x)).toEqual([8, 7, 6, 3, 0, 1]);
108
+ });
109
+
110
+ test("splits the ring into two polylines for two separate culled runs", () => {
111
+ const culled = [2, 6];
112
+ const paths = buildVisiblePaths(ring, i => !culled.includes(i), byIndex);
113
+
114
+ expect(paths).toHaveLength(2);
115
+ expect(paths.map(path => path.map(p => p.x))).toEqual([
116
+ [5, 8, 7],
117
+ [3, 0, 1],
118
+ ]);
119
+ });
120
+
121
+ test("drops runs that are too short to form a segment", () => {
122
+ // only index 8 stays visible, a single point has nothing to sample along
123
+ const paths = buildVisiblePaths(ring, i => i === 8, byIndex);
124
+
125
+ expect(paths).toEqual([]);
126
+ });
127
+
128
+ test("returns nothing for a degenerate ring", () => {
129
+ expect(buildVisiblePaths([], () => true, byIndex)).toEqual([]);
130
+ expect(buildVisiblePaths([3], () => true, byIndex)).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe("the sleeve of softshell_designer_2d", () => {
135
+ // baseShape of the sleeve design area on the front view, from demo_items/designer.json
136
+ const width = 173;
137
+ const height = 124;
138
+ const radius = 64;
139
+ const rotation = { x: 0.1213, y: 0.6705, z: 0.17792256549810162 };
140
+
141
+ const createSleeveMesh = () => {
142
+ const theta = Math.min(width / radius, 2 * Math.PI);
143
+ const geometry = new CylinderGeometry(
144
+ radius,
145
+ radius,
146
+ height,
147
+ Math.round(theta * radius),
148
+ 1,
149
+ true,
150
+ -theta / 2,
151
+ theta
152
+ );
153
+ const mesh = new Mesh(geometry, new MeshBasicMaterial());
154
+ Object.assign(mesh.rotation, rotation);
155
+ mesh.updateWorldMatrix(true, false);
156
+ return mesh;
157
+ };
158
+
159
+ const isFacingCamera = mesh => {
160
+ const normalMatrix = new Matrix3().getNormalMatrix(mesh.matrixWorld);
161
+ const normal = mesh.geometry.attributes.normal;
162
+ const direction = new Vector3();
163
+ return index => {
164
+ direction.fromBufferAttribute(normal, index).applyMatrix3(normalMatrix);
165
+ return direction.z > 0;
166
+ };
167
+ };
168
+
169
+ test("the whole cylinder surface is on the uv boundary", () => {
170
+ const mesh = createSleeveMesh();
171
+
172
+ // the cylinder has a single height segment, so every vertex sits on the top or bottom edge
173
+ expect(getUvBoundaryIndices(mesh.geometry)).toHaveLength(
174
+ mesh.geometry.attributes.position.count
175
+ );
176
+ });
177
+
178
+ test("the part of the border that faces away from the camera is culled", () => {
179
+ const mesh = createSleeveMesh();
180
+ const indices = getUvBoundaryIndices(mesh.geometry);
181
+
182
+ const paths = buildVisiblePaths(indices, isFacingCamera(mesh), i => ({ x: i, y: 0 }));
183
+ const sampled = paths.reduce((sum, path) => sum + path.length, 0);
184
+
185
+ // the arc spans 155 degrees and the mesh is rotated by 38 degrees around y, so a part of it
186
+ // turns behind the silhouette and its border is never drawn
187
+ expect(paths.length).toBeGreaterThan(0);
188
+ expect(sampled).toBeGreaterThan(0);
189
+ expect(sampled).toBeLessThan(indices.length);
190
+ });
191
+ });
@@ -0,0 +1,229 @@
1
+ import { averageRingColorFromImageData, getSampleBounds } from "../sampleBorderRingColor";
2
+
3
+ /**
4
+ * Builds an ImageData like object, filled by the given callback.
5
+ * @param {number} width
6
+ * @param {number} height
7
+ * @param {function(number, number): number[]} fill Returns [r, g, b, a] for an x/y position
8
+ */
9
+ const createImageData = (width, height, fill) => {
10
+ const data = new Uint8ClampedArray(width * height * 4);
11
+ for (let y = 0; y < height; y++) {
12
+ for (let x = 0; x < width; x++) {
13
+ const [r, g, b, a] = fill(x, y);
14
+ const i = (y * width + x) * 4;
15
+ data[i] = r;
16
+ data[i + 1] = g;
17
+ data[i + 2] = b;
18
+ data[i + 3] = a;
19
+ }
20
+ }
21
+ return { width, height, data };
22
+ };
23
+
24
+ const solid = (r, g, b, a = 255) => () => [r, g, b, a];
25
+ const origin = { x: 0, y: 0 };
26
+
27
+ /**
28
+ * Closed rectangular path, the shape the plane design areas produce.
29
+ */
30
+ const rectPath = (cx, cy, width, height) => {
31
+ const hw = width / 2;
32
+ const hh = height / 2;
33
+ return [
34
+ [
35
+ { x: cx - hw, y: cy - hh },
36
+ { x: cx + hw, y: cy - hh },
37
+ { x: cx + hw, y: cy + hh },
38
+ { x: cx - hw, y: cy + hh },
39
+ { x: cx - hw, y: cy - hh },
40
+ ],
41
+ ];
42
+ };
43
+
44
+ describe("averageRingColorFromImageData", () => {
45
+ test("returns the color of a uniformly colored image", () => {
46
+ const imageData = createImageData(100, 100, solid(10, 20, 30));
47
+
48
+ expect(averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 40, 40))).toEqual({
49
+ r: 10,
50
+ g: 20,
51
+ b: 30,
52
+ });
53
+ });
54
+
55
+ test("averages a border that runs across a light and a dark region", () => {
56
+ // the left half is black, the right half is white
57
+ const imageData = createImageData(100, 100, x =>
58
+ x < 50 ? [0, 0, 0, 255] : [255, 255, 255, 255]
59
+ );
60
+
61
+ const color = averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 40, 40));
62
+
63
+ // the ring is symmetric around the split, so the average lands in the middle
64
+ expect(color.r).toBeGreaterThan(110);
65
+ expect(color.r).toBeLessThan(145);
66
+ expect(color.r).toBe(color.g);
67
+ expect(color.g).toBe(color.b);
68
+ });
69
+
70
+ test("ignores transparent pixels when averaging", () => {
71
+ // fully transparent on the left, opaque red on the right
72
+ const imageData = createImageData(100, 100, x => (x < 50 ? [0, 255, 0, 0] : [255, 0, 0, 255]));
73
+
74
+ expect(averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 40, 40))).toEqual({
75
+ r: 255,
76
+ g: 0,
77
+ b: 0,
78
+ });
79
+ });
80
+
81
+ test("returns null for a fully transparent region", () => {
82
+ const imageData = createImageData(100, 100, solid(255, 0, 0, 0));
83
+
84
+ expect(averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 40, 40))).toBeNull();
85
+ });
86
+
87
+ test("returns null when the region is mostly transparent", () => {
88
+ const imageData = createImageData(100, 100, solid(255, 0, 0, 20));
89
+
90
+ expect(averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 40, 40))).toBeNull();
91
+ });
92
+
93
+ test("returns null without any path to sample", () => {
94
+ const imageData = createImageData(100, 100, solid(10, 20, 30));
95
+
96
+ expect(averageRingColorFromImageData(imageData, origin, [])).toBeNull();
97
+ });
98
+
99
+ test("respects the origin of the image data within the source", () => {
100
+ // the image data covers the source starting at 40/40, and is red on its left half
101
+ const imageData = createImageData(40, 40, x => (x < 20 ? [255, 0, 0, 255] : [0, 0, 255, 255]));
102
+ // a design area fully inside the right (blue) half of that excerpt
103
+ const paths = rectPath(70, 60, 12, 12);
104
+
105
+ expect(averageRingColorFromImageData(imageData, { x: 40, y: 40 }, paths)).toEqual({
106
+ r: 0,
107
+ g: 0,
108
+ b: 255,
109
+ });
110
+ });
111
+
112
+ test("samples the rotated footprint of the design area", () => {
113
+ // red outside of the horizontal band between y 40 and y 60, white inside
114
+ const imageData = createImageData(100, 100, (x, y) =>
115
+ y < 40 || y > 60 ? [255, 0, 0, 255] : [255, 255, 255, 255]
116
+ );
117
+ // 40 x 10 area in the center stays inside the white band
118
+ expect(averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 40, 10))).toEqual({
119
+ r: 255,
120
+ g: 255,
121
+ b: 255,
122
+ });
123
+
124
+ // the same area rotated by 90 degrees becomes 10 x 40 and reaches into the red region
125
+ const rotated = averageRingColorFromImageData(imageData, origin, rectPath(50, 50, 10, 40));
126
+ expect(rotated.r).toBe(255);
127
+ expect(rotated.g).toBeLessThan(255);
128
+ });
129
+
130
+ test("samples along a skewed quadrilateral", () => {
131
+ // white on the left of the diagonal, blue on the right
132
+ const imageData = createImageData(100, 100, (x, y) =>
133
+ x > y ? [0, 0, 255, 255] : [255, 255, 255, 255]
134
+ );
135
+ // a quad whose edges follow the diagonal, so it sits almost entirely in the blue half
136
+ const paths = [
137
+ [
138
+ { x: 30, y: 20 },
139
+ { x: 80, y: 20 },
140
+ { x: 80, y: 70 },
141
+ { x: 30, y: 20 },
142
+ ],
143
+ ];
144
+
145
+ const color = averageRingColorFromImageData(imageData, origin, paths);
146
+ expect(color.b).toBe(255);
147
+ expect(color.r).toBeLessThan(180);
148
+ });
149
+
150
+ test("samples every given path", () => {
151
+ // left half red, right half blue
152
+ const imageData = createImageData(100, 100, x =>
153
+ x < 50 ? [255, 0, 0, 255] : [0, 0, 255, 255]
154
+ );
155
+ const left = [
156
+ [
157
+ { x: 10, y: 40 },
158
+ { x: 30, y: 40 },
159
+ ],
160
+ ];
161
+ const right = [
162
+ [
163
+ { x: 70, y: 40 },
164
+ { x: 90, y: 40 },
165
+ ],
166
+ ];
167
+
168
+ expect(averageRingColorFromImageData(imageData, origin, left)).toEqual({ r: 255, g: 0, b: 0 });
169
+ expect(averageRingColorFromImageData(imageData, origin, right)).toEqual({ r: 0, g: 0, b: 255 });
170
+
171
+ // both open polylines together average to half red, half blue
172
+ const both = averageRingColorFromImageData(imageData, origin, [...left, ...right]);
173
+ expect(both.r).toBeGreaterThan(100);
174
+ expect(both.b).toBeGreaterThan(100);
175
+ });
176
+
177
+ test("skips sample points outside of the image data", () => {
178
+ const imageData = createImageData(100, 100, solid(0, 128, 0));
179
+ // the design area reaches beyond the top left corner of the image
180
+ const paths = rectPath(10, 10, 60, 60);
181
+
182
+ expect(averageRingColorFromImageData(imageData, origin, paths)).toEqual({
183
+ r: 0,
184
+ g: 128,
185
+ b: 0,
186
+ });
187
+ });
188
+ });
189
+
190
+ describe("getSampleBounds", () => {
191
+ const source = { width: 100, height: 100 };
192
+
193
+ test("covers the paths grown by half the ring width", () => {
194
+ expect(getSampleBounds(source, rectPath(50, 50, 40, 20), 4)).toEqual({
195
+ x: 28,
196
+ y: 38,
197
+ width: 44,
198
+ height: 24,
199
+ });
200
+ });
201
+
202
+ test("clamps the bounds to the source image", () => {
203
+ expect(getSampleBounds(source, rectPath(10, 10, 60, 60), 4)).toEqual({
204
+ x: 0,
205
+ y: 0,
206
+ width: 42,
207
+ height: 42,
208
+ });
209
+ });
210
+
211
+ test("covers all of the given paths", () => {
212
+ const paths = [
213
+ [
214
+ { x: 10, y: 10 },
215
+ { x: 20, y: 20 },
216
+ ],
217
+ [
218
+ { x: 60, y: 70 },
219
+ { x: 80, y: 90 },
220
+ ],
221
+ ];
222
+
223
+ expect(getSampleBounds(source, paths, 4)).toEqual({ x: 8, y: 8, width: 74, height: 84 });
224
+ });
225
+
226
+ test("returns an empty area without paths", () => {
227
+ expect(getSampleBounds(source, [], 4)).toEqual({ x: 0, y: 0, width: 0, height: 0 });
228
+ });
229
+ });