@wodira/browser 0.3.0 → 1.0.0
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 +17 -1
- package/README.md +97 -48
- package/dist/index.cjs +5 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/ui/boxes/cdn.js +9 -9
- package/dist/ui/index-tfej862z.js +3264 -0
- package/dist/ui/index-xdhg885t.js +997 -0
- package/dist/ui/index.js +26 -2277
- package/dist/ui/shirt-3d-renderer-jyks1h8f.js +10 -0
- package/dist/ui/shirt-sponsors/cdn.js +5115 -73
- package/dist/ui/shirt-sponsors/index.js +8 -1667
- package/dist/ui/shirt-sponsors/render/auction-model.d.ts +55 -0
- package/dist/ui/shirt-sponsors/render/auction-model.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/render/index.d.ts +7 -0
- package/dist/ui/shirt-sponsors/render/index.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/render/index.js +34 -0
- package/dist/ui/shirt-sponsors/render/shirt-3d-renderer.d.ts +109 -0
- package/dist/ui/shirt-sponsors/render/shirt-3d-renderer.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/render/shirt-artwork-projection.d.ts +13 -0
- package/dist/ui/shirt-sponsors/render/shirt-artwork-projection.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/render/shirt-artwork.d.ts +17 -0
- package/dist/ui/shirt-sponsors/render/shirt-artwork.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/render/shirt-cloth-motion.d.ts +31 -0
- package/dist/ui/shirt-sponsors/render/shirt-cloth-motion.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/render/shirt-silhouette.d.ts +9 -0
- package/dist/ui/shirt-sponsors/render/shirt-silhouette.d.ts.map +1 -0
- package/dist/ui/shirt-sponsors/shirt-auction.d.ts +101 -15
- package/dist/ui/shirt-sponsors/shirt-auction.d.ts.map +1 -1
- package/package.json +12 -4
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
// src/ui/shirt-sponsors/render/auction-model.ts
|
|
2
|
+
var leader2 = (slot) => slot.bids.find((bid) => bid.status === "leading");
|
|
3
|
+
var minimumBid2 = (slot) => {
|
|
4
|
+
const current = leader2(slot);
|
|
5
|
+
return current ? current.amount + slot.increment : slot.startingPrice;
|
|
6
|
+
};
|
|
7
|
+
var hasPaidBids2 = (campaign) => campaign.slots.some((slot) => slot.bids.length > 0);
|
|
8
|
+
function reserveBid2(campaign, slotId, buyer, amount, now) {
|
|
9
|
+
if (!campaign.published || now >= campaign.closesAt)
|
|
10
|
+
throw new Error("La subasta está cerrada.");
|
|
11
|
+
const slot = campaign.slots.find((item) => item.id === slotId);
|
|
12
|
+
if (!slot)
|
|
13
|
+
throw new Error("Selecciona un espacio.");
|
|
14
|
+
if (!buyer.trim())
|
|
15
|
+
throw new Error("Escribe el nombre del anunciante.");
|
|
16
|
+
if (slot.lock && slot.lock.expiresAt > now)
|
|
17
|
+
throw new Error("Hay un pago en curso.");
|
|
18
|
+
if (leader2(slot)?.buyer === buyer.trim())
|
|
19
|
+
throw new Error("Ya lideras este espacio.");
|
|
20
|
+
if (!Number.isSafeInteger(amount) || amount < minimumBid2(slot))
|
|
21
|
+
throw new Error("La puja no alcanza el mínimo.");
|
|
22
|
+
return {
|
|
23
|
+
...campaign,
|
|
24
|
+
slots: campaign.slots.map((item) => item.id === slotId ? { ...item, lock: { buyer: buyer.trim(), amount, expiresAt: now + 5 * 60000 } } : item)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function settleBid2(campaign, slotId, outcome, now, id) {
|
|
28
|
+
const slot = campaign.slots.find((item) => item.id === slotId);
|
|
29
|
+
if (!slot?.lock)
|
|
30
|
+
throw new Error("No hay un pago pendiente.");
|
|
31
|
+
if (slot.lock.expiresAt <= now)
|
|
32
|
+
throw new Error("El turno ha caducado. Un cobro tardío se devolvería.");
|
|
33
|
+
const paid = outcome === "paid";
|
|
34
|
+
const late = paid && campaign.closesAt - now <= 2 * 60000;
|
|
35
|
+
const closesAt = late ? Math.min(Math.max(campaign.closesAt, now + 2 * 60000), campaign.originalClose + 10 * 60000) : campaign.closesAt;
|
|
36
|
+
const bid = {
|
|
37
|
+
id,
|
|
38
|
+
buyer: slot.lock.buyer,
|
|
39
|
+
amount: slot.lock.amount,
|
|
40
|
+
paidAt: now,
|
|
41
|
+
status: "leading",
|
|
42
|
+
views: 0,
|
|
43
|
+
opens: 0,
|
|
44
|
+
clicks: 0
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
...campaign,
|
|
48
|
+
closesAt,
|
|
49
|
+
slots: campaign.slots.map((item) => item.id === slotId ? {
|
|
50
|
+
...item,
|
|
51
|
+
lock: undefined,
|
|
52
|
+
bids: paid ? [...item.bids.map((old) => ({ ...old, status: "refunded" })), bid] : item.bids
|
|
53
|
+
} : item)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function demoCampaign2(now) {
|
|
57
|
+
const closesAt = now + 24 * 60 * 60000;
|
|
58
|
+
return {
|
|
59
|
+
title: "Carrera híbrida · Demo",
|
|
60
|
+
front: "/images/sponsor-studio/white-race-shirt-front-v1.png",
|
|
61
|
+
back: "/images/sponsor-studio/white-race-shirt-back-v1.png",
|
|
62
|
+
quantity: 500,
|
|
63
|
+
closesAt,
|
|
64
|
+
originalClose: closesAt,
|
|
65
|
+
published: false,
|
|
66
|
+
slots: [
|
|
67
|
+
{
|
|
68
|
+
id: "chest",
|
|
69
|
+
name: "Pecho principal",
|
|
70
|
+
side: "front",
|
|
71
|
+
x: 32,
|
|
72
|
+
y: 29,
|
|
73
|
+
width: 36,
|
|
74
|
+
height: 18,
|
|
75
|
+
printWidth: 24,
|
|
76
|
+
printHeight: 12,
|
|
77
|
+
startingPrice: 1e4,
|
|
78
|
+
increment: 1000,
|
|
79
|
+
bids: []
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "back",
|
|
83
|
+
name: "Espalda",
|
|
84
|
+
side: "back",
|
|
85
|
+
x: 30,
|
|
86
|
+
y: 29,
|
|
87
|
+
width: 40,
|
|
88
|
+
height: 24,
|
|
89
|
+
printWidth: 28,
|
|
90
|
+
printHeight: 18,
|
|
91
|
+
startingPrice: 15000,
|
|
92
|
+
increment: 1000,
|
|
93
|
+
bids: []
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/ui/shirt-sponsors/render/shirt-3d-renderer.ts
|
|
100
|
+
import * as THREE3 from "three";
|
|
101
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
102
|
+
|
|
103
|
+
// src/ui/shirt-sponsors/render/shirt-cloth-motion.ts
|
|
104
|
+
import * as THREE from "three";
|
|
105
|
+
var clamp = (value, limit) => Math.max(-limit, Math.min(limit, value));
|
|
106
|
+
|
|
107
|
+
class ShirtClothMotion2 {
|
|
108
|
+
yaw = { offset: 0, velocity: 0 };
|
|
109
|
+
pitch = { offset: 0, velocity: 0 };
|
|
110
|
+
flutter = { offset: 0, velocity: 0 };
|
|
111
|
+
turn(yaw, pitch) {
|
|
112
|
+
if (!Number.isFinite(yaw) || !Number.isFinite(pitch))
|
|
113
|
+
return;
|
|
114
|
+
this.yaw.velocity = clamp(this.yaw.velocity - clamp(yaw, 1) * 5.5, 9);
|
|
115
|
+
this.pitch.velocity = clamp(this.pitch.velocity - clamp(pitch, 1) * 4, 7);
|
|
116
|
+
this.flutter.velocity = clamp(this.flutter.velocity + Math.min(Math.hypot(yaw, pitch), 1) * 3, 6);
|
|
117
|
+
}
|
|
118
|
+
advance(seconds) {
|
|
119
|
+
if (!Number.isFinite(seconds) || seconds < 0 || seconds > 0.25) {
|
|
120
|
+
this.reset();
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
let remaining = seconds;
|
|
124
|
+
while (remaining > 0) {
|
|
125
|
+
const dt = Math.min(remaining, 1 / 120);
|
|
126
|
+
this.integrate(this.yaw, dt, 75, 9, 0.34);
|
|
127
|
+
this.integrate(this.pitch, dt, 90, 10, 0.24);
|
|
128
|
+
this.integrate(this.flutter, dt, 220, 8, 0.22);
|
|
129
|
+
remaining -= dt;
|
|
130
|
+
}
|
|
131
|
+
const active = [this.yaw, this.pitch, this.flutter].some((spring) => Math.abs(spring.offset) > 0.0001 || Math.abs(spring.velocity) > 0.001);
|
|
132
|
+
if (!active)
|
|
133
|
+
this.reset();
|
|
134
|
+
return active;
|
|
135
|
+
}
|
|
136
|
+
reset() {
|
|
137
|
+
for (const spring of [this.yaw, this.pitch, this.flutter]) {
|
|
138
|
+
spring.offset = 0;
|
|
139
|
+
spring.velocity = 0;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
integrate(spring, dt, stiffness, damping, limit) {
|
|
143
|
+
spring.velocity += (-stiffness * spring.offset - damping * spring.velocity) * dt;
|
|
144
|
+
spring.offset += spring.velocity * dt;
|
|
145
|
+
if (Math.abs(spring.offset) > limit) {
|
|
146
|
+
spring.offset = clamp(spring.offset, limit);
|
|
147
|
+
spring.velocity = 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class ShirtClothMesh2 {
|
|
153
|
+
geometry;
|
|
154
|
+
rest;
|
|
155
|
+
restNormals;
|
|
156
|
+
normalCorrections;
|
|
157
|
+
weights;
|
|
158
|
+
ripples;
|
|
159
|
+
height;
|
|
160
|
+
deformed = false;
|
|
161
|
+
constructor(geometry) {
|
|
162
|
+
this.geometry = geometry;
|
|
163
|
+
const positions = geometry.getAttribute("position");
|
|
164
|
+
if (!geometry.getAttribute("normal"))
|
|
165
|
+
geometry.computeVertexNormals();
|
|
166
|
+
const normals = geometry.getAttribute("normal");
|
|
167
|
+
this.rest = new Float32Array(positions.count * 3);
|
|
168
|
+
this.restNormals = new Float32Array(normals.count * 3);
|
|
169
|
+
this.normalCorrections = new Float32Array(normals.count * 3);
|
|
170
|
+
this.weights = new Float32Array(positions.count);
|
|
171
|
+
this.ripples = new Float32Array(positions.count);
|
|
172
|
+
geometry.computeBoundingBox();
|
|
173
|
+
geometry.computeBoundingSphere();
|
|
174
|
+
const bounds = geometry.boundingBox;
|
|
175
|
+
this.height = Math.max(bounds.max.y - bounds.min.y, 0.001);
|
|
176
|
+
const halfWidth = Math.max(Math.abs(bounds.min.x), Math.abs(bounds.max.x), 0.001);
|
|
177
|
+
for (let i = 0;i < positions.count; i++) {
|
|
178
|
+
const x = positions.getX(i), y = positions.getY(i), z = positions.getZ(i);
|
|
179
|
+
this.rest.set([x, y, z], i * 3);
|
|
180
|
+
this.restNormals.set([normals.getX(i), normals.getY(i), normals.getZ(i)], i * 3);
|
|
181
|
+
const drop = (bounds.max.y - y) / this.height;
|
|
182
|
+
const hem = THREE.MathUtils.smoothstep(drop, 0.25, 1);
|
|
183
|
+
const sleeve = THREE.MathUtils.smoothstep(Math.abs(x) / halfWidth, 0.58, 1) * THREE.MathUtils.smoothstep(drop, 0.06, 0.28);
|
|
184
|
+
this.weights[i] = Math.max(hem * hem, sleeve * 0.8);
|
|
185
|
+
this.ripples[i] = Math.sin(x / halfWidth * 8 + drop * 5) * (hem ** 3 + sleeve * 0.7);
|
|
186
|
+
}
|
|
187
|
+
geometry.computeVertexNormals();
|
|
188
|
+
for (let i = 0;i < normals.count; i++) {
|
|
189
|
+
this.normalCorrections[i * 3] = this.restNormals[i * 3] - normals.getX(i);
|
|
190
|
+
this.normalCorrections[i * 3 + 1] = this.restNormals[i * 3 + 1] - normals.getY(i);
|
|
191
|
+
this.normalCorrections[i * 3 + 2] = this.restNormals[i * 3 + 2] - normals.getZ(i);
|
|
192
|
+
normals.setXYZ(i, this.restNormals[i * 3], this.restNormals[i * 3 + 1], this.restNormals[i * 3 + 2]);
|
|
193
|
+
}
|
|
194
|
+
geometry.boundingBox.expandByScalar(this.height * 0.35);
|
|
195
|
+
geometry.boundingSphere.radius += this.height * 0.35;
|
|
196
|
+
if (positions instanceof THREE.BufferAttribute)
|
|
197
|
+
positions.setUsage(THREE.DynamicDrawUsage);
|
|
198
|
+
if (normals instanceof THREE.BufferAttribute)
|
|
199
|
+
normals.setUsage(THREE.DynamicDrawUsage);
|
|
200
|
+
}
|
|
201
|
+
apply(motion) {
|
|
202
|
+
const positions = this.geometry.getAttribute("position");
|
|
203
|
+
for (let i = 0;i < positions.count; i++) {
|
|
204
|
+
const x = this.rest[i * 3], y = this.rest[i * 3 + 1], z = this.rest[i * 3 + 2];
|
|
205
|
+
const weight = this.weights[i];
|
|
206
|
+
const twist = motion.yaw.offset * weight;
|
|
207
|
+
const cos = Math.cos(twist), sin = Math.sin(twist);
|
|
208
|
+
const ripple = motion.flutter.offset * this.height * 0.13 * this.ripples[i];
|
|
209
|
+
positions.setXYZ(i, x * cos + z * sin + motion.yaw.offset * this.height * 0.12 * weight, y + Math.abs(twist) * this.height * 0.025 * weight + ripple * 0.25, z * cos - x * sin + motion.pitch.offset * this.height * 0.4 * weight + ripple);
|
|
210
|
+
}
|
|
211
|
+
positions.needsUpdate = true;
|
|
212
|
+
this.geometry.computeVertexNormals();
|
|
213
|
+
const normals = this.geometry.getAttribute("normal");
|
|
214
|
+
for (let i = 0;i < normals.count; i++) {
|
|
215
|
+
const x = normals.getX(i) + this.normalCorrections[i * 3];
|
|
216
|
+
const y = normals.getY(i) + this.normalCorrections[i * 3 + 1];
|
|
217
|
+
const z = normals.getZ(i) + this.normalCorrections[i * 3 + 2];
|
|
218
|
+
const length = Math.hypot(x, y, z) || 1;
|
|
219
|
+
normals.setXYZ(i, x / length, y / length, z / length);
|
|
220
|
+
}
|
|
221
|
+
this.deformed = true;
|
|
222
|
+
}
|
|
223
|
+
restore() {
|
|
224
|
+
if (!this.deformed)
|
|
225
|
+
return;
|
|
226
|
+
const positions = this.geometry.getAttribute("position");
|
|
227
|
+
const normals = this.geometry.getAttribute("normal");
|
|
228
|
+
for (let i = 0;i < positions.count; i++) {
|
|
229
|
+
positions.setXYZ(i, this.rest[i * 3], this.rest[i * 3 + 1], this.rest[i * 3 + 2]);
|
|
230
|
+
normals.setXYZ(i, this.restNormals[i * 3], this.restNormals[i * 3 + 1], this.restNormals[i * 3 + 2]);
|
|
231
|
+
}
|
|
232
|
+
positions.needsUpdate = normals.needsUpdate = true;
|
|
233
|
+
this.deformed = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/ui/shirt-sponsors/render/shirt-artwork-projection.ts
|
|
238
|
+
import * as THREE2 from "three";
|
|
239
|
+
|
|
240
|
+
// src/ui/shirt-sponsors/render/shirt-silhouette.ts
|
|
241
|
+
function shirtArmhole2(spans) {
|
|
242
|
+
const radius = Math.max(1, Math.round(spans.length * 0.02));
|
|
243
|
+
let best = 0;
|
|
244
|
+
let row = Math.round(spans.length * 0.42);
|
|
245
|
+
for (let i = Math.round(spans.length * 0.22);i < spans.length * 0.6; i++) {
|
|
246
|
+
const above = spans[Math.max(0, i - radius)];
|
|
247
|
+
const below = spans[Math.min(spans.length - 1, i + radius)];
|
|
248
|
+
const reduction = above.right - above.left - (below.right - below.left);
|
|
249
|
+
if (reduction > best) {
|
|
250
|
+
best = reduction;
|
|
251
|
+
row = i;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return row / (spans.length - 1);
|
|
255
|
+
}
|
|
256
|
+
function mapShirtHeight2(y, fromArmhole, toArmhole) {
|
|
257
|
+
return y <= fromArmhole ? y / fromArmhole * toArmhole : toArmhole + (y - fromArmhole) / (1 - fromArmhole) * (1 - toArmhole);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/ui/shirt-sponsors/render/shirt-artwork.ts
|
|
261
|
+
function paintShirtArtwork2(context, artwork, images, width, height, silhouette = []) {
|
|
262
|
+
context.clearRect(0, 0, width * 2, height);
|
|
263
|
+
for (const [index, side] of ["front", "back"].entries()) {
|
|
264
|
+
const layer = artwork?.[side];
|
|
265
|
+
const image = layer && images.get(layer.url);
|
|
266
|
+
if (!layer || !image?.complete || !image.naturalWidth)
|
|
267
|
+
continue;
|
|
268
|
+
const scale = Math.min(width / image.naturalWidth, height / image.naturalHeight) * layer.scale;
|
|
269
|
+
const w = image.naturalWidth * scale;
|
|
270
|
+
const h = image.naturalHeight * scale;
|
|
271
|
+
context.save();
|
|
272
|
+
context.beginPath();
|
|
273
|
+
context.rect(index * width, 0, width, height);
|
|
274
|
+
context.clip();
|
|
275
|
+
if (layer.photo && silhouette.length) {
|
|
276
|
+
context.translate(index * width + width / 2 + layer.x * width / 100, height / 2 + layer.y * height / 100);
|
|
277
|
+
context.scale(layer.scale, layer.scale);
|
|
278
|
+
const armhole = shirtArmhole2(silhouette);
|
|
279
|
+
const torso = silhouette[Math.round(silhouette.length * 0.58)];
|
|
280
|
+
for (let row = 0;row < silhouette.length; row++) {
|
|
281
|
+
const span = silhouette[row];
|
|
282
|
+
const left = side === "front" ? span.left : 1 - span.right;
|
|
283
|
+
const right = side === "front" ? span.right : 1 - span.left;
|
|
284
|
+
const y = row / silhouette.length;
|
|
285
|
+
const torsoLeft = side === "front" ? torso.left : 1 - torso.right;
|
|
286
|
+
const torsoRight = side === "front" ? torso.right : 1 - torso.left;
|
|
287
|
+
const strips = [
|
|
288
|
+
[0, 0.25, left, torsoLeft],
|
|
289
|
+
[0.25, 0.5, torsoLeft, torsoRight],
|
|
290
|
+
[0.75, 0.25, torsoRight, right]
|
|
291
|
+
];
|
|
292
|
+
for (const [sx, sw, from, to] of strips) {
|
|
293
|
+
if (to <= from)
|
|
294
|
+
continue;
|
|
295
|
+
context.drawImage(image, sx * image.naturalWidth, mapShirtHeight2(y, armhole, 0.42) * (image.naturalHeight - 1), sw * image.naturalWidth, image.naturalHeight / silhouette.length, (from - 0.5) * width - 2, (y - 0.5) * height, (to - from) * width + 4, height / silhouette.length + 1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
context.restore();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
context.drawImage(image, index * width + (width - w) / 2 + layer.x * width / 100, (height - h) / 2 + layer.y * height / 100, w, h);
|
|
302
|
+
context.restore();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/ui/shirt-sponsors/render/shirt-artwork-projection.ts
|
|
307
|
+
class ShirtArtworkProjection2 {
|
|
308
|
+
silhouette = [];
|
|
309
|
+
canvas = document.createElement("canvas");
|
|
310
|
+
texture = new THREE2.CanvasTexture(this.canvas);
|
|
311
|
+
uniforms = {
|
|
312
|
+
wodiraArtworkMap: { value: this.texture },
|
|
313
|
+
wodiraArtworkEnabled: { value: false },
|
|
314
|
+
wodiraFrontFabricColor: { value: new THREE2.Color },
|
|
315
|
+
wodiraBackFabricColor: { value: new THREE2.Color }
|
|
316
|
+
};
|
|
317
|
+
constructor(model) {
|
|
318
|
+
model.updateMatrixWorld(true);
|
|
319
|
+
const inverseRoot = model.matrixWorld.clone().invert();
|
|
320
|
+
const bounds = new THREE2.Box3;
|
|
321
|
+
const meshes = [];
|
|
322
|
+
model.traverse((object) => {
|
|
323
|
+
if (!(object instanceof THREE2.Mesh))
|
|
324
|
+
return;
|
|
325
|
+
const attribute = object.geometry.getAttribute("position");
|
|
326
|
+
const transform = inverseRoot.clone().multiply(object.matrixWorld);
|
|
327
|
+
if (!object.geometry.hasAttribute("normal"))
|
|
328
|
+
object.geometry.computeVertexNormals();
|
|
329
|
+
const normals = object.geometry.getAttribute("normal");
|
|
330
|
+
const normalTransform = new THREE2.Matrix3().getNormalMatrix(transform);
|
|
331
|
+
const facing = Array.from({ length: normals.count }, (_, index) => new THREE2.Vector3().fromBufferAttribute(normals, index).applyNormalMatrix(normalTransform).z);
|
|
332
|
+
const positions = Array.from({ length: attribute.count }, (_, index) => {
|
|
333
|
+
const point = new THREE2.Vector3().fromBufferAttribute(attribute, index).applyMatrix4(transform);
|
|
334
|
+
bounds.expandByPoint(point);
|
|
335
|
+
return point;
|
|
336
|
+
});
|
|
337
|
+
meshes.push({ mesh: object, positions, facing });
|
|
338
|
+
});
|
|
339
|
+
const size = bounds.getSize(new THREE2.Vector3);
|
|
340
|
+
const resolution = 512;
|
|
341
|
+
this.silhouette = Array.from({ length: resolution }, () => ({ left: 1, right: 0 }));
|
|
342
|
+
for (const { mesh, positions } of meshes) {
|
|
343
|
+
const indices = mesh.geometry.index;
|
|
344
|
+
const count = indices?.count ?? positions.length;
|
|
345
|
+
for (let i = 0;i < count; i += 3) {
|
|
346
|
+
const triangle = [0, 1, 2].map((j) => positions[indices ? indices.getX(i + j) : i + j]);
|
|
347
|
+
for (let edge = 0;edge < 3; edge++) {
|
|
348
|
+
const a = triangle[edge];
|
|
349
|
+
const b = triangle[(edge + 1) % 3];
|
|
350
|
+
const ay = (bounds.max.y - a.y) / size.y * resolution;
|
|
351
|
+
const by = (bounds.max.y - b.y) / size.y * resolution;
|
|
352
|
+
if (Math.abs(by - ay) < 0.00001)
|
|
353
|
+
continue;
|
|
354
|
+
const start = Math.max(0, Math.ceil(Math.min(ay, by) - 0.5));
|
|
355
|
+
const end = Math.min(resolution - 1, Math.floor(Math.max(ay, by) - 0.5));
|
|
356
|
+
for (let row = start;row <= end; row++) {
|
|
357
|
+
const t = (row + 0.5 - ay) / (by - ay);
|
|
358
|
+
const x = (a.x + (b.x - a.x) * t - bounds.min.x) / size.x;
|
|
359
|
+
const span = this.silhouette[row];
|
|
360
|
+
span.left = Math.min(span.left, x);
|
|
361
|
+
span.right = Math.max(span.right, x);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (let row = 0;row < resolution; row++) {
|
|
367
|
+
if (this.silhouette[row].right <= this.silhouette[row].left)
|
|
368
|
+
this.silhouette[row] = { ...this.silhouette[row - 1] ?? { left: 0, right: 1 } };
|
|
369
|
+
}
|
|
370
|
+
this.canvas.width = 2048;
|
|
371
|
+
this.canvas.height = Math.min(2048, Math.max(1, Math.round(1024 * size.y / size.x)));
|
|
372
|
+
this.texture.colorSpace = THREE2.SRGBColorSpace;
|
|
373
|
+
this.texture.flipY = false;
|
|
374
|
+
this.texture.generateMipmaps = false;
|
|
375
|
+
this.texture.minFilter = THREE2.LinearFilter;
|
|
376
|
+
for (const { mesh, positions, facing } of meshes) {
|
|
377
|
+
const uv = new Float32Array(positions.length * 3);
|
|
378
|
+
positions.forEach((point, index) => {
|
|
379
|
+
uv[index * 3] = (point.x - bounds.min.x) / size.x;
|
|
380
|
+
uv[index * 3 + 1] = (bounds.max.y - point.y) / size.y;
|
|
381
|
+
uv[index * 3 + 2] = facing[index];
|
|
382
|
+
});
|
|
383
|
+
mesh.geometry.setAttribute("wodiraArtworkUv", new THREE2.BufferAttribute(uv, 3));
|
|
384
|
+
for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {
|
|
385
|
+
if (!(material instanceof THREE2.MeshStandardMaterial))
|
|
386
|
+
continue;
|
|
387
|
+
material.onBeforeCompile = (shader) => {
|
|
388
|
+
Object.assign(shader.uniforms, this.uniforms);
|
|
389
|
+
shader.vertexShader = shader.vertexShader.replace("#include <common>", `#include <common>
|
|
390
|
+
attribute vec3 wodiraArtworkUv;
|
|
391
|
+
varying vec3 vWodiraArtworkUv;`).replace("#include <begin_vertex>", `#include <begin_vertex>
|
|
392
|
+
vWodiraArtworkUv = wodiraArtworkUv;`);
|
|
393
|
+
shader.fragmentShader = shader.fragmentShader.replace("#include <common>", `#include <common>
|
|
394
|
+
uniform sampler2D wodiraArtworkMap;
|
|
395
|
+
uniform bool wodiraArtworkEnabled;
|
|
396
|
+
uniform vec3 wodiraFrontFabricColor;
|
|
397
|
+
uniform vec3 wodiraBackFabricColor;
|
|
398
|
+
varying vec3 vWodiraArtworkUv;`).replace("#include <map_fragment>", `
|
|
399
|
+
// The authored atlas partitions sleeves per triangle. Continue the torso
|
|
400
|
+
// print across that seam instead of clipping it to a jagged UV island.
|
|
401
|
+
bool front = vWodiraArtworkUv.z >= 0.0;
|
|
402
|
+
float torsoU = front ? vWodiraArtworkUv.x * 0.25 : 0.5 - vWodiraArtworkUv.x * 0.25;
|
|
403
|
+
torsoU = front ? clamp(torsoU, 0.00025, 0.24975) : clamp(torsoU, 0.25025, 0.49975);
|
|
404
|
+
vec4 sponsorColor = texture2D(map, vMapUv);
|
|
405
|
+
if (vMapUv.x >= 0.5) {
|
|
406
|
+
vec4 torsoColor = texture2D(map, vec2(torsoU, vWodiraArtworkUv.y));
|
|
407
|
+
// Sleeve artwork takes priority where its pixels are opaque.
|
|
408
|
+
float alpha = sponsorColor.a + torsoColor.a * (1.0 - sponsorColor.a);
|
|
409
|
+
vec3 color = sponsorColor.rgb * sponsorColor.a
|
|
410
|
+
+ torsoColor.rgb * torsoColor.a * (1.0 - sponsorColor.a);
|
|
411
|
+
sponsorColor = vec4(color / max(alpha, 0.00001), alpha);
|
|
412
|
+
}
|
|
413
|
+
vec3 fabricColor = front ? wodiraFrontFabricColor : wodiraBackFabricColor;
|
|
414
|
+
vec3 clothColor = fabricColor;
|
|
415
|
+
if (wodiraArtworkEnabled) {
|
|
416
|
+
// Mirror the back's horizontal axis so lettering reads from behind.
|
|
417
|
+
float u = vWodiraArtworkUv.z >= 0.0
|
|
418
|
+
? clamp(vWodiraArtworkUv.x * 0.5, 0.00025, 0.49975)
|
|
419
|
+
: clamp(1.0 - vWodiraArtworkUv.x * 0.5, 0.50025, 0.99975);
|
|
420
|
+
vec4 printColor = texture2D(wodiraArtworkMap, vec2(u, vWodiraArtworkUv.y));
|
|
421
|
+
clothColor = mix(fabricColor, printColor.rgb, printColor.a);
|
|
422
|
+
}
|
|
423
|
+
diffuseColor.rgb *= mix(clothColor, sponsorColor.rgb, sponsorColor.a);
|
|
424
|
+
`);
|
|
425
|
+
shader.fragmentShader = shader.fragmentShader.replace("#include <lights_physical_fragment>", `#include <lights_physical_fragment>
|
|
426
|
+
#ifdef USE_SHEEN
|
|
427
|
+
if (wodiraArtworkEnabled) material.sheenColor = 0.3 * mix(diffuseColor.rgb, vec3(1.0), 0.35);
|
|
428
|
+
#endif`);
|
|
429
|
+
};
|
|
430
|
+
material.customProgramCacheKey = () => "wodira-artwork-continuous-sponsors-v3";
|
|
431
|
+
material.needsUpdate = true;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
update(artwork, images, dye) {
|
|
436
|
+
this.uniforms.wodiraArtworkEnabled.value = !!(artwork?.front || artwork?.back);
|
|
437
|
+
const front = artwork?.front?.fabricColor;
|
|
438
|
+
const back = artwork?.back?.fabricColor;
|
|
439
|
+
this.uniforms.wodiraFrontFabricColor.value.copy(front || back ? new THREE2.Color(front ?? back) : dye);
|
|
440
|
+
this.uniforms.wodiraBackFabricColor.value.copy(back || front ? new THREE2.Color(back ?? front) : dye);
|
|
441
|
+
const context = this.canvas.getContext("2d");
|
|
442
|
+
if (context)
|
|
443
|
+
paintShirtArtwork2(context, artwork, images, this.canvas.width / 2, this.canvas.height, this.silhouette);
|
|
444
|
+
this.texture.needsUpdate = true;
|
|
445
|
+
}
|
|
446
|
+
dispose() {
|
|
447
|
+
this.texture.dispose();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/ui/shirt-sponsors/render/shirt-3d-renderer.ts
|
|
452
|
+
var defaultShirt3dPalette2 = {
|
|
453
|
+
slot: "#e32397",
|
|
454
|
+
slotPreview: "#ff1593",
|
|
455
|
+
slotText: "#ffffff",
|
|
456
|
+
priceLeading: "#e40086",
|
|
457
|
+
price: "#101632",
|
|
458
|
+
reserved: "#c9c5d3",
|
|
459
|
+
selected: "#101632"
|
|
460
|
+
};
|
|
461
|
+
var SHIRT_MODEL_PATH2 = "/images/sponsor-studio/white-shirt-realistic-v1.glb?v=7";
|
|
462
|
+
|
|
463
|
+
class Shirt3dRenderer2 {
|
|
464
|
+
canvas;
|
|
465
|
+
fail;
|
|
466
|
+
recovering;
|
|
467
|
+
renderer;
|
|
468
|
+
scene = new THREE3.Scene;
|
|
469
|
+
camera = new THREE3.OrthographicCamera(-1, 1, 1.12, -1.12, 0.1, 20);
|
|
470
|
+
model;
|
|
471
|
+
observer;
|
|
472
|
+
disposed = false;
|
|
473
|
+
loadController = new AbortController;
|
|
474
|
+
contextUnavailable = false;
|
|
475
|
+
recoveryTimer;
|
|
476
|
+
firstFrame;
|
|
477
|
+
frame = 0;
|
|
478
|
+
textureDirty = false;
|
|
479
|
+
atlas = document.createElement("canvas");
|
|
480
|
+
texture = new THREE3.CanvasTexture(this.atlas);
|
|
481
|
+
fabricTexture = this.createFabricTexture();
|
|
482
|
+
fabricMaterials = [];
|
|
483
|
+
images = new Map;
|
|
484
|
+
slots = [];
|
|
485
|
+
rotation = 0;
|
|
486
|
+
tilt = 0;
|
|
487
|
+
side = "front";
|
|
488
|
+
viewSurface;
|
|
489
|
+
rotating = false;
|
|
490
|
+
zoom = 1;
|
|
491
|
+
shirtColor = "#ffffff";
|
|
492
|
+
showBidPrices = false;
|
|
493
|
+
artwork = null;
|
|
494
|
+
artworkProjection;
|
|
495
|
+
priceFormatter;
|
|
496
|
+
palette;
|
|
497
|
+
selectedSlotId = null;
|
|
498
|
+
modelUrl;
|
|
499
|
+
raycaster = new THREE3.Raycaster;
|
|
500
|
+
cloth = new ShirtClothMotion2;
|
|
501
|
+
clothMeshes = [];
|
|
502
|
+
clothActive = false;
|
|
503
|
+
lastFrameTime = 0;
|
|
504
|
+
reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
|
|
505
|
+
constructor(canvas, fail, recovering = () => {}, options = {}) {
|
|
506
|
+
this.canvas = canvas;
|
|
507
|
+
this.fail = fail;
|
|
508
|
+
this.recovering = recovering;
|
|
509
|
+
this.modelUrl = new URL(options.modelUrl ?? SHIRT_MODEL_PATH2, document.baseURI);
|
|
510
|
+
this.palette = { ...defaultShirt3dPalette2, ...options.palette };
|
|
511
|
+
this.priceFormatter = this.createPriceFormatter(options.locale);
|
|
512
|
+
this.atlas.width = 2048;
|
|
513
|
+
this.atlas.height = 574;
|
|
514
|
+
this.texture.generateMipmaps = false;
|
|
515
|
+
this.texture.minFilter = THREE3.LinearFilter;
|
|
516
|
+
this.texture.colorSpace = THREE3.SRGBColorSpace;
|
|
517
|
+
this.texture.flipY = false;
|
|
518
|
+
this.renderer = new THREE3.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
|
519
|
+
this.renderer.debug.onShaderError = () => {
|
|
520
|
+
throw new Error("Shirt material shader unavailable");
|
|
521
|
+
};
|
|
522
|
+
this.renderer.toneMapping = THREE3.ACESFilmicToneMapping;
|
|
523
|
+
this.renderer.toneMappingExposure = 1;
|
|
524
|
+
this.renderer.setPixelRatio(Math.min(devicePixelRatio, matchMedia("(pointer: coarse)").matches ? 1.5 : 2));
|
|
525
|
+
this.fabricTexture.anisotropy = Math.min(4, this.renderer.capabilities.getMaxAnisotropy());
|
|
526
|
+
this.camera.position.z = 5;
|
|
527
|
+
this.scene.add(new THREE3.HemisphereLight(16777215, 7827851, 2.3));
|
|
528
|
+
const key = new THREE3.DirectionalLight(16777215, 2.2);
|
|
529
|
+
key.position.set(-3, 4, 5);
|
|
530
|
+
this.scene.add(key);
|
|
531
|
+
const back = new THREE3.DirectionalLight(16777215, 1.6);
|
|
532
|
+
back.position.set(3, 2, -4);
|
|
533
|
+
this.scene.add(back);
|
|
534
|
+
this.observer = new ResizeObserver(() => {
|
|
535
|
+
if (this.disposed || this.contextUnavailable)
|
|
536
|
+
return;
|
|
537
|
+
try {
|
|
538
|
+
const rect = canvas.getBoundingClientRect();
|
|
539
|
+
this.renderer.setSize(rect.width, rect.height, false);
|
|
540
|
+
this.render();
|
|
541
|
+
} catch {
|
|
542
|
+
this.fail();
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
this.observer.observe(canvas);
|
|
546
|
+
canvas.addEventListener("webglcontextlost", this.contextLost);
|
|
547
|
+
canvas.addEventListener("webglcontextrestored", this.contextRestored);
|
|
548
|
+
this.reducedMotion.addEventListener("change", this.resetCloth);
|
|
549
|
+
document.addEventListener("visibilitychange", this.resetCloth);
|
|
550
|
+
}
|
|
551
|
+
async load() {
|
|
552
|
+
const url = this.modelUrl;
|
|
553
|
+
const response = await fetch(url, { signal: this.loadController.signal });
|
|
554
|
+
if (!response.ok)
|
|
555
|
+
throw new Error(`Shirt model request failed (${response.status})`);
|
|
556
|
+
const data = await response.arrayBuffer();
|
|
557
|
+
this.loadController.signal.throwIfAborted();
|
|
558
|
+
const gltf = await new GLTFLoader().parseAsync(data, new URL(".", url).href);
|
|
559
|
+
if (this.disposed) {
|
|
560
|
+
this.disposeModel(gltf.scene);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
this.model = gltf.scene;
|
|
564
|
+
this.model.traverse((object) => {
|
|
565
|
+
if (!(object instanceof THREE3.Mesh))
|
|
566
|
+
return;
|
|
567
|
+
this.clothMeshes.push(new ShirtClothMesh2(object.geometry));
|
|
568
|
+
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
569
|
+
for (const material of materials) {
|
|
570
|
+
if (!(material instanceof THREE3.MeshStandardMaterial))
|
|
571
|
+
continue;
|
|
572
|
+
material.color.set(16777215);
|
|
573
|
+
material.side = THREE3.DoubleSide;
|
|
574
|
+
material.metalness = 0;
|
|
575
|
+
material.roughness = 1;
|
|
576
|
+
material.roughnessMap = this.fabricTexture;
|
|
577
|
+
material.bumpMap = this.fabricTexture;
|
|
578
|
+
material.bumpScale = 0.0012;
|
|
579
|
+
material.map = this.texture;
|
|
580
|
+
if (material instanceof THREE3.MeshPhysicalMaterial) {
|
|
581
|
+
material.sheen = 0.3;
|
|
582
|
+
material.sheenColorMap = null;
|
|
583
|
+
material.sheenRoughness = 1;
|
|
584
|
+
material.specularIntensity = 0.3;
|
|
585
|
+
material.clearcoat = 0;
|
|
586
|
+
this.fabricMaterials.push(material);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
this.scene.add(this.model);
|
|
591
|
+
this.paint();
|
|
592
|
+
await new Promise((resolve, reject) => {
|
|
593
|
+
this.firstFrame = { resolve, reject };
|
|
594
|
+
this.render();
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
update(slots, side, rotating, color = "#ffffff", showBidPrices = false, artwork = null, viewSurface) {
|
|
598
|
+
this.slots = slots;
|
|
599
|
+
if (side !== this.side || viewSurface !== this.viewSurface) {
|
|
600
|
+
this.viewSurface = viewSurface;
|
|
601
|
+
this.resetView();
|
|
602
|
+
}
|
|
603
|
+
this.side = side;
|
|
604
|
+
this.rotating = rotating;
|
|
605
|
+
this.shirtColor = color;
|
|
606
|
+
this.showBidPrices = showBidPrices;
|
|
607
|
+
this.artwork = artwork;
|
|
608
|
+
const urls = new Set([
|
|
609
|
+
...slots.map((slot) => slot.isReserved ? slot.reservedLogo : leader2(slot)?.logo),
|
|
610
|
+
artwork?.front?.url,
|
|
611
|
+
artwork?.back?.url
|
|
612
|
+
].filter((url) => !!url));
|
|
613
|
+
for (const [url, image] of this.images)
|
|
614
|
+
if (!urls.has(url)) {
|
|
615
|
+
image.onload = null;
|
|
616
|
+
this.images.delete(url);
|
|
617
|
+
}
|
|
618
|
+
for (const url of urls)
|
|
619
|
+
if (!this.images.has(url)) {
|
|
620
|
+
const image = new Image;
|
|
621
|
+
if (/^https?:\/\//i.test(url))
|
|
622
|
+
image.crossOrigin = "anonymous";
|
|
623
|
+
this.images.set(url, image);
|
|
624
|
+
image.onload = () => {
|
|
625
|
+
if (!this.disposed) {
|
|
626
|
+
this.paint();
|
|
627
|
+
this.render();
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
image.src = url;
|
|
631
|
+
}
|
|
632
|
+
this.paint();
|
|
633
|
+
this.render();
|
|
634
|
+
}
|
|
635
|
+
async snapshot(side = "front") {
|
|
636
|
+
return this.capture(side, "image/png");
|
|
637
|
+
}
|
|
638
|
+
async productionSnapshot(view) {
|
|
639
|
+
return this.capture(view, "image/webp");
|
|
640
|
+
}
|
|
641
|
+
async capture(view, mimeType) {
|
|
642
|
+
if (!this.model || this.disposed || this.contextUnavailable)
|
|
643
|
+
throw new Error("Shirt renderer unavailable");
|
|
644
|
+
await Promise.all([...this.images.values()].map((image) => image.decode()));
|
|
645
|
+
if (this.disposed || !this.model || this.contextUnavailable)
|
|
646
|
+
throw new Error("Shirt renderer unavailable");
|
|
647
|
+
this.resetCloth();
|
|
648
|
+
this.paintNow();
|
|
649
|
+
const rotation = this.model.rotation.clone();
|
|
650
|
+
const zoom = this.camera.zoom;
|
|
651
|
+
try {
|
|
652
|
+
this.model.rotation.set(view === "top" ? Math.PI / 2 : 0, view === "back" ? Math.PI : view === "left" ? Math.PI / 2 : view === "right" ? -Math.PI / 2 : 0, 0);
|
|
653
|
+
this.camera.zoom = 1;
|
|
654
|
+
this.camera.updateProjectionMatrix();
|
|
655
|
+
this.renderer.render(this.scene, this.camera);
|
|
656
|
+
const preview = document.createElement("canvas");
|
|
657
|
+
preview.width = 640;
|
|
658
|
+
preview.height = Math.round(640 * this.renderer.domElement.height / this.renderer.domElement.width);
|
|
659
|
+
const context = preview.getContext("2d");
|
|
660
|
+
if (!context)
|
|
661
|
+
throw new Error("Canvas unavailable");
|
|
662
|
+
if (mimeType === "image/webp") {
|
|
663
|
+
context.fillStyle = "#ffffff";
|
|
664
|
+
context.fillRect(0, 0, preview.width, preview.height);
|
|
665
|
+
}
|
|
666
|
+
context.drawImage(this.renderer.domElement, 0, 0, preview.width, preview.height);
|
|
667
|
+
return await new Promise((resolve, reject) => preview.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Preview encoding failed")), mimeType, mimeType === "image/webp" ? 0.82 : undefined));
|
|
668
|
+
} finally {
|
|
669
|
+
this.model.rotation.copy(rotation);
|
|
670
|
+
this.camera.zoom = zoom;
|
|
671
|
+
this.camera.updateProjectionMatrix();
|
|
672
|
+
this.render();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
setSelectedSlot(id) {
|
|
676
|
+
if (id === this.selectedSlotId)
|
|
677
|
+
return;
|
|
678
|
+
this.selectedSlotId = id;
|
|
679
|
+
this.paint();
|
|
680
|
+
this.render();
|
|
681
|
+
}
|
|
682
|
+
setAppearance(options) {
|
|
683
|
+
if (options.palette)
|
|
684
|
+
this.palette = { ...defaultShirt3dPalette2, ...options.palette };
|
|
685
|
+
if (options.locale !== undefined)
|
|
686
|
+
this.priceFormatter = this.createPriceFormatter(options.locale);
|
|
687
|
+
this.paint();
|
|
688
|
+
this.render();
|
|
689
|
+
}
|
|
690
|
+
createPriceFormatter(locale) {
|
|
691
|
+
const options = { style: "currency", currency: "EUR", maximumFractionDigits: 0 };
|
|
692
|
+
try {
|
|
693
|
+
return new Intl.NumberFormat(locale || document.documentElement.lang || "es-ES", options);
|
|
694
|
+
} catch {
|
|
695
|
+
return new Intl.NumberFormat("es-ES", options);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
rotate(dx, dy) {
|
|
699
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy))
|
|
700
|
+
return;
|
|
701
|
+
const previousTilt = this.tilt;
|
|
702
|
+
this.rotation += dx * 0.01;
|
|
703
|
+
this.tilt = Math.max(-0.7, Math.min(0.7, this.tilt + dy * 0.008));
|
|
704
|
+
if (!this.reducedMotion.matches && !document.hidden && (dx || this.tilt !== previousTilt)) {
|
|
705
|
+
this.cloth.turn(dx * 0.01, this.tilt - previousTilt);
|
|
706
|
+
this.clothActive = true;
|
|
707
|
+
}
|
|
708
|
+
this.render();
|
|
709
|
+
}
|
|
710
|
+
zoomBy(delta) {
|
|
711
|
+
this.zoom = Math.max(0.7, Math.min(1.8, this.zoom + delta));
|
|
712
|
+
this.render();
|
|
713
|
+
}
|
|
714
|
+
resetView() {
|
|
715
|
+
this.rotation = this.viewSurface === "left-sleeve" ? Math.PI / 2 : this.viewSurface === "right-sleeve" ? -Math.PI / 2 : 0;
|
|
716
|
+
this.tilt = 0;
|
|
717
|
+
this.zoom = 1;
|
|
718
|
+
this.resetCloth();
|
|
719
|
+
}
|
|
720
|
+
surface(clientX, clientY) {
|
|
721
|
+
if (!this.model)
|
|
722
|
+
return;
|
|
723
|
+
const bounds = this.canvas.getBoundingClientRect();
|
|
724
|
+
this.raycaster.setFromCamera(new THREE3.Vector2((clientX - bounds.left) / bounds.width * 2 - 1, -((clientY - bounds.top) / bounds.height) * 2 + 1), this.camera);
|
|
725
|
+
const hit = this.raycaster.intersectObject(this.model, true).find((item) => item.uv);
|
|
726
|
+
if (!hit?.uv)
|
|
727
|
+
return;
|
|
728
|
+
const column = Math.min(3, Math.floor(hit.uv.x * 4));
|
|
729
|
+
let torso;
|
|
730
|
+
if (column >= 2 && hit.object instanceof THREE3.Mesh && hit.face) {
|
|
731
|
+
const geometry = hit.object.geometry;
|
|
732
|
+
const projection = geometry.getAttribute("wodiraArtworkUv");
|
|
733
|
+
const positions = geometry.getAttribute("position");
|
|
734
|
+
if (projection) {
|
|
735
|
+
const { a, b, c } = hit.face;
|
|
736
|
+
const weights = THREE3.Triangle.getBarycoord(hit.object.worldToLocal(hit.point.clone()), new THREE3.Vector3().fromBufferAttribute(positions, a), new THREE3.Vector3().fromBufferAttribute(positions, b), new THREE3.Vector3().fromBufferAttribute(positions, c), new THREE3.Vector3);
|
|
737
|
+
if (weights) {
|
|
738
|
+
const uv = new THREE3.Vector3().addScaledVector(new THREE3.Vector3().fromBufferAttribute(projection, a), weights.x).addScaledVector(new THREE3.Vector3().fromBufferAttribute(projection, b), weights.y).addScaledVector(new THREE3.Vector3().fromBufferAttribute(projection, c), weights.z);
|
|
739
|
+
torso = {
|
|
740
|
+
side: uv.z >= 0 ? "front" : "back",
|
|
741
|
+
surface: undefined,
|
|
742
|
+
x: (uv.z >= 0 ? uv.x : 1 - uv.x) * 100,
|
|
743
|
+
y: uv.y * 100
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
torso,
|
|
750
|
+
side: column === 1 ? "back" : "front",
|
|
751
|
+
surface: column === 2 ? "left-sleeve" : column === 3 ? "right-sleeve" : undefined,
|
|
752
|
+
x: (hit.uv.x * 4 - column) * 100,
|
|
753
|
+
y: hit.uv.y * 100
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
pick(clientX, clientY) {
|
|
757
|
+
const point = this.surface(clientX, clientY);
|
|
758
|
+
if (!point)
|
|
759
|
+
return;
|
|
760
|
+
for (const candidate of [point, point.torso]) {
|
|
761
|
+
if (!candidate)
|
|
762
|
+
continue;
|
|
763
|
+
const slot = this.slots.find((slot) => candidate.side === slot.side && candidate.surface === slot.surface && candidate.x >= slot.x && candidate.x <= slot.x + slot.width && candidate.y >= slot.y && candidate.y <= slot.y + slot.height);
|
|
764
|
+
if (slot)
|
|
765
|
+
return slot;
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
paint() {
|
|
770
|
+
this.textureDirty = true;
|
|
771
|
+
}
|
|
772
|
+
paintNow() {
|
|
773
|
+
const ctx = this.atlas.getContext("2d");
|
|
774
|
+
if (!ctx)
|
|
775
|
+
return;
|
|
776
|
+
ctx.setTransform(this.atlas.width / 4000, 0, 0, this.atlas.height / 1120, 0, 0);
|
|
777
|
+
const dye = new THREE3.Color(this.shirtColor).lerp(new THREE3.Color(16777215), 0.004);
|
|
778
|
+
if (this.model && !this.artworkProjection) {
|
|
779
|
+
this.resetCloth();
|
|
780
|
+
this.artworkProjection = new ShirtArtworkProjection2(this.model);
|
|
781
|
+
}
|
|
782
|
+
this.artworkProjection?.update(this.artwork, this.images, dye);
|
|
783
|
+
for (const material of this.fabricMaterials) {
|
|
784
|
+
material.sheenColor.copy(dye).lerp(new THREE3.Color(16777215), 0.35);
|
|
785
|
+
}
|
|
786
|
+
ctx.clearRect(0, 0, 4000, 1120);
|
|
787
|
+
for (const slot of this.slots) {
|
|
788
|
+
const x = (slot.surface === "left-sleeve" ? 2000 : slot.surface === "right-sleeve" ? 3000 : slot.side === "back" ? 1000 : 0) + slot.x * 10, y = slot.y * 11.2, w = slot.width * 10, h = slot.height * 11.2;
|
|
789
|
+
ctx.save();
|
|
790
|
+
if (slot.surface === "left-sleeve") {
|
|
791
|
+
ctx.translate(2 * x + w, 0);
|
|
792
|
+
ctx.scale(-1, 1);
|
|
793
|
+
}
|
|
794
|
+
const currentBid = leader2(slot);
|
|
795
|
+
const logo = slot.isReserved ? slot.reservedLogo : currentBid?.logo;
|
|
796
|
+
const image = logo ? this.images.get(logo) : undefined;
|
|
797
|
+
if (image?.complete && image.naturalWidth) {
|
|
798
|
+
const scale = Math.min(w / image.naturalWidth, h / image.naturalHeight);
|
|
799
|
+
const iw = image.naturalWidth * scale, ih = image.naturalHeight * scale;
|
|
800
|
+
ctx.drawImage(image, x + (w - iw) / 2, y + (h - ih) / 2, iw, ih);
|
|
801
|
+
} else if (this.rotating) {
|
|
802
|
+
ctx.fillStyle = slot.isReserved ? this.palette.reserved : slot.id === "drawing-preview" ? this.palette.slotPreview : this.palette.slot;
|
|
803
|
+
ctx.fillRect(x, y, w, h);
|
|
804
|
+
ctx.strokeStyle = this.palette.slotText;
|
|
805
|
+
ctx.lineWidth = 5;
|
|
806
|
+
ctx.strokeRect(x, y, w, h);
|
|
807
|
+
ctx.fillStyle = this.palette.slotText;
|
|
808
|
+
ctx.font = "bold 24px sans-serif";
|
|
809
|
+
ctx.textBaseline = "middle";
|
|
810
|
+
ctx.textAlign = "center";
|
|
811
|
+
ctx.fillText(slot.name, x + w / 2, y + h * (this.showBidPrices ? 0.36 : 0.5), w - 10);
|
|
812
|
+
}
|
|
813
|
+
if (slot.id === this.selectedSlotId) {
|
|
814
|
+
ctx.strokeStyle = this.palette.selected;
|
|
815
|
+
ctx.lineWidth = 12;
|
|
816
|
+
ctx.strokeRect(x - 6, y - 6, w + 12, h + 12);
|
|
817
|
+
}
|
|
818
|
+
if (this.showBidPrices && !slot.isReserved && slot.id !== "drawing-preview") {
|
|
819
|
+
this.paintBidPrice(ctx, x, y, w, h, slot.bidPrice ?? currentBid?.amount ?? slot.startingPrice, !!currentBid);
|
|
820
|
+
}
|
|
821
|
+
ctx.restore();
|
|
822
|
+
}
|
|
823
|
+
this.texture.needsUpdate = true;
|
|
824
|
+
}
|
|
825
|
+
paintBidPrice(ctx, x, y, width, height, amount, hasBid) {
|
|
826
|
+
if (!Number.isFinite(amount) || amount <= 0)
|
|
827
|
+
return;
|
|
828
|
+
const label = this.priceFormatter.format(amount);
|
|
829
|
+
const badgeHeight = Math.min(56, height * 0.43);
|
|
830
|
+
const fontSize = Math.min(46, badgeHeight * 0.82);
|
|
831
|
+
ctx.save();
|
|
832
|
+
ctx.font = `700 ${fontSize}px sans-serif`;
|
|
833
|
+
const badgeWidth = Math.min(width - 12, ctx.measureText(label).width + 26);
|
|
834
|
+
const badgeX = x + (width - badgeWidth) / 2;
|
|
835
|
+
const badgeY = y + height - badgeHeight - 8;
|
|
836
|
+
ctx.beginPath();
|
|
837
|
+
ctx.roundRect(badgeX, badgeY, badgeWidth, badgeHeight, Math.min(15, badgeHeight / 3));
|
|
838
|
+
ctx.fillStyle = hasBid ? this.palette.priceLeading : this.palette.price;
|
|
839
|
+
ctx.shadowColor = "rgba(16, 22, 50, 0.3)";
|
|
840
|
+
ctx.shadowBlur = 10;
|
|
841
|
+
ctx.shadowOffsetY = 3;
|
|
842
|
+
ctx.fill();
|
|
843
|
+
ctx.shadowColor = "transparent";
|
|
844
|
+
ctx.strokeStyle = this.palette.slotText;
|
|
845
|
+
ctx.lineWidth = 3;
|
|
846
|
+
ctx.stroke();
|
|
847
|
+
ctx.fillStyle = this.palette.slotText;
|
|
848
|
+
ctx.textAlign = "center";
|
|
849
|
+
ctx.textBaseline = "middle";
|
|
850
|
+
ctx.fillText(label, x + width / 2, badgeY + badgeHeight / 2, badgeWidth - 14);
|
|
851
|
+
ctx.restore();
|
|
852
|
+
}
|
|
853
|
+
render() {
|
|
854
|
+
if (this.disposed || this.contextUnavailable)
|
|
855
|
+
return;
|
|
856
|
+
if (this.frame)
|
|
857
|
+
return;
|
|
858
|
+
if (!this.lastFrameTime)
|
|
859
|
+
this.lastFrameTime = performance.now();
|
|
860
|
+
this.frame = requestAnimationFrame((time) => {
|
|
861
|
+
this.frame = 0;
|
|
862
|
+
if (this.disposed || this.contextUnavailable)
|
|
863
|
+
return;
|
|
864
|
+
try {
|
|
865
|
+
const elapsed = Math.max(0, (time - this.lastFrameTime) / 1000);
|
|
866
|
+
this.lastFrameTime = time;
|
|
867
|
+
if (this.clothActive) {
|
|
868
|
+
this.clothActive = this.cloth.advance(elapsed);
|
|
869
|
+
for (const mesh of this.clothMeshes) {
|
|
870
|
+
if (this.clothActive)
|
|
871
|
+
mesh.apply(this.cloth);
|
|
872
|
+
else
|
|
873
|
+
mesh.restore();
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (this.textureDirty) {
|
|
877
|
+
this.paintNow();
|
|
878
|
+
this.textureDirty = false;
|
|
879
|
+
}
|
|
880
|
+
if (this.model)
|
|
881
|
+
this.model.rotation.set(this.tilt, (this.side === "back" ? Math.PI : 0) + this.rotation, 0);
|
|
882
|
+
this.camera.zoom = this.zoom;
|
|
883
|
+
this.camera.updateProjectionMatrix();
|
|
884
|
+
this.renderer.render(this.scene, this.camera);
|
|
885
|
+
if (this.renderer.getContext().isContextLost())
|
|
886
|
+
return;
|
|
887
|
+
if (this.model && this.canvas.clientWidth > 0 && this.canvas.clientHeight > 0) {
|
|
888
|
+
this.firstFrame?.resolve();
|
|
889
|
+
this.firstFrame = undefined;
|
|
890
|
+
clearTimeout(this.recoveryTimer);
|
|
891
|
+
this.recovering(false);
|
|
892
|
+
}
|
|
893
|
+
if (this.clothActive)
|
|
894
|
+
this.render();
|
|
895
|
+
else
|
|
896
|
+
this.lastFrameTime = 0;
|
|
897
|
+
} catch {
|
|
898
|
+
this.fail();
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
resetCloth = () => {
|
|
903
|
+
this.cloth.reset();
|
|
904
|
+
this.clothActive = false;
|
|
905
|
+
this.lastFrameTime = this.frame ? performance.now() : 0;
|
|
906
|
+
for (const mesh of this.clothMeshes)
|
|
907
|
+
mesh.restore();
|
|
908
|
+
this.render();
|
|
909
|
+
};
|
|
910
|
+
createFabricTexture() {
|
|
911
|
+
const size = 128;
|
|
912
|
+
const data = new Uint8Array(size * size * 4);
|
|
913
|
+
for (let y = 0;y < size; y++)
|
|
914
|
+
for (let x = 0;x < size; x++) {
|
|
915
|
+
const row = y / 16 * Math.PI * 2;
|
|
916
|
+
const column = x / 16 * Math.PI * 2;
|
|
917
|
+
const yarn = Math.sin(column + Math.sin(row) * 0.65) * Math.cos(row);
|
|
918
|
+
const fibre = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
|
|
919
|
+
const grain = (fibre - Math.floor(fibre) - 0.5) * 10;
|
|
920
|
+
const offset = (y * size + x) * 4;
|
|
921
|
+
data[offset] = Math.round(128 + yarn * 35 + grain);
|
|
922
|
+
data[offset + 1] = Math.round(237 + yarn * 10 + grain * 0.3);
|
|
923
|
+
data[offset + 2] = 0;
|
|
924
|
+
data[offset + 3] = 255;
|
|
925
|
+
}
|
|
926
|
+
const texture = new THREE3.DataTexture(data, size, size, THREE3.RGBAFormat);
|
|
927
|
+
texture.wrapS = texture.wrapT = THREE3.RepeatWrapping;
|
|
928
|
+
texture.repeat.set(64, 20);
|
|
929
|
+
texture.generateMipmaps = true;
|
|
930
|
+
texture.minFilter = THREE3.LinearMipmapLinearFilter;
|
|
931
|
+
texture.magFilter = THREE3.LinearFilter;
|
|
932
|
+
texture.needsUpdate = true;
|
|
933
|
+
return texture;
|
|
934
|
+
}
|
|
935
|
+
contextLost = (event) => {
|
|
936
|
+
event.preventDefault();
|
|
937
|
+
if (this.disposed)
|
|
938
|
+
return;
|
|
939
|
+
this.contextUnavailable = true;
|
|
940
|
+
cancelAnimationFrame(this.frame);
|
|
941
|
+
this.frame = 0;
|
|
942
|
+
this.recovering(true);
|
|
943
|
+
clearTimeout(this.recoveryTimer);
|
|
944
|
+
this.recoveryTimer = setTimeout(() => this.fail(), 8000);
|
|
945
|
+
};
|
|
946
|
+
contextRestored = () => {
|
|
947
|
+
if (this.disposed)
|
|
948
|
+
return;
|
|
949
|
+
this.contextUnavailable = false;
|
|
950
|
+
try {
|
|
951
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
952
|
+
this.renderer.setSize(rect.width, rect.height, false);
|
|
953
|
+
this.paint();
|
|
954
|
+
this.resetCloth();
|
|
955
|
+
} catch {
|
|
956
|
+
this.fail();
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
disposeModel(model) {
|
|
960
|
+
model.traverse((object) => {
|
|
961
|
+
if (!(object instanceof THREE3.Mesh))
|
|
962
|
+
return;
|
|
963
|
+
object.geometry.dispose();
|
|
964
|
+
for (const material of Array.isArray(object.material) ? object.material : [object.material])
|
|
965
|
+
material.dispose();
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
dispose() {
|
|
969
|
+
if (this.disposed)
|
|
970
|
+
return;
|
|
971
|
+
this.disposed = true;
|
|
972
|
+
this.loadController.abort();
|
|
973
|
+
clearTimeout(this.recoveryTimer);
|
|
974
|
+
this.firstFrame?.reject(new Error("Shirt renderer disposed"));
|
|
975
|
+
this.firstFrame = undefined;
|
|
976
|
+
cancelAnimationFrame(this.frame);
|
|
977
|
+
this.observer.disconnect();
|
|
978
|
+
this.canvas.removeEventListener("webglcontextlost", this.contextLost);
|
|
979
|
+
this.canvas.removeEventListener("webglcontextrestored", this.contextRestored);
|
|
980
|
+
this.reducedMotion.removeEventListener("change", this.resetCloth);
|
|
981
|
+
document.removeEventListener("visibilitychange", this.resetCloth);
|
|
982
|
+
this.clothMeshes = [];
|
|
983
|
+
for (const image of this.images.values())
|
|
984
|
+
image.onload = null;
|
|
985
|
+
this.images.clear();
|
|
986
|
+
this.texture.dispose();
|
|
987
|
+
this.artworkProjection?.dispose();
|
|
988
|
+
this.fabricTexture.dispose();
|
|
989
|
+
this.fabricMaterials = [];
|
|
990
|
+
if (this.model)
|
|
991
|
+
this.disposeModel(this.model);
|
|
992
|
+
this.renderer.dispose();
|
|
993
|
+
this.renderer.forceContextLoss();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export { leader2, minimumBid2, hasPaidBids2, reserveBid2, settleBid2, demoCampaign2, ShirtClothMotion2, ShirtClothMesh2, shirtArmhole2, mapShirtHeight2, paintShirtArtwork2, ShirtArtworkProjection2, defaultShirt3dPalette2, SHIRT_MODEL_PATH2, Shirt3dRenderer2 };
|