customforge 0.1.0-alpha.2 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -56
- package/LICENSES/NunitoSans-OFL.txt +93 -93
- package/LICENSES/plain-mug-CC-BY-4.0.txt +11 -0
- package/README.md +592 -461
- package/README.zh-CN.md +587 -459
- package/dist/{ProductCustomizer-rMRyWe7L.js → ProductCustomizer-BnjY3v7d.js} +898 -156
- package/dist/ProductCustomizer-BnjY3v7d.js.map +1 -0
- package/dist/bridge/TextureBridge.d.ts +1 -1
- package/dist/core/api.d.ts +94 -0
- package/dist/core/api.d.ts.map +1 -0
- package/dist/core/config.d.ts +3 -12
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/design.d.ts.map +1 -1
- package/dist/core/types.d.ts +183 -5
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/uv.d.ts +8 -0
- package/dist/core/uv.d.ts.map +1 -0
- package/dist/cup_decal_small_margins.glb +0 -0
- package/dist/customizer/ProductCustomizer.d.ts +98 -9
- package/dist/customizer/ProductCustomizer.d.ts.map +1 -1
- package/dist/editor/DesignEditor.d.ts +118 -10
- package/dist/editor/DesignEditor.d.ts.map +1 -1
- package/dist/editor/uvBounds.d.ts +15 -0
- package/dist/editor/uvBounds.d.ts.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/style.css +2120 -1327
- package/dist/viewer/ProductViewer.d.ts +37 -6
- package/dist/viewer/ProductViewer.d.ts.map +1 -1
- package/dist/viewer/uvLayout.d.ts +11 -0
- package/dist/viewer/uvLayout.d.ts.map +1 -0
- package/dist/workbench/CustomForgeWorkbench.d.ts +85 -8
- package/dist/workbench/CustomForgeWorkbench.d.ts.map +1 -1
- package/dist/workbench/config.d.ts +42 -1
- package/dist/workbench/config.d.ts.map +1 -1
- package/dist/workbench/icons.d.ts.map +1 -1
- package/dist/workbench/index.d.ts +3 -3
- package/dist/workbench/index.d.ts.map +1 -1
- package/dist/workbench/template.d.ts.map +1 -1
- package/dist/workbench/types.d.ts +261 -13
- package/dist/workbench/types.d.ts.map +1 -1
- package/dist/workbench.js +1248 -116
- package/dist/workbench.js.map +1 -1
- package/package.json +27 -20
- package/dist/ProductCustomizer-rMRyWe7L.js.map +0 -1
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
import { Canvas, FabricImage, Textbox } from "fabric";
|
|
2
|
-
import { ACESFilmicToneMapping, Box3, CanvasTexture,
|
|
1
|
+
import { ActiveSelection, Canvas, FabricImage, Point, Textbox } from "fabric";
|
|
2
|
+
import { ACESFilmicToneMapping, AmbientLight, Box3, CanvasTexture, DirectionalLight, MathUtils, Mesh, MeshStandardMaterial, PerspectiveCamera, SRGBColorSpace, Scene, Vector3, WebGLRenderer } from "three";
|
|
3
3
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
4
4
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
5
5
|
/**
|
|
6
6
|
* 清理产品配置并补全运行所需的默认值
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* 远程模型和随包提供的默认 GLB 模型均默认不翻转纹理
|
|
9
9
|
*
|
|
10
10
|
* @param product 外部传入的产品配置
|
|
11
11
|
* @returns 可以直接交给编辑器和查看器使用的完整配置
|
|
12
12
|
*/
|
|
13
13
|
function normalizeProductConfiguration(product = {}) {
|
|
14
|
-
const modelUrl = product.modelUrl?.trim() || void 0;
|
|
15
14
|
return {
|
|
16
|
-
modelUrl,
|
|
15
|
+
modelUrl: product.modelUrl?.trim() || void 0,
|
|
17
16
|
textureUrl: product.textureUrl?.trim() || void 0,
|
|
18
17
|
surfaceMesh: product.surfaceMesh?.trim() || "PrintArea",
|
|
19
|
-
textureFlipY: product.textureFlipY ??
|
|
18
|
+
textureFlipY: product.textureFlipY ?? false
|
|
20
19
|
};
|
|
21
20
|
}
|
|
22
21
|
//#endregion
|
|
@@ -63,6 +62,20 @@ function readImageRole(value, path) {
|
|
|
63
62
|
if (value !== "element" && value !== "background") throw new TypeError(`${path} must be element or background`);
|
|
64
63
|
return value;
|
|
65
64
|
}
|
|
65
|
+
function readTextAlignment(value, path) {
|
|
66
|
+
if (value !== "left" && value !== "center" && value !== "right") throw new TypeError(`${path} must be left, center, or right`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function readTextFontStyle(value, path) {
|
|
70
|
+
if (value !== "normal" && value !== "italic") throw new TypeError(`${path} must be normal or italic`);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function readTextFontWeight(value, path) {
|
|
74
|
+
if (value === "normal" || value === "bold") return value;
|
|
75
|
+
const weight = readFiniteNumber(value, path);
|
|
76
|
+
if (weight <= 0 || weight > 1e3) throw new TypeError(`${path} must be between 1 and 1000`);
|
|
77
|
+
return weight;
|
|
78
|
+
}
|
|
66
79
|
function parseObjectState(object, path) {
|
|
67
80
|
return {
|
|
68
81
|
...object.name === void 0 ? {} : { name: readString(object.name, `${path}.name`).trim() },
|
|
@@ -97,7 +110,14 @@ function parseObject(value, index) {
|
|
|
97
110
|
width: readPositiveNumber(object.width, `${path}.width`),
|
|
98
111
|
fontFamily: readString(object.fontFamily, `${path}.fontFamily`),
|
|
99
112
|
fontSize: readPositiveNumber(object.fontSize, `${path}.fontSize`),
|
|
100
|
-
color: readString(object.color, `${path}.color`)
|
|
113
|
+
color: readString(object.color, `${path}.color`),
|
|
114
|
+
...object.fontWeight === void 0 ? {} : { fontWeight: readTextFontWeight(object.fontWeight, `${path}.fontWeight`) },
|
|
115
|
+
...object.fontStyle === void 0 ? {} : { fontStyle: readTextFontStyle(object.fontStyle, `${path}.fontStyle`) },
|
|
116
|
+
...object.underline === void 0 ? {} : { underline: readBoolean(object.underline, `${path}.underline`) },
|
|
117
|
+
...object.textAlign === void 0 ? {} : { textAlign: readTextAlignment(object.textAlign, `${path}.textAlign`) },
|
|
118
|
+
...object.lineHeight === void 0 ? {} : { lineHeight: readPositiveNumber(object.lineHeight, `${path}.lineHeight`) },
|
|
119
|
+
...object.charSpacing === void 0 ? {} : { charSpacing: readFiniteNumber(object.charSpacing, `${path}.charSpacing`) },
|
|
120
|
+
...object.backgroundColor === void 0 ? {} : { backgroundColor: readString(object.backgroundColor, `${path}.backgroundColor`) }
|
|
101
121
|
};
|
|
102
122
|
if (object.type === "image") {
|
|
103
123
|
const src = readString(object.src, `${path}.src`);
|
|
@@ -230,6 +250,55 @@ function calculateContainmentOffset(bounds, canvasWidth, canvasHeight) {
|
|
|
230
250
|
};
|
|
231
251
|
}
|
|
232
252
|
//#endregion
|
|
253
|
+
//#region src/editor/uvBounds.ts
|
|
254
|
+
function fullCanvasBounds(width, height) {
|
|
255
|
+
return {
|
|
256
|
+
left: 0,
|
|
257
|
+
top: 0,
|
|
258
|
+
width,
|
|
259
|
+
height
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 将 UV 三角形的坐标范围转换为逻辑画布包围框
|
|
264
|
+
*
|
|
265
|
+
* UV 坐标会限制在 0 到 1 之间;布局为空、无效或退化时回退到整张画布
|
|
266
|
+
*
|
|
267
|
+
* @param layout 当前可打印 Mesh 的 UV 布局
|
|
268
|
+
* @param flipY 是否按照垂直翻转后的纹理方向计算
|
|
269
|
+
* @param canvasWidth 逻辑画布宽度
|
|
270
|
+
* @param canvasHeight 逻辑画布高度
|
|
271
|
+
* @returns 当前 UV 可打印区域在逻辑画布中的轴对齐包围框
|
|
272
|
+
*/
|
|
273
|
+
function calculateUvCanvasBounds(layout, flipY, canvasWidth, canvasHeight) {
|
|
274
|
+
const coordinates = layout.triangleCoordinates;
|
|
275
|
+
let minimumX = Number.POSITIVE_INFINITY;
|
|
276
|
+
let minimumY = Number.POSITIVE_INFINITY;
|
|
277
|
+
let maximumX = Number.NEGATIVE_INFINITY;
|
|
278
|
+
let maximumY = Number.NEGATIVE_INFINITY;
|
|
279
|
+
for (let index = 0; index + 1 < coordinates.length; index += 2) {
|
|
280
|
+
const u = coordinates[index];
|
|
281
|
+
const v = coordinates[index + 1];
|
|
282
|
+
if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
|
|
283
|
+
const x = Math.min(Math.max(u, 0), 1) * canvasWidth;
|
|
284
|
+
const normalizedY = flipY ? 1 - v : v;
|
|
285
|
+
const y = Math.min(Math.max(normalizedY, 0), 1) * canvasHeight;
|
|
286
|
+
minimumX = Math.min(minimumX, x);
|
|
287
|
+
minimumY = Math.min(minimumY, y);
|
|
288
|
+
maximumX = Math.max(maximumX, x);
|
|
289
|
+
maximumY = Math.max(maximumY, y);
|
|
290
|
+
}
|
|
291
|
+
const width = maximumX - minimumX;
|
|
292
|
+
const height = maximumY - minimumY;
|
|
293
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return fullCanvasBounds(canvasWidth, canvasHeight);
|
|
294
|
+
return {
|
|
295
|
+
left: minimumX,
|
|
296
|
+
top: minimumY,
|
|
297
|
+
width,
|
|
298
|
+
height
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
233
302
|
//#region src/editor/imageSource.ts
|
|
234
303
|
function readBlobAsDataUrl(blob) {
|
|
235
304
|
return new Promise((resolve, reject) => {
|
|
@@ -261,10 +330,38 @@ async function resolvePersistentImageSource(src) {
|
|
|
261
330
|
}
|
|
262
331
|
//#endregion
|
|
263
332
|
//#region src/editor/DesignEditor.ts
|
|
333
|
+
var EDITOR_DISPLAY_GUTTER = 56;
|
|
334
|
+
function normalizeEditorAppearance(appearance = {}) {
|
|
335
|
+
const controlSize = appearance.controlSize ?? 8;
|
|
336
|
+
if (!Number.isFinite(controlSize) || controlSize <= 0) throw new RangeError("appearance.editor.controlSize must be greater than zero");
|
|
337
|
+
return {
|
|
338
|
+
selectionFill: appearance.selectionFill?.trim() || "rgba(19, 113, 125, 0.12)",
|
|
339
|
+
selectionBorder: appearance.selectionBorder?.trim() || "#13717d",
|
|
340
|
+
controlFill: appearance.controlFill?.trim() || "#ffffff",
|
|
341
|
+
controlBorder: appearance.controlBorder?.trim() || "#13717d",
|
|
342
|
+
objectBorder: appearance.objectBorder?.trim() || "#13717d",
|
|
343
|
+
controlSize,
|
|
344
|
+
uvFill: appearance.uvFill?.trim() || void 0,
|
|
345
|
+
uvBoundary: appearance.uvBoundary?.trim() || "#dc2626"
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/** 将 Fabric 主画布重绘为不含选择框的实时纹理 */
|
|
349
|
+
var DesignCanvas = class extends Canvas {
|
|
350
|
+
/** 将纯设计内容绘制到稳定的纹理 Canvas */
|
|
351
|
+
renderTexture(context) {
|
|
352
|
+
const previousSkipControlsDrawing = this.skipControlsDrawing;
|
|
353
|
+
this.skipControlsDrawing = true;
|
|
354
|
+
try {
|
|
355
|
+
this.renderCanvas(context, this.getObjects());
|
|
356
|
+
} finally {
|
|
357
|
+
this.skipControlsDrawing = previousSkipControlsDrawing;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
264
361
|
/**
|
|
265
362
|
* 基于 Fabric.js 的二维纹理编辑器
|
|
266
363
|
*
|
|
267
|
-
*
|
|
364
|
+
* 负责基础纹理、UV 区域辅助层、文字、图片、对象选择和 PNG 导出
|
|
268
365
|
* 显示尺寸可以响应容器变化,内部逻辑尺寸保持不变
|
|
269
366
|
*/
|
|
270
367
|
var DesignEditor = class {
|
|
@@ -273,6 +370,12 @@ var DesignEditor = class {
|
|
|
273
370
|
host;
|
|
274
371
|
width;
|
|
275
372
|
height;
|
|
373
|
+
defaultText;
|
|
374
|
+
textObjectName;
|
|
375
|
+
imageObjectName;
|
|
376
|
+
backgroundObjectName;
|
|
377
|
+
appearance;
|
|
378
|
+
editableBounds;
|
|
276
379
|
renderListeners = /* @__PURE__ */ new Set();
|
|
277
380
|
selectionListeners = /* @__PURE__ */ new Set();
|
|
278
381
|
historyListeners = /* @__PURE__ */ new Set();
|
|
@@ -285,9 +388,14 @@ var DesignEditor = class {
|
|
|
285
388
|
imageRoles = /* @__PURE__ */ new WeakMap();
|
|
286
389
|
resizeObserver;
|
|
287
390
|
history;
|
|
391
|
+
uvOverlay;
|
|
392
|
+
textureRenderer;
|
|
393
|
+
textureCanvasElement;
|
|
394
|
+
textureContext;
|
|
288
395
|
historySignature;
|
|
289
396
|
historyTimer;
|
|
290
397
|
historyBusy = false;
|
|
398
|
+
renderingTexture = false;
|
|
291
399
|
objectIdSequence = 0;
|
|
292
400
|
/**
|
|
293
401
|
* @param host 二维编辑器挂载容器
|
|
@@ -297,23 +405,53 @@ var DesignEditor = class {
|
|
|
297
405
|
this.host = host;
|
|
298
406
|
this.width = options.width;
|
|
299
407
|
this.height = options.height;
|
|
408
|
+
this.defaultText = options.defaultText ?? "Edit this text";
|
|
409
|
+
this.textObjectName = options.textObjectName ?? "Text";
|
|
410
|
+
this.imageObjectName = options.imageObjectName ?? "Image";
|
|
411
|
+
this.backgroundObjectName = options.backgroundObjectName ?? "Background";
|
|
412
|
+
this.appearance = normalizeEditorAppearance(options.appearance);
|
|
413
|
+
this.editableBounds = {
|
|
414
|
+
left: 0,
|
|
415
|
+
top: 0,
|
|
416
|
+
width: this.width,
|
|
417
|
+
height: this.height
|
|
418
|
+
};
|
|
300
419
|
const element = document.createElement("canvas");
|
|
301
|
-
element.setAttribute("aria-label", "UV texture editor");
|
|
420
|
+
element.setAttribute("aria-label", options.ariaLabel ?? "UV texture editor");
|
|
302
421
|
host.replaceChildren(element);
|
|
303
|
-
|
|
422
|
+
const canvas = new DesignCanvas(element, {
|
|
304
423
|
width: this.width,
|
|
305
424
|
height: this.height,
|
|
306
|
-
backgroundColor: "#f7f7f5",
|
|
307
425
|
preserveObjectStacking: true,
|
|
308
|
-
selectionColor:
|
|
309
|
-
selectionBorderColor:
|
|
426
|
+
selectionColor: this.appearance.selectionFill,
|
|
427
|
+
selectionBorderColor: this.appearance.selectionBorder
|
|
310
428
|
});
|
|
429
|
+
this.canvas = canvas;
|
|
430
|
+
this.textureRenderer = canvas;
|
|
431
|
+
this.textureCanvasElement = document.createElement("canvas");
|
|
432
|
+
this.textureCanvasElement.width = this.width;
|
|
433
|
+
this.textureCanvasElement.height = this.height;
|
|
434
|
+
const textureContext = this.textureCanvasElement.getContext("2d");
|
|
435
|
+
if (!textureContext) {
|
|
436
|
+
canvas.dispose();
|
|
437
|
+
throw new Error("Unable to create the live texture canvas");
|
|
438
|
+
}
|
|
439
|
+
this.textureContext = textureContext;
|
|
311
440
|
const initialDesign = this.saveDesign();
|
|
312
441
|
this.history = new DesignHistory(initialDesign, options.historyLimit);
|
|
313
442
|
this.historySignature = JSON.stringify(initialDesign);
|
|
314
443
|
this.canvas.wrapperEl.classList.add("customforge-design-canvas");
|
|
444
|
+
this.uvOverlay = document.createElement("canvas");
|
|
445
|
+
this.uvOverlay.width = this.width;
|
|
446
|
+
this.uvOverlay.height = this.height;
|
|
447
|
+
this.uvOverlay.className = "customforge-uv-layout";
|
|
448
|
+
this.uvOverlay.setAttribute("aria-hidden", "true");
|
|
449
|
+
this.uvOverlay.hidden = true;
|
|
450
|
+
this.canvas.wrapperEl.insertBefore(this.uvOverlay, this.canvas.upperCanvasEl);
|
|
315
451
|
this.host.dataset.renderState = "pending";
|
|
316
452
|
this.canvas.on("after:render", () => {
|
|
453
|
+
if (this.renderingTexture) return;
|
|
454
|
+
this.renderTextureCanvas();
|
|
317
455
|
this.markRenderState();
|
|
318
456
|
this.renderListeners.forEach((listener) => listener());
|
|
319
457
|
});
|
|
@@ -341,9 +479,54 @@ var DesignEditor = class {
|
|
|
341
479
|
this.resizeObserver.observe(host);
|
|
342
480
|
this.resizeDisplay();
|
|
343
481
|
}
|
|
344
|
-
/** 供 Three.js 创建 CanvasTexture
|
|
482
|
+
/** 供 Three.js 创建 CanvasTexture 的纯设计画布,不包含选择框和变换控件 */
|
|
345
483
|
get textureCanvas() {
|
|
346
|
-
return this.
|
|
484
|
+
return this.textureCanvasElement;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* 在交互画布上显示目标 Mesh 的 UV 可打印区域,但不写入实时纹理或导出图片
|
|
488
|
+
*
|
|
489
|
+
* @param layout 从当前目标 Mesh 提取的 UV 布局
|
|
490
|
+
* @param flipY 是否按照垂直翻转后的纹理方向显示
|
|
491
|
+
*/
|
|
492
|
+
setUvLayout(layout, flipY) {
|
|
493
|
+
this.editableBounds = calculateUvCanvasBounds(layout, flipY, this.width, this.height);
|
|
494
|
+
const context = this.uvOverlay.getContext("2d");
|
|
495
|
+
if (!context) return;
|
|
496
|
+
context.clearRect(0, 0, this.width, this.height);
|
|
497
|
+
const { triangleCoordinates, boundaryCoordinates } = layout;
|
|
498
|
+
if (triangleCoordinates.length === 0) {
|
|
499
|
+
this.uvOverlay.hidden = true;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
this.uvOverlay.hidden = false;
|
|
503
|
+
const accent = this.appearance.uvFill ?? (getComputedStyle(this.canvas.wrapperEl).getPropertyValue("--cfw-accent").trim() || "#268a4b");
|
|
504
|
+
const canvasX = (u) => Math.min(Math.max(u * this.width, 1), this.width - 1);
|
|
505
|
+
const canvasY = (v) => Math.min(Math.max((flipY ? 1 - v : v) * this.height, 1), this.height - 1);
|
|
506
|
+
context.save();
|
|
507
|
+
context.beginPath();
|
|
508
|
+
for (let index = 0; index < triangleCoordinates.length; index += 6) {
|
|
509
|
+
context.moveTo(canvasX(triangleCoordinates[index]), canvasY(triangleCoordinates[index + 1]));
|
|
510
|
+
context.lineTo(canvasX(triangleCoordinates[index + 2]), canvasY(triangleCoordinates[index + 3]));
|
|
511
|
+
context.lineTo(canvasX(triangleCoordinates[index + 4]), canvasY(triangleCoordinates[index + 5]));
|
|
512
|
+
context.closePath();
|
|
513
|
+
}
|
|
514
|
+
context.fillStyle = accent;
|
|
515
|
+
context.globalAlpha = .012;
|
|
516
|
+
context.fill();
|
|
517
|
+
context.beginPath();
|
|
518
|
+
for (let index = 0; index < boundaryCoordinates.length; index += 4) {
|
|
519
|
+
context.moveTo(canvasX(boundaryCoordinates[index]), canvasY(boundaryCoordinates[index + 1]));
|
|
520
|
+
context.lineTo(canvasX(boundaryCoordinates[index + 2]), canvasY(boundaryCoordinates[index + 3]));
|
|
521
|
+
}
|
|
522
|
+
context.globalAlpha = .28;
|
|
523
|
+
context.strokeStyle = this.appearance.uvBoundary;
|
|
524
|
+
context.lineWidth = 1;
|
|
525
|
+
context.lineCap = "round";
|
|
526
|
+
context.lineJoin = "round";
|
|
527
|
+
context.setLineDash([6, 6]);
|
|
528
|
+
context.stroke();
|
|
529
|
+
context.restore();
|
|
347
530
|
}
|
|
348
531
|
/** 当前画布中设计对象的数量,不包含产品基础纹理 */
|
|
349
532
|
get objectCount() {
|
|
@@ -395,6 +578,17 @@ var DesignEditor = class {
|
|
|
395
578
|
getObjects() {
|
|
396
579
|
return this.canvas.getObjects().map((object) => this.serializeObject(object));
|
|
397
580
|
}
|
|
581
|
+
/** 返回当前逻辑画布尺寸 */
|
|
582
|
+
getCanvasSize() {
|
|
583
|
+
return {
|
|
584
|
+
width: this.width,
|
|
585
|
+
height: this.height
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** 返回当前 UV 可打印区域的独立包围框 */
|
|
589
|
+
getPrintableBounds() {
|
|
590
|
+
return { ...this.editableBounds };
|
|
591
|
+
}
|
|
398
592
|
/** 当前选中对象的 ID,按画布层级从后到前排列 */
|
|
399
593
|
getSelectedObjectIds() {
|
|
400
594
|
return this.canvas.getActiveObjects().map((object) => this.objectIds.get(object)).filter((id) => Boolean(id));
|
|
@@ -406,9 +600,34 @@ var DesignEditor = class {
|
|
|
406
600
|
* @returns 是否找到并选中了对象
|
|
407
601
|
*/
|
|
408
602
|
selectObject(id) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
603
|
+
return this.selectObjects([id]);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* 按稳定 ID 同时选中多个可见对象
|
|
607
|
+
*
|
|
608
|
+
* 所有 ID 都必须有效且对象可见,否则保持原选区不变
|
|
609
|
+
*
|
|
610
|
+
* @param ids 对象 ID,空数组表示清除选区
|
|
611
|
+
* @returns 是否应用了请求的选区
|
|
612
|
+
*/
|
|
613
|
+
selectObjects(ids) {
|
|
614
|
+
const uniqueIds = [...new Set(ids)];
|
|
615
|
+
if (uniqueIds.length === 0) return this.clearSelection();
|
|
616
|
+
const objects = uniqueIds.map((id) => this.objectsById.get(id));
|
|
617
|
+
if (objects.some((object) => !object || !object.visible)) return false;
|
|
618
|
+
this.applySelection(objects);
|
|
619
|
+
this.canvas.requestRenderAll();
|
|
620
|
+
this.notifySelectionChange();
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* 清除当前画布选区,不修改设计内容或历史记录
|
|
625
|
+
*
|
|
626
|
+
* @returns 清除前是否存在选中对象
|
|
627
|
+
*/
|
|
628
|
+
clearSelection() {
|
|
629
|
+
if (!this.canvas.getActiveObject()) return false;
|
|
630
|
+
this.canvas.discardActiveObject();
|
|
412
631
|
this.canvas.requestRenderAll();
|
|
413
632
|
this.notifySelectionChange();
|
|
414
633
|
return true;
|
|
@@ -457,6 +676,29 @@ var DesignEditor = class {
|
|
|
457
676
|
return true;
|
|
458
677
|
}
|
|
459
678
|
/**
|
|
679
|
+
* 将现有图片转换为铺满当前 UV 可打印区域的设计背景
|
|
680
|
+
*
|
|
681
|
+
* 转换会替换已有设计背景、重置旋转、锁定对象并移动到最底层
|
|
682
|
+
*
|
|
683
|
+
* @param id Design JSON 中的图片对象 ID
|
|
684
|
+
* @returns 是否找到普通图片并完成转换
|
|
685
|
+
*/
|
|
686
|
+
setImageAsBackground(id) {
|
|
687
|
+
this.flushHistoryCommit();
|
|
688
|
+
const object = this.objectsById.get(id);
|
|
689
|
+
if (!(object instanceof FabricImage) || this.imageRoles.get(object) === "background") return false;
|
|
690
|
+
this.removeDesignBackgrounds();
|
|
691
|
+
this.imageRoles.set(object, "background");
|
|
692
|
+
this.fitImageToEditableBounds(object);
|
|
693
|
+
this.applyObjectLock(object, true);
|
|
694
|
+
this.canvas.moveObjectTo(object, 0);
|
|
695
|
+
this.canvas.setActiveObject(object);
|
|
696
|
+
this.canvas.requestRenderAll();
|
|
697
|
+
this.notifySelectionChange();
|
|
698
|
+
this.commitHistory();
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
460
702
|
* 修改对象在图层面板中的名称
|
|
461
703
|
*
|
|
462
704
|
* @param id Design JSON 中的对象 ID
|
|
@@ -508,7 +750,125 @@ var DesignEditor = class {
|
|
|
508
750
|
return true;
|
|
509
751
|
}
|
|
510
752
|
/**
|
|
511
|
-
*
|
|
753
|
+
* 更新对象中心位置、缩放、旋转和翻转
|
|
754
|
+
*
|
|
755
|
+
* 对象最终仍会约束在逻辑画布内,连续调用会合并为一个历史步骤
|
|
756
|
+
*
|
|
757
|
+
* @param id Design JSON 中的对象 ID
|
|
758
|
+
* @param options 要更新的变换字段
|
|
759
|
+
* @returns 约束后的对象快照,找不到对象时返回 undefined
|
|
760
|
+
* @throws 数值无效或缩放倍数不大于零时抛出错误
|
|
761
|
+
*/
|
|
762
|
+
updateObjectTransform(id, options) {
|
|
763
|
+
const object = this.objectsById.get(id);
|
|
764
|
+
if (!object) return;
|
|
765
|
+
this.validateTransformOptions(options);
|
|
766
|
+
const selectedIds = this.getSelectedObjectIds();
|
|
767
|
+
if (object.group instanceof ActiveSelection) this.canvas.discardActiveObject();
|
|
768
|
+
const current = this.serializeTransform(object);
|
|
769
|
+
const x = options.x ?? current.x;
|
|
770
|
+
const y = options.y ?? current.y;
|
|
771
|
+
object.set({
|
|
772
|
+
scaleX: options.scaleX ?? current.scaleX,
|
|
773
|
+
scaleY: options.scaleY ?? current.scaleY,
|
|
774
|
+
angle: options.rotation ?? current.rotation,
|
|
775
|
+
flipX: options.flipX ?? current.flipX,
|
|
776
|
+
flipY: options.flipY ?? current.flipY
|
|
777
|
+
});
|
|
778
|
+
object.setPositionByOrigin(new Point(x, y), "center", "center");
|
|
779
|
+
this.constrainObjectToCanvas(object);
|
|
780
|
+
const selectedObjects = selectedIds.map((selectedId) => this.objectsById.get(selectedId)).filter((selected) => Boolean(selected?.visible));
|
|
781
|
+
if (selectedObjects.length > 0) this.applySelection(selectedObjects);
|
|
782
|
+
this.canvas.requestRenderAll();
|
|
783
|
+
this.notifySelectionChange();
|
|
784
|
+
this.scheduleHistoryCommit();
|
|
785
|
+
return this.serializeObject(object);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* 修改已有文字对象的内容和排版样式
|
|
789
|
+
*
|
|
790
|
+
* 连续调用会在短暂空闲后合并为一个历史步骤
|
|
791
|
+
* 字体必须由消费页面提前加载,否则浏览器会使用回退字体
|
|
792
|
+
*
|
|
793
|
+
* @param id Design JSON 中的对象 ID
|
|
794
|
+
* @param options 要修改的文字属性,未传字段保持不变
|
|
795
|
+
* @returns 是否找到并更新了文字对象
|
|
796
|
+
* @throws 字号、行高、字距、字重或 CSS 颜色不符合约束时抛出错误
|
|
797
|
+
*/
|
|
798
|
+
updateText(id, options) {
|
|
799
|
+
const object = this.objectsById.get(id);
|
|
800
|
+
if (!(object instanceof Textbox)) return false;
|
|
801
|
+
if (options.text !== void 0) object.set({ text: options.text });
|
|
802
|
+
if (options.fontFamily !== void 0) {
|
|
803
|
+
const fontFamily = options.fontFamily.trim();
|
|
804
|
+
if (!fontFamily) throw new TypeError("fontFamily must be a non-empty string");
|
|
805
|
+
object.set({ fontFamily });
|
|
806
|
+
}
|
|
807
|
+
if (options.fontSize !== void 0) {
|
|
808
|
+
if (!Number.isFinite(options.fontSize) || options.fontSize <= 0) throw new RangeError("fontSize must be greater than zero");
|
|
809
|
+
object.set({ fontSize: options.fontSize });
|
|
810
|
+
}
|
|
811
|
+
if (options.color !== void 0) {
|
|
812
|
+
if (!options.color.trim()) throw new TypeError("color must be a non-empty CSS color");
|
|
813
|
+
object.set({ fill: options.color });
|
|
814
|
+
}
|
|
815
|
+
if (options.fontWeight !== void 0) {
|
|
816
|
+
if (!(options.fontWeight === "normal" || options.fontWeight === "bold" || typeof options.fontWeight === "number" && Number.isFinite(options.fontWeight) && options.fontWeight > 0 && options.fontWeight <= 1e3)) throw new RangeError("fontWeight must be normal, bold, or between 1 and 1000");
|
|
817
|
+
object.set({ fontWeight: options.fontWeight });
|
|
818
|
+
}
|
|
819
|
+
if (options.fontStyle !== void 0) {
|
|
820
|
+
if (options.fontStyle !== "normal" && options.fontStyle !== "italic") throw new TypeError("fontStyle must be normal or italic");
|
|
821
|
+
object.set({ fontStyle: options.fontStyle });
|
|
822
|
+
}
|
|
823
|
+
if (options.underline !== void 0) object.set({ underline: options.underline });
|
|
824
|
+
if (options.textAlign !== void 0) {
|
|
825
|
+
if (![
|
|
826
|
+
"left",
|
|
827
|
+
"center",
|
|
828
|
+
"right"
|
|
829
|
+
].includes(options.textAlign)) throw new TypeError("textAlign must be left, center, or right");
|
|
830
|
+
object.set({ textAlign: options.textAlign });
|
|
831
|
+
}
|
|
832
|
+
if (options.lineHeight !== void 0) {
|
|
833
|
+
if (!Number.isFinite(options.lineHeight) || options.lineHeight <= 0) throw new RangeError("lineHeight must be greater than zero");
|
|
834
|
+
object.set({ lineHeight: options.lineHeight });
|
|
835
|
+
}
|
|
836
|
+
if (options.charSpacing !== void 0) {
|
|
837
|
+
if (!Number.isFinite(options.charSpacing)) throw new TypeError("charSpacing must be a finite number");
|
|
838
|
+
object.set({ charSpacing: options.charSpacing });
|
|
839
|
+
}
|
|
840
|
+
if (options.backgroundColor !== void 0) {
|
|
841
|
+
if (options.backgroundColor !== null && !options.backgroundColor.trim()) throw new TypeError("backgroundColor must be a non-empty CSS color or null");
|
|
842
|
+
object.set({ backgroundColor: options.backgroundColor ?? "" });
|
|
843
|
+
}
|
|
844
|
+
object.initDimensions();
|
|
845
|
+
if (object.group instanceof ActiveSelection) {
|
|
846
|
+
object.group.triggerLayout();
|
|
847
|
+
this.constrainObjectToCanvas(object.group);
|
|
848
|
+
} else this.constrainObjectToCanvas(object);
|
|
849
|
+
this.canvas.requestRenderAll();
|
|
850
|
+
this.scheduleHistoryCommit();
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* 让一个未锁定文字对象进入画布内联编辑状态
|
|
855
|
+
*
|
|
856
|
+
* @param id Design JSON 中的对象 ID
|
|
857
|
+
* @returns 是否找到文字对象并进入编辑状态
|
|
858
|
+
*/
|
|
859
|
+
editText(id) {
|
|
860
|
+
this.flushHistoryCommit();
|
|
861
|
+
const object = this.objectsById.get(id);
|
|
862
|
+
if (!(object instanceof Textbox) || !object.visible || (this.objectLocks.get(object) ?? false)) return false;
|
|
863
|
+
this.canvas.setActiveObject(object);
|
|
864
|
+
object.enterEditing();
|
|
865
|
+
object.hiddenTextarea?.focus();
|
|
866
|
+
this.canvas.requestRenderAll();
|
|
867
|
+
this.notifySelectionChange();
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* 设置铺满画布的基础纹理,不传地址时恢复透明背景
|
|
512
872
|
*
|
|
513
873
|
* 背景纹理不参与对象选择,但会包含在实时纹理和 PNG 导出中
|
|
514
874
|
*
|
|
@@ -518,7 +878,7 @@ var DesignEditor = class {
|
|
|
518
878
|
async setBackgroundTexture(url) {
|
|
519
879
|
if (!url) {
|
|
520
880
|
this.canvas.backgroundImage = void 0;
|
|
521
|
-
this.canvas.backgroundColor = "
|
|
881
|
+
this.canvas.backgroundColor = "";
|
|
522
882
|
this.canvas.requestRenderAll();
|
|
523
883
|
return;
|
|
524
884
|
}
|
|
@@ -545,41 +905,42 @@ var DesignEditor = class {
|
|
|
545
905
|
* 对象过大时会等比缩小,越界时会自动移回画布
|
|
546
906
|
*
|
|
547
907
|
* @param options 文字内容、位置和样式
|
|
548
|
-
* @returns
|
|
908
|
+
* @returns 创建后的可持久化文字对象快照
|
|
549
909
|
*/
|
|
550
910
|
addText(options = {}) {
|
|
551
911
|
this.flushHistoryCommit();
|
|
552
|
-
const text = new Textbox(options.text ??
|
|
912
|
+
const text = new Textbox(options.text ?? this.defaultText, {
|
|
553
913
|
left: options.x ?? this.width * .18,
|
|
554
914
|
top: options.y ?? this.height * .36,
|
|
555
915
|
width: options.width ?? this.width * .42,
|
|
556
916
|
fontFamily: options.fontFamily ?? "Arial",
|
|
557
|
-
fontSize: options.fontSize ??
|
|
558
|
-
fontWeight: 700,
|
|
917
|
+
fontSize: options.fontSize ?? 22,
|
|
918
|
+
fontWeight: options.fontWeight ?? 700,
|
|
919
|
+
fontStyle: options.fontStyle ?? "normal",
|
|
920
|
+
underline: options.underline ?? false,
|
|
559
921
|
fill: options.color ?? "#172126",
|
|
922
|
+
backgroundColor: options.backgroundColor ?? "",
|
|
560
923
|
originX: "left",
|
|
561
924
|
originY: "top",
|
|
562
|
-
textAlign: "center",
|
|
925
|
+
textAlign: options.textAlign ?? "center",
|
|
926
|
+
lineHeight: options.lineHeight ?? 1.16,
|
|
927
|
+
charSpacing: options.charSpacing ?? 0,
|
|
563
928
|
editable: true,
|
|
564
|
-
|
|
565
|
-
cornerColor: "#ffffff",
|
|
566
|
-
cornerStrokeColor: "#13717d",
|
|
567
|
-
borderColor: "#13717d",
|
|
568
|
-
cornerSize: 16
|
|
929
|
+
...this.objectControlAppearance()
|
|
569
930
|
});
|
|
570
931
|
this.addAndSelect(text, options.name);
|
|
571
|
-
return text;
|
|
932
|
+
return this.serializeObject(text);
|
|
572
933
|
}
|
|
573
934
|
/**
|
|
574
935
|
* 加载、添加并选中一个图片对象
|
|
575
936
|
*
|
|
576
|
-
* role 为 background
|
|
937
|
+
* role 为 background 时替换已有设计背景、铺满当前 UV 可打印区域并默认锁定在最底层
|
|
577
938
|
* 图片位置使用画布像素坐标,原点位于图片中心
|
|
578
939
|
* 对象过大时会等比缩小,越界时会自动移回画布
|
|
579
940
|
* 远程图片必须提供正确的 CORS 响应头才能安全导出 PNG
|
|
580
941
|
*
|
|
581
942
|
* @param options 图片地址、中心位置和显示宽度
|
|
582
|
-
* @returns
|
|
943
|
+
* @returns 创建后的可持久化图片对象快照
|
|
583
944
|
* @throws 图片加载失败或被 CORS 策略阻止时抛出错误
|
|
584
945
|
*/
|
|
585
946
|
async addImage(options) {
|
|
@@ -589,22 +950,21 @@ var DesignEditor = class {
|
|
|
589
950
|
const role = options.role ?? "element";
|
|
590
951
|
const scale = (options.width ?? this.width * .22) / Math.max(image.width, 1);
|
|
591
952
|
image.set({
|
|
592
|
-
left:
|
|
593
|
-
top:
|
|
953
|
+
left: options.x ?? this.width * .62,
|
|
954
|
+
top: options.y ?? this.height * .29,
|
|
594
955
|
originX: "center",
|
|
595
956
|
originY: "center",
|
|
596
|
-
scaleX:
|
|
597
|
-
scaleY:
|
|
598
|
-
|
|
599
|
-
cornerColor: "#ffffff",
|
|
600
|
-
cornerStrokeColor: "#13717d",
|
|
601
|
-
borderColor: "#13717d",
|
|
602
|
-
cornerSize: 16
|
|
957
|
+
scaleX: scale,
|
|
958
|
+
scaleY: scale,
|
|
959
|
+
...this.objectControlAppearance()
|
|
603
960
|
});
|
|
604
|
-
if (role === "background")
|
|
961
|
+
if (role === "background") {
|
|
962
|
+
this.removeDesignBackgrounds();
|
|
963
|
+
this.fitImageToEditableBounds(image);
|
|
964
|
+
}
|
|
605
965
|
this.imageSources.set(image, source);
|
|
606
966
|
this.addAndSelect(image, options.name, role === "background", role);
|
|
607
|
-
return image;
|
|
967
|
+
return this.serializeObject(image);
|
|
608
968
|
}
|
|
609
969
|
/**
|
|
610
970
|
* 返回与 Fabric.js 无关的当前设计快照
|
|
@@ -744,16 +1104,36 @@ var DesignEditor = class {
|
|
|
744
1104
|
* @throws 画布被无 CORS 授权的远程图片污染时抛出安全错误
|
|
745
1105
|
*/
|
|
746
1106
|
exportTexture(filename = "custom-texture.png") {
|
|
747
|
-
this.canvas.discardActiveObject();
|
|
748
|
-
this.canvas.renderAll();
|
|
749
1107
|
const anchor = document.createElement("a");
|
|
750
1108
|
anchor.download = filename;
|
|
751
|
-
anchor.href = this.
|
|
752
|
-
format: "png",
|
|
753
|
-
multiplier: 1
|
|
754
|
-
});
|
|
1109
|
+
anchor.href = this.getTextureDataUrl();
|
|
755
1110
|
anchor.click();
|
|
756
1111
|
}
|
|
1112
|
+
/**
|
|
1113
|
+
* 返回不含选择框和 UV 辅助层的 PNG Data URL
|
|
1114
|
+
*
|
|
1115
|
+
* @returns 与逻辑画布同尺寸的 PNG Data URL
|
|
1116
|
+
* @throws 远程图片污染 Canvas 时抛出安全错误
|
|
1117
|
+
*/
|
|
1118
|
+
getTextureDataUrl() {
|
|
1119
|
+
this.renderTextureCanvas();
|
|
1120
|
+
return this.textureCanvasElement.toDataURL("image/png");
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* 异步返回不含选择框和 UV 辅助层的 PNG Blob
|
|
1124
|
+
*
|
|
1125
|
+
* @returns 与逻辑画布同尺寸的 PNG Blob
|
|
1126
|
+
* @throws 远程图片污染 Canvas 或浏览器无法编码时抛出错误
|
|
1127
|
+
*/
|
|
1128
|
+
getTextureBlob() {
|
|
1129
|
+
this.renderTextureCanvas();
|
|
1130
|
+
return new Promise((resolve, reject) => {
|
|
1131
|
+
this.textureCanvasElement.toBlob((blob) => {
|
|
1132
|
+
if (blob) resolve(blob);
|
|
1133
|
+
else reject(/* @__PURE__ */ new Error("The texture canvas could not be encoded as PNG"));
|
|
1134
|
+
}, "image/png");
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
757
1137
|
/** 释放 ResizeObserver、事件监听和 Fabric Canvas */
|
|
758
1138
|
destroy() {
|
|
759
1139
|
if (this.historyTimer !== void 0) {
|
|
@@ -820,7 +1200,14 @@ var DesignEditor = class {
|
|
|
820
1200
|
width: object.width,
|
|
821
1201
|
fontFamily: object.fontFamily,
|
|
822
1202
|
fontSize: object.fontSize,
|
|
823
|
-
color: object.fill
|
|
1203
|
+
color: object.fill,
|
|
1204
|
+
fontWeight: object.fontWeight,
|
|
1205
|
+
fontStyle: object.fontStyle,
|
|
1206
|
+
underline: object.underline,
|
|
1207
|
+
textAlign: object.textAlign,
|
|
1208
|
+
lineHeight: object.lineHeight,
|
|
1209
|
+
charSpacing: object.charSpacing,
|
|
1210
|
+
...object.backgroundColor ? { backgroundColor: object.backgroundColor } : {}
|
|
824
1211
|
};
|
|
825
1212
|
}
|
|
826
1213
|
if (object instanceof FabricImage) {
|
|
@@ -861,20 +1248,21 @@ var DesignEditor = class {
|
|
|
861
1248
|
flipX: design.transform.flipX,
|
|
862
1249
|
flipY: design.transform.flipY,
|
|
863
1250
|
visible: design.visible ?? true,
|
|
864
|
-
|
|
865
|
-
cornerColor: "#ffffff",
|
|
866
|
-
cornerStrokeColor: "#13717d",
|
|
867
|
-
borderColor: "#13717d",
|
|
868
|
-
cornerSize: 16
|
|
1251
|
+
...this.objectControlAppearance()
|
|
869
1252
|
};
|
|
870
1253
|
if (design.type === "text") return new Textbox(design.text, {
|
|
871
1254
|
...common,
|
|
872
1255
|
width: design.width,
|
|
873
1256
|
fontFamily: design.fontFamily,
|
|
874
1257
|
fontSize: design.fontSize,
|
|
875
|
-
fontWeight: 700,
|
|
1258
|
+
fontWeight: design.fontWeight ?? 700,
|
|
1259
|
+
fontStyle: design.fontStyle ?? "normal",
|
|
1260
|
+
underline: design.underline ?? false,
|
|
876
1261
|
fill: design.color,
|
|
877
|
-
|
|
1262
|
+
backgroundColor: design.backgroundColor ?? "",
|
|
1263
|
+
textAlign: design.textAlign ?? "center",
|
|
1264
|
+
lineHeight: design.lineHeight ?? 1.16,
|
|
1265
|
+
charSpacing: design.charSpacing ?? 0,
|
|
878
1266
|
editable: true
|
|
879
1267
|
});
|
|
880
1268
|
const image = await this.loadImage(design.src);
|
|
@@ -885,8 +1273,9 @@ var DesignEditor = class {
|
|
|
885
1273
|
return FabricImage.fromURL(source, source.startsWith("data:") ? void 0 : { crossOrigin: "anonymous" });
|
|
886
1274
|
}
|
|
887
1275
|
createObjectName(object) {
|
|
888
|
-
if (object instanceof Textbox) return object.text.trim().slice(0, 48) ||
|
|
889
|
-
|
|
1276
|
+
if (object instanceof Textbox) return object.text.trim().slice(0, 48) || this.textObjectName;
|
|
1277
|
+
if (object instanceof FabricImage) return this.imageRoles.get(object) === "background" ? this.backgroundObjectName : this.imageObjectName;
|
|
1278
|
+
return "Object";
|
|
890
1279
|
}
|
|
891
1280
|
applyObjectLock(object, locked) {
|
|
892
1281
|
this.objectLocks.set(object, locked);
|
|
@@ -970,14 +1359,55 @@ var DesignEditor = class {
|
|
|
970
1359
|
}
|
|
971
1360
|
}
|
|
972
1361
|
resizeDisplay() {
|
|
973
|
-
const availableWidth = Math.max(this.host.clientWidth -
|
|
974
|
-
const availableHeight = Math.max(this.host.clientHeight -
|
|
1362
|
+
const availableWidth = Math.max(this.host.clientWidth - EDITOR_DISPLAY_GUTTER, 1);
|
|
1363
|
+
const availableHeight = Math.max(this.host.clientHeight - EDITOR_DISPLAY_GUTTER, 1);
|
|
975
1364
|
const scale = Math.min(availableWidth / this.width, availableHeight / this.height, 1);
|
|
976
1365
|
this.canvas.setDimensions({
|
|
977
1366
|
width: `${Math.floor(this.width * scale)}px`,
|
|
978
1367
|
height: `${Math.floor(this.height * scale)}px`
|
|
979
1368
|
}, { cssOnly: true });
|
|
980
1369
|
}
|
|
1370
|
+
applySelection(objects) {
|
|
1371
|
+
this.canvas.discardActiveObject();
|
|
1372
|
+
this.canvas.setActiveObject(objects.length === 1 ? objects[0] : new ActiveSelection(objects, { canvas: this.canvas }));
|
|
1373
|
+
}
|
|
1374
|
+
objectControlAppearance() {
|
|
1375
|
+
return {
|
|
1376
|
+
transparentCorners: false,
|
|
1377
|
+
cornerColor: this.appearance.controlFill,
|
|
1378
|
+
cornerStrokeColor: this.appearance.controlBorder,
|
|
1379
|
+
borderColor: this.appearance.objectBorder,
|
|
1380
|
+
cornerSize: this.appearance.controlSize
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
validateTransformOptions(options) {
|
|
1384
|
+
for (const [name, value] of [
|
|
1385
|
+
["x", options.x],
|
|
1386
|
+
["y", options.y],
|
|
1387
|
+
["rotation", options.rotation]
|
|
1388
|
+
]) if (value !== void 0 && !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`);
|
|
1389
|
+
for (const [name, value] of [["scaleX", options.scaleX], ["scaleY", options.scaleY]]) if (value !== void 0 && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be greater than zero`);
|
|
1390
|
+
for (const [name, value] of [["flipX", options.flipX], ["flipY", options.flipY]]) if (value !== void 0 && typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`);
|
|
1391
|
+
}
|
|
1392
|
+
fitImageToEditableBounds(image) {
|
|
1393
|
+
const bounds = this.editableBounds;
|
|
1394
|
+
image.set({
|
|
1395
|
+
left: bounds.left + bounds.width / 2,
|
|
1396
|
+
top: bounds.top + bounds.height / 2,
|
|
1397
|
+
angle: 0,
|
|
1398
|
+
scaleX: bounds.width / Math.max(image.width, 1),
|
|
1399
|
+
scaleY: bounds.height / Math.max(image.height, 1)
|
|
1400
|
+
});
|
|
1401
|
+
image.setCoords();
|
|
1402
|
+
}
|
|
1403
|
+
renderTextureCanvas() {
|
|
1404
|
+
this.renderingTexture = true;
|
|
1405
|
+
try {
|
|
1406
|
+
this.textureRenderer.renderTexture(this.textureContext);
|
|
1407
|
+
} finally {
|
|
1408
|
+
this.renderingTexture = false;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
981
1411
|
markRenderState() {
|
|
982
1412
|
if (this.host.dataset.renderState === "nonblank") return;
|
|
983
1413
|
const context = this.canvas.getContext();
|
|
@@ -1009,7 +1439,7 @@ var TextureBridge = class {
|
|
|
1009
1439
|
stopListening;
|
|
1010
1440
|
updateFrame = 0;
|
|
1011
1441
|
/**
|
|
1012
|
-
* @param editor
|
|
1442
|
+
* @param editor 提供不含交互控件的纹理 Canvas 和渲染事件的二维编辑器
|
|
1013
1443
|
* @param viewer 接收实时纹理的三维查看器
|
|
1014
1444
|
* @param flipY 是否垂直翻转纹理
|
|
1015
1445
|
*/
|
|
@@ -1047,7 +1477,96 @@ var TextureBridge = class {
|
|
|
1047
1477
|
}
|
|
1048
1478
|
};
|
|
1049
1479
|
//#endregion
|
|
1480
|
+
//#region src/viewer/assets/cup_decal_small_margins.glb?url&no-inline
|
|
1481
|
+
var cup_decal_small_margins_default = "" + new URL("cup_decal_small_margins.glb", import.meta.url).href;
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/viewer/uvLayout.ts
|
|
1484
|
+
var UV_EDGE_PRECISION = 1e6;
|
|
1485
|
+
function createPointKey(u, v) {
|
|
1486
|
+
return `${Math.round(u * UV_EDGE_PRECISION)},${Math.round(v * UV_EDGE_PRECISION)}`;
|
|
1487
|
+
}
|
|
1488
|
+
function extractBoundaryCoordinates(triangleCoordinates) {
|
|
1489
|
+
const edges = /* @__PURE__ */ new Map();
|
|
1490
|
+
for (let index = 0; index < triangleCoordinates.length; index += 6) {
|
|
1491
|
+
const points = [
|
|
1492
|
+
[triangleCoordinates[index], triangleCoordinates[index + 1]],
|
|
1493
|
+
[triangleCoordinates[index + 2], triangleCoordinates[index + 3]],
|
|
1494
|
+
[triangleCoordinates[index + 4], triangleCoordinates[index + 5]]
|
|
1495
|
+
];
|
|
1496
|
+
for (const [startIndex, endIndex] of [
|
|
1497
|
+
[0, 1],
|
|
1498
|
+
[1, 2],
|
|
1499
|
+
[2, 0]
|
|
1500
|
+
]) {
|
|
1501
|
+
const start = points[startIndex];
|
|
1502
|
+
const end = points[endIndex];
|
|
1503
|
+
const startKey = createPointKey(start[0], start[1]);
|
|
1504
|
+
const endKey = createPointKey(end[0], end[1]);
|
|
1505
|
+
const edgeKey = startKey < endKey ? `${startKey}|${endKey}` : `${endKey}|${startKey}`;
|
|
1506
|
+
const edge = edges.get(edgeKey);
|
|
1507
|
+
if (edge) edge.occurrences += 1;
|
|
1508
|
+
else edges.set(edgeKey, {
|
|
1509
|
+
coordinates: [
|
|
1510
|
+
start[0],
|
|
1511
|
+
start[1],
|
|
1512
|
+
end[0],
|
|
1513
|
+
end[1]
|
|
1514
|
+
],
|
|
1515
|
+
occurrences: 1
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return new Float32Array([...edges.values()].filter((edge) => edge.occurrences === 1).flatMap((edge) => edge.coordinates));
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* 提取目标 Mesh 第一个材质槽使用的 UV 三角形
|
|
1523
|
+
*
|
|
1524
|
+
* @param mesh 接收实时纹理的目标 Mesh
|
|
1525
|
+
* @returns 保持模型原始 V 方向的 UV 三角形和外边界布局
|
|
1526
|
+
* @throws Mesh 缺少 UV、面数据无效或第一个材质槽没有三角形时抛出错误
|
|
1527
|
+
*/
|
|
1528
|
+
function extractUvLayout(mesh) {
|
|
1529
|
+
const geometry = mesh.geometry;
|
|
1530
|
+
const uv = geometry.getAttribute("uv");
|
|
1531
|
+
if (!uv) throw new Error(`Customizable mesh does not contain UV coordinates: ${mesh.name}`);
|
|
1532
|
+
const index = geometry.getIndex();
|
|
1533
|
+
const elementCount = index?.count ?? uv.count;
|
|
1534
|
+
const ranges = Array.isArray(mesh.material) && geometry.groups.length > 0 ? geometry.groups.filter((group) => (group.materialIndex ?? 0) === 0).map(({ start, count }) => ({
|
|
1535
|
+
start,
|
|
1536
|
+
count
|
|
1537
|
+
})) : [{
|
|
1538
|
+
start: 0,
|
|
1539
|
+
count: elementCount
|
|
1540
|
+
}];
|
|
1541
|
+
const coordinateCount = ranges.reduce((total, range) => {
|
|
1542
|
+
if (!Number.isInteger(range.start) || !Number.isInteger(range.count) || range.start < 0 || range.count < 0 || range.start + range.count > elementCount || range.count % 3 !== 0) throw new Error(`Customizable mesh has invalid triangle groups: ${mesh.name}`);
|
|
1543
|
+
return total + range.count * 2;
|
|
1544
|
+
}, 0);
|
|
1545
|
+
if (coordinateCount === 0) throw new Error(`Customizable mesh has no triangles in its first material slot: ${mesh.name}`);
|
|
1546
|
+
const triangleCoordinates = new Float32Array(coordinateCount);
|
|
1547
|
+
let targetIndex = 0;
|
|
1548
|
+
for (const range of ranges) for (let offset = range.start; offset < range.start + range.count; offset += 1) {
|
|
1549
|
+
const vertexIndex = index ? index.getX(offset) : offset;
|
|
1550
|
+
const u = uv.getX(vertexIndex);
|
|
1551
|
+
const v = uv.getY(vertexIndex);
|
|
1552
|
+
if (!Number.isFinite(u) || !Number.isFinite(v)) throw new Error(`Customizable mesh has invalid UV coordinates: ${mesh.name}`);
|
|
1553
|
+
triangleCoordinates[targetIndex] = u;
|
|
1554
|
+
triangleCoordinates[targetIndex + 1] = v;
|
|
1555
|
+
targetIndex += 2;
|
|
1556
|
+
}
|
|
1557
|
+
return {
|
|
1558
|
+
triangleCoordinates,
|
|
1559
|
+
boundaryCoordinates: extractBoundaryCoordinates(triangleCoordinates)
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
//#endregion
|
|
1050
1563
|
//#region src/viewer/ProductViewer.ts
|
|
1564
|
+
var DEFAULT_PRODUCT_HEIGHT = 2.35;
|
|
1565
|
+
var DEFAULT_PRODUCT_BASE_Y = -1.17;
|
|
1566
|
+
var DEFAULT_PRODUCT_ROTATION = MathUtils.degToRad(-72);
|
|
1567
|
+
var DEFAULT_CAMERA_AZIMUTH = MathUtils.degToRad(18);
|
|
1568
|
+
var DEFAULT_CAMERA_ELEVATION = MathUtils.degToRad(10);
|
|
1569
|
+
var CAMERA_VIEWPORT_FILL = .7;
|
|
1051
1570
|
/**
|
|
1052
1571
|
* 基于 Three.js 的三维产品查看器
|
|
1053
1572
|
*
|
|
@@ -1066,10 +1585,17 @@ var ProductViewer = class {
|
|
|
1066
1585
|
surface;
|
|
1067
1586
|
texture;
|
|
1068
1587
|
animationFrame = 0;
|
|
1588
|
+
viewChangeListeners = /* @__PURE__ */ new Set();
|
|
1589
|
+
handleControlsChange = () => {
|
|
1590
|
+
const state = this.getViewState();
|
|
1591
|
+
this.viewChangeListeners.forEach((listener) => listener(state));
|
|
1592
|
+
};
|
|
1069
1593
|
/**
|
|
1070
1594
|
* @param host 三维查看器挂载容器
|
|
1595
|
+
* @param ariaLabel 三维产品画布的无障碍名称
|
|
1596
|
+
* @param appearance WebGL 画布清屏颜色等外观配置
|
|
1071
1597
|
*/
|
|
1072
|
-
constructor(host) {
|
|
1598
|
+
constructor(host, ariaLabel = "Interactive 3D product preview", appearance = {}) {
|
|
1073
1599
|
this.host = host;
|
|
1074
1600
|
host.replaceChildren();
|
|
1075
1601
|
this.renderer = new WebGLRenderer({
|
|
@@ -1080,29 +1606,30 @@ var ProductViewer = class {
|
|
|
1080
1606
|
this.renderer.outputColorSpace = SRGBColorSpace;
|
|
1081
1607
|
this.renderer.toneMapping = ACESFilmicToneMapping;
|
|
1082
1608
|
this.renderer.toneMappingExposure = 1.05;
|
|
1083
|
-
this.renderer.
|
|
1084
|
-
this.renderer.domElement.setAttribute("aria-label",
|
|
1609
|
+
if (appearance.backgroundColor?.trim()) this.renderer.setClearColor(appearance.backgroundColor.trim(), 1);
|
|
1610
|
+
this.renderer.domElement.setAttribute("aria-label", ariaLabel);
|
|
1085
1611
|
this.renderer.domElement.classList.add("customforge-viewer-canvas");
|
|
1086
1612
|
host.append(this.renderer.domElement);
|
|
1087
1613
|
host.dataset.renderState = "pending";
|
|
1088
|
-
this.scene.background = new Color("#eef0ef");
|
|
1089
1614
|
this.camera.position.set(4.6, 2.8, 5.8);
|
|
1090
1615
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
1091
1616
|
this.controls.enableDamping = true;
|
|
1092
1617
|
this.controls.minDistance = 3.2;
|
|
1093
1618
|
this.controls.maxDistance = 11;
|
|
1094
1619
|
this.controls.target.set(0, 0, 0);
|
|
1095
|
-
this.
|
|
1620
|
+
this.controls.addEventListener("change", this.handleControlsChange);
|
|
1621
|
+
this.addStudioLighting();
|
|
1096
1622
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
1097
1623
|
this.resizeObserver.observe(host);
|
|
1098
1624
|
this.resize();
|
|
1099
1625
|
this.render();
|
|
1100
1626
|
}
|
|
1101
1627
|
/**
|
|
1102
|
-
*
|
|
1628
|
+
* 加载远程模型或随包提供的默认 GLB 模型
|
|
1103
1629
|
*
|
|
1104
1630
|
* @param product 已完成默认值补全的产品配置
|
|
1105
|
-
* @
|
|
1631
|
+
* @returns 目标 Mesh 第一个材质槽使用的 UV 三角形布局
|
|
1632
|
+
* @throws 模型加载失败、找不到目标 Mesh 或目标 Mesh 缺少有效 UV 时抛出错误
|
|
1106
1633
|
*/
|
|
1107
1634
|
async loadProduct(product) {
|
|
1108
1635
|
this.removeProduct();
|
|
@@ -1111,13 +1638,18 @@ var ProductViewer = class {
|
|
|
1111
1638
|
this.productRoot = gltf.scene;
|
|
1112
1639
|
this.surface = this.findSurface(gltf.scene, product.surfaceMesh);
|
|
1113
1640
|
} else {
|
|
1114
|
-
const
|
|
1115
|
-
this.productRoot =
|
|
1116
|
-
this.surface =
|
|
1641
|
+
const gltf = await this.loader.loadAsync(cup_decal_small_margins_default);
|
|
1642
|
+
this.productRoot = gltf.scene;
|
|
1643
|
+
this.surface = this.findSurface(gltf.scene, product.surfaceMesh);
|
|
1644
|
+
this.prepareBundledProduct(gltf.scene, this.surface);
|
|
1117
1645
|
}
|
|
1646
|
+
const surface = this.surface;
|
|
1647
|
+
if (!surface) throw new Error("Customizable mesh was not initialized");
|
|
1648
|
+
const uvLayout = extractUvLayout(surface);
|
|
1118
1649
|
this.scene.add(this.productRoot);
|
|
1119
1650
|
if (this.texture) this.attachTexture(this.texture);
|
|
1120
1651
|
this.fitCamera(this.productRoot);
|
|
1652
|
+
return uvLayout;
|
|
1121
1653
|
}
|
|
1122
1654
|
/**
|
|
1123
1655
|
* 保存并绑定二维编辑器生成的实时纹理
|
|
@@ -1134,71 +1666,82 @@ var ProductViewer = class {
|
|
|
1134
1666
|
resetView() {
|
|
1135
1667
|
if (this.productRoot) this.fitCamera(this.productRoot);
|
|
1136
1668
|
}
|
|
1669
|
+
/** 返回当前相机位置和轨道控制目标点的独立快照 */
|
|
1670
|
+
getViewState() {
|
|
1671
|
+
return {
|
|
1672
|
+
position: this.vectorToValue(this.camera.position),
|
|
1673
|
+
target: this.vectorToValue(this.controls.target)
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* 恢复相机位置和轨道控制目标点
|
|
1678
|
+
*
|
|
1679
|
+
* @param state 要恢复的三维观察视角
|
|
1680
|
+
* @returns 应用后的独立视角快照
|
|
1681
|
+
* @throws 坐标不是有限数值或相机与目标点重合时抛出错误
|
|
1682
|
+
*/
|
|
1683
|
+
setViewState(state) {
|
|
1684
|
+
this.validateViewState(state);
|
|
1685
|
+
this.camera.position.set(state.position.x, state.position.y, state.position.z);
|
|
1686
|
+
this.controls.target.set(state.target.x, state.target.y, state.target.z);
|
|
1687
|
+
this.camera.updateProjectionMatrix();
|
|
1688
|
+
this.controls.update();
|
|
1689
|
+
return this.getViewState();
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* 订阅三维视角变化
|
|
1693
|
+
*
|
|
1694
|
+
* 用户旋转、缩放、平移以及 API 恢复视角时都会触发
|
|
1695
|
+
*
|
|
1696
|
+
* @param listener 接收独立视角快照的监听函数
|
|
1697
|
+
* @returns 用于取消本次订阅的函数
|
|
1698
|
+
*/
|
|
1699
|
+
onViewChange(listener) {
|
|
1700
|
+
this.viewChangeListeners.add(listener);
|
|
1701
|
+
return () => this.viewChangeListeners.delete(listener);
|
|
1702
|
+
}
|
|
1137
1703
|
/** 释放动画帧、相机控制、模型材质和 WebGLRenderer */
|
|
1138
1704
|
destroy() {
|
|
1139
1705
|
cancelAnimationFrame(this.animationFrame);
|
|
1140
1706
|
this.resizeObserver.disconnect();
|
|
1707
|
+
this.controls.removeEventListener("change", this.handleControlsChange);
|
|
1141
1708
|
this.controls.dispose();
|
|
1709
|
+
this.viewChangeListeners.clear();
|
|
1142
1710
|
this.removeProduct();
|
|
1143
1711
|
this.renderer.dispose();
|
|
1144
1712
|
this.renderer.domElement.remove();
|
|
1145
1713
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
key.position.set(4, 6, 5);
|
|
1151
|
-
key.castShadow = true;
|
|
1714
|
+
addStudioLighting() {
|
|
1715
|
+
this.scene.add(new AmbientLight("#ffffff", .9));
|
|
1716
|
+
const key = new DirectionalLight("#ffffff", 2.1);
|
|
1717
|
+
key.position.set(4, 5, 6);
|
|
1152
1718
|
this.scene.add(key);
|
|
1153
|
-
const fill = new DirectionalLight("#
|
|
1154
|
-
fill.position.set(-5, 2,
|
|
1719
|
+
const fill = new DirectionalLight("#e8f0f2", 1.15);
|
|
1720
|
+
fill.position.set(-5, 2, 4);
|
|
1155
1721
|
this.scene.add(fill);
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
root.rotation.y = MathUtils.degToRad(-14);
|
|
1168
|
-
const bodyMaterial = new MeshStandardMaterial({
|
|
1169
|
-
color: "#ffffff",
|
|
1170
|
-
roughness: .42,
|
|
1171
|
-
metalness: 0
|
|
1172
|
-
});
|
|
1173
|
-
const surface = new Mesh(new CylinderGeometry(1.28, 1.16, 2.35, 96, 1, true), bodyMaterial);
|
|
1174
|
-
surface.name = "PrintArea";
|
|
1175
|
-
surface.castShadow = true;
|
|
1176
|
-
surface.receiveShadow = true;
|
|
1177
|
-
root.add(surface);
|
|
1178
|
-
const ceramic = new MeshStandardMaterial({
|
|
1179
|
-
color: "#f4f4f1",
|
|
1180
|
-
roughness: .35
|
|
1722
|
+
const rim = new DirectionalLight("#ffffff", .8);
|
|
1723
|
+
rim.position.set(1, 4, -5);
|
|
1724
|
+
this.scene.add(rim);
|
|
1725
|
+
}
|
|
1726
|
+
prepareBundledProduct(root, surface) {
|
|
1727
|
+
surface.renderOrder = 1;
|
|
1728
|
+
(Array.isArray(surface.material) ? surface.material : [surface.material]).forEach((material) => {
|
|
1729
|
+
material.depthWrite = false;
|
|
1730
|
+
material.polygonOffset = true;
|
|
1731
|
+
material.polygonOffsetFactor = -1;
|
|
1732
|
+
material.polygonOffsetUnits = -1;
|
|
1181
1733
|
});
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
root.
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
root.add(inside);
|
|
1194
|
-
const bottom = new Mesh(new CircleGeometry(1.15, 96), ceramic);
|
|
1195
|
-
bottom.rotation.x = Math.PI / 2;
|
|
1196
|
-
bottom.position.y = -1.17;
|
|
1197
|
-
root.add(bottom);
|
|
1198
|
-
return {
|
|
1199
|
-
root,
|
|
1200
|
-
surface
|
|
1201
|
-
};
|
|
1734
|
+
const initialHeight = new Box3().setFromObject(root).getSize(new Vector3()).y;
|
|
1735
|
+
if (!Number.isFinite(initialHeight) || initialHeight <= 0) throw new Error("The bundled product has invalid bounds");
|
|
1736
|
+
root.scale.multiplyScalar(DEFAULT_PRODUCT_HEIGHT / initialHeight);
|
|
1737
|
+
root.rotation.y = DEFAULT_PRODUCT_ROTATION;
|
|
1738
|
+
root.updateMatrixWorld(true);
|
|
1739
|
+
const bounds = new Box3().setFromObject(root);
|
|
1740
|
+
const center = bounds.getCenter(new Vector3());
|
|
1741
|
+
root.position.x -= center.x;
|
|
1742
|
+
root.position.y += DEFAULT_PRODUCT_BASE_Y - bounds.min.y;
|
|
1743
|
+
root.position.z -= center.z;
|
|
1744
|
+
root.updateMatrixWorld(true);
|
|
1202
1745
|
}
|
|
1203
1746
|
/**
|
|
1204
1747
|
* 按名称查找接收实时纹理的 Mesh
|
|
@@ -1231,19 +1774,61 @@ var ProductViewer = class {
|
|
|
1231
1774
|
material.color.set("#ffffff");
|
|
1232
1775
|
material.needsUpdate = true;
|
|
1233
1776
|
}
|
|
1234
|
-
fitCamera(root) {
|
|
1777
|
+
fitCamera(root, direction) {
|
|
1235
1778
|
const bounds = new Box3().setFromObject(root);
|
|
1236
1779
|
const size = bounds.getSize(new Vector3());
|
|
1237
1780
|
const center = bounds.getCenter(new Vector3());
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1781
|
+
const diagonal = size.length();
|
|
1782
|
+
if (!Number.isFinite(diagonal) || diagonal <= 0) throw new Error("The product has invalid bounds");
|
|
1783
|
+
const viewDirection = direction?.clone() ?? this.defaultCameraDirection();
|
|
1784
|
+
if (viewDirection.lengthSq() === 0) viewDirection.copy(this.defaultCameraDirection());
|
|
1785
|
+
viewDirection.normalize();
|
|
1786
|
+
const right = new Vector3().crossVectors(this.camera.up, viewDirection);
|
|
1787
|
+
if (right.lengthSq() < 1e-8) right.set(1, 0, 0);
|
|
1788
|
+
else right.normalize();
|
|
1789
|
+
const up = new Vector3().crossVectors(viewDirection, right).normalize();
|
|
1790
|
+
const verticalSlope = Math.tan(MathUtils.degToRad(this.camera.fov / 2)) * CAMERA_VIEWPORT_FILL;
|
|
1791
|
+
const horizontalSlope = verticalSlope * Math.max(this.camera.aspect, .1);
|
|
1792
|
+
let distance = 0;
|
|
1793
|
+
for (const x of [bounds.min.x, bounds.max.x]) for (const y of [bounds.min.y, bounds.max.y]) for (const z of [bounds.min.z, bounds.max.z]) {
|
|
1794
|
+
const offset = new Vector3(x, y, z).sub(center);
|
|
1795
|
+
const depth = offset.dot(viewDirection);
|
|
1796
|
+
distance = Math.max(distance, depth + Math.abs(offset.dot(right)) / horizontalSlope, depth + Math.abs(offset.dot(up)) / verticalSlope);
|
|
1797
|
+
}
|
|
1798
|
+
distance = Math.max(distance, diagonal * .55, .1);
|
|
1799
|
+
const minDistance = Math.max(diagonal * .45, distance * .3, .05);
|
|
1800
|
+
const maxDistance = Math.max(distance * 4, diagonal * 4);
|
|
1240
1801
|
this.controls.target.copy(center);
|
|
1241
|
-
this.camera.position.copy(center).
|
|
1242
|
-
this.camera.near = Math.max(distance
|
|
1243
|
-
this.camera.far =
|
|
1802
|
+
this.camera.position.copy(center).addScaledVector(viewDirection, distance);
|
|
1803
|
+
this.camera.near = Math.max(Math.min(distance * .02, diagonal * .01), .01);
|
|
1804
|
+
this.camera.far = Math.max(maxDistance + diagonal * 2, 10);
|
|
1244
1805
|
this.camera.updateProjectionMatrix();
|
|
1806
|
+
this.controls.minDistance = minDistance;
|
|
1807
|
+
this.controls.maxDistance = maxDistance;
|
|
1245
1808
|
this.controls.update();
|
|
1246
1809
|
}
|
|
1810
|
+
defaultCameraDirection() {
|
|
1811
|
+
const horizontal = Math.cos(DEFAULT_CAMERA_ELEVATION);
|
|
1812
|
+
return new Vector3(horizontal * Math.sin(DEFAULT_CAMERA_AZIMUTH), Math.sin(DEFAULT_CAMERA_ELEVATION), horizontal * Math.cos(DEFAULT_CAMERA_AZIMUTH));
|
|
1813
|
+
}
|
|
1814
|
+
vectorToValue(vector) {
|
|
1815
|
+
return {
|
|
1816
|
+
x: vector.x,
|
|
1817
|
+
y: vector.y,
|
|
1818
|
+
z: vector.z
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
validateViewState(state) {
|
|
1822
|
+
if (![
|
|
1823
|
+
state.position.x,
|
|
1824
|
+
state.position.y,
|
|
1825
|
+
state.position.z,
|
|
1826
|
+
state.target.x,
|
|
1827
|
+
state.target.y,
|
|
1828
|
+
state.target.z
|
|
1829
|
+
].every(Number.isFinite)) throw new TypeError("View position and target must contain finite numbers");
|
|
1830
|
+
if ((state.position.x - state.target.x) ** 2 + (state.position.y - state.target.y) ** 2 + (state.position.z - state.target.z) ** 2 <= Number.EPSILON) throw new RangeError("View position and target must not be identical");
|
|
1831
|
+
}
|
|
1247
1832
|
removeProduct() {
|
|
1248
1833
|
if (!this.productRoot) return;
|
|
1249
1834
|
this.scene.remove(this.productRoot);
|
|
@@ -1258,9 +1843,11 @@ var ProductViewer = class {
|
|
|
1258
1843
|
resize() {
|
|
1259
1844
|
const width = Math.max(this.host.clientWidth, 1);
|
|
1260
1845
|
const height = Math.max(this.host.clientHeight, 1);
|
|
1846
|
+
const direction = this.camera.position.clone().sub(this.controls.target);
|
|
1261
1847
|
this.renderer.setSize(width, height, false);
|
|
1262
1848
|
this.camera.aspect = width / height;
|
|
1263
|
-
this.
|
|
1849
|
+
if (this.productRoot && direction.lengthSq() > 0) this.fitCamera(this.productRoot, direction);
|
|
1850
|
+
else this.camera.updateProjectionMatrix();
|
|
1264
1851
|
}
|
|
1265
1852
|
render = () => {
|
|
1266
1853
|
this.animationFrame = requestAnimationFrame(this.render);
|
|
@@ -1275,23 +1862,37 @@ var ProductViewer = class {
|
|
|
1275
1862
|
const height = context.drawingBufferHeight;
|
|
1276
1863
|
if (width < 2 || height < 2) return;
|
|
1277
1864
|
const pixel = /* @__PURE__ */ new Uint8Array(4);
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1865
|
+
const minimum = [
|
|
1866
|
+
255,
|
|
1867
|
+
255,
|
|
1868
|
+
255,
|
|
1869
|
+
255
|
|
1870
|
+
];
|
|
1871
|
+
const maximum = [
|
|
1872
|
+
0,
|
|
1873
|
+
0,
|
|
1874
|
+
0,
|
|
1875
|
+
0
|
|
1876
|
+
];
|
|
1877
|
+
const sampleRatios = [
|
|
1878
|
+
.1,
|
|
1285
1879
|
.25,
|
|
1880
|
+
.4,
|
|
1881
|
+
.45,
|
|
1286
1882
|
.5,
|
|
1287
|
-
.
|
|
1288
|
-
|
|
1883
|
+
.55,
|
|
1884
|
+
.6,
|
|
1885
|
+
.75,
|
|
1886
|
+
.9
|
|
1887
|
+
];
|
|
1888
|
+
for (const xRatio of sampleRatios) for (const yRatio of sampleRatios) {
|
|
1289
1889
|
context.readPixels(Math.floor(width * xRatio), Math.floor(height * yRatio), 1, 1, context.RGBA, context.UNSIGNED_BYTE, pixel);
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1890
|
+
for (let channel = 0; channel < pixel.length; channel += 1) {
|
|
1891
|
+
minimum[channel] = Math.min(minimum[channel], pixel[channel]);
|
|
1892
|
+
maximum[channel] = Math.max(maximum[channel], pixel[channel]);
|
|
1893
|
+
}
|
|
1293
1894
|
}
|
|
1294
|
-
if (maximum - minimum >
|
|
1895
|
+
if (maximum.some((value, channel) => value - minimum[channel] > 4)) this.host.dataset.renderState = "nonblank";
|
|
1295
1896
|
}
|
|
1296
1897
|
};
|
|
1297
1898
|
//#endregion
|
|
@@ -1312,6 +1913,7 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1312
1913
|
stopSelectionListener;
|
|
1313
1914
|
stopRenderListener;
|
|
1314
1915
|
stopHistoryListener;
|
|
1916
|
+
stopViewListener;
|
|
1315
1917
|
destroyed = false;
|
|
1316
1918
|
constructor(options) {
|
|
1317
1919
|
const editorHost = resolveElement(options.editor, "Editor");
|
|
@@ -1323,9 +1925,15 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1323
1925
|
this.editor = new DesignEditor(editorHost, {
|
|
1324
1926
|
width: options.editorWidth ?? 1024,
|
|
1325
1927
|
height: options.editorHeight ?? 512,
|
|
1326
|
-
historyLimit
|
|
1928
|
+
historyLimit,
|
|
1929
|
+
ariaLabel: options.editorAriaLabel,
|
|
1930
|
+
defaultText: options.defaultText,
|
|
1931
|
+
textObjectName: options.textObjectName,
|
|
1932
|
+
imageObjectName: options.imageObjectName,
|
|
1933
|
+
backgroundObjectName: options.backgroundObjectName,
|
|
1934
|
+
appearance: options.appearance?.editor
|
|
1327
1935
|
});
|
|
1328
|
-
this.viewer = new ProductViewer(viewerHost);
|
|
1936
|
+
this.viewer = new ProductViewer(viewerHost, options.viewerAriaLabel, options.appearance?.viewer);
|
|
1329
1937
|
this.textureBridge = new TextureBridge(this.editor, this.viewer, this.product.textureFlipY);
|
|
1330
1938
|
this.stopSelectionListener = this.editor.onSelectionChange((objectIds) => {
|
|
1331
1939
|
this.emit("selectionchange", {
|
|
@@ -1339,6 +1947,9 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1339
1947
|
this.stopHistoryListener = this.editor.onHistoryChange((state) => {
|
|
1340
1948
|
this.emit("historychange", state);
|
|
1341
1949
|
});
|
|
1950
|
+
this.stopViewListener = this.viewer.onViewChange((state) => {
|
|
1951
|
+
this.emit("viewchange", state);
|
|
1952
|
+
});
|
|
1342
1953
|
}
|
|
1343
1954
|
/**
|
|
1344
1955
|
* 创建实例并完成初始产品加载
|
|
@@ -1377,27 +1988,56 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1377
1988
|
* 创建后和用户变换期间,对象会自动缩放或平移以保持完整可见
|
|
1378
1989
|
*
|
|
1379
1990
|
* @param options 文字内容、位置和样式配置
|
|
1991
|
+
* @returns 创建后的可持久化文字对象快照
|
|
1380
1992
|
*/
|
|
1381
1993
|
addText(options) {
|
|
1382
|
-
this.editor.addText(options);
|
|
1994
|
+
return this.editor.addText(options);
|
|
1383
1995
|
}
|
|
1384
1996
|
/**
|
|
1385
1997
|
* 加载图片并将其添加到二维画布
|
|
1386
1998
|
*
|
|
1387
|
-
* role 为 background
|
|
1999
|
+
* role 为 background 时会替换已有设计背景、铺满当前 UV 可打印区域并默认锁定在最底层
|
|
1388
2000
|
* 创建后和用户变换期间,对象会自动缩放或平移以保持完整可见
|
|
1389
2001
|
*
|
|
1390
2002
|
* @param options 图片地址、位置和显示宽度
|
|
2003
|
+
* @returns 创建后的可持久化图片对象快照
|
|
1391
2004
|
* @throws 图片无法访问、加载失败或被 CORS 策略阻止时抛出错误
|
|
1392
2005
|
*/
|
|
1393
2006
|
async addImage(options) {
|
|
1394
2007
|
try {
|
|
1395
|
-
await this.editor.addImage(options);
|
|
2008
|
+
return await this.editor.addImage(options);
|
|
1396
2009
|
} catch (error) {
|
|
1397
2010
|
this.reportError(error);
|
|
1398
2011
|
throw error;
|
|
1399
2012
|
}
|
|
1400
2013
|
}
|
|
2014
|
+
/** 返回产品、画布、对象、选区、历史和视角的独立状态快照 */
|
|
2015
|
+
getState() {
|
|
2016
|
+
return {
|
|
2017
|
+
product: this.getProduct(),
|
|
2018
|
+
canvas: this.getCanvasSize(),
|
|
2019
|
+
printableBounds: this.getPrintableBounds(),
|
|
2020
|
+
objects: this.getObjects(),
|
|
2021
|
+
selectedObjectIds: this.getSelectedObjectIds(),
|
|
2022
|
+
history: {
|
|
2023
|
+
canUndo: this.canUndo(),
|
|
2024
|
+
canRedo: this.canRedo()
|
|
2025
|
+
},
|
|
2026
|
+
view: this.getViewState()
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
/** 返回当前补全默认值后的产品配置独立快照 */
|
|
2030
|
+
getProduct() {
|
|
2031
|
+
return { ...this.product };
|
|
2032
|
+
}
|
|
2033
|
+
/** 返回当前二维逻辑画布尺寸 */
|
|
2034
|
+
getCanvasSize() {
|
|
2035
|
+
return this.editor.getCanvasSize();
|
|
2036
|
+
}
|
|
2037
|
+
/** 返回当前产品 UV 在逻辑画布中的可打印包围框 */
|
|
2038
|
+
getPrintableBounds() {
|
|
2039
|
+
return this.editor.getPrintableBounds();
|
|
2040
|
+
}
|
|
1401
2041
|
/**
|
|
1402
2042
|
* 返回当前可编辑对象的独立快照
|
|
1403
2043
|
*
|
|
@@ -1420,6 +2060,25 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1420
2060
|
return this.editor.selectObject(id);
|
|
1421
2061
|
}
|
|
1422
2062
|
/**
|
|
2063
|
+
* 按稳定 ID 同时选中多个可见对象
|
|
2064
|
+
*
|
|
2065
|
+
* 所有 ID 都必须有效且可见,否则保持原选区不变;空数组清除选区
|
|
2066
|
+
*
|
|
2067
|
+
* @param ids Design JSON 中的对象 ID
|
|
2068
|
+
* @returns 是否应用了请求的选区
|
|
2069
|
+
*/
|
|
2070
|
+
selectObjects(ids) {
|
|
2071
|
+
return this.editor.selectObjects(ids);
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* 清除当前画布选区,不修改设计内容或历史记录
|
|
2075
|
+
*
|
|
2076
|
+
* @returns 清除前是否存在选中对象
|
|
2077
|
+
*/
|
|
2078
|
+
clearSelection() {
|
|
2079
|
+
return this.editor.clearSelection();
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
1423
2082
|
* 按稳定 ID 删除一个对象
|
|
1424
2083
|
*
|
|
1425
2084
|
* @param id Design JSON 中的对象 ID
|
|
@@ -1439,6 +2098,17 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1439
2098
|
return this.editor.moveObject(id, index);
|
|
1440
2099
|
}
|
|
1441
2100
|
/**
|
|
2101
|
+
* 将现有图片转换为铺满当前 UV 可打印区域的设计背景
|
|
2102
|
+
*
|
|
2103
|
+
* 转换会替换已有设计背景、重置旋转、锁定对象并移动到最底层
|
|
2104
|
+
*
|
|
2105
|
+
* @param id Design JSON 中的图片对象 ID
|
|
2106
|
+
* @returns 是否找到普通图片并完成转换
|
|
2107
|
+
*/
|
|
2108
|
+
setImageAsBackground(id) {
|
|
2109
|
+
return this.editor.setImageAsBackground(id);
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
1442
2112
|
* 修改对象在图层面板中的名称
|
|
1443
2113
|
*
|
|
1444
2114
|
* @param id Design JSON 中的对象 ID
|
|
@@ -1469,6 +2139,39 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1469
2139
|
return this.editor.setObjectLocked(id, locked);
|
|
1470
2140
|
}
|
|
1471
2141
|
/**
|
|
2142
|
+
* 更新对象中心位置、缩放、旋转和翻转
|
|
2143
|
+
*
|
|
2144
|
+
* @param id Design JSON 中的对象 ID
|
|
2145
|
+
* @param options 要更新的变换字段
|
|
2146
|
+
* @returns 约束后的对象快照,找不到对象时返回 undefined
|
|
2147
|
+
* @throws 数值无效或缩放倍数不大于零时抛出错误
|
|
2148
|
+
*/
|
|
2149
|
+
updateObjectTransform(id, options) {
|
|
2150
|
+
return this.editor.updateObjectTransform(id, options);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* 修改已有文字对象的内容和排版样式
|
|
2154
|
+
*
|
|
2155
|
+
* 连续调用会在短暂空闲后合并为一个撤销步骤
|
|
2156
|
+
*
|
|
2157
|
+
* @param id Design JSON 中的对象 ID
|
|
2158
|
+
* @param options 要修改的文字属性,未传字段保持不变
|
|
2159
|
+
* @returns 是否找到并更新了文字对象
|
|
2160
|
+
* @throws 字号、行高、字距、字重或 CSS 颜色不符合约束时抛出错误
|
|
2161
|
+
*/
|
|
2162
|
+
updateText(id, options) {
|
|
2163
|
+
return this.editor.updateText(id, options);
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* 让一个未锁定文字对象进入画布内联编辑状态
|
|
2167
|
+
*
|
|
2168
|
+
* @param id Design JSON 中的对象 ID
|
|
2169
|
+
* @returns 是否找到文字对象并进入编辑状态
|
|
2170
|
+
*/
|
|
2171
|
+
editText(id) {
|
|
2172
|
+
return this.editor.editText(id);
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
1472
2175
|
* 删除二维编辑器中的当前对象或选区
|
|
1473
2176
|
*
|
|
1474
2177
|
* @returns 是否删除了至少一个对象
|
|
@@ -1552,6 +2255,28 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1552
2255
|
this.editor.clearHistory();
|
|
1553
2256
|
}
|
|
1554
2257
|
/**
|
|
2258
|
+
* 返回当前合成纹理的 PNG Data URL
|
|
2259
|
+
*
|
|
2260
|
+
* 结果与逻辑画布同尺寸,不包含选择框和 UV 辅助层
|
|
2261
|
+
*
|
|
2262
|
+
* @returns PNG Data URL
|
|
2263
|
+
* @throws 远程图片污染 Canvas 时抛出安全错误
|
|
2264
|
+
*/
|
|
2265
|
+
getTextureDataUrl() {
|
|
2266
|
+
return this.editor.getTextureDataUrl();
|
|
2267
|
+
}
|
|
2268
|
+
/**
|
|
2269
|
+
* 异步返回当前合成纹理的 PNG Blob
|
|
2270
|
+
*
|
|
2271
|
+
* 结果与逻辑画布同尺寸,不包含选择框和 UV 辅助层
|
|
2272
|
+
*
|
|
2273
|
+
* @returns PNG Blob
|
|
2274
|
+
* @throws 远程图片污染 Canvas 或浏览器无法编码时抛出错误
|
|
2275
|
+
*/
|
|
2276
|
+
getTextureBlob() {
|
|
2277
|
+
return this.editor.getTextureBlob();
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
1555
2280
|
* 将当前二维设计合成为 PNG 并触发浏览器下载
|
|
1556
2281
|
*
|
|
1557
2282
|
* @param filename 下载文件名,默认为 `custom-texture.png`
|
|
@@ -1564,6 +2289,20 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1564
2289
|
resetView() {
|
|
1565
2290
|
this.viewer.resetView();
|
|
1566
2291
|
}
|
|
2292
|
+
/** 返回当前三维相机位置和观察目标点 */
|
|
2293
|
+
getViewState() {
|
|
2294
|
+
return this.viewer.getViewState();
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* 恢复三维观察视角
|
|
2298
|
+
*
|
|
2299
|
+
* @param state 要恢复的相机位置和观察目标点
|
|
2300
|
+
* @returns 应用后的独立视角快照
|
|
2301
|
+
* @throws 坐标不是有限数值或相机与目标点重合时抛出错误
|
|
2302
|
+
*/
|
|
2303
|
+
setViewState(state) {
|
|
2304
|
+
return this.viewer.setViewState(state);
|
|
2305
|
+
}
|
|
1567
2306
|
/**
|
|
1568
2307
|
* 更换模型、基础纹理和接收纹理的目标 Mesh
|
|
1569
2308
|
*
|
|
@@ -1576,12 +2315,13 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1576
2315
|
const nextProduct = normalizeProductConfiguration(product);
|
|
1577
2316
|
this.emit("status", { message: "Loading product" });
|
|
1578
2317
|
try {
|
|
1579
|
-
await this.viewer.loadProduct(nextProduct);
|
|
2318
|
+
const uvLayout = await this.viewer.loadProduct(nextProduct);
|
|
1580
2319
|
await this.editor.setBackgroundTexture(nextProduct.textureUrl);
|
|
1581
2320
|
this.textureBridge.setFlipY(nextProduct.textureFlipY);
|
|
2321
|
+
this.editor.setUvLayout(uvLayout, nextProduct.textureFlipY);
|
|
1582
2322
|
this.product = nextProduct;
|
|
1583
2323
|
this.editor.clearHistory();
|
|
1584
|
-
this.emit("ready", { product: this.
|
|
2324
|
+
this.emit("ready", { product: this.getProduct() });
|
|
1585
2325
|
this.emit("status", { message: nextProduct.modelUrl ? "Remote product ready" : "Demo product ready" });
|
|
1586
2326
|
} catch (error) {
|
|
1587
2327
|
this.reportError(error);
|
|
@@ -1599,15 +2339,17 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1599
2339
|
this.stopSelectionListener?.();
|
|
1600
2340
|
this.stopRenderListener?.();
|
|
1601
2341
|
this.stopHistoryListener?.();
|
|
2342
|
+
this.stopViewListener?.();
|
|
1602
2343
|
this.textureBridge.destroy();
|
|
1603
2344
|
this.editor.destroy();
|
|
1604
2345
|
this.viewer.destroy();
|
|
1605
2346
|
}
|
|
1606
2347
|
async initialize() {
|
|
1607
|
-
await this.viewer.loadProduct(this.product);
|
|
2348
|
+
const uvLayout = await this.viewer.loadProduct(this.product);
|
|
1608
2349
|
await this.editor.setBackgroundTexture(this.product.textureUrl);
|
|
1609
2350
|
this.textureBridge.setFlipY(this.product.textureFlipY);
|
|
1610
|
-
this.
|
|
2351
|
+
this.editor.setUvLayout(uvLayout, this.product.textureFlipY);
|
|
2352
|
+
this.emit("ready", { product: this.getProduct() });
|
|
1611
2353
|
}
|
|
1612
2354
|
emit(event, detail) {
|
|
1613
2355
|
this.events.dispatchEvent(new CustomEvent(event, { detail }));
|
|
@@ -1621,4 +2363,4 @@ var ProductCustomizer = class ProductCustomizer {
|
|
|
1621
2363
|
//#endregion
|
|
1622
2364
|
export { resolveElement as n, ProductCustomizer as t };
|
|
1623
2365
|
|
|
1624
|
-
//# sourceMappingURL=ProductCustomizer-
|
|
2366
|
+
//# sourceMappingURL=ProductCustomizer-BnjY3v7d.js.map
|