customforge 0.1.0-alpha.0 → 0.1.0-alpha.2
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 +61 -27
- package/LICENSE +201 -201
- package/LICENSES/NunitoSans-OFL.txt +93 -0
- package/README.md +339 -152
- package/README.zh-CN.md +337 -152
- package/dist/NunitoSans-Variable.ttf +0 -0
- package/dist/ProductCustomizer-rMRyWe7L.js +1624 -0
- package/dist/ProductCustomizer-rMRyWe7L.js.map +1 -0
- package/dist/core/design.d.ts.map +1 -1
- package/dist/core/types.d.ts +35 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/customizer/ProductCustomizer.d.ts +78 -0
- package/dist/customizer/ProductCustomizer.d.ts.map +1 -1
- package/dist/editor/DesignEditor.d.ts +108 -6
- package/dist/editor/DesignEditor.d.ts.map +1 -1
- package/dist/editor/DesignHistory.d.ts +27 -0
- package/dist/editor/DesignHistory.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1108
- package/dist/index.js.map +1 -1
- package/dist/style.css +1330 -11
- package/dist/workbench/CustomForgeWorkbench.d.ts +128 -0
- package/dist/workbench/CustomForgeWorkbench.d.ts.map +1 -0
- package/dist/workbench/assets/catalog.d.ts +8 -0
- package/dist/workbench/assets/catalog.d.ts.map +1 -0
- package/dist/workbench/config.d.ts +55 -0
- package/dist/workbench/config.d.ts.map +1 -0
- package/dist/workbench/icons.d.ts +4 -0
- package/dist/workbench/icons.d.ts.map +1 -0
- package/dist/workbench/index.d.ts +25 -0
- package/dist/workbench/index.d.ts.map +1 -0
- package/dist/workbench/template.d.ts +3 -0
- package/dist/workbench/template.d.ts.map +1 -0
- package/dist/workbench/types.d.ts +294 -0
- package/dist/workbench/types.d.ts.map +1 -0
- package/dist/workbench.js +1920 -0
- package/dist/workbench.js.map +1 -0
- package/package.json +8 -5
package/dist/index.js
CHANGED
|
@@ -1,1111 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ACESFilmicToneMapping, Box3, CanvasTexture, CircleGeometry, Color, CylinderGeometry, DirectionalLight, Group, HemisphereLight, MathUtils, Mesh, MeshStandardMaterial, PerspectiveCamera, PlaneGeometry, SRGBColorSpace, Scene, TorusGeometry, Vector3, WebGLRenderer } from "three";
|
|
3
|
-
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
4
|
-
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
5
|
-
/**
|
|
6
|
-
* 清理产品配置并补全运行所需的默认值
|
|
7
|
-
*
|
|
8
|
-
* 远程模型默认不翻转纹理,内置演示模型默认翻转纹理
|
|
9
|
-
*
|
|
10
|
-
* @param product 外部传入的产品配置
|
|
11
|
-
* @returns 可以直接交给编辑器和查看器使用的完整配置
|
|
12
|
-
*/
|
|
13
|
-
function normalizeProductConfiguration(product = {}) {
|
|
14
|
-
const modelUrl = product.modelUrl?.trim() || void 0;
|
|
15
|
-
return {
|
|
16
|
-
modelUrl,
|
|
17
|
-
textureUrl: product.textureUrl?.trim() || void 0,
|
|
18
|
-
surfaceMesh: product.surfaceMesh?.trim() || "PrintArea",
|
|
19
|
-
textureFlipY: product.textureFlipY ?? !modelUrl
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
//#endregion
|
|
23
|
-
//#region src/core/dom.ts
|
|
24
|
-
/**
|
|
25
|
-
* 将 HTMLElement 或 CSS 选择器解析为挂载容器
|
|
26
|
-
*
|
|
27
|
-
* @param target DOM 元素或 CSS 选择器
|
|
28
|
-
* @param label 错误消息中使用的容器名称
|
|
29
|
-
* @returns 解析得到的 DOM 元素
|
|
30
|
-
* @throws CSS 选择器无法找到对应元素时抛出错误
|
|
31
|
-
*/
|
|
32
|
-
function resolveElement(target, label) {
|
|
33
|
-
if (target instanceof HTMLElement) return target;
|
|
34
|
-
const element = document.querySelector(target);
|
|
35
|
-
if (!element) throw new Error(`${label} element was not found: ${target}`);
|
|
36
|
-
return element;
|
|
37
|
-
}
|
|
38
|
-
function isRecord(value) {
|
|
39
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
-
}
|
|
41
|
-
function readRecord(value, path) {
|
|
42
|
-
if (!isRecord(value)) throw new TypeError(`${path} must be an object`);
|
|
43
|
-
return value;
|
|
44
|
-
}
|
|
45
|
-
function readString(value, path, allowEmpty = false) {
|
|
46
|
-
if (typeof value !== "string" || !allowEmpty && value.trim().length === 0) throw new TypeError(`${path} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
|
|
47
|
-
return value;
|
|
48
|
-
}
|
|
49
|
-
function readFiniteNumber(value, path) {
|
|
50
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${path} must be a finite number`);
|
|
51
|
-
return value;
|
|
52
|
-
}
|
|
53
|
-
function readPositiveNumber(value, path) {
|
|
54
|
-
const number = readFiniteNumber(value, path);
|
|
55
|
-
if (number <= 0) throw new TypeError(`${path} must be greater than zero`);
|
|
56
|
-
return number;
|
|
57
|
-
}
|
|
58
|
-
function readBoolean(value, path) {
|
|
59
|
-
if (typeof value !== "boolean") throw new TypeError(`${path} must be a boolean`);
|
|
60
|
-
return value;
|
|
61
|
-
}
|
|
62
|
-
function parseTransform(value, path) {
|
|
63
|
-
const transform = readRecord(value, path);
|
|
64
|
-
return {
|
|
65
|
-
x: readFiniteNumber(transform.x, `${path}.x`),
|
|
66
|
-
y: readFiniteNumber(transform.y, `${path}.y`),
|
|
67
|
-
scaleX: readPositiveNumber(transform.scaleX, `${path}.scaleX`),
|
|
68
|
-
scaleY: readPositiveNumber(transform.scaleY, `${path}.scaleY`),
|
|
69
|
-
rotation: readFiniteNumber(transform.rotation, `${path}.rotation`),
|
|
70
|
-
flipX: readBoolean(transform.flipX, `${path}.flipX`),
|
|
71
|
-
flipY: readBoolean(transform.flipY, `${path}.flipY`)
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
function parseObject(value, index) {
|
|
75
|
-
const path = `design.objects[${index}]`;
|
|
76
|
-
const object = readRecord(value, path);
|
|
77
|
-
const id = readString(object.id, `${path}.id`);
|
|
78
|
-
const transform = parseTransform(object.transform, `${path}.transform`);
|
|
79
|
-
if (object.type === "text") return {
|
|
80
|
-
id,
|
|
81
|
-
type: "text",
|
|
82
|
-
transform,
|
|
83
|
-
text: readString(object.text, `${path}.text`, true),
|
|
84
|
-
width: readPositiveNumber(object.width, `${path}.width`),
|
|
85
|
-
fontFamily: readString(object.fontFamily, `${path}.fontFamily`),
|
|
86
|
-
fontSize: readPositiveNumber(object.fontSize, `${path}.fontSize`),
|
|
87
|
-
color: readString(object.color, `${path}.color`)
|
|
88
|
-
};
|
|
89
|
-
if (object.type === "image") {
|
|
90
|
-
const src = readString(object.src, `${path}.src`);
|
|
91
|
-
if (src.startsWith("blob:")) throw new TypeError(`${path}.src must not use a Blob URL`);
|
|
92
|
-
return {
|
|
93
|
-
id,
|
|
94
|
-
type: "image",
|
|
95
|
-
transform,
|
|
96
|
-
src
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
throw new TypeError(`${path}.type is not supported`);
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* 校验并净化外部 Design JSON
|
|
103
|
-
*
|
|
104
|
-
* @param value JSON.parse 结果或其他未知输入
|
|
105
|
-
* @returns 只包含当前 Schema 字段的新设计文档
|
|
106
|
-
* @throws 文档版本、字段类型、对象 ID 或图片来源不符合契约时抛出 TypeError
|
|
107
|
-
*/
|
|
108
|
-
function parseDesignDocument(value) {
|
|
109
|
-
const design = readRecord(value, "design");
|
|
110
|
-
if (design.version !== 1) throw new TypeError(`design.version must be 1`);
|
|
111
|
-
const canvas = readRecord(design.canvas, "design.canvas");
|
|
112
|
-
if (!Array.isArray(design.objects)) throw new TypeError("design.objects must be an array");
|
|
113
|
-
const objects = design.objects.map(parseObject);
|
|
114
|
-
const ids = /* @__PURE__ */ new Set();
|
|
115
|
-
for (const object of objects) {
|
|
116
|
-
if (ids.has(object.id)) throw new TypeError(`design object id is duplicated: ${object.id}`);
|
|
117
|
-
ids.add(object.id);
|
|
118
|
-
}
|
|
119
|
-
return {
|
|
120
|
-
version: 1,
|
|
121
|
-
canvas: {
|
|
122
|
-
width: readPositiveNumber(canvas.width, "design.canvas.width"),
|
|
123
|
-
height: readPositiveNumber(canvas.height, "design.canvas.height")
|
|
124
|
-
},
|
|
125
|
-
objects
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
//#endregion
|
|
129
|
-
//#region src/editor/objectBounds.ts
|
|
130
|
-
/**
|
|
131
|
-
* 计算对象完整进入画布所需的最大等比缩放系数
|
|
132
|
-
*
|
|
133
|
-
* @param bounds 对象当前的轴对齐包围盒
|
|
134
|
-
* @param canvasWidth 画布逻辑宽度
|
|
135
|
-
* @param canvasHeight 画布逻辑高度
|
|
136
|
-
* @returns 不大于 1 的缩放系数,对象已经可容纳时返回 1
|
|
137
|
-
*/
|
|
138
|
-
function calculateContainmentScale(bounds, canvasWidth, canvasHeight) {
|
|
139
|
-
const widthScale = bounds.width > canvasWidth ? canvasWidth / bounds.width : 1;
|
|
140
|
-
const heightScale = bounds.height > canvasHeight ? canvasHeight / bounds.height : 1;
|
|
141
|
-
return Math.min(widthScale, heightScale, 1);
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* 计算可容纳对象移回画布所需的平移距离
|
|
145
|
-
*
|
|
146
|
-
* 调用前应先确保包围盒不大于画布,否则无法同时满足两侧边界
|
|
147
|
-
*
|
|
148
|
-
* @param bounds 对象当前的轴对齐包围盒
|
|
149
|
-
* @param canvasWidth 画布逻辑宽度
|
|
150
|
-
* @param canvasHeight 画布逻辑高度
|
|
151
|
-
* @returns 应叠加到对象位置的画布坐标偏移量
|
|
152
|
-
*/
|
|
153
|
-
function calculateContainmentOffset(bounds, canvasWidth, canvasHeight) {
|
|
154
|
-
return {
|
|
155
|
-
x: bounds.left < 0 ? -bounds.left : Math.min(0, canvasWidth - bounds.left - bounds.width),
|
|
156
|
-
y: bounds.top < 0 ? -bounds.top : Math.min(0, canvasHeight - bounds.top - bounds.height)
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
//#endregion
|
|
160
|
-
//#region src/editor/imageSource.ts
|
|
161
|
-
function readBlobAsDataUrl(blob) {
|
|
162
|
-
return new Promise((resolve, reject) => {
|
|
163
|
-
const reader = new FileReader();
|
|
164
|
-
reader.addEventListener("load", () => {
|
|
165
|
-
if (typeof reader.result === "string") resolve(reader.result);
|
|
166
|
-
else reject(/* @__PURE__ */ new Error("Blob image could not be converted to a Data URL"));
|
|
167
|
-
});
|
|
168
|
-
reader.addEventListener("error", () => {
|
|
169
|
-
reject(reader.error ?? /* @__PURE__ */ new Error("Blob image could not be read"));
|
|
170
|
-
});
|
|
171
|
-
reader.readAsDataURL(blob);
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* 将短生命周期 Blob URL 转换为可写入 Design JSON 的 Data URL
|
|
176
|
-
*
|
|
177
|
-
* 远程 URL 和已有 Data URL 会保持不变
|
|
178
|
-
*
|
|
179
|
-
* @param src 图片来源
|
|
180
|
-
* @returns 可跨页面会话重新加载的图片来源
|
|
181
|
-
* @throws Blob URL 已失效或浏览器无法读取对应 Blob 时抛出错误
|
|
182
|
-
*/
|
|
183
|
-
async function resolvePersistentImageSource(src) {
|
|
184
|
-
if (!src.startsWith("blob:")) return src;
|
|
185
|
-
const response = await fetch(src);
|
|
186
|
-
if (!response.ok) throw new Error(`Blob image could not be loaded: ${response.status}`);
|
|
187
|
-
return readBlobAsDataUrl(await response.blob());
|
|
188
|
-
}
|
|
189
|
-
//#endregion
|
|
190
|
-
//#region src/editor/DesignEditor.ts
|
|
191
|
-
/**
|
|
192
|
-
* 基于 Fabric.js 的二维纹理编辑器
|
|
193
|
-
*
|
|
194
|
-
* 负责基础纹理、文字、图片、对象选择和 PNG 导出
|
|
195
|
-
* 显示尺寸可以响应容器变化,内部逻辑尺寸保持不变
|
|
196
|
-
*/
|
|
197
|
-
var DesignEditor = class {
|
|
198
|
-
/** 当前实例使用的 Fabric Canvas */
|
|
199
|
-
canvas;
|
|
200
|
-
host;
|
|
201
|
-
width;
|
|
202
|
-
height;
|
|
203
|
-
renderListeners = /* @__PURE__ */ new Set();
|
|
204
|
-
selectionListeners = /* @__PURE__ */ new Set();
|
|
205
|
-
objectIds = /* @__PURE__ */ new WeakMap();
|
|
206
|
-
usedObjectIds = /* @__PURE__ */ new Set();
|
|
207
|
-
imageSources = /* @__PURE__ */ new WeakMap();
|
|
208
|
-
resizeObserver;
|
|
209
|
-
objectIdSequence = 0;
|
|
210
|
-
/**
|
|
211
|
-
* @param host 二维编辑器挂载容器
|
|
212
|
-
* @param options 画布逻辑尺寸
|
|
213
|
-
*/
|
|
214
|
-
constructor(host, options) {
|
|
215
|
-
this.host = host;
|
|
216
|
-
this.width = options.width;
|
|
217
|
-
this.height = options.height;
|
|
218
|
-
const element = document.createElement("canvas");
|
|
219
|
-
element.setAttribute("aria-label", "UV texture editor");
|
|
220
|
-
host.replaceChildren(element);
|
|
221
|
-
this.canvas = new Canvas(element, {
|
|
222
|
-
width: this.width,
|
|
223
|
-
height: this.height,
|
|
224
|
-
backgroundColor: "#f7f7f5",
|
|
225
|
-
preserveObjectStacking: true,
|
|
226
|
-
selectionColor: "rgba(19, 113, 125, 0.12)",
|
|
227
|
-
selectionBorderColor: "#13717d"
|
|
228
|
-
});
|
|
229
|
-
this.canvas.wrapperEl.classList.add("customforge-design-canvas");
|
|
230
|
-
this.host.dataset.renderState = "pending";
|
|
231
|
-
this.canvas.on("after:render", () => {
|
|
232
|
-
this.markRenderState();
|
|
233
|
-
this.renderListeners.forEach((listener) => listener());
|
|
234
|
-
});
|
|
235
|
-
const notifySelection = () => {
|
|
236
|
-
const hasSelection = Boolean(this.canvas.getActiveObject());
|
|
237
|
-
this.selectionListeners.forEach((listener) => listener(hasSelection));
|
|
238
|
-
};
|
|
239
|
-
this.canvas.on("selection:created", notifySelection);
|
|
240
|
-
this.canvas.on("selection:updated", notifySelection);
|
|
241
|
-
this.canvas.on("selection:cleared", notifySelection);
|
|
242
|
-
const constrainTarget = ({ target }) => {
|
|
243
|
-
this.constrainObjectToCanvas(target);
|
|
244
|
-
};
|
|
245
|
-
this.canvas.on("object:moving", constrainTarget);
|
|
246
|
-
this.canvas.on("object:scaling", constrainTarget);
|
|
247
|
-
this.canvas.on("object:rotating", constrainTarget);
|
|
248
|
-
this.canvas.on("object:skewing", constrainTarget);
|
|
249
|
-
this.canvas.on("object:resizing", constrainTarget);
|
|
250
|
-
this.canvas.on("object:modified", constrainTarget);
|
|
251
|
-
this.canvas.on("text:changed", constrainTarget);
|
|
252
|
-
this.resizeObserver = new ResizeObserver(() => this.resizeDisplay());
|
|
253
|
-
this.resizeObserver.observe(host);
|
|
254
|
-
this.resizeDisplay();
|
|
255
|
-
}
|
|
256
|
-
/** 供 Three.js 创建 CanvasTexture 的底层 HTML Canvas */
|
|
257
|
-
get textureCanvas() {
|
|
258
|
-
return this.canvas.getElement();
|
|
259
|
-
}
|
|
260
|
-
/** 当前画布中可编辑对象的数量,不包含背景纹理 */
|
|
261
|
-
get objectCount() {
|
|
262
|
-
return this.canvas.getObjects().length;
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* 订阅 Fabric Canvas 完成渲染事件
|
|
266
|
-
*
|
|
267
|
-
* @param listener 每次画布完成渲染后调用的函数
|
|
268
|
-
* @returns 用于取消本次订阅的函数
|
|
269
|
-
*/
|
|
270
|
-
onRender(listener) {
|
|
271
|
-
this.renderListeners.add(listener);
|
|
272
|
-
return () => this.renderListeners.delete(listener);
|
|
273
|
-
}
|
|
274
|
-
/**
|
|
275
|
-
* 订阅画布选中状态变化
|
|
276
|
-
*
|
|
277
|
-
* @param listener 接收当前是否存在选区的函数
|
|
278
|
-
* @returns 用于取消本次订阅的函数
|
|
279
|
-
*/
|
|
280
|
-
onSelectionChange(listener) {
|
|
281
|
-
this.selectionListeners.add(listener);
|
|
282
|
-
return () => this.selectionListeners.delete(listener);
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* 设置铺满画布的基础纹理,不传地址时恢复默认背景色
|
|
286
|
-
*
|
|
287
|
-
* 背景纹理不参与对象选择,但会包含在实时纹理和 PNG 导出中
|
|
288
|
-
*
|
|
289
|
-
* @param url 基础纹理地址
|
|
290
|
-
* @throws 图片加载失败或被 CORS 策略阻止时抛出错误
|
|
291
|
-
*/
|
|
292
|
-
async setBackgroundTexture(url) {
|
|
293
|
-
if (!url) {
|
|
294
|
-
this.canvas.backgroundImage = void 0;
|
|
295
|
-
this.canvas.backgroundColor = "#f7f7f5";
|
|
296
|
-
this.canvas.requestRenderAll();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
const image = await FabricImage.fromURL(url, { crossOrigin: "anonymous" });
|
|
300
|
-
const scaleX = this.width / Math.max(image.width, 1);
|
|
301
|
-
const scaleY = this.height / Math.max(image.height, 1);
|
|
302
|
-
image.set({
|
|
303
|
-
left: 0,
|
|
304
|
-
top: 0,
|
|
305
|
-
originX: "left",
|
|
306
|
-
originY: "top",
|
|
307
|
-
scaleX,
|
|
308
|
-
scaleY,
|
|
309
|
-
selectable: false,
|
|
310
|
-
evented: false
|
|
311
|
-
});
|
|
312
|
-
this.canvas.backgroundImage = image;
|
|
313
|
-
this.canvas.requestRenderAll();
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* 添加并选中一个可编辑文字对象
|
|
317
|
-
*
|
|
318
|
-
* 文字位置使用画布像素坐标,原点位于对象左上角
|
|
319
|
-
* 对象过大时会等比缩小,越界时会自动移回画布
|
|
320
|
-
*
|
|
321
|
-
* @param options 文字内容、位置和样式
|
|
322
|
-
* @returns 创建的 Fabric Textbox
|
|
323
|
-
*/
|
|
324
|
-
addText(options = {}) {
|
|
325
|
-
const text = new Textbox(options.text ?? "Edit this text", {
|
|
326
|
-
left: options.x ?? this.width * .18,
|
|
327
|
-
top: options.y ?? this.height * .36,
|
|
328
|
-
width: options.width ?? this.width * .42,
|
|
329
|
-
fontFamily: options.fontFamily ?? "Arial",
|
|
330
|
-
fontSize: options.fontSize ?? Math.round(this.height * .12),
|
|
331
|
-
fontWeight: 700,
|
|
332
|
-
fill: options.color ?? "#172126",
|
|
333
|
-
originX: "left",
|
|
334
|
-
originY: "top",
|
|
335
|
-
textAlign: "center",
|
|
336
|
-
editable: true,
|
|
337
|
-
transparentCorners: false,
|
|
338
|
-
cornerColor: "#ffffff",
|
|
339
|
-
cornerStrokeColor: "#13717d",
|
|
340
|
-
borderColor: "#13717d",
|
|
341
|
-
cornerSize: 16
|
|
342
|
-
});
|
|
343
|
-
this.addAndSelect(text);
|
|
344
|
-
return text;
|
|
345
|
-
}
|
|
346
|
-
/**
|
|
347
|
-
* 加载、添加并选中一个图片对象
|
|
348
|
-
*
|
|
349
|
-
* 图片位置使用画布像素坐标,原点位于图片中心
|
|
350
|
-
* 对象过大时会等比缩小,越界时会自动移回画布
|
|
351
|
-
* 远程图片必须提供正确的 CORS 响应头才能安全导出 PNG
|
|
352
|
-
*
|
|
353
|
-
* @param options 图片地址、中心位置和显示宽度
|
|
354
|
-
* @returns 创建的 FabricImage
|
|
355
|
-
* @throws 图片加载失败或被 CORS 策略阻止时抛出错误
|
|
356
|
-
*/
|
|
357
|
-
async addImage(options) {
|
|
358
|
-
const source = await resolvePersistentImageSource(options.src);
|
|
359
|
-
const image = await this.loadImage(source);
|
|
360
|
-
const scale = (options.width ?? this.width * .22) / Math.max(image.width, 1);
|
|
361
|
-
image.set({
|
|
362
|
-
left: options.x ?? this.width * .62,
|
|
363
|
-
top: options.y ?? this.height * .29,
|
|
364
|
-
originX: "center",
|
|
365
|
-
originY: "center",
|
|
366
|
-
scaleX: scale,
|
|
367
|
-
scaleY: scale,
|
|
368
|
-
transparentCorners: false,
|
|
369
|
-
cornerColor: "#ffffff",
|
|
370
|
-
cornerStrokeColor: "#13717d",
|
|
371
|
-
borderColor: "#13717d",
|
|
372
|
-
cornerSize: 16
|
|
373
|
-
});
|
|
374
|
-
this.imageSources.set(image, source);
|
|
375
|
-
this.addAndSelect(image);
|
|
376
|
-
return image;
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* 返回与 Fabric.js 无关的当前设计快照
|
|
380
|
-
*
|
|
381
|
-
* 文档只包含可编辑对象和逻辑画布尺寸,不包含背景纹理或产品模型配置
|
|
382
|
-
*
|
|
383
|
-
* @returns 可以安全传给 JSON.stringify 的 Design JSON 文档
|
|
384
|
-
* @throws 画布包含不支持的对象或非字符串文字填充时抛出错误
|
|
385
|
-
*/
|
|
386
|
-
saveDesign() {
|
|
387
|
-
return {
|
|
388
|
-
version: 1,
|
|
389
|
-
canvas: {
|
|
390
|
-
width: this.width,
|
|
391
|
-
height: this.height
|
|
392
|
-
},
|
|
393
|
-
objects: this.canvas.getObjects().map((object) => this.serializeObject(object))
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* 校验并恢复 Design JSON,图片全部加载成功后才替换当前对象
|
|
398
|
-
*
|
|
399
|
-
* 背景纹理和当前产品保持不变,恢复后不选中任何对象
|
|
400
|
-
*
|
|
401
|
-
* @param value JSON.parse 结果或符合 DesignDocument 的对象
|
|
402
|
-
* @throws Schema 无效、画布尺寸不匹配或图片无法加载时抛出错误
|
|
403
|
-
*/
|
|
404
|
-
async loadDesign(value) {
|
|
405
|
-
const design = parseDesignDocument(value);
|
|
406
|
-
if (design.canvas.width !== this.width || design.canvas.height !== this.height) throw new RangeError(`Design canvas ${design.canvas.width}x${design.canvas.height} does not match editor canvas ${this.width}x${this.height}`);
|
|
407
|
-
const entries = [];
|
|
408
|
-
try {
|
|
409
|
-
for (const object of design.objects) entries.push({
|
|
410
|
-
id: object.id,
|
|
411
|
-
object: await this.createObjectFromDesign(object),
|
|
412
|
-
source: object.type === "image" ? object.src : void 0
|
|
413
|
-
});
|
|
414
|
-
} catch (error) {
|
|
415
|
-
entries.forEach(({ object }) => object.dispose());
|
|
416
|
-
throw error;
|
|
417
|
-
}
|
|
418
|
-
const previousObjects = this.canvas.getObjects();
|
|
419
|
-
this.canvas.discardActiveObject();
|
|
420
|
-
this.canvas.remove(...previousObjects);
|
|
421
|
-
previousObjects.forEach((object) => object.dispose());
|
|
422
|
-
this.usedObjectIds.clear();
|
|
423
|
-
for (const entry of entries) {
|
|
424
|
-
this.registerObject(entry.object, entry.id);
|
|
425
|
-
if (entry.object instanceof FabricImage && entry.source) this.imageSources.set(entry.object, entry.source);
|
|
426
|
-
this.canvas.add(entry.object);
|
|
427
|
-
this.constrainObjectToCanvas(entry.object);
|
|
428
|
-
}
|
|
429
|
-
this.canvas.requestRenderAll();
|
|
430
|
-
this.selectionListeners.forEach((listener) => listener(false));
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* 删除当前对象或多选选区
|
|
434
|
-
*
|
|
435
|
-
* @returns 是否删除了至少一个对象
|
|
436
|
-
*/
|
|
437
|
-
deleteSelected() {
|
|
438
|
-
const selection = this.canvas.getActiveObjects();
|
|
439
|
-
if (selection.length === 0) return false;
|
|
440
|
-
this.canvas.remove(...selection);
|
|
441
|
-
this.canvas.discardActiveObject();
|
|
442
|
-
this.canvas.requestRenderAll();
|
|
443
|
-
this.selectionListeners.forEach((listener) => listener(false));
|
|
444
|
-
return true;
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* 将当前画布内容导出为 PNG 并触发浏览器下载
|
|
448
|
-
*
|
|
449
|
-
* @param filename 下载文件名
|
|
450
|
-
* @throws 画布被无 CORS 授权的远程图片污染时抛出安全错误
|
|
451
|
-
*/
|
|
452
|
-
exportTexture(filename = "custom-texture.png") {
|
|
453
|
-
this.canvas.discardActiveObject();
|
|
454
|
-
this.canvas.renderAll();
|
|
455
|
-
const anchor = document.createElement("a");
|
|
456
|
-
anchor.download = filename;
|
|
457
|
-
anchor.href = this.canvas.toDataURL({
|
|
458
|
-
format: "png",
|
|
459
|
-
multiplier: 1
|
|
460
|
-
});
|
|
461
|
-
anchor.click();
|
|
462
|
-
}
|
|
463
|
-
/** 释放 ResizeObserver、事件监听和 Fabric Canvas */
|
|
464
|
-
destroy() {
|
|
465
|
-
this.resizeObserver.disconnect();
|
|
466
|
-
this.renderListeners.clear();
|
|
467
|
-
this.selectionListeners.clear();
|
|
468
|
-
this.canvas.dispose();
|
|
469
|
-
this.host.replaceChildren();
|
|
470
|
-
}
|
|
471
|
-
addAndSelect(object) {
|
|
472
|
-
this.registerObject(object);
|
|
473
|
-
this.canvas.add(object);
|
|
474
|
-
this.constrainObjectToCanvas(object);
|
|
475
|
-
this.canvas.setActiveObject(object);
|
|
476
|
-
this.canvas.requestRenderAll();
|
|
477
|
-
this.selectionListeners.forEach((listener) => listener(true));
|
|
478
|
-
}
|
|
479
|
-
registerObject(object, id = this.createObjectId()) {
|
|
480
|
-
if (this.usedObjectIds.has(id)) throw new Error(`Design object id is already in use: ${id}`);
|
|
481
|
-
this.usedObjectIds.add(id);
|
|
482
|
-
this.objectIds.set(object, id);
|
|
483
|
-
}
|
|
484
|
-
createObjectId() {
|
|
485
|
-
let id;
|
|
486
|
-
do {
|
|
487
|
-
this.objectIdSequence += 1;
|
|
488
|
-
id = `object-${this.objectIdSequence}`;
|
|
489
|
-
} while (this.usedObjectIds.has(id));
|
|
490
|
-
return id;
|
|
491
|
-
}
|
|
492
|
-
serializeObject(object) {
|
|
493
|
-
const id = this.objectIds.get(object);
|
|
494
|
-
if (!id) throw new Error("Design object is missing its stable id");
|
|
495
|
-
const transform = this.serializeTransform(object);
|
|
496
|
-
if (object instanceof Textbox) {
|
|
497
|
-
if (typeof object.fill !== "string") throw new Error(`Text object ${id} uses an unsupported non-string fill`);
|
|
498
|
-
return {
|
|
499
|
-
id,
|
|
500
|
-
type: "text",
|
|
501
|
-
transform,
|
|
502
|
-
text: object.text,
|
|
503
|
-
width: object.width,
|
|
504
|
-
fontFamily: object.fontFamily,
|
|
505
|
-
fontSize: object.fontSize,
|
|
506
|
-
color: object.fill
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
if (object instanceof FabricImage) {
|
|
510
|
-
const src = this.imageSources.get(object) ?? object.getSrc();
|
|
511
|
-
if (!src || src.startsWith("blob:")) throw new Error(`Image object ${id} does not have a persistent source`);
|
|
512
|
-
return {
|
|
513
|
-
id,
|
|
514
|
-
type: "image",
|
|
515
|
-
transform,
|
|
516
|
-
src
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
throw new Error(`Design object ${id} has an unsupported type`);
|
|
520
|
-
}
|
|
521
|
-
serializeTransform(object) {
|
|
522
|
-
const center = object.getCenterPoint();
|
|
523
|
-
return {
|
|
524
|
-
x: center.x,
|
|
525
|
-
y: center.y,
|
|
526
|
-
scaleX: Math.abs(object.scaleX),
|
|
527
|
-
scaleY: Math.abs(object.scaleY),
|
|
528
|
-
rotation: object.angle,
|
|
529
|
-
flipX: object.scaleX < 0 ? !object.flipX : object.flipX,
|
|
530
|
-
flipY: object.scaleY < 0 ? !object.flipY : object.flipY
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
async createObjectFromDesign(design) {
|
|
534
|
-
const common = {
|
|
535
|
-
left: design.transform.x,
|
|
536
|
-
top: design.transform.y,
|
|
537
|
-
originX: "center",
|
|
538
|
-
originY: "center",
|
|
539
|
-
scaleX: design.transform.scaleX,
|
|
540
|
-
scaleY: design.transform.scaleY,
|
|
541
|
-
angle: design.transform.rotation,
|
|
542
|
-
flipX: design.transform.flipX,
|
|
543
|
-
flipY: design.transform.flipY,
|
|
544
|
-
transparentCorners: false,
|
|
545
|
-
cornerColor: "#ffffff",
|
|
546
|
-
cornerStrokeColor: "#13717d",
|
|
547
|
-
borderColor: "#13717d",
|
|
548
|
-
cornerSize: 16
|
|
549
|
-
};
|
|
550
|
-
if (design.type === "text") return new Textbox(design.text, {
|
|
551
|
-
...common,
|
|
552
|
-
width: design.width,
|
|
553
|
-
fontFamily: design.fontFamily,
|
|
554
|
-
fontSize: design.fontSize,
|
|
555
|
-
fontWeight: 700,
|
|
556
|
-
fill: design.color,
|
|
557
|
-
textAlign: "center",
|
|
558
|
-
editable: true
|
|
559
|
-
});
|
|
560
|
-
const image = await this.loadImage(design.src);
|
|
561
|
-
image.set(common);
|
|
562
|
-
return image;
|
|
563
|
-
}
|
|
564
|
-
loadImage(source) {
|
|
565
|
-
return FabricImage.fromURL(source, source.startsWith("data:") ? void 0 : { crossOrigin: "anonymous" });
|
|
566
|
-
}
|
|
567
|
-
constrainObjectToCanvas(object) {
|
|
568
|
-
object.setCoords();
|
|
569
|
-
let bounds = object.getBoundingRect();
|
|
570
|
-
const scale = calculateContainmentScale(bounds, this.width, this.height);
|
|
571
|
-
if (scale < 1) {
|
|
572
|
-
object.set({
|
|
573
|
-
scaleX: object.scaleX * scale,
|
|
574
|
-
scaleY: object.scaleY * scale
|
|
575
|
-
});
|
|
576
|
-
object.setCoords();
|
|
577
|
-
bounds = object.getBoundingRect();
|
|
578
|
-
}
|
|
579
|
-
const offset = calculateContainmentOffset(bounds, this.width, this.height);
|
|
580
|
-
if (offset.x !== 0 || offset.y !== 0) {
|
|
581
|
-
object.set({
|
|
582
|
-
left: object.left + offset.x,
|
|
583
|
-
top: object.top + offset.y
|
|
584
|
-
});
|
|
585
|
-
object.setCoords();
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
resizeDisplay() {
|
|
589
|
-
const availableWidth = Math.max(this.host.clientWidth - 32, 1);
|
|
590
|
-
const availableHeight = Math.max(this.host.clientHeight - 32, 1);
|
|
591
|
-
const scale = Math.min(availableWidth / this.width, availableHeight / this.height, 1);
|
|
592
|
-
this.canvas.setDimensions({
|
|
593
|
-
width: `${Math.floor(this.width * scale)}px`,
|
|
594
|
-
height: `${Math.floor(this.height * scale)}px`
|
|
595
|
-
}, { cssOnly: true });
|
|
596
|
-
}
|
|
597
|
-
markRenderState() {
|
|
598
|
-
if (this.host.dataset.renderState === "nonblank") return;
|
|
599
|
-
const context = this.canvas.getContext();
|
|
600
|
-
let minimum = 255;
|
|
601
|
-
let maximum = 0;
|
|
602
|
-
let opaqueSamples = 0;
|
|
603
|
-
for (let row = 1; row < 8; row += 1) for (let column = 1; column < 16; column += 1) {
|
|
604
|
-
const x = Math.floor(column / 16 * this.width);
|
|
605
|
-
const y = Math.floor(row / 8 * this.height);
|
|
606
|
-
const pixel = context.getImageData(x, y, 1, 1).data;
|
|
607
|
-
const luminance = (pixel[0] + pixel[1] + pixel[2]) / 3;
|
|
608
|
-
minimum = Math.min(minimum, luminance);
|
|
609
|
-
maximum = Math.max(maximum, luminance);
|
|
610
|
-
if (pixel[3] > 0) opaqueSamples += 1;
|
|
611
|
-
}
|
|
612
|
-
if (opaqueSamples > 0 && maximum - minimum > 8) this.host.dataset.renderState = "nonblank";
|
|
613
|
-
}
|
|
614
|
-
};
|
|
615
|
-
//#endregion
|
|
616
|
-
//#region src/bridge/TextureBridge.ts
|
|
617
|
-
/**
|
|
618
|
-
* 将二维编辑画布转换为 Three.js 实时纹理
|
|
619
|
-
*
|
|
620
|
-
* 多次画布渲染会合并到下一个动画帧,避免重复标记纹理更新
|
|
621
|
-
*/
|
|
622
|
-
var TextureBridge = class {
|
|
623
|
-
/** 绑定到三维产品材质的实时 CanvasTexture */
|
|
624
|
-
texture;
|
|
625
|
-
stopListening;
|
|
626
|
-
updateFrame = 0;
|
|
627
|
-
/**
|
|
628
|
-
* @param editor 提供底层 HTML Canvas 和渲染事件的二维编辑器
|
|
629
|
-
* @param viewer 接收实时纹理的三维查看器
|
|
630
|
-
* @param flipY 是否垂直翻转纹理
|
|
631
|
-
*/
|
|
632
|
-
constructor(editor, viewer, flipY) {
|
|
633
|
-
this.texture = new CanvasTexture(editor.textureCanvas);
|
|
634
|
-
this.texture.colorSpace = SRGBColorSpace;
|
|
635
|
-
this.texture.flipY = flipY;
|
|
636
|
-
this.texture.anisotropy = 4;
|
|
637
|
-
viewer.setTexture(this.texture);
|
|
638
|
-
this.stopListening = editor.onRender(() => this.scheduleUpdate());
|
|
639
|
-
this.scheduleUpdate();
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* 更新纹理垂直翻转状态并立即标记刷新
|
|
643
|
-
*
|
|
644
|
-
* @param flipY 是否垂直翻转纹理
|
|
645
|
-
*/
|
|
646
|
-
setFlipY(flipY) {
|
|
647
|
-
this.texture.flipY = flipY;
|
|
648
|
-
this.texture.needsUpdate = true;
|
|
649
|
-
}
|
|
650
|
-
/** 取消编辑器订阅、动画帧并释放 CanvasTexture */
|
|
651
|
-
destroy() {
|
|
652
|
-
this.stopListening();
|
|
653
|
-
cancelAnimationFrame(this.updateFrame);
|
|
654
|
-
this.texture.dispose();
|
|
655
|
-
}
|
|
656
|
-
/** 将同一帧内的多次画布渲染合并为一次纹理更新 */
|
|
657
|
-
scheduleUpdate() {
|
|
658
|
-
if (this.updateFrame) return;
|
|
659
|
-
this.updateFrame = requestAnimationFrame(() => {
|
|
660
|
-
this.updateFrame = 0;
|
|
661
|
-
this.texture.needsUpdate = true;
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
};
|
|
665
|
-
//#endregion
|
|
666
|
-
//#region src/viewer/ProductViewer.ts
|
|
667
|
-
/**
|
|
668
|
-
* 基于 Three.js 的三维产品查看器
|
|
669
|
-
*
|
|
670
|
-
* 负责模型加载、目标 Mesh 查找、实时纹理绑定、相机控制和资源释放
|
|
671
|
-
* 当前只将纹理应用到目标 Mesh 的第一个材质槽
|
|
672
|
-
*/
|
|
673
|
-
var ProductViewer = class {
|
|
674
|
-
host;
|
|
675
|
-
scene = new Scene();
|
|
676
|
-
camera = new PerspectiveCamera(35, 1, .05, 100);
|
|
677
|
-
renderer;
|
|
678
|
-
controls;
|
|
679
|
-
loader = new GLTFLoader();
|
|
680
|
-
resizeObserver;
|
|
681
|
-
productRoot;
|
|
682
|
-
surface;
|
|
683
|
-
texture;
|
|
684
|
-
animationFrame = 0;
|
|
685
|
-
/**
|
|
686
|
-
* @param host 三维查看器挂载容器
|
|
687
|
-
*/
|
|
688
|
-
constructor(host) {
|
|
689
|
-
this.host = host;
|
|
690
|
-
host.replaceChildren();
|
|
691
|
-
this.renderer = new WebGLRenderer({
|
|
692
|
-
antialias: true,
|
|
693
|
-
alpha: true
|
|
694
|
-
});
|
|
695
|
-
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
696
|
-
this.renderer.outputColorSpace = SRGBColorSpace;
|
|
697
|
-
this.renderer.toneMapping = ACESFilmicToneMapping;
|
|
698
|
-
this.renderer.toneMappingExposure = 1.05;
|
|
699
|
-
this.renderer.shadowMap.enabled = true;
|
|
700
|
-
this.renderer.domElement.setAttribute("aria-label", "Interactive 3D product preview");
|
|
701
|
-
this.renderer.domElement.classList.add("customforge-viewer-canvas");
|
|
702
|
-
host.append(this.renderer.domElement);
|
|
703
|
-
host.dataset.renderState = "pending";
|
|
704
|
-
this.scene.background = new Color("#eef0ef");
|
|
705
|
-
this.camera.position.set(4.6, 2.8, 5.8);
|
|
706
|
-
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
707
|
-
this.controls.enableDamping = true;
|
|
708
|
-
this.controls.minDistance = 3.2;
|
|
709
|
-
this.controls.maxDistance = 11;
|
|
710
|
-
this.controls.target.set(0, 0, 0);
|
|
711
|
-
this.addEnvironment();
|
|
712
|
-
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
713
|
-
this.resizeObserver.observe(host);
|
|
714
|
-
this.resize();
|
|
715
|
-
this.render();
|
|
716
|
-
}
|
|
717
|
-
/**
|
|
718
|
-
* 加载远程模型或创建内置演示模型
|
|
719
|
-
*
|
|
720
|
-
* @param product 已完成默认值补全的产品配置
|
|
721
|
-
* @throws 模型加载失败或找不到目标 Mesh 时抛出错误
|
|
722
|
-
*/
|
|
723
|
-
async loadProduct(product) {
|
|
724
|
-
this.removeProduct();
|
|
725
|
-
if (product.modelUrl) {
|
|
726
|
-
const gltf = await this.loader.loadAsync(product.modelUrl);
|
|
727
|
-
this.productRoot = gltf.scene;
|
|
728
|
-
this.surface = this.findSurface(gltf.scene, product.surfaceMesh);
|
|
729
|
-
} else {
|
|
730
|
-
const demo = this.createDemoProduct();
|
|
731
|
-
this.productRoot = demo.root;
|
|
732
|
-
this.surface = demo.surface;
|
|
733
|
-
}
|
|
734
|
-
this.scene.add(this.productRoot);
|
|
735
|
-
if (this.texture) this.attachTexture(this.texture);
|
|
736
|
-
this.fitCamera(this.productRoot);
|
|
737
|
-
}
|
|
738
|
-
/**
|
|
739
|
-
* 保存并绑定二维编辑器生成的实时纹理
|
|
740
|
-
*
|
|
741
|
-
* 如果模型尚未加载,纹理会在模型加载完成后自动绑定
|
|
742
|
-
*
|
|
743
|
-
* @param texture 由二维画布创建的 CanvasTexture
|
|
744
|
-
*/
|
|
745
|
-
setTexture(texture) {
|
|
746
|
-
this.texture = texture;
|
|
747
|
-
if (this.surface) this.attachTexture(texture);
|
|
748
|
-
}
|
|
749
|
-
/** 根据当前模型包围盒恢复默认相机位置 */
|
|
750
|
-
resetView() {
|
|
751
|
-
if (this.productRoot) this.fitCamera(this.productRoot);
|
|
752
|
-
}
|
|
753
|
-
/** 释放动画帧、相机控制、模型材质和 WebGLRenderer */
|
|
754
|
-
destroy() {
|
|
755
|
-
cancelAnimationFrame(this.animationFrame);
|
|
756
|
-
this.resizeObserver.disconnect();
|
|
757
|
-
this.controls.dispose();
|
|
758
|
-
this.removeProduct();
|
|
759
|
-
this.renderer.dispose();
|
|
760
|
-
this.renderer.domElement.remove();
|
|
761
|
-
}
|
|
762
|
-
addEnvironment() {
|
|
763
|
-
const sky = new HemisphereLight("#ffffff", "#7c8581", 2.2);
|
|
764
|
-
this.scene.add(sky);
|
|
765
|
-
const key = new DirectionalLight("#ffffff", 3.8);
|
|
766
|
-
key.position.set(4, 6, 5);
|
|
767
|
-
key.castShadow = true;
|
|
768
|
-
this.scene.add(key);
|
|
769
|
-
const fill = new DirectionalLight("#b7dce0", 1.2);
|
|
770
|
-
fill.position.set(-5, 2, 3);
|
|
771
|
-
this.scene.add(fill);
|
|
772
|
-
const floor = new Mesh(new PlaneGeometry(30, 30), new MeshStandardMaterial({
|
|
773
|
-
color: "#dfe3e1",
|
|
774
|
-
roughness: .95
|
|
775
|
-
}));
|
|
776
|
-
floor.rotation.x = -Math.PI / 2;
|
|
777
|
-
floor.position.y = -1.34;
|
|
778
|
-
floor.receiveShadow = true;
|
|
779
|
-
this.scene.add(floor);
|
|
780
|
-
}
|
|
781
|
-
createDemoProduct() {
|
|
782
|
-
const root = new Group();
|
|
783
|
-
root.rotation.y = MathUtils.degToRad(-14);
|
|
784
|
-
const bodyMaterial = new MeshStandardMaterial({
|
|
785
|
-
color: "#ffffff",
|
|
786
|
-
roughness: .42,
|
|
787
|
-
metalness: 0
|
|
788
|
-
});
|
|
789
|
-
const surface = new Mesh(new CylinderGeometry(1.28, 1.16, 2.35, 96, 1, true), bodyMaterial);
|
|
790
|
-
surface.name = "PrintArea";
|
|
791
|
-
surface.castShadow = true;
|
|
792
|
-
surface.receiveShadow = true;
|
|
793
|
-
root.add(surface);
|
|
794
|
-
const ceramic = new MeshStandardMaterial({
|
|
795
|
-
color: "#f4f4f1",
|
|
796
|
-
roughness: .35
|
|
797
|
-
});
|
|
798
|
-
const rim = new Mesh(new TorusGeometry(1.28, .08, 20, 96), ceramic);
|
|
799
|
-
rim.rotation.x = Math.PI / 2;
|
|
800
|
-
rim.position.y = 1.18;
|
|
801
|
-
rim.castShadow = true;
|
|
802
|
-
root.add(rim);
|
|
803
|
-
const inside = new Mesh(new CircleGeometry(1.2, 96), new MeshStandardMaterial({
|
|
804
|
-
color: "#29312f",
|
|
805
|
-
roughness: .7
|
|
806
|
-
}));
|
|
807
|
-
inside.rotation.x = -Math.PI / 2;
|
|
808
|
-
inside.position.y = 1.16;
|
|
809
|
-
root.add(inside);
|
|
810
|
-
const bottom = new Mesh(new CircleGeometry(1.15, 96), ceramic);
|
|
811
|
-
bottom.rotation.x = Math.PI / 2;
|
|
812
|
-
bottom.position.y = -1.17;
|
|
813
|
-
root.add(bottom);
|
|
814
|
-
return {
|
|
815
|
-
root,
|
|
816
|
-
surface
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
/**
|
|
820
|
-
* 按名称查找接收实时纹理的 Mesh
|
|
821
|
-
*
|
|
822
|
-
* @throws 找不到对象或同名对象不是 Mesh 时抛出错误
|
|
823
|
-
*/
|
|
824
|
-
findSurface(root, meshName) {
|
|
825
|
-
const object = root.getObjectByName(meshName);
|
|
826
|
-
if (!(object instanceof Mesh)) throw new Error(`Customizable mesh was not found: ${meshName}`);
|
|
827
|
-
return object;
|
|
828
|
-
}
|
|
829
|
-
/**
|
|
830
|
-
* 将纹理应用到目标 Mesh 的第一个材质槽
|
|
831
|
-
*
|
|
832
|
-
* 非 MeshStandardMaterial 会被替换为基础标准材质
|
|
833
|
-
*/
|
|
834
|
-
attachTexture(texture) {
|
|
835
|
-
if (!this.surface) return;
|
|
836
|
-
const material = (Array.isArray(this.surface.material) ? this.surface.material : [this.surface.material])[0];
|
|
837
|
-
if (!(material instanceof MeshStandardMaterial)) {
|
|
838
|
-
const replacement = new MeshStandardMaterial({
|
|
839
|
-
color: "#ffffff",
|
|
840
|
-
roughness: .45,
|
|
841
|
-
map: texture
|
|
842
|
-
});
|
|
843
|
-
this.surface.material = replacement;
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
material.map = texture;
|
|
847
|
-
material.color.set("#ffffff");
|
|
848
|
-
material.needsUpdate = true;
|
|
849
|
-
}
|
|
850
|
-
fitCamera(root) {
|
|
851
|
-
const bounds = new Box3().setFromObject(root);
|
|
852
|
-
const size = bounds.getSize(new Vector3());
|
|
853
|
-
const center = bounds.getCenter(new Vector3());
|
|
854
|
-
const radius = Math.max(size.x, size.y, size.z) * .5;
|
|
855
|
-
const distance = Math.max(radius / Math.tan(MathUtils.degToRad(this.camera.fov / 2)), 3);
|
|
856
|
-
this.controls.target.copy(center);
|
|
857
|
-
this.camera.position.copy(center).add(new Vector3(distance * .55, distance * .42, distance));
|
|
858
|
-
this.camera.near = Math.max(distance / 100, .01);
|
|
859
|
-
this.camera.far = distance * 100;
|
|
860
|
-
this.camera.updateProjectionMatrix();
|
|
861
|
-
this.controls.update();
|
|
862
|
-
}
|
|
863
|
-
removeProduct() {
|
|
864
|
-
if (!this.productRoot) return;
|
|
865
|
-
this.scene.remove(this.productRoot);
|
|
866
|
-
this.productRoot.traverse((object) => {
|
|
867
|
-
if (!(object instanceof Mesh)) return;
|
|
868
|
-
object.geometry.dispose();
|
|
869
|
-
(Array.isArray(object.material) ? object.material : [object.material]).forEach((material) => material.dispose());
|
|
870
|
-
});
|
|
871
|
-
this.productRoot = void 0;
|
|
872
|
-
this.surface = void 0;
|
|
873
|
-
}
|
|
874
|
-
resize() {
|
|
875
|
-
const width = Math.max(this.host.clientWidth, 1);
|
|
876
|
-
const height = Math.max(this.host.clientHeight, 1);
|
|
877
|
-
this.renderer.setSize(width, height, false);
|
|
878
|
-
this.camera.aspect = width / height;
|
|
879
|
-
this.camera.updateProjectionMatrix();
|
|
880
|
-
}
|
|
881
|
-
render = () => {
|
|
882
|
-
this.animationFrame = requestAnimationFrame(this.render);
|
|
883
|
-
this.controls.update();
|
|
884
|
-
this.renderer.render(this.scene, this.camera);
|
|
885
|
-
this.markRenderState();
|
|
886
|
-
};
|
|
887
|
-
markRenderState() {
|
|
888
|
-
if (this.host.dataset.renderState === "nonblank") return;
|
|
889
|
-
const context = this.renderer.getContext();
|
|
890
|
-
const width = context.drawingBufferWidth;
|
|
891
|
-
const height = context.drawingBufferHeight;
|
|
892
|
-
if (width < 2 || height < 2) return;
|
|
893
|
-
const pixel = /* @__PURE__ */ new Uint8Array(4);
|
|
894
|
-
let minimum = 255;
|
|
895
|
-
let maximum = 0;
|
|
896
|
-
for (const xRatio of [
|
|
897
|
-
.25,
|
|
898
|
-
.5,
|
|
899
|
-
.75
|
|
900
|
-
]) for (const yRatio of [
|
|
901
|
-
.25,
|
|
902
|
-
.5,
|
|
903
|
-
.75
|
|
904
|
-
]) {
|
|
905
|
-
context.readPixels(Math.floor(width * xRatio), Math.floor(height * yRatio), 1, 1, context.RGBA, context.UNSIGNED_BYTE, pixel);
|
|
906
|
-
const luminance = (pixel[0] + pixel[1] + pixel[2]) / 3;
|
|
907
|
-
minimum = Math.min(minimum, luminance);
|
|
908
|
-
maximum = Math.max(maximum, luminance);
|
|
909
|
-
}
|
|
910
|
-
if (maximum - minimum > 8) this.host.dataset.renderState = "nonblank";
|
|
911
|
-
}
|
|
912
|
-
};
|
|
913
|
-
//#endregion
|
|
914
|
-
//#region src/customizer/ProductCustomizer.ts
|
|
915
|
-
/**
|
|
916
|
-
* 统一管理二维编辑器、三维查看器和实时纹理同步
|
|
917
|
-
*
|
|
918
|
-
* 仅支持具有 DOM、Canvas、WebGL 和 ResizeObserver 的浏览器环境
|
|
919
|
-
* 每个实例独立持有 DOM 事件、Fabric 状态和 WebGL 资源
|
|
920
|
-
* 不再使用实例时必须调用 `destroy()`
|
|
921
|
-
*/
|
|
922
|
-
var ProductCustomizer = class ProductCustomizer {
|
|
923
|
-
events = new EventTarget();
|
|
924
|
-
textureBridge;
|
|
925
|
-
editor;
|
|
926
|
-
viewer;
|
|
927
|
-
product;
|
|
928
|
-
stopSelectionListener;
|
|
929
|
-
stopRenderListener;
|
|
930
|
-
destroyed = false;
|
|
931
|
-
constructor(options) {
|
|
932
|
-
const editorHost = resolveElement(options.editor, "Editor");
|
|
933
|
-
const viewerHost = resolveElement(options.viewer, "Viewer");
|
|
934
|
-
if (editorHost === viewerHost) throw new Error("Editor and viewer must use different elements");
|
|
935
|
-
this.product = normalizeProductConfiguration(options.product);
|
|
936
|
-
this.editor = new DesignEditor(editorHost, {
|
|
937
|
-
width: options.editorWidth ?? 1024,
|
|
938
|
-
height: options.editorHeight ?? 512
|
|
939
|
-
});
|
|
940
|
-
this.viewer = new ProductViewer(viewerHost);
|
|
941
|
-
this.textureBridge = new TextureBridge(this.editor, this.viewer, this.product.textureFlipY);
|
|
942
|
-
this.stopSelectionListener = this.editor.onSelectionChange((hasSelection) => {
|
|
943
|
-
this.emit("selectionchange", { hasSelection });
|
|
944
|
-
});
|
|
945
|
-
this.stopRenderListener = this.editor.onRender(() => {
|
|
946
|
-
this.emit("change", { objectCount: this.editor.objectCount });
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* 创建实例并完成初始产品加载
|
|
951
|
-
*
|
|
952
|
-
* @param options 产品定制器初始化配置
|
|
953
|
-
* @returns 初始化完成的产品定制器实例
|
|
954
|
-
* @throws DOM 容器无效或初始化失败时释放已创建资源并继续抛出原始错误
|
|
955
|
-
*/
|
|
956
|
-
static async create(options) {
|
|
957
|
-
const customizer = new ProductCustomizer(options);
|
|
958
|
-
try {
|
|
959
|
-
await customizer.initialize();
|
|
960
|
-
return customizer;
|
|
961
|
-
} catch (error) {
|
|
962
|
-
customizer.destroy();
|
|
963
|
-
throw error;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
/**
|
|
967
|
-
* 订阅产品定制器事件
|
|
968
|
-
*
|
|
969
|
-
* @param event 事件名称
|
|
970
|
-
* @param listener 接收对应事件载荷的监听函数
|
|
971
|
-
* @returns 用于取消本次订阅的函数
|
|
972
|
-
*/
|
|
973
|
-
on(event, listener) {
|
|
974
|
-
const wrapped = (browserEvent) => {
|
|
975
|
-
listener(browserEvent.detail);
|
|
976
|
-
};
|
|
977
|
-
this.events.addEventListener(event, wrapped);
|
|
978
|
-
return () => this.events.removeEventListener(event, wrapped);
|
|
979
|
-
}
|
|
980
|
-
/**
|
|
981
|
-
* 在二维画布中添加并选中一个文字对象
|
|
982
|
-
*
|
|
983
|
-
* 创建后和用户变换期间,对象会自动缩放或平移以保持完整可见
|
|
984
|
-
*
|
|
985
|
-
* @param options 文字内容、位置和样式配置
|
|
986
|
-
*/
|
|
987
|
-
addText(options) {
|
|
988
|
-
this.editor.addText(options);
|
|
989
|
-
}
|
|
990
|
-
/**
|
|
991
|
-
* 加载图片并将其添加到二维画布
|
|
992
|
-
*
|
|
993
|
-
* 创建后和用户变换期间,对象会自动缩放或平移以保持完整可见
|
|
994
|
-
*
|
|
995
|
-
* @param options 图片地址、位置和显示宽度
|
|
996
|
-
* @throws 图片无法访问、加载失败或被 CORS 策略阻止时抛出错误
|
|
997
|
-
*/
|
|
998
|
-
async addImage(options) {
|
|
999
|
-
try {
|
|
1000
|
-
await this.editor.addImage(options);
|
|
1001
|
-
} catch (error) {
|
|
1002
|
-
this.reportError(error);
|
|
1003
|
-
throw error;
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
/**
|
|
1007
|
-
* 删除二维编辑器中的当前对象或选区
|
|
1008
|
-
*
|
|
1009
|
-
* @returns 是否删除了至少一个对象
|
|
1010
|
-
*/
|
|
1011
|
-
deleteSelected() {
|
|
1012
|
-
return this.editor.deleteSelected();
|
|
1013
|
-
}
|
|
1014
|
-
/**
|
|
1015
|
-
* 创建当前二维设计的版本化 JSON 快照
|
|
1016
|
-
*
|
|
1017
|
-
* 快照不包含产品模型、目标 Mesh 或基础纹理配置
|
|
1018
|
-
* Blob URL 图片会在添加时转换为 Data URL,因此返回值可以跨页面会话保存
|
|
1019
|
-
*
|
|
1020
|
-
* @returns 可以安全传给 JSON.stringify 的 DesignDocument
|
|
1021
|
-
* @throws 设计中存在不支持的对象或文字填充时抛出错误
|
|
1022
|
-
*/
|
|
1023
|
-
saveDesign() {
|
|
1024
|
-
return this.editor.saveDesign();
|
|
1025
|
-
}
|
|
1026
|
-
/**
|
|
1027
|
-
* 校验并恢复版本化 Design JSON
|
|
1028
|
-
*
|
|
1029
|
-
* 图片全部加载成功后才替换当前二维对象,产品和基础纹理保持不变
|
|
1030
|
-
* 输入画布尺寸必须与当前编辑器的逻辑尺寸完全一致
|
|
1031
|
-
*
|
|
1032
|
-
* @param value JSON.parse 结果或符合 DesignDocument 的对象
|
|
1033
|
-
* @throws Schema 无效、画布尺寸不匹配或图片加载失败时抛出错误
|
|
1034
|
-
*/
|
|
1035
|
-
async loadDesign(value) {
|
|
1036
|
-
this.emit("status", { message: "Loading design" });
|
|
1037
|
-
try {
|
|
1038
|
-
await this.editor.loadDesign(value);
|
|
1039
|
-
this.emit("status", { message: "Design loaded" });
|
|
1040
|
-
} catch (error) {
|
|
1041
|
-
this.reportError(error);
|
|
1042
|
-
throw error;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
/**
|
|
1046
|
-
* 将当前二维设计合成为 PNG 并触发浏览器下载
|
|
1047
|
-
*
|
|
1048
|
-
* @param filename 下载文件名,默认为 `custom-texture.png`
|
|
1049
|
-
* @throws 远程图片污染 Canvas 时可能抛出安全错误
|
|
1050
|
-
*/
|
|
1051
|
-
exportTexture(filename) {
|
|
1052
|
-
this.editor.exportTexture(filename);
|
|
1053
|
-
}
|
|
1054
|
-
/** 恢复三维产品的默认相机位置 */
|
|
1055
|
-
resetView() {
|
|
1056
|
-
this.viewer.resetView();
|
|
1057
|
-
}
|
|
1058
|
-
/**
|
|
1059
|
-
* 更换模型、基础纹理和接收纹理的目标 Mesh
|
|
1060
|
-
*
|
|
1061
|
-
* @param product 新的产品配置
|
|
1062
|
-
* @throws 模型或纹理加载失败、目标 Mesh 不存在时抛出错误
|
|
1063
|
-
*/
|
|
1064
|
-
async loadProduct(product) {
|
|
1065
|
-
const nextProduct = normalizeProductConfiguration(product);
|
|
1066
|
-
this.emit("status", { message: "Loading product" });
|
|
1067
|
-
try {
|
|
1068
|
-
await this.viewer.loadProduct(nextProduct);
|
|
1069
|
-
await this.editor.setBackgroundTexture(nextProduct.textureUrl);
|
|
1070
|
-
this.textureBridge.setFlipY(nextProduct.textureFlipY);
|
|
1071
|
-
this.product = nextProduct;
|
|
1072
|
-
this.emit("ready", { product: this.product });
|
|
1073
|
-
this.emit("status", { message: nextProduct.modelUrl ? "Remote product ready" : "Demo product ready" });
|
|
1074
|
-
} catch (error) {
|
|
1075
|
-
this.reportError(error);
|
|
1076
|
-
throw error;
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
/**
|
|
1080
|
-
* 释放事件监听、Fabric Canvas、纹理和 WebGL 资源
|
|
1081
|
-
*
|
|
1082
|
-
* 重复调用不会再次释放资源,首次调用后不得继续使用其他实例方法
|
|
1083
|
-
*/
|
|
1084
|
-
destroy() {
|
|
1085
|
-
if (this.destroyed) return;
|
|
1086
|
-
this.destroyed = true;
|
|
1087
|
-
this.stopSelectionListener?.();
|
|
1088
|
-
this.stopRenderListener?.();
|
|
1089
|
-
this.textureBridge.destroy();
|
|
1090
|
-
this.editor.destroy();
|
|
1091
|
-
this.viewer.destroy();
|
|
1092
|
-
}
|
|
1093
|
-
async initialize() {
|
|
1094
|
-
await this.viewer.loadProduct(this.product);
|
|
1095
|
-
await this.editor.setBackgroundTexture(this.product.textureUrl);
|
|
1096
|
-
this.textureBridge.setFlipY(this.product.textureFlipY);
|
|
1097
|
-
this.emit("ready", { product: this.product });
|
|
1098
|
-
}
|
|
1099
|
-
emit(event, detail) {
|
|
1100
|
-
this.events.dispatchEvent(new CustomEvent(event, { detail }));
|
|
1101
|
-
}
|
|
1102
|
-
reportError(error) {
|
|
1103
|
-
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1104
|
-
this.emit("error", { error: normalized });
|
|
1105
|
-
this.emit("status", { message: normalized.message });
|
|
1106
|
-
}
|
|
1107
|
-
};
|
|
1108
|
-
//#endregion
|
|
1
|
+
import { t as ProductCustomizer } from "./ProductCustomizer-rMRyWe7L.js";
|
|
1109
2
|
//#region src/index.ts
|
|
1110
3
|
/**
|
|
1111
4
|
* 创建产品定制器实例并完成模型、编辑器和纹理同步初始化
|