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.
- 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
|
@@ -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 = ["
|
|
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
|
-
|
|
199
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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",
|
package/src/Three/Three2D.js
CHANGED
|
@@ -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
|
+
});
|