Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Sync from huggingface/lerobot-dataset-visualizer via hub-sync
Browse files
src/app/[org]/[dataset]/[episode]/fetch-data.ts
CHANGED
|
@@ -22,7 +22,13 @@ import {
|
|
| 22 |
depthEncodingFromFeature,
|
| 23 |
} from "@/utils/colormaps";
|
| 24 |
import type { VideoInfo } from "@/types";
|
| 25 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
|
| 27 |
import { authHeaders, getAuthToken } from "@/utils/auth";
|
| 28 |
|
|
@@ -590,6 +596,109 @@ export async function getEpisodeData(
|
|
| 590 |
}
|
| 591 |
}
|
| 592 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
// Legacy v2.x data loading
|
| 594 |
async function getEpisodeDataV2(
|
| 595 |
repoId: string,
|
|
@@ -620,7 +729,19 @@ async function getEpisodeDataV2(
|
|
| 620 |
.map((x) => parseInt(x.trim(), 10))
|
| 621 |
.filter((x) => !isNaN(x));
|
| 622 |
|
| 623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
const videosInfo =
|
| 625 |
info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
|
| 626 |
|
|
|
|
| 22 |
depthEncodingFromFeature,
|
| 23 |
} from "@/utils/colormaps";
|
| 24 |
import type { VideoInfo } from "@/types";
|
| 25 |
+
import {
|
| 26 |
+
LeRobotDataset,
|
| 27 |
+
HttpError,
|
| 28 |
+
fetchRange,
|
| 29 |
+
formatPathTemplate,
|
| 30 |
+
parseInfo,
|
| 31 |
+
} from "@huggingface/lerobot";
|
| 32 |
import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
|
| 33 |
import { authHeaders, getAuthToken } from "@/utils/auth";
|
| 34 |
|
|
|
|
| 596 |
}
|
| 597 |
}
|
| 598 |
|
| 599 |
+
const v2ContiguousIndexes = new Map<string, Promise<boolean>>();
|
| 600 |
+
/// The package's first 8 KB read of episodes.jsonl already holds the first few dozen episodes, so
|
| 601 |
+
/// probing for those would only add a round trip.
|
| 602 |
+
const V2_PROBE_FROM_EPISODE = 32;
|
| 603 |
+
|
| 604 |
+
/**
|
| 605 |
+
* Whether line N of `meta/episodes.jsonl` is episode N, judged from its first and last lines.
|
| 606 |
+
*
|
| 607 |
+
* The package finds line N by re-downloading ever longer prefixes of the file (8 KB × 4^k) one
|
| 608 |
+
* after another and gives up past 4 MB, so the upper episodes of a large v2 dataset could not open.
|
| 609 |
+
* An index running 0..total-1 lets the episode be located from info.json instead; indexes that
|
| 610 |
+
* start elsewhere (10, 20, 30, ... in behavior-1k) keep the package's lookup.
|
| 611 |
+
*/
|
| 612 |
+
function isV2IndexContiguous(
|
| 613 |
+
repoId: string,
|
| 614 |
+
totalEpisodes: number,
|
| 615 |
+
): Promise<boolean> {
|
| 616 |
+
const url = leRobotDataset(repoId).fileUrl("meta/episodes.jsonl");
|
| 617 |
+
let contiguous = v2ContiguousIndexes.get(url);
|
| 618 |
+
if (!contiguous) {
|
| 619 |
+
const read = (start: number, end?: number) =>
|
| 620 |
+
fetchRange(url, start, end, {
|
| 621 |
+
additionalFetchHeaders: authHeaders(),
|
| 622 |
+
}).then(({ bytes }) => new TextDecoder().decode(bytes));
|
| 623 |
+
const headRead = read(0, 4095);
|
| 624 |
+
/// The Hub answers a suffix longer than the file with a 416; the head then holds all of it.
|
| 625 |
+
const tailRead = read(-4096).catch((error) => {
|
| 626 |
+
if (error instanceof HttpError && error.status === 416) return headRead;
|
| 627 |
+
throw error;
|
| 628 |
+
});
|
| 629 |
+
contiguous = Promise.all([headRead, tailRead]).then(
|
| 630 |
+
([head, tail]) => {
|
| 631 |
+
try {
|
| 632 |
+
const first = JSON.parse(head.split("\n")[0]);
|
| 633 |
+
const last = JSON.parse(tail.trimEnd().split("\n").pop() ?? "");
|
| 634 |
+
return (
|
| 635 |
+
first.episode_index === 0 &&
|
| 636 |
+
last.episode_index === totalEpisodes - 1
|
| 637 |
+
);
|
| 638 |
+
} catch {
|
| 639 |
+
/// A line longer than the 4 KB window: the answer would not change on a retry.
|
| 640 |
+
return false;
|
| 641 |
+
}
|
| 642 |
+
},
|
| 643 |
+
() => {
|
| 644 |
+
v2ContiguousIndexes.delete(url);
|
| 645 |
+
return false;
|
| 646 |
+
},
|
| 647 |
+
);
|
| 648 |
+
v2ContiguousIndexes.set(url, contiguous);
|
| 649 |
+
}
|
| 650 |
+
return contiguous;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
/**
|
| 654 |
+
* Enough of the package's episode for getEpisodeDataV2, built from info.json alone. Only
|
| 655 |
+
* `videos[].url` and `data` are filled in: `length`, `durationSec`, `tasks` and `toSec` stay empty,
|
| 656 |
+
* since that path takes its duration from the chart timestamps.
|
| 657 |
+
*/
|
| 658 |
+
async function v2EpisodeFromTemplates(
|
| 659 |
+
repoId: string,
|
| 660 |
+
episodeId: number,
|
| 661 |
+
): Promise<LeRobotEpisode> {
|
| 662 |
+
const dataset = leRobotDataset(repoId);
|
| 663 |
+
const info = await dataset.info();
|
| 664 |
+
/// The package's lookup keeps answering for ids that name no episode ("not found" past the end).
|
| 665 |
+
if (
|
| 666 |
+
!Number.isInteger(episodeId) ||
|
| 667 |
+
episodeId < 0 ||
|
| 668 |
+
episodeId >= info.totalEpisodes
|
| 669 |
+
) {
|
| 670 |
+
return loadLeRobotEpisode(repoId, episodeId);
|
| 671 |
+
}
|
| 672 |
+
const { videoPath } = info;
|
| 673 |
+
const vars = {
|
| 674 |
+
episode_chunk: Math.floor(episodeId / info.chunksSize),
|
| 675 |
+
episode_index: episodeId,
|
| 676 |
+
};
|
| 677 |
+
return {
|
| 678 |
+
index: episodeId,
|
| 679 |
+
length: 0,
|
| 680 |
+
durationSec: 0,
|
| 681 |
+
tasks: [],
|
| 682 |
+
videos:
|
| 683 |
+
videoPath === undefined
|
| 684 |
+
? []
|
| 685 |
+
: info.cameras.map((camera) => ({
|
| 686 |
+
cameraKey: camera.key,
|
| 687 |
+
url: dataset.fileUrl(
|
| 688 |
+
formatPathTemplate(videoPath, { ...vars, video_key: camera.key }),
|
| 689 |
+
),
|
| 690 |
+
fromSec: 0,
|
| 691 |
+
toSec: 0,
|
| 692 |
+
})),
|
| 693 |
+
data: {
|
| 694 |
+
url: dataset.fileUrl(formatPathTemplate(info.dataPath, vars)),
|
| 695 |
+
/// The file holds this one episode, and hyparquet stops at its last row.
|
| 696 |
+
fromRow: 0,
|
| 697 |
+
toRow: Number.MAX_SAFE_INTEGER,
|
| 698 |
+
},
|
| 699 |
+
};
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
// Legacy v2.x data loading
|
| 703 |
async function getEpisodeDataV2(
|
| 704 |
repoId: string,
|
|
|
|
| 729 |
.map((x) => parseInt(x.trim(), 10))
|
| 730 |
.filter((x) => !isNaN(x));
|
| 731 |
|
| 732 |
+
/// Both ways of locating the episode need the package's info.json; fetch it alongside the probe.
|
| 733 |
+
const probe =
|
| 734 |
+
episodeId >= V2_PROBE_FROM_EPISODE ||
|
| 735 |
+
v2ContiguousIndexes.has(
|
| 736 |
+
leRobotDataset(repoId).fileUrl("meta/episodes.jsonl"),
|
| 737 |
+
);
|
| 738 |
+
const [contiguous] = await Promise.all([
|
| 739 |
+
probe && isV2IndexContiguous(repoId, info.total_episodes),
|
| 740 |
+
leRobotDataset(repoId).info(),
|
| 741 |
+
]);
|
| 742 |
+
const leRobotEpisode = contiguous
|
| 743 |
+
? await v2EpisodeFromTemplates(repoId, episodeId)
|
| 744 |
+
: await loadLeRobotEpisode(repoId, episodeId);
|
| 745 |
const videosInfo =
|
| 746 |
info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
|
| 747 |
|
src/components/urdf-viewer.tsx
CHANGED
|
@@ -57,6 +57,104 @@ function loadStlGeometry(url: string): Promise<THREE.BufferGeometry> {
|
|
| 57 |
return loading;
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
// URDFs + meshes are hosted in the Hub bucket at
|
| 61 |
// https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
|
| 62 |
// relative mesh paths against the URDF's own URL, so the bucket layout
|
|
@@ -410,93 +508,14 @@ function RobotScene({
|
|
| 410 |
return group;
|
| 411 |
};
|
| 412 |
loader.loadMeshCb = (url, mgr, onLoad) => {
|
| 413 |
-
// DAE (Collada) files — ColladaLoader yields whatever the .dae author
|
| 414 |
-
// baked in: flat MeshPhongMaterial/MeshBasicMaterial colors plus, in
|
| 415 |
-
// OpenArm's case, ~23 per-file PointLight/SpotLight nodes. The stray
|
| 416 |
-
// lights caused the scene to look pure-white everywhere regardless of
|
| 417 |
-
// our own lighting, and the flat materials looked cartoonish. We strip
|
| 418 |
-
// both and rebuild every mesh with a MeshStandardMaterial bucketed into
|
| 419 |
-
// one of three archetypes (carbon-black, brushed metal, off-white paint)
|
| 420 |
-
// based on the original base-color lightness.
|
| 421 |
if (url.endsWith(".dae")) {
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
if (
|
| 430 |
-
(child as THREE.Light).isLight &&
|
| 431 |
-
!(child instanceof THREE.AmbientLight)
|
| 432 |
-
) {
|
| 433 |
-
strayLights.push(child);
|
| 434 |
-
}
|
| 435 |
-
});
|
| 436 |
-
for (const l of strayLights) l.parent?.remove(l);
|
| 437 |
-
|
| 438 |
-
collada.scene.traverse((child) => {
|
| 439 |
-
if (!(child instanceof THREE.Mesh) || !child.material) return;
|
| 440 |
-
|
| 441 |
-
const originals = Array.isArray(child.material)
|
| 442 |
-
? child.material
|
| 443 |
-
: [child.material];
|
| 444 |
-
|
| 445 |
-
const rebuilt = originals.map((orig) => {
|
| 446 |
-
const srcColor =
|
| 447 |
-
(orig as THREE.MeshStandardMaterial).color ??
|
| 448 |
-
new THREE.Color("#c0c4cc");
|
| 449 |
-
const hsl = { h: 0, s: 0, l: 0 };
|
| 450 |
-
srcColor.getHSL(hsl);
|
| 451 |
-
|
| 452 |
-
// Archetype classification by original lightness.
|
| 453 |
-
let color: THREE.Color;
|
| 454 |
-
let metalness: number;
|
| 455 |
-
let roughness: number;
|
| 456 |
-
let envMapIntensity: number;
|
| 457 |
-
if (hsl.l < 0.3) {
|
| 458 |
-
// Carbon / anodised structural parts
|
| 459 |
-
color = new THREE.Color().setHSL(hsl.h, 0.02, 0.09);
|
| 460 |
-
metalness = 0.15;
|
| 461 |
-
roughness = 0.75;
|
| 462 |
-
envMapIntensity = 0.6;
|
| 463 |
-
} else if (hsl.l < 0.7) {
|
| 464 |
-
// Brushed metal joint collars / accents
|
| 465 |
-
color = new THREE.Color().setHSL(hsl.h, 0.04, 0.42);
|
| 466 |
-
metalness = 0.75;
|
| 467 |
-
roughness = 0.35;
|
| 468 |
-
envMapIntensity = 1.1;
|
| 469 |
-
} else {
|
| 470 |
-
// Off-white painted plates
|
| 471 |
-
color = new THREE.Color().setHSL(hsl.h, 0.03, 0.6);
|
| 472 |
-
metalness = 0.1;
|
| 473 |
-
roughness = 0.5;
|
| 474 |
-
envMapIntensity = 0.9;
|
| 475 |
-
}
|
| 476 |
-
|
| 477 |
-
const mat = new THREE.MeshStandardMaterial({
|
| 478 |
-
color,
|
| 479 |
-
metalness,
|
| 480 |
-
roughness,
|
| 481 |
-
envMapIntensity,
|
| 482 |
-
side: THREE.DoubleSide,
|
| 483 |
-
});
|
| 484 |
-
orig.dispose?.();
|
| 485 |
-
return mat;
|
| 486 |
-
});
|
| 487 |
-
|
| 488 |
-
child.material = Array.isArray(child.material)
|
| 489 |
-
? rebuilt
|
| 490 |
-
: rebuilt[0];
|
| 491 |
-
child.castShadow = true;
|
| 492 |
-
child.receiveShadow = true;
|
| 493 |
-
});
|
| 494 |
-
}
|
| 495 |
-
onLoad(collada.scene);
|
| 496 |
-
},
|
| 497 |
-
undefined,
|
| 498 |
-
(err) => onLoad(new THREE.Object3D(), err as Error),
|
| 499 |
-
);
|
| 500 |
return;
|
| 501 |
}
|
| 502 |
// STL files — apply final PBR materials directly here. We used to do a
|
|
|
|
| 57 |
return loading;
|
| 58 |
}
|
| 59 |
|
| 60 |
+
/// One download and parse per DAE file per session: the OpenArm URDF references finger.dae 4 times
|
| 61 |
+
/// and each arm link twice, and the viewer remounts on every visit to the 3D Replay tab.
|
| 62 |
+
const daeSceneCache = new Map<string, Promise<THREE.Object3D>>();
|
| 63 |
+
|
| 64 |
+
function loadDaeScene(url: string): Promise<THREE.Object3D> {
|
| 65 |
+
let loading = daeSceneCache.get(url);
|
| 66 |
+
if (!loading) {
|
| 67 |
+
loading = new ColladaLoader()
|
| 68 |
+
.loadAsync(url)
|
| 69 |
+
.then((collada) => {
|
| 70 |
+
// DAE (Collada) files — ColladaLoader yields whatever the .dae author
|
| 71 |
+
// baked in: flat MeshPhongMaterial/MeshBasicMaterial colors plus, in
|
| 72 |
+
// OpenArm's case, ~23 per-file PointLight/SpotLight nodes. The stray
|
| 73 |
+
// lights caused the scene to look pure-white everywhere regardless of
|
| 74 |
+
// our own lighting, and the flat materials looked cartoonish. We strip
|
| 75 |
+
// both and rebuild every mesh with a MeshStandardMaterial bucketed into
|
| 76 |
+
// one of three archetypes (carbon-black, brushed metal, off-white paint)
|
| 77 |
+
// based on the original base-color lightness.
|
| 78 |
+
if (url.includes("openarm")) {
|
| 79 |
+
const strayLights: THREE.Object3D[] = [];
|
| 80 |
+
collada.scene.traverse((child) => {
|
| 81 |
+
if (
|
| 82 |
+
(child as THREE.Light).isLight &&
|
| 83 |
+
!(child instanceof THREE.AmbientLight)
|
| 84 |
+
) {
|
| 85 |
+
strayLights.push(child);
|
| 86 |
+
}
|
| 87 |
+
});
|
| 88 |
+
for (const l of strayLights) l.parent?.remove(l);
|
| 89 |
+
|
| 90 |
+
collada.scene.traverse((child) => {
|
| 91 |
+
if (!(child instanceof THREE.Mesh) || !child.material) return;
|
| 92 |
+
|
| 93 |
+
const originals = Array.isArray(child.material)
|
| 94 |
+
? child.material
|
| 95 |
+
: [child.material];
|
| 96 |
+
|
| 97 |
+
const rebuilt = originals.map((orig) => {
|
| 98 |
+
const srcColor =
|
| 99 |
+
(orig as THREE.MeshStandardMaterial).color ??
|
| 100 |
+
new THREE.Color("#c0c4cc");
|
| 101 |
+
const hsl = { h: 0, s: 0, l: 0 };
|
| 102 |
+
srcColor.getHSL(hsl);
|
| 103 |
+
|
| 104 |
+
// Archetype classification by original lightness.
|
| 105 |
+
let color: THREE.Color;
|
| 106 |
+
let metalness: number;
|
| 107 |
+
let roughness: number;
|
| 108 |
+
let envMapIntensity: number;
|
| 109 |
+
if (hsl.l < 0.3) {
|
| 110 |
+
// Carbon / anodised structural parts
|
| 111 |
+
color = new THREE.Color().setHSL(hsl.h, 0.02, 0.09);
|
| 112 |
+
metalness = 0.15;
|
| 113 |
+
roughness = 0.75;
|
| 114 |
+
envMapIntensity = 0.6;
|
| 115 |
+
} else if (hsl.l < 0.7) {
|
| 116 |
+
// Brushed metal joint collars / accents
|
| 117 |
+
color = new THREE.Color().setHSL(hsl.h, 0.04, 0.42);
|
| 118 |
+
metalness = 0.75;
|
| 119 |
+
roughness = 0.35;
|
| 120 |
+
envMapIntensity = 1.1;
|
| 121 |
+
} else {
|
| 122 |
+
// Off-white painted plates
|
| 123 |
+
color = new THREE.Color().setHSL(hsl.h, 0.03, 0.6);
|
| 124 |
+
metalness = 0.1;
|
| 125 |
+
roughness = 0.5;
|
| 126 |
+
envMapIntensity = 0.9;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const mat = new THREE.MeshStandardMaterial({
|
| 130 |
+
color,
|
| 131 |
+
metalness,
|
| 132 |
+
roughness,
|
| 133 |
+
envMapIntensity,
|
| 134 |
+
side: THREE.DoubleSide,
|
| 135 |
+
});
|
| 136 |
+
orig.dispose?.();
|
| 137 |
+
return mat;
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
child.material = Array.isArray(child.material)
|
| 141 |
+
? rebuilt
|
| 142 |
+
: rebuilt[0];
|
| 143 |
+
child.castShadow = true;
|
| 144 |
+
child.receiveShadow = true;
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
return collada.scene;
|
| 148 |
+
})
|
| 149 |
+
.catch((err) => {
|
| 150 |
+
daeSceneCache.delete(url);
|
| 151 |
+
throw err;
|
| 152 |
+
});
|
| 153 |
+
daeSceneCache.set(url, loading);
|
| 154 |
+
}
|
| 155 |
+
return loading;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
// URDFs + meshes are hosted in the Hub bucket at
|
| 159 |
// https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
|
| 160 |
// relative mesh paths against the URDF's own URL, so the bucket layout
|
|
|
|
| 508 |
return group;
|
| 509 |
};
|
| 510 |
loader.loadMeshCb = (url, mgr, onLoad) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
if (url.endsWith(".dae")) {
|
| 512 |
+
/// URDFLoader re-parents and re-orients what it is handed, so it gets a clone (sharing
|
| 513 |
+
/// geometry and materials) and the cached scene stays intact.
|
| 514 |
+
mgr.itemStart(url);
|
| 515 |
+
loadDaeScene(url)
|
| 516 |
+
.then((daeScene) => onLoad(daeScene.clone()))
|
| 517 |
+
.catch((err) => onLoad(new THREE.Object3D(), err as Error))
|
| 518 |
+
.finally(() => mgr.itemEnd(url));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
return;
|
| 520 |
}
|
| 521 |
// STL files — apply final PBR materials directly here. We used to do a
|