@gradio/model3d 0.16.8 → 0.17.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 +18 -0
- package/Model3D.test.ts +737 -0
- package/dist/shared/Canvas3DGS.svelte +2 -2
- package/dist/shared/Model3D.svelte +1 -0
- package/package.json +6 -6
- package/shared/Canvas3DGS.svelte +2 -2
- package/shared/Model3D.svelte +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @gradio/model3d
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- [#13275](https://github.com/gradio-app/gradio/pull/13275) [`2d025c5`](https://github.com/gradio-app/gradio/commit/2d025c56cffb5c4eecbe47515ec771b5ecf46611) - Model3D Unit Tests. Thanks @dawoodkhan82!
|
|
8
|
+
|
|
9
|
+
## 0.16.9
|
|
10
|
+
|
|
11
|
+
### Fixes
|
|
12
|
+
|
|
13
|
+
- [#13288](https://github.com/gradio-app/gradio/pull/13288) [`d75e311`](https://github.com/gradio-app/gradio/commit/d75e311a4b64fc1998b6deda3134a3dc6efb5dd5) - `model3d` ply freeze fix. Thanks @dawoodkhan82!
|
|
14
|
+
|
|
15
|
+
### Dependency updates
|
|
16
|
+
|
|
17
|
+
- @gradio/atoms@0.24.0
|
|
18
|
+
- @gradio/statustracker@0.14.1
|
|
19
|
+
- @gradio/upload@0.17.9
|
|
20
|
+
|
|
3
21
|
## 0.16.8
|
|
4
22
|
|
|
5
23
|
### Dependency updates
|
package/Model3D.test.ts
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import {
|
|
2
|
+
test,
|
|
3
|
+
describe,
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeAll,
|
|
6
|
+
afterAll,
|
|
7
|
+
expect,
|
|
8
|
+
vi
|
|
9
|
+
} from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
cleanup,
|
|
12
|
+
render,
|
|
13
|
+
waitFor,
|
|
14
|
+
mock_client,
|
|
15
|
+
upload_file,
|
|
16
|
+
drop_file,
|
|
17
|
+
download_file,
|
|
18
|
+
TEST_GLTF,
|
|
19
|
+
TEST_PLY,
|
|
20
|
+
TEST_SPLAT
|
|
21
|
+
} from "@self/tootils/render";
|
|
22
|
+
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
|
|
23
|
+
import event from "@testing-library/user-event";
|
|
24
|
+
|
|
25
|
+
import Model3D from "./Index.svelte";
|
|
26
|
+
|
|
27
|
+
// BabylonJS and gsplat perform async initialization (canvas setup, model loading)
|
|
28
|
+
// that can complete after component cleanup, causing unhandled rejections.
|
|
29
|
+
// These are harmless race conditions in the 3D rendering libraries, not test bugs.
|
|
30
|
+
function suppress_3d_library_errors(e: PromiseRejectionEvent): void {
|
|
31
|
+
const msg = e.reason?.message ?? "";
|
|
32
|
+
if (
|
|
33
|
+
msg.includes("addEventListener") ||
|
|
34
|
+
msg.includes("Viewer is disposed") ||
|
|
35
|
+
msg.includes("Invalid URL") ||
|
|
36
|
+
msg.includes("Unsupported property type")
|
|
37
|
+
) {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
window.addEventListener("unhandledrejection", suppress_3d_library_errors);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
window.removeEventListener("unhandledrejection", suppress_3d_library_errors);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const base_props = {
|
|
51
|
+
label: "3D Model",
|
|
52
|
+
show_label: true,
|
|
53
|
+
interactive: false,
|
|
54
|
+
value: null as any,
|
|
55
|
+
display_mode: "solid" as "solid" | "point_cloud" | "wireframe",
|
|
56
|
+
clear_color: [0, 0, 0, 0] as [number, number, number, number],
|
|
57
|
+
zoom_speed: 1,
|
|
58
|
+
camera_position: [null, null, null] as [
|
|
59
|
+
number | null,
|
|
60
|
+
number | null,
|
|
61
|
+
number | null
|
|
62
|
+
],
|
|
63
|
+
has_change_history: false,
|
|
64
|
+
buttons: null as any,
|
|
65
|
+
height: undefined
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const interactive_props = {
|
|
69
|
+
...base_props,
|
|
70
|
+
interactive: true,
|
|
71
|
+
client: mock_client()
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Model3D uses BlockLabel (floating label) instead of the standard block-info label,
|
|
75
|
+
// so the shared label tests don't apply — label behaviour is tested manually below.
|
|
76
|
+
run_shared_prop_tests({
|
|
77
|
+
component: Model3D,
|
|
78
|
+
name: "Model3D",
|
|
79
|
+
base_props,
|
|
80
|
+
has_label: false
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("Model3D", () => {
|
|
84
|
+
afterEach(() => cleanup());
|
|
85
|
+
|
|
86
|
+
test("renders empty placeholder when value is null (static)", async () => {
|
|
87
|
+
const { queryByTestId } = await render(Model3D, {
|
|
88
|
+
...base_props,
|
|
89
|
+
value: null
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(queryByTestId("model3d")).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("renders viewer when value is set (static)", async () => {
|
|
96
|
+
const { getByTestId } = await render(Model3D, {
|
|
97
|
+
...base_props,
|
|
98
|
+
value: TEST_GLTF
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("default label is '3D Model' when label is empty", async () => {
|
|
107
|
+
const { getByText } = await render(Model3D, {
|
|
108
|
+
...base_props,
|
|
109
|
+
label: "",
|
|
110
|
+
show_label: true
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(getByText("3D Model")).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("custom label is rendered", async () => {
|
|
117
|
+
const { getByText } = await render(Model3D, {
|
|
118
|
+
...base_props,
|
|
119
|
+
label: "My Fancy Model",
|
|
120
|
+
show_label: true
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(getByText("My Fancy Model")).toBeVisible();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("show_label: false hides the label visually", async () => {
|
|
127
|
+
const { getByText } = await render(Model3D, {
|
|
128
|
+
...base_props,
|
|
129
|
+
label: "Hidden Label",
|
|
130
|
+
show_label: false
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(getByText("Hidden Label")).not.toBeVisible();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("Static mode", () => {
|
|
138
|
+
afterEach(() => cleanup());
|
|
139
|
+
|
|
140
|
+
test("download produces file with orig_name as filename", async () => {
|
|
141
|
+
await render(Model3D, {
|
|
142
|
+
...base_props,
|
|
143
|
+
value: TEST_GLTF
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { suggested_filename } = await download_file(
|
|
147
|
+
"[data-testid='model3d-download-link']"
|
|
148
|
+
);
|
|
149
|
+
expect(suggested_filename).toBe(TEST_GLTF.orig_name);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("download falls back to path when orig_name is missing", async () => {
|
|
153
|
+
const value_no_orig = {
|
|
154
|
+
path: "mymodel.gltf",
|
|
155
|
+
url: "/test/test_files/Box.gltf",
|
|
156
|
+
orig_name: undefined,
|
|
157
|
+
size: 100,
|
|
158
|
+
mime_type: "model/gltf+json"
|
|
159
|
+
};
|
|
160
|
+
await render(Model3D, {
|
|
161
|
+
...base_props,
|
|
162
|
+
value: value_no_orig as any
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const { suggested_filename } = await download_file(
|
|
166
|
+
"[data-testid='model3d-download-link']"
|
|
167
|
+
);
|
|
168
|
+
expect(suggested_filename).toBe("mymodel.gltf");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("undo button is disabled when has_change_history is false", async () => {
|
|
172
|
+
const { getByLabelText } = await render(Model3D, {
|
|
173
|
+
...base_props,
|
|
174
|
+
value: TEST_GLTF,
|
|
175
|
+
has_change_history: false
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(getByLabelText("Undo")).toBeDisabled();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test(".ply value skips undo button (Canvas3DGS branch)", async () => {
|
|
184
|
+
const { queryByLabelText, getByTestId } = await render(Model3D, {
|
|
185
|
+
...base_props,
|
|
186
|
+
value: TEST_PLY
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await waitFor(() => {
|
|
190
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
191
|
+
});
|
|
192
|
+
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test(".splat value skips undo button (Canvas3DGS branch)", async () => {
|
|
196
|
+
const { queryByLabelText, getByTestId } = await render(Model3D, {
|
|
197
|
+
...base_props,
|
|
198
|
+
value: TEST_SPLAT
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test(".gltf value shows undo button (Canvas3D branch)", async () => {
|
|
208
|
+
const { getByLabelText } = await render(Model3D, {
|
|
209
|
+
...base_props,
|
|
210
|
+
value: TEST_GLTF
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(getByLabelText("Undo")).toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("Interactive mode", () => {
|
|
220
|
+
afterEach(() => cleanup());
|
|
221
|
+
|
|
222
|
+
test("renders upload dropzone when value is null", async () => {
|
|
223
|
+
const { getByLabelText } = await render(Model3D, {
|
|
224
|
+
...interactive_props,
|
|
225
|
+
value: null
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(getByLabelText("model3d.drop_to_upload")).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("renders viewer with clear button when value is set", async () => {
|
|
232
|
+
const { getByLabelText, queryByLabelText } = await render(Model3D, {
|
|
233
|
+
...interactive_props,
|
|
234
|
+
value: TEST_GLTF
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(getByLabelText("common.clear")).toBeInTheDocument();
|
|
238
|
+
expect(queryByLabelText("model3d.drop_to_upload")).not.toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("clicking clear transitions back to upload dropzone", async () => {
|
|
242
|
+
const { getByLabelText } = await render(Model3D, {
|
|
243
|
+
...interactive_props,
|
|
244
|
+
value: TEST_GLTF
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const clear_btn = await waitFor(() => getByLabelText("common.clear"));
|
|
248
|
+
await event.click(clear_btn);
|
|
249
|
+
|
|
250
|
+
await waitFor(() => {
|
|
251
|
+
expect(getByLabelText("model3d.drop_to_upload")).toBeInTheDocument();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("undo is available for .gltf files", async () => {
|
|
256
|
+
const { getByLabelText } = await render(Model3D, {
|
|
257
|
+
...interactive_props,
|
|
258
|
+
value: TEST_GLTF
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(getByLabelText("common.undo")).toBeInTheDocument();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("undo is not available for .ply files", async () => {
|
|
265
|
+
const { getByLabelText, queryByLabelText } = await render(Model3D, {
|
|
266
|
+
...interactive_props,
|
|
267
|
+
value: TEST_PLY
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(getByLabelText("common.clear")).toBeInTheDocument();
|
|
271
|
+
expect(queryByLabelText("common.undo")).not.toBeInTheDocument();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("undo is not available for .splat files", async () => {
|
|
275
|
+
const { getByLabelText, queryByLabelText } = await render(Model3D, {
|
|
276
|
+
...interactive_props,
|
|
277
|
+
value: TEST_SPLAT
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(getByLabelText("common.clear")).toBeInTheDocument();
|
|
281
|
+
expect(queryByLabelText("common.undo")).not.toBeInTheDocument();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("clicking undo does not clear the value", async () => {
|
|
285
|
+
const { getByLabelText, get_data } = await render(Model3D, {
|
|
286
|
+
...interactive_props,
|
|
287
|
+
value: TEST_GLTF
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await event.click(getByLabelText("common.undo"));
|
|
291
|
+
|
|
292
|
+
const data = await get_data();
|
|
293
|
+
expect(data.value).toEqual(TEST_GLTF);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("set_data to null shows upload dropzone", async () => {
|
|
297
|
+
const { set_data, getByLabelText } = await render(Model3D, {
|
|
298
|
+
...interactive_props,
|
|
299
|
+
value: TEST_GLTF
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
await set_data({ value: null });
|
|
303
|
+
|
|
304
|
+
await waitFor(() => {
|
|
305
|
+
expect(getByLabelText("model3d.drop_to_upload")).toBeInTheDocument();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("set_data with value shows viewer and clear button", async () => {
|
|
310
|
+
const { set_data, getByLabelText, queryByLabelText } = await render(
|
|
311
|
+
Model3D,
|
|
312
|
+
{
|
|
313
|
+
...interactive_props,
|
|
314
|
+
value: null
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await set_data({ value: TEST_GLTF });
|
|
319
|
+
|
|
320
|
+
await waitFor(() => {
|
|
321
|
+
expect(getByLabelText("common.clear")).toBeInTheDocument();
|
|
322
|
+
});
|
|
323
|
+
expect(queryByLabelText("model3d.drop_to_upload")).not.toBeInTheDocument();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("Events", () => {
|
|
328
|
+
afterEach(() => cleanup());
|
|
329
|
+
|
|
330
|
+
test("mounting with a null value does not fire change", async () => {
|
|
331
|
+
const { listen } = await render(Model3D, {
|
|
332
|
+
...base_props,
|
|
333
|
+
value: null
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const change = listen("change", { retrospective: true });
|
|
337
|
+
expect(change).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("mounting with an initial value does not fire change", async () => {
|
|
341
|
+
const { listen } = await render(Model3D, {
|
|
342
|
+
...base_props,
|
|
343
|
+
value: TEST_GLTF
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const change = listen("change", { retrospective: true });
|
|
347
|
+
expect(change).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("change fires when value changes via set_data", async () => {
|
|
351
|
+
const { listen, set_data } = await render(Model3D, {
|
|
352
|
+
...base_props,
|
|
353
|
+
value: null
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const change = listen("change");
|
|
357
|
+
await set_data({ value: TEST_GLTF });
|
|
358
|
+
|
|
359
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("change deduplicates when set_data called with same value", async () => {
|
|
363
|
+
const { listen, set_data } = await render(Model3D, {
|
|
364
|
+
...base_props,
|
|
365
|
+
value: TEST_GLTF
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const change = listen("change");
|
|
369
|
+
await set_data({ value: TEST_GLTF });
|
|
370
|
+
await set_data({ value: TEST_GLTF });
|
|
371
|
+
|
|
372
|
+
expect(change).not.toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("change fires when set_data clears the value", async () => {
|
|
376
|
+
const { listen, set_data } = await render(Model3D, {
|
|
377
|
+
...base_props,
|
|
378
|
+
value: TEST_GLTF
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const change = listen("change");
|
|
382
|
+
await set_data({ value: null });
|
|
383
|
+
|
|
384
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("clear event fires when clear button clicked (interactive)", async () => {
|
|
388
|
+
const { getByLabelText, listen } = await render(Model3D, {
|
|
389
|
+
...interactive_props,
|
|
390
|
+
value: TEST_GLTF
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const clear = listen("clear");
|
|
394
|
+
const change = listen("change");
|
|
395
|
+
|
|
396
|
+
const clear_btn = await waitFor(() => getByLabelText("common.clear"));
|
|
397
|
+
await event.click(clear_btn);
|
|
398
|
+
|
|
399
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
400
|
+
expect(change).toHaveBeenCalled();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("custom_button_click fires with id when toolbar button clicked", async () => {
|
|
404
|
+
const { getByLabelText, listen } = await render(Model3D, {
|
|
405
|
+
...base_props,
|
|
406
|
+
value: null,
|
|
407
|
+
show_label: true,
|
|
408
|
+
buttons: [{ value: "Action", id: 42, icon: null }] as any
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const custom_click = listen("custom_button_click");
|
|
412
|
+
await event.click(getByLabelText("Action"));
|
|
413
|
+
|
|
414
|
+
expect(custom_click).toHaveBeenCalledTimes(1);
|
|
415
|
+
expect(custom_click).toHaveBeenCalledWith({ id: 42 });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("change fires when switching file types via set_data", async () => {
|
|
419
|
+
const { listen, set_data } = await render(Model3D, {
|
|
420
|
+
...base_props,
|
|
421
|
+
value: TEST_GLTF
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const change = listen("change");
|
|
425
|
+
await set_data({ value: TEST_PLY });
|
|
426
|
+
|
|
427
|
+
expect(change).toHaveBeenCalledTimes(1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("upload event fires when file is uploaded", async () => {
|
|
431
|
+
const { listen } = await render(Model3D, {
|
|
432
|
+
...interactive_props,
|
|
433
|
+
value: null
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const upload = listen("upload");
|
|
437
|
+
const change = listen("change");
|
|
438
|
+
|
|
439
|
+
await upload_file(TEST_GLTF);
|
|
440
|
+
|
|
441
|
+
await waitFor(() => {
|
|
442
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
443
|
+
});
|
|
444
|
+
expect(change).toHaveBeenCalled();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("drag and drop triggers upload and change events", async () => {
|
|
448
|
+
const { listen } = await render(Model3D, {
|
|
449
|
+
...interactive_props,
|
|
450
|
+
value: null
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const upload = listen("upload");
|
|
454
|
+
const change = listen("change");
|
|
455
|
+
|
|
456
|
+
await drop_file(TEST_GLTF, "[aria-label='model3d.drop_to_upload']");
|
|
457
|
+
|
|
458
|
+
await waitFor(() => {
|
|
459
|
+
expect(upload).toHaveBeenCalledTimes(1);
|
|
460
|
+
});
|
|
461
|
+
expect(change).toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("upload failure dispatches error event", async () => {
|
|
465
|
+
const failing_upload = vi
|
|
466
|
+
.fn()
|
|
467
|
+
.mockRejectedValue(new Error("File too large"));
|
|
468
|
+
const { listen } = await render(Model3D, {
|
|
469
|
+
...interactive_props,
|
|
470
|
+
client: {
|
|
471
|
+
upload: failing_upload,
|
|
472
|
+
stream: async () => ({ onmessage: null, close: () => {} })
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const error = listen("error");
|
|
477
|
+
|
|
478
|
+
await upload_file(TEST_GLTF);
|
|
479
|
+
|
|
480
|
+
await waitFor(() => {
|
|
481
|
+
expect(failing_upload).toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
await waitFor(() => {
|
|
485
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
486
|
+
});
|
|
487
|
+
expect(error).toHaveBeenCalledWith("File too large");
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("Props: buttons", () => {
|
|
492
|
+
afterEach(() => cleanup());
|
|
493
|
+
|
|
494
|
+
test("renders buttons when show_label is true and buttons is non-empty", async () => {
|
|
495
|
+
const { getByLabelText } = await render(Model3D, {
|
|
496
|
+
...base_props,
|
|
497
|
+
value: null,
|
|
498
|
+
show_label: true,
|
|
499
|
+
buttons: [{ value: "MyBtn", id: 1, icon: null }] as any
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(getByLabelText("MyBtn")).toBeInTheDocument();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("does not render buttons when show_label is false", async () => {
|
|
506
|
+
const { queryByLabelText } = await render(Model3D, {
|
|
507
|
+
...base_props,
|
|
508
|
+
value: null,
|
|
509
|
+
show_label: false,
|
|
510
|
+
buttons: [{ value: "HiddenBtn", id: 1, icon: null }] as any
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(queryByLabelText("HiddenBtn")).not.toBeInTheDocument();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("does not render custom button when buttons is null", async () => {
|
|
517
|
+
const { queryByLabelText } = await render(Model3D, {
|
|
518
|
+
...base_props,
|
|
519
|
+
value: null,
|
|
520
|
+
show_label: true,
|
|
521
|
+
buttons: null
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(queryByLabelText("Custom action")).not.toBeInTheDocument();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("does not render custom button when buttons is empty array", async () => {
|
|
528
|
+
const { queryByLabelText } = await render(Model3D, {
|
|
529
|
+
...base_props,
|
|
530
|
+
value: null,
|
|
531
|
+
show_label: true,
|
|
532
|
+
buttons: []
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(queryByLabelText("Custom action")).not.toBeInTheDocument();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("renders multiple custom buttons", async () => {
|
|
539
|
+
const { getByLabelText } = await render(Model3D, {
|
|
540
|
+
...base_props,
|
|
541
|
+
value: null,
|
|
542
|
+
show_label: true,
|
|
543
|
+
buttons: [
|
|
544
|
+
{ value: "First", id: 1, icon: null },
|
|
545
|
+
{ value: "Second", id: 2, icon: null }
|
|
546
|
+
] as any
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
expect(getByLabelText("First")).toBeInTheDocument();
|
|
550
|
+
expect(getByLabelText("Second")).toBeInTheDocument();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe("get_data / set_data", () => {
|
|
555
|
+
afterEach(() => cleanup());
|
|
556
|
+
|
|
557
|
+
test("get_data returns null when value is null", async () => {
|
|
558
|
+
const { get_data } = await render(Model3D, {
|
|
559
|
+
...base_props,
|
|
560
|
+
value: null
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const data = await get_data();
|
|
564
|
+
expect(data.value).toBeNull();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("get_data returns FileData when value is set", async () => {
|
|
568
|
+
const { get_data } = await render(Model3D, {
|
|
569
|
+
...base_props,
|
|
570
|
+
value: TEST_GLTF
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const data = await get_data();
|
|
574
|
+
expect(data.value).toEqual(TEST_GLTF);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("set_data populates the viewer", async () => {
|
|
578
|
+
const { set_data, getByTestId } = await render(Model3D, {
|
|
579
|
+
...base_props,
|
|
580
|
+
value: null
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
await set_data({ value: TEST_GLTF });
|
|
584
|
+
|
|
585
|
+
await waitFor(() => {
|
|
586
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("set_data to null removes the viewer (static)", async () => {
|
|
591
|
+
const { set_data, queryByTestId } = await render(Model3D, {
|
|
592
|
+
...base_props,
|
|
593
|
+
value: TEST_GLTF
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
await set_data({ value: null });
|
|
597
|
+
|
|
598
|
+
await waitFor(() => {
|
|
599
|
+
expect(queryByTestId("model3d")).not.toBeInTheDocument();
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("round-trip: set_data then get_data returns same value", async () => {
|
|
604
|
+
const { set_data, get_data } = await render(Model3D, {
|
|
605
|
+
...base_props,
|
|
606
|
+
value: null
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
await set_data({ value: TEST_GLTF });
|
|
610
|
+
const data = await get_data();
|
|
611
|
+
expect(data.value).toEqual(TEST_GLTF);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("user clear reflected in get_data (interactive)", async () => {
|
|
615
|
+
const { getByLabelText, get_data } = await render(Model3D, {
|
|
616
|
+
...interactive_props,
|
|
617
|
+
value: TEST_GLTF
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const clear_btn = await waitFor(() => getByLabelText("common.clear"));
|
|
621
|
+
await event.click(clear_btn);
|
|
622
|
+
|
|
623
|
+
const data = await get_data();
|
|
624
|
+
expect(data.value).toBeNull();
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("set_data switches file type and get_data reflects it", async () => {
|
|
628
|
+
const { set_data, get_data } = await render(Model3D, {
|
|
629
|
+
...base_props,
|
|
630
|
+
value: TEST_GLTF
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
await set_data({ value: TEST_PLY });
|
|
634
|
+
const data = await get_data();
|
|
635
|
+
expect(data.value).toEqual(TEST_PLY);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("set_data in interactive mode reflects in get_data", async () => {
|
|
639
|
+
const { set_data, get_data } = await render(Model3D, {
|
|
640
|
+
...interactive_props,
|
|
641
|
+
value: null
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
await set_data({ value: TEST_GLTF });
|
|
645
|
+
const data = await get_data();
|
|
646
|
+
expect(data.value).toEqual(TEST_GLTF);
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
describe("Edge cases", () => {
|
|
651
|
+
afterEach(() => cleanup());
|
|
652
|
+
|
|
653
|
+
test("value transition null → set → null renders correctly", async () => {
|
|
654
|
+
const { set_data, getByTestId, queryByTestId } = await render(Model3D, {
|
|
655
|
+
...base_props,
|
|
656
|
+
value: null
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await set_data({ value: TEST_GLTF });
|
|
660
|
+
await waitFor(() => {
|
|
661
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
await set_data({ value: null });
|
|
665
|
+
await waitFor(() => {
|
|
666
|
+
expect(queryByTestId("model3d")).not.toBeInTheDocument();
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("switching from gltf to ply removes undo button", async () => {
|
|
671
|
+
const { set_data, getByTestId, getByLabelText, queryByLabelText } =
|
|
672
|
+
await render(Model3D, {
|
|
673
|
+
...base_props,
|
|
674
|
+
value: TEST_GLTF
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
await waitFor(() => {
|
|
678
|
+
expect(getByLabelText("Undo")).toBeInTheDocument();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
await set_data({ value: TEST_PLY });
|
|
682
|
+
await waitFor(() => {
|
|
683
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
684
|
+
});
|
|
685
|
+
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("switching from ply to gltf restores undo button", async () => {
|
|
689
|
+
const { set_data, getByTestId, getByLabelText, queryByLabelText } =
|
|
690
|
+
await render(Model3D, {
|
|
691
|
+
...base_props,
|
|
692
|
+
value: TEST_PLY
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
await waitFor(() => {
|
|
696
|
+
expect(getByTestId("model3d")).toBeInTheDocument();
|
|
697
|
+
});
|
|
698
|
+
expect(queryByLabelText("Undo")).not.toBeInTheDocument();
|
|
699
|
+
|
|
700
|
+
await set_data({ value: TEST_GLTF });
|
|
701
|
+
await waitFor(() => {
|
|
702
|
+
expect(getByLabelText("Undo")).toBeInTheDocument();
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test.todo(
|
|
708
|
+
"VISUAL: display_mode='solid' renders a solid-shaded mesh — needs Playwright visual regression screenshot comparison"
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
test.todo(
|
|
712
|
+
"VISUAL: display_mode='point_cloud' renders the mesh as points — needs Playwright visual regression screenshot comparison"
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
test.todo(
|
|
716
|
+
"VISUAL: display_mode='wireframe' renders the mesh as wireframe — needs Playwright visual regression screenshot comparison"
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
test.todo(
|
|
720
|
+
"VISUAL: clear_color sets the scene background colour — needs Playwright visual regression screenshot comparison"
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
test.todo(
|
|
724
|
+
"VISUAL: zoom_speed affects wheel zoom sensitivity — needs Playwright interaction + screenshot comparison"
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
test.todo(
|
|
728
|
+
"VISUAL: camera_position sets initial alpha/beta/radius of the camera — needs Playwright visual regression screenshot comparison"
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
test.todo(
|
|
732
|
+
"VISUAL: height prop resizes the Block containing the viewer — needs Playwright visual regression screenshot comparison"
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
test.todo(
|
|
736
|
+
"VISUAL: pan_speed affects drag pan sensitivity — needs Playwright interaction + screenshot comparison"
|
|
737
|
+
);
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
let canvas: HTMLCanvasElement;
|
|
19
19
|
let scene: SPLAT.Scene;
|
|
20
20
|
let camera: SPLAT.Camera;
|
|
21
|
-
let renderer
|
|
21
|
+
let renderer: SPLAT.WebGLRenderer | null = null;
|
|
22
22
|
let controls: SPLAT.OrbitControls;
|
|
23
23
|
let mounted = $state(false);
|
|
24
|
-
let frameId
|
|
24
|
+
let frameId: number | null = null;
|
|
25
25
|
|
|
26
26
|
function reset_scene(): void {
|
|
27
27
|
if (frameId !== null) {
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
href={value.url}
|
|
99
99
|
target={window.__is_colab__ ? "_blank" : null}
|
|
100
100
|
download={window.__is_colab__ ? null : value.orig_name || value.path}
|
|
101
|
+
data-testid="model3d-download-link"
|
|
101
102
|
>
|
|
102
103
|
<IconButton Icon={Download} label={i18n("common.download")} />
|
|
103
104
|
</a>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradio/model3d",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Gradio UI packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "",
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"@types/babylon": "^6.16.9",
|
|
14
14
|
"dequal": "^2.0.3",
|
|
15
15
|
"gsplat": "^1.2.9",
|
|
16
|
-
"@gradio/atoms": "^0.
|
|
16
|
+
"@gradio/atoms": "^0.24.0",
|
|
17
17
|
"@gradio/client": "^2.2.0",
|
|
18
|
-
"@gradio/
|
|
19
|
-
"@gradio/
|
|
20
|
-
"@gradio/
|
|
21
|
-
"@gradio/
|
|
18
|
+
"@gradio/statustracker": "^0.14.1",
|
|
19
|
+
"@gradio/upload": "^0.17.9",
|
|
20
|
+
"@gradio/utils": "^0.12.2",
|
|
21
|
+
"@gradio/icons": "^0.15.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@gradio/preview": "^0.16.2"
|
package/shared/Canvas3DGS.svelte
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
let canvas: HTMLCanvasElement;
|
|
19
19
|
let scene: SPLAT.Scene;
|
|
20
20
|
let camera: SPLAT.Camera;
|
|
21
|
-
let renderer
|
|
21
|
+
let renderer: SPLAT.WebGLRenderer | null = null;
|
|
22
22
|
let controls: SPLAT.OrbitControls;
|
|
23
23
|
let mounted = $state(false);
|
|
24
|
-
let frameId
|
|
24
|
+
let frameId: number | null = null;
|
|
25
25
|
|
|
26
26
|
function reset_scene(): void {
|
|
27
27
|
if (frameId !== null) {
|
package/shared/Model3D.svelte
CHANGED
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
href={value.url}
|
|
99
99
|
target={window.__is_colab__ ? "_blank" : null}
|
|
100
100
|
download={window.__is_colab__ ? null : value.orig_name || value.path}
|
|
101
|
+
data-testid="model3d-download-link"
|
|
101
102
|
>
|
|
102
103
|
<IconButton Icon={Download} label={i18n("common.download")} />
|
|
103
104
|
</a>
|