Compare commits

..
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 1edeaedfef Bump Crafting App downloads to their latest releases
VectorCraft and EffectCraft move to 0.3.1; FilmCraft, LightCraft,
PrintCraft and DesignCraft to 0.2.1 (the new shared default). PhotoCraft
stays on 0.2.0, which is still its latest release. Every expected asset
exists in each release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:25:18 +07:00
31 changed files with 128 additions and 1046 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 74 KiB

@@ -32,12 +32,11 @@ import { ColorAccent } from "./app-wordmark";
const CELL_HEADING_CLASSES =
"mt-4 font-display text-3xl font-medium leading-[1.05] tracking-[-0.03em] text-ink-strong sm:text-4xl";
// The hero carries shot 01. Window captures pair up, and an odd one out
// leads at full width; portrait pages (layout spreads) sit four across.
// The hero carries shot 01. Window captures lead with a full-width shot,
// then pair; portrait pages (layout spreads) sit four across.
export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
const [, ...shots] = app.shots;
const portrait = shots.every((shot) => shot.portrait);
const leadFullWidth = !portrait && shots.length % 2 === 1;
return (
<SectionShell id="gallery">
<SectionEyebrow
@@ -56,7 +55,7 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) {
<figure
key={shot.file}
data-reveal
className={twMerge("bg-bg", leadFullWidth && i === 0 && "md:col-span-2")}
className={twMerge("bg-bg", !portrait && i === 0 && "md:col-span-2")}
>
<a
href={craftShotSourceUrl(app, shot)}
@@ -171,34 +171,15 @@ export function craftRelease(
return { version, assets };
}
// Windows on ARM builds, which only some apps' releases include so far.
const WITH_WINDOWS_ARM64: readonly CraftReleaseAsset[] = [
...CRAFT_RELEASE_ASSETS,
{
file: "{slug}-{version}-windows-arm64.msi",
group: "Windows",
label: "Installer (ARM64)",
description: "Windows 11 on ARM laptops.",
arch: "aarch64",
},
{
file: "{slug}-{version}-windows-arm64-portable.zip",
group: "Windows",
label: "Portable (ARM64)",
description: "Unzip and run on Windows on ARM.",
arch: "aarch64",
},
];
const LATEST = craftRelease(CRAFTING_APPS_LATEST_VERSION);
// Every app must be listed, so a new slug fails the type check until it is.
export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {
photocraft: craftRelease("0.3.0", WITH_WINDOWS_ARM64),
vectorcraft: craftRelease("0.4.0", WITH_WINDOWS_ARM64),
photocraft: craftRelease("0.2.0"),
vectorcraft: craftRelease("0.3.1"),
filmcraft: LATEST,
lightcraft: LATEST,
printcraft: LATEST,
effectcraft: craftRelease("0.4.0", WITH_WINDOWS_ARM64),
effectcraft: craftRelease("0.3.1"),
designcraft: LATEST,
};
@@ -177,31 +177,25 @@ export const CRAFTING_APPS: CraftApp[] = [
file: "hero.webp",
source: "shot-1-neon.png",
caption: "Neon Drive — made in VectorCraft",
alt: "VectorCraft editing the Neon Drive poster, with the title's live outer glow settings in the Appearance panel and its character settings in the Properties panel",
alt: "VectorCraft editing a synthwave poster titled Neon Drive with an outer glow in the Appearance panel",
},
{
file: "ribbons.webp",
source: "shot-2-ribbons.png",
caption: "Live blends and layers",
alt: "VectorCraft showing three live blend ribbons clipped to the artboard, one selected with its key paths, and the Layers panel listing the clip group and blends",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Pen and direct selection",
alt: "VectorCraft direct-selecting the anchor points and bezier handles of a crescent moon, with the contextual task bar below it",
},
{
file: "perspective.webp",
source: "shot-5-perspective.png",
caption: "Perspective grid, still editable",
alt: "VectorCraft with two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset",
caption: "Live blends between editable spines",
alt: "VectorCraft showing live blends: 70 smooth-color steps between two ribbon paths",
},
{
file: "sheet.webp",
source: "shot-3-sheet.png",
caption: "Booleans, mesh, repeat and envelope",
alt: "VectorCraft in its light theme with four artboards: shape booleans, a gradient mesh, a live radial repeat and an envelope distort",
alt: "VectorCraft feature sheet with exact booleans, a gradient mesh, a live radial repeat and an envelope distort",
},
{
file: "bezier.webp",
source: "shot-4-bezier.png",
caption: "Direct selection on bezier anchors",
alt: "VectorCraft direct-selecting the anchors and handles of a crescent moon path",
},
],
},
@@ -3,7 +3,6 @@ import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import Konva from "konva";
import { disposeObject3D } from "@storyteller/ui-viewer-3d";
import { Node } from "../Node";
import {
Model3DParams,
@@ -227,17 +226,11 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
front.position.set(0, 4, 10);
scene.add(front);
// Load model. Set by cleanup: a model that finishes loading after the
// overlay closed or switched models is freed instead of added.
let cancelled = false;
// Load model
const loader = new GLTFLoader();
loader.load(
node.modelUrl!,
(gltf) => {
if (cancelled) {
disposeObject3D(gltf.scene);
return;
}
const model = gltf.scene;
// Auto-fit + apply user scale
@@ -268,15 +261,18 @@ export const Model3DOverlay = React.forwardRef<Model3DOverlayHandle, Model3DOver
animate();
return () => {
cancelled = true;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
controls.dispose();
disposeObject3D(scene);
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose();
const mats = Array.isArray(obj.material)
? obj.material
: [obj.material];
mats.forEach((m) => m?.dispose());
}
});
renderer.dispose();
// A model change re-runs this effect on the same canvas, which hands
// the next renderer this same context, so only force the loss once
// the canvas has left the DOM (the overlay closed).
if (!canvas.isConnected) renderer.forceContextLoss();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.modelUrl]);
@@ -1,82 +0,0 @@
import * as THREE from "three";
import {
DEFAULT_MODEL3D_PARAMS,
render3DModelToDataUrl,
} from "./render3DModel";
const { renderers, gltfOutcome } = vi.hoisted(() => ({
renderers: [] as FakeRenderer[],
gltfOutcome: { scene: undefined as unknown, error: undefined as unknown },
}));
type FakeRenderer = {
dispose: ReturnType<typeof vi.fn>;
forceContextLoss: ReturnType<typeof vi.fn>;
};
// jsdom has no WebGL; only the teardown calls matter here.
vi.mock("three", async (importOriginal) => {
const actual = await importOriginal<typeof import("three")>();
class WebGLRenderer {
domElement = { toDataURL: () => "data:image/png;base64,AAAA" };
dispose = vi.fn();
forceContextLoss = vi.fn();
constructor() {
renderers.push(this);
}
setClearColor() {}
setSize() {}
render() {}
}
return { ...actual, WebGLRenderer };
});
vi.mock("three/examples/jsm/loaders/GLTFLoader.js", () => ({
GLTFLoader: class {
load(
_url: string,
onLoad: (gltf: { scene: unknown }) => void,
_onProgress: unknown,
onError: (error: unknown) => void,
) {
if (gltfOutcome.error) onError(gltfOutcome.error);
else onLoad({ scene: gltfOutcome.scene });
}
},
}));
describe("render3DModelToDataUrl", () => {
beforeEach(() => {
renderers.length = 0;
gltfOutcome.scene = undefined;
gltfOutcome.error = undefined;
});
it("frees the model and the renderer's context after rendering", async () => {
const geometry = new THREE.BoxGeometry();
const onDispose = vi.fn();
geometry.addEventListener("dispose", onDispose);
gltfOutcome.scene = new THREE.Mesh(geometry);
const dataUrl = await render3DModelToDataUrl(
"model.glb",
DEFAULT_MODEL3D_PARAMS,
);
expect(dataUrl).toMatch(/^data:image\/png/);
expect(onDispose).toHaveBeenCalledTimes(1);
expect(renderers[0].dispose).toHaveBeenCalledTimes(1);
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
});
it("frees the renderer's context when the model fails to load", async () => {
gltfOutcome.error = new Error("404");
await expect(
render3DModelToDataUrl("missing.glb", DEFAULT_MODEL3D_PARAMS),
).rejects.toThrow("404");
expect(renderers[0].dispose).toHaveBeenCalledTimes(1);
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,5 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { disposeObject3D } from "@storyteller/ui-viewer-3d";
export interface Model3DParams {
cameraPosition: { x: number; y: number; z: number };
@@ -77,16 +76,18 @@ function normalizeAndPlaceModel(
model.position.y = -scaledBox.min.y;
}
// The renderer and its canvas are throwaway, so free the scene and then the
// context itself; otherwise each render holds a WebGL context until the
// canvas is garbage collected.
function disposeScene(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
): void {
disposeObject3D(scene);
scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose();
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((m) => m?.dispose());
}
});
renderer.dispose();
renderer.forceContextLoss();
}
export async function render3DModelToDataUrl(
@@ -88,9 +88,6 @@
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
}
]
}
@@ -1127,27 +1127,11 @@ class Editor {
this.bus.emit(new SceneLoadedEvent(false));
this.stopRenderLoop();
this.viewport.dispose();
// Free the scene's GPU resources. EngineProvider serialized the scene
// before calling us, and the next mount builds a fresh Editor, so
// nothing reads this scene again. The gizmo's dispose also removes its
// pointer listeners from the canvas.
this.entranceAnimator.clear();
this.gizmo.control?.dispose();
this.activeScene.dispose();
// Fix: dispose 3D contexts
this.renderer?.dispose();
this.postProcessing.dispose();
this.rawRenderer?.dispose();
// dispose() leaves the GL objects themselves (render targets, programs)
// in the context. Leaving the page detaches the canvases, and the
// context then lingers until garbage collection, two per visit. A
// scene change keeps the canvases mounted and the next Editor reuses
// their contexts, so only release a context whose canvas is gone.
releaseContextIfDetached(this.renderer);
releaseContextIfDetached(this.rawRenderer);
this.isMounted = false;
this.bus.emit(new EngineInitializedEvent(false));
@@ -1161,10 +1145,4 @@ class Editor {
}
}
function releaseContextIfDetached(renderer: THREE.WebGLRenderer | undefined) {
if (renderer && !renderer.domElement.isConnected) {
renderer.forceContextLoss();
}
}
export default Editor;
@@ -1,38 +0,0 @@
import { ViewportController } from "./ViewportController";
describe("ViewportController", () => {
it("stops observing the container on dispose", () => {
const observers = stubResizeObserver();
const renderScene = vi.fn();
const viewport = new ViewportController({
getCamera: () => null,
getRenderCamera: () => null,
getRenderer: () => ({ setSize: vi.fn(), setPixelRatio: vi.fn() }) as never,
getRenderAspectRatio: () => 1,
resizePostProcessing: vi.fn(),
renderScene,
});
viewport.container = document.createElement("div");
viewport.setupResizeObserver();
viewport.dispose();
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
});
});
// jsdom has no ResizeObserver; record each one the controller creates.
function stubResizeObserver() {
const observers: Array<{ disconnect: ReturnType<typeof vi.fn> }> = [];
vi.stubGlobal(
"ResizeObserver",
class {
disconnect = vi.fn();
constructor() {
observers.push(this);
}
observe() {}
},
);
return observers;
}
@@ -100,12 +100,4 @@ export class ViewportController {
});
this.observer.observe(this.container);
}
// Stop observing on unmount. The container can outlive this editor (a
// scene change keeps it mounted), and a live observer would keep
// re-rendering the dead editor's scene on every resize.
dispose() {
this.observer?.disconnect();
this.observer = undefined;
}
}
@@ -148,11 +148,45 @@ function removeObject3D(object3D) {
return;
}
// Finally remove the object from the scene and free its GPU resources.
// Undo re-loads deleted objects from a snapshot (DeleteAction), so
// nothing reuses this instance.
// Finally remove the object from the scene
this.scene.scene.remove(obj);
this.scene.disposeObject(obj);
obj.traverse(child => {
(child as THREE.Mesh)?.geometry?.dispose()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const childTexture = (child as any).texture;
if (Array.isArray(childTexture)) {
childTexture.forEach(mat => mat.dispose());
} else if (childTexture) {
childTexture.dispose();
}
const childMaterial = (child as THREE.Mesh).material;
if (Array.isArray(childMaterial)) {
childMaterial.forEach(mat => mat.dispose());
} else if (childMaterial) {
childMaterial.dispose();
}
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objTexture = (obj as any).texture;
if (Array.isArray(objTexture)) {
objTexture.forEach(mat => mat.dispose());
} else if (objTexture) {
objTexture.dispose();
}
const objMaterial = (obj as THREE.Mesh).material;
if (Array.isArray(objMaterial)) {
objMaterial.forEach(mat => mat.dispose());
} else if (objMaterial) {
objMaterial.dispose();
}
if ((obj as THREE.Mesh).geometry) {
(obj as THREE.Mesh).geometry.dispose()
}
this.deps.bus.emit(new ObjectRemovedEvent(uuid));
this.deps.clearSelected();
@@ -1,167 +0,0 @@
import * as THREE from "three";
import Scene from "./scene";
// Scene imports Spark for splat loading; none of these tests load one.
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
// The Addons barrel pulls in a lottie canvas module jsdom cannot run; Scene
// only needs these two names from it.
vi.mock("three/examples/jsm/Addons.js", async () => {
const { Mesh } = await import("three");
return { MMDAnimationHelper: class {}, Water: class extends Mesh {} };
});
describe("Scene disposal", () => {
describe("disposeObject", () => {
it("frees the object's GPU resources", () => {
const scene = makeScene();
const geometry = new THREE.BoxGeometry();
const onDispose = vi.fn();
geometry.addEventListener("dispose", onDispose);
scene.disposeObject(new THREE.Mesh(geometry));
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("stops shader ticks for disposed water but keeps the rest", () => {
const scene = makeScene();
const water = waterLikeMesh();
const otherWater = waterLikeMesh();
scene.shader_objects = [water, otherWater] as never;
const group = new THREE.Group();
group.add(water);
scene.disposeObject(group);
expect(scene.shader_objects).toEqual([otherWater]);
});
it("stops and forgets the video behind a video plane", () => {
const scene = makeScene();
const video = stubbedVideo();
const otherVideo = stubbedVideo();
scene.video_planes = [video, otherVideo];
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(),
new THREE.MeshBasicMaterial({ map: new THREE.VideoTexture(video) }),
);
scene.disposeObject(plane);
expect(video.pause).toHaveBeenCalled();
expect(video.load).toHaveBeenCalled();
expect(video.hasAttribute("src")).toBe(false);
expect(scene.video_planes).toEqual([otherVideo]);
expect(otherVideo.pause).not.toHaveBeenCalled();
});
it("removes MMD meshes from the animation helper", () => {
const scene = makeScene();
const mmd = new THREE.SkinnedMesh();
const other = new THREE.SkinnedMesh();
const remove = vi.fn();
scene.helper = { meshes: [mmd, other], remove } as never;
scene.disposeObject(mmd);
expect(remove).toHaveBeenCalledTimes(1);
expect(remove).toHaveBeenCalledWith(mmd);
});
});
describe("disposeContents", () => {
it("empties the scene and frees every child", () => {
const scene = makeScene();
const geometries = [new THREE.BoxGeometry(), new THREE.SphereGeometry()];
const disposed = new Set<THREE.BufferGeometry>();
for (const geometry of geometries) {
geometry.addEventListener("dispose", () => disposed.add(geometry));
scene.scene.add(new THREE.Mesh(geometry));
}
const child = scene.scene.children[0];
scene.hot_items = [child];
scene.disposeContents();
expect(scene.scene.children).toHaveLength(0);
expect(child.parent).toBeNull();
expect(scene.hot_items).toEqual([]);
expect(disposed).toEqual(new Set(geometries));
});
it("leaves the background for the skybox to replace", () => {
const scene = makeScene();
const background = new THREE.CubeTexture();
const onDispose = vi.fn();
background.addEventListener("dispose", onDispose);
scene.scene.background = background;
scene.disposeContents();
expect(scene.scene.background).toBe(background);
expect(onDispose).not.toHaveBeenCalled();
});
});
it("dispose frees the contents and the background", () => {
const scene = makeScene();
const geometry = new THREE.BoxGeometry();
const background = new THREE.CubeTexture();
const disposed = new Set<unknown>();
geometry.addEventListener("dispose", () => disposed.add(geometry));
background.addEventListener("dispose", () => disposed.add(background));
scene.scene.add(new THREE.Mesh(geometry));
scene.scene.background = background;
scene.dispose();
expect(disposed).toEqual(new Set([geometry, background]));
expect(scene.scene.children).toHaveLength(0);
expect(scene.scene.background).toBeNull();
});
it("disposeBackground frees a texture background", () => {
const scene = makeScene();
const background = new THREE.CubeTexture();
const onDispose = vi.fn();
background.addEventListener("dispose", onDispose);
scene.scene.background = background;
scene.disposeBackground();
expect(onDispose).toHaveBeenCalledTimes(1);
});
});
function makeScene(): Scene {
return new Scene("test", "::CAM::", () => undefined, 1, {
getCameras: () => [],
getSelectedCameraId: () => "main",
fetchAsset: () => Promise.reject(new Error("no network in tests")),
getMediaUrlByToken: () => Promise.reject(new Error("no network in tests")),
});
}
// Scene only tracks Water instances by identity; a plain mesh stands in.
function waterLikeMesh(): THREE.Mesh {
return new THREE.Mesh(
new THREE.PlaneGeometry(),
new THREE.MeshBasicMaterial(),
);
}
// jsdom does not implement media playback, so stub the calls stopVideo makes.
function stubbedVideo(): HTMLVideoElement {
const video = document.createElement("video");
video.src = "https://example.invalid/clip.mp4";
video.pause = vi.fn();
video.load = vi.fn();
return video;
}
@@ -13,10 +13,6 @@ import type { Camera } from "@storyteller/common";
import toast from "react-hot-toast";
import { SplatMesh } from "@sparkjsdev/spark";
import { ensureInternalBbox } from "./internalBbox";
import {
collectObjectTextures,
disposeObject3D,
} from "@storyteller/ui-viewer-3d";
// Capabilities Scene needs from outside its own state. Editor wires
// these in inline at construction (Phase 2 idiom — same shape as
@@ -138,64 +134,16 @@ class Scene {
}
clear() {
this.disposeContents();
this.scene.children = [];
// hot_items held detached keyframe-point refs across reloads; reset it
// so it never carries stale objects from a previous scene.
this.hot_items = [];
this._createGrid();
this._create_base_lighting();
this._create_skybox();
this._create_camera_obj();
}
// Remove everything from the scene and free its GPU resources. The
// background is left in place: _create_skybox frees it when it is
// replaced, and teardown frees it through disposeBackground.
disposeContents() {
this.disposeObject(this.scene);
this.scene.clear();
// hot_items held detached keyframe-point refs across reloads; reset it
// so it never carries stale objects from a previous scene.
this.hot_items = [];
}
// Free an object tree that has left the scene for good: its GPU
// resources, plus its entries in the per-object registries (Water
// shaders ticked every frame, video elements, MMD animation).
disposeObject(root: THREE.Object3D) {
const inTree = new Set<THREE.Object3D>();
root.traverse((child) => inTree.add(child));
this.shader_objects = this.shader_objects.filter(
(water) => !inTree.has(water),
);
for (const mesh of [...(this.helper?.meshes ?? [])]) {
if (inTree.has(mesh)) this.helper.remove(mesh);
}
const videos = new Set<HTMLVideoElement>();
for (const texture of collectObjectTextures(root)) {
if (texture instanceof THREE.VideoTexture) videos.add(texture.image);
}
for (const video of videos) stopVideo(video);
this.video_planes = this.video_planes.filter(
(video) => !videos.has(video),
);
disposeObject3D(root);
}
// Final teardown when the owning Editor unmounts: free the contents and
// the background. The Scene is not used again — every mount builds a new
// Editor, and EngineProvider serializes the scene before unmounting.
dispose() {
this.disposeContents();
this.disposeBackground();
this.scene.background = null;
}
disposeBackground() {
if (this.scene.background instanceof THREE.Texture) {
this.scene.background.dispose();
}
}
async instantiate(
name: string,
pos: THREE.Vector3 = new THREE.Vector3(0, 0, 0),
@@ -1170,9 +1118,6 @@ class Scene {
// default skybox.
_create_skybox() {
const loader = new THREE.CubeTextureLoader();
// Every branch below replaces the background, so free the cube texture
// being replaced (skybox switches and New Scene used to leak it).
this.disposeBackground();
// Theme-aware: in light mode, use a near-white background for better contrast with UI
try {
@@ -1327,12 +1272,4 @@ class Scene {
}
// Pausing alone keeps the decoder and buffered media alive; dropping the
// source and reloading is what releases them.
function stopVideo(video: HTMLVideoElement) {
video.pause();
video.removeAttribute("src");
video.load();
}
export default Scene;
@@ -1,91 +0,0 @@
import * as THREE from "three";
import Scene from "../engine/scene";
import type { LoadTicket } from "../engine/save_manager";
import { StoryTellerProxyScene } from "./storyteller_proxy_scene";
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
// The Addons barrel pulls in a lottie canvas module jsdom cannot run.
vi.mock("three/examples/jsm/Addons.js", async () => {
const { Mesh } = await import("three");
return { MMDAnimationHelper: class {}, Water: class extends Mesh {} };
});
const BOX_JSON = {
media_file_token: "Parim",
object_name: "Box",
object_user_data_name: "Box",
object_uuid: "box-uuid",
position: new THREE.Vector3(),
rotation: new THREE.Vector3(),
scale: new THREE.Vector3(1, 1, 1),
color: "#FFFFFF",
metalness: 0,
shininess: 0.5,
specular: 0,
locked: false,
visible: true,
media_file_type: "none",
user_data: { shapeKey: "Box" },
};
describe("loadFromSceneJson", () => {
it("removes and frees objects that finish after the load is cancelled", async () => {
const scene = makeScene();
const proxy = new StoryTellerProxyScene(2, scene);
const ticket = makeTicket();
const onDispose = vi.fn();
const addListener = trackFirstMeshGeometry(scene, onDispose);
const load = proxy.loadFromSceneJson([BOX_JSON] as never, "m_1", 2, ticket);
// The box is created before the loader's first await on its tasks;
// cancel while that await is pending, as a newer load or an unmount
// would.
addListener();
ticket.cancelled = true;
await load;
expect(scene.scene.getObjectByProperty("name", "Box")).toBeUndefined();
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("keeps the loaded objects when the load is not cancelled", async () => {
const scene = makeScene();
const proxy = new StoryTellerProxyScene(2, scene);
await proxy.loadFromSceneJson([BOX_JSON] as never, "m_1", 2, makeTicket());
expect(scene.get_object_by_uuid("box-uuid")).toBeDefined();
});
});
function makeScene(): Scene {
return new Scene("test", "::CAM::", () => undefined, 2, {
getCameras: () => [],
getSelectedCameraId: () => "main",
fetchAsset: () => Promise.reject(new Error("no network in tests")),
getMediaUrlByToken: () => Promise.reject(new Error("no network in tests")),
});
}
function makeTicket(): LoadTicket {
return { cancelled: false, signal: new AbortController().signal };
}
// Returns a function that, once the load has created its box, subscribes
// `onDispose` to that box's geometry.
function trackFirstMeshGeometry(
scene: Scene,
onDispose: () => void,
): () => void {
return () => {
const box = scene.scene.getObjectByProperty("name", "Box") as THREE.Mesh;
box.geometry.addEventListener("dispose", onDispose);
};
}
@@ -128,9 +128,9 @@ export class StoryTellerProxyScene {
) {
if (scene_json == null || this.scene == null) return;
// Free the outgoing scene before loading over it; removing the
// children alone left all of their GPU resources allocated.
this.scene.disposeContents();
while (this.scene.scene.children.length > 0) {
this.scene.scene.remove(this.scene.scene.children[0]);
}
// Warm Scene's URL cache up front so per-asset loadObject() calls
// never hit the network for token→URL resolution. One batch call
@@ -229,10 +229,7 @@ export class StoryTellerProxyScene {
);
const settled = await Promise.allSettled(tasks);
if (ticket?.cancelled) {
this.discardLoadedObjects(settled);
return;
}
if (ticket?.cancelled) return;
// Synchronous transform-application pass. We walk results in
// original JSON order to preserve scene.children insertion
@@ -298,21 +295,4 @@ export class StoryTellerProxyScene {
this.scene._createGrid();
this.scene.updateSkybox(skybox_media_id);
}
// A cancelled load has still added whatever finished loading to the
// scene. Take those objects back out and free them, or they would leak
// and, when a newer load superseded this one, show up in its scene.
// Lights are skipped: Scene tracks its base lights in fields that a
// newer load may have re-pointed at them.
private discardLoadedObjects(
settled: PromiseSettledResult<{ obj: THREE.Object3D | undefined }>[],
) {
for (const result of settled) {
if (result.status !== "fulfilled") continue;
const { obj } = result.value;
if (!obj || obj instanceof THREE.Light) continue;
obj.removeFromParent();
this.scene.disposeObject(obj);
}
}
}
@@ -124,9 +124,6 @@
},
{
"path": "../../icons/tsconfig.lib.json"
},
{
"path": "../viewer-3d/tsconfig.lib.json"
}
]
}
@@ -160,13 +160,12 @@ export const UploadFiles3D = ({
});
};
// Tears down the current preview: stops its loop, cancels in-flight
// loads and frees its scene and renderer (see loadPreviewOnCanvas).
const disposePreviewRef = useRef<(() => void) | null>(null);
const disposeRenderer = () => {
disposePreviewRef.current?.();
disposePreviewRef.current = null;
rendererRef.current = null;
if (rendererRef.current) {
rendererRef.current.setAnimationLoop(null);
rendererRef.current.dispose();
rendererRef.current = null;
}
};
// Normalize an FBX to GLB in the background. Completion swaps the entry's
@@ -238,7 +237,7 @@ export const UploadFiles3D = ({
return;
}
const { renderer, camera, selectAnimation, setSkeletonVisible, dispose } =
const { renderer, camera, selectAnimation, setSkeletonVisible, cancel } =
loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
@@ -251,15 +250,17 @@ export const UploadFiles3D = ({
},
});
rendererRef.current = renderer;
disposePreviewRef.current = dispose;
cameraRef.current = camera;
selectAnimationRef.current = selectAnimation;
setSkeletonVisibleRef.current = setSkeletonVisible;
// dispose() cancels first: an in-flight load's callbacks must not
// render on the disposed renderer or write into the next preview's
// status/animation/rig state (or its thumbnail).
return disposeRenderer;
return () => {
// Cancel BEFORE disposing: an in-flight load's callbacks must not
// render on the disposed renderer or write into the next preview's
// status/animation/rig state (or its thumbnail).
cancel();
disposeRenderer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIndex, filesVersion]);
@@ -1,124 +0,0 @@
import * as THREE from "three";
import { loadPreviewOnCanvas } from "./loadPreviewOnCanvas";
const { renderers, pendingGltfLoads } = vi.hoisted(() => ({
renderers: [] as Array<{
dispose: ReturnType<typeof vi.fn>;
forceContextLoss: ReturnType<typeof vi.fn>;
setAnimationLoop: ReturnType<typeof vi.fn>;
}>,
pendingGltfLoads: [] as Array<(gltf: { scene: unknown }) => void>,
}));
// jsdom has no WebGL; only the lifecycle calls matter here.
vi.mock("three", async (importOriginal) => {
const actual = await importOriginal<typeof import("three")>();
class WebGLRenderer {
dispose = vi.fn();
forceContextLoss = vi.fn();
setAnimationLoop = vi.fn();
constructor() {
renderers.push(this);
}
setSize() {}
render() {}
}
return { ...actual, WebGLRenderer };
});
// Loads stay pending until a test resolves them, to model a load that
// finishes after the preview was superseded.
vi.mock("three/addons/loaders/GLTFLoader.js", () => ({
GLTFLoader: class {
load(_url: string, onLoad: (gltf: { scene: unknown }) => void) {
pendingGltfLoads.push(onLoad);
}
},
}));
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
return {
SplatFileType: { SPZ: "spz" },
SplatMesh: class extends Object3D {},
SparkRenderer: class extends Object3D {},
};
});
describe("loadPreviewOnCanvas dispose", () => {
beforeEach(() => {
renderers.length = 0;
pendingGltfLoads.length = 0;
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => "blob:preview"),
revokeObjectURL: vi.fn(),
});
});
it("stops the loop, frees the renderer and revokes the blob URL", () => {
const canvas = attachedCanvas();
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas,
statusCallback: vi.fn(),
});
preview.dispose();
const renderer = renderers[0];
expect(renderer.setAnimationLoop).toHaveBeenLastCalledWith(null);
expect(renderer.dispose).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:preview");
});
it("keeps the context while the canvas is reused for the next file", () => {
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas: attachedCanvas(),
statusCallback: vi.fn(),
});
preview.dispose();
expect(renderers[0].forceContextLoss).not.toHaveBeenCalled();
});
it("releases the context once the canvas has left the DOM", () => {
const canvas = attachedCanvas();
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas,
statusCallback: vi.fn(),
});
canvas.remove();
preview.dispose();
expect(renderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
});
it("ignores a model that finishes loading after dispose", () => {
const statusCallback = vi.fn();
const preview = loadPreviewOnCanvas({
file: new File([], "model.glb"),
canvas: attachedCanvas(),
statusCallback,
});
preview.dispose();
const lateModel = new THREE.Group();
lateModel.add(new THREE.Mesh(new THREE.BoxGeometry()));
pendingGltfLoads[0]({ scene: lateModel });
expect(lateModel.children).toHaveLength(1);
expect(statusCallback).not.toHaveBeenCalled();
});
});
function attachedCanvas(): HTMLCanvasElement {
const canvas = document.createElement("canvas");
canvas.getContext = vi.fn(() => null) as never;
document.body.appendChild(canvas);
return canvas;
}
@@ -3,12 +3,10 @@ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { SplatFileType, SplatMesh } from "@sparkjsdev/spark";
import { NodeHierarchyHelper, disposeObject3D } from "@storyteller/ui-viewer-3d";
import { NodeHierarchyHelper } from "@storyteller/ui-viewer-3d";
interface LoaderInterface {
file: File;
// Blob URL for `file`, revoked when the preview is disposed.
fileUrl: string;
camera: THREE.PerspectiveCamera;
scene: THREE.Scene;
renderer: THREE.WebGLRenderer;
@@ -33,10 +31,9 @@ interface PreviewReturn {
// Show/hide the skeleton overlay (rigged GLBs only; no-op otherwise).
// Defaults visible for mesh-less models so there's something to see.
setSkeletonVisible: (visible: boolean) => void;
// Call when a newer preview supersedes this one or the preview goes away
// (effect cleanup): in-flight loads become no-ops, the animation loop
// stops, and the scene, renderer and blob URL are freed.
dispose: () => void;
// Call when a newer preview supersedes this one (effect cleanup): any
// still-in-flight load's callbacks become no-ops.
cancel: () => void;
}
export const loadPreviewOnCanvas = ({
@@ -86,7 +83,6 @@ export const loadPreviewOnCanvas = ({
scene.add(light);
let splatMesh: SplatMesh | null = null;
const fileUrl = URL.createObjectURL(file);
// Supersession flag: each preview invocation owns one. The effect that
// created this preview flips it in its cleanup, and every async loader
@@ -94,6 +90,9 @@ export const loadPreviewOnCanvas = ({
// renderer, write status/animation/rig state into the NEXT preview, or
// trigger a thumbnail snapshot of the wrong model.
let cancelled = false;
const cancel = () => {
cancelled = true;
};
const isCancelled = () => cancelled;
// Animation playback state for GLBs with baked clips. The mixer is rooted
@@ -144,7 +143,6 @@ export const loadPreviewOnCanvas = ({
if (file.name.endsWith(".glb")) {
glbLoader({
file,
fileUrl,
scene,
camera,
renderer,
@@ -183,15 +181,7 @@ export const loadPreviewOnCanvas = ({
},
});
} else if (file.name.endsWith(".pmd")) {
pmdLoader({
file,
fileUrl,
scene,
camera,
renderer,
statusCallback,
isCancelled,
});
pmdLoader({ file, scene, camera, renderer, statusCallback, isCancelled });
} else if (
file.name.endsWith(".png") ||
file.name.endsWith(".jpg") ||
@@ -200,7 +190,6 @@ export const loadPreviewOnCanvas = ({
) {
imagePlaneLoader({
file,
fileUrl,
scene,
camera,
renderer,
@@ -253,26 +242,11 @@ export const loadPreviewOnCanvas = ({
};
renderer.setAnimationLoop(animate);
// The canvas is reused for the next file's preview, which hands that
// renderer this same WebGL context, so the context loss is forced only
// once the canvas has left the DOM (the modal closed).
const dispose = () => {
cancelled = true;
renderer.setAnimationLoop(null);
disposeObject3D(scene);
// A splat still decoding when the preview was superseded never made it
// into the scene.
if (splatMesh && !splatMesh.parent) splatMesh.dispose();
URL.revokeObjectURL(fileUrl);
renderer.dispose();
if (!canvas.isConnected) renderer.forceContextLoss();
};
return { renderer, camera, selectAnimation, setSkeletonVisible, dispose };
return { renderer, camera, selectAnimation, setSkeletonVisible, cancel };
};
const glbLoader = ({
fileUrl,
file,
camera,
scene,
renderer,
@@ -283,7 +257,7 @@ const glbLoader = ({
}: LoaderInterface) => {
const loader = new GLTFLoader();
loader.load(
fileUrl,
URL.createObjectURL(file),
(data) => {
if (isCancelled?.()) return;
// Inspect BEFORE re-parenting (the loop below moves children out of
@@ -429,7 +403,7 @@ const pmdLoader = ({
};
const imagePlaneLoader = ({
fileUrl,
file,
scene,
statusCallback,
isCancelled,
@@ -437,7 +411,7 @@ const imagePlaneLoader = ({
const geometry = new THREE.PlaneGeometry(1, 1);
const loader = new THREE.TextureLoader();
const texture = loader.load(
fileUrl,
URL.createObjectURL(file),
undefined,
undefined,
(loaderError) => {
@@ -92,13 +92,12 @@ export const UploadFilesSplat = ({
const [overallProgress, setOverallProgress] = useState<{ current: number; total: number } | null>(null);
const [selectionError, setSelectionError] = useState<string | undefined>();
// Tears down the current preview: stops its loop, cancels in-flight
// loads and frees its scene and renderer (see loadPreviewOnCanvas).
const disposePreviewRef = useRef<(() => void) | null>(null);
const disposeRenderer = () => {
disposePreviewRef.current?.();
disposePreviewRef.current = null;
rendererRef.current = null;
if (rendererRef.current) {
rendererRef.current.setAnimationLoop(null);
rendererRef.current.dispose();
rendererRef.current = null;
}
};
useEffect(() => {
@@ -108,13 +107,12 @@ export const UploadFilesSplat = ({
disposeRenderer();
setPreviewStatus({ type: "init" });
const { renderer, camera, dispose } = loadPreviewOnCanvas({
const { renderer, camera } = loadPreviewOnCanvas({
file: currentFile,
canvas: canvasRef.current,
statusCallback: setPreviewStatus,
});
rendererRef.current = renderer;
disposePreviewRef.current = dispose;
cameraRef.current = camera;
return disposeRenderer;
@@ -1,4 +1,3 @@
export * from './lib/viewer-3d';
export * from './lib/NodeHierarchyHelper';
export * from './lib/disposeObject3D';
@@ -1,160 +0,0 @@
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import { disposeObject3D } from "./disposeObject3D";
// A real SplatMesh / SparkRenderer needs Spark's WASM decoder and a WebGL
// renderer; only `instanceof` and the dispose() calls matter here.
vi.mock("@sparkjsdev/spark", async () => {
const { Object3D } = await import("three");
const accumulator = () => ({ splats: { dispose: vi.fn() } });
return {
SplatMesh: class extends Object3D {
dispose = vi.fn();
},
SparkRenderer: class extends Object3D {
defaultView = { dispose: vi.fn() };
active = accumulator();
freeAccumulators = [accumulator(), accumulator()];
},
};
});
describe("disposeObject3D", () => {
it("disposes nested geometries, materials and their textures", () => {
const map = new THREE.Texture();
const normalMap = new THREE.Texture();
const material = new THREE.MeshStandardMaterial({ map, normalMap });
const geometry = new THREE.BoxGeometry();
const root = new THREE.Group();
const child = new THREE.Group();
child.add(new THREE.Mesh(geometry, material));
root.add(child);
const disposed = trackDisposals([map, normalMap, material, geometry]);
disposeObject3D(root);
expect(disposed).toEqual(new Set([map, normalMap, material, geometry]));
});
it("disposes every material of a multi-material mesh", () => {
const first = new THREE.MeshBasicMaterial({ map: new THREE.Texture() });
const second = new THREE.MeshBasicMaterial();
const mesh = new THREE.Mesh(new THREE.BoxGeometry(), [first, second]);
const disposed = trackDisposals([first, second, first.map!]);
disposeObject3D(mesh);
expect(disposed).toEqual(new Set([first, second, first.map]));
});
it("disposes a material shared by several meshes once", () => {
const material = new THREE.MeshBasicMaterial();
const root = new THREE.Group();
root.add(new THREE.Mesh(new THREE.BoxGeometry(), material));
root.add(new THREE.Mesh(new THREE.BoxGeometry(), material));
const onDispose = vi.fn();
material.addEventListener("dispose", onDispose);
disposeObject3D(root);
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("disposes skinned mesh skeletons", () => {
const bone = new THREE.Bone();
const skeleton = new THREE.Skeleton([bone]);
const mesh = new THREE.SkinnedMesh(
new THREE.BoxGeometry(),
new THREE.MeshBasicMaterial(),
);
mesh.add(bone);
mesh.bind(skeleton);
const dispose = vi.spyOn(skeleton, "dispose");
disposeObject3D(mesh);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("frees splat buffers", () => {
const splat = new SplatMesh();
const root = new THREE.Group();
root.add(splat);
disposeObject3D(root);
expect(splat.dispose).toHaveBeenCalledTimes(1);
});
it("frees a SparkRenderer's viewpoint and accumulators", () => {
const spark = new SparkRenderer({} as never);
const root = new THREE.Group();
root.add(spark);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { freeAccumulators } = spark as any;
disposeObject3D(root);
expect(spark.defaultView.dispose).toHaveBeenCalledTimes(1);
expect(spark.active.splats.dispose).toHaveBeenCalledTimes(1);
for (const accumulator of freeAccumulators) {
expect(accumulator.splats.dispose).toHaveBeenCalledTimes(1);
}
});
it("disposes textures held in ShaderMaterial uniforms", () => {
const normals = new THREE.Texture();
const mirror = new THREE.WebGLRenderTarget(4, 4).texture;
const layers = [new THREE.Texture(), new THREE.Texture()];
const material = new THREE.ShaderMaterial({
uniforms: {
normalSampler: { value: normals },
mirrorSampler: { value: mirror },
layerSamplers: { value: layers },
size: { value: 1.0 },
},
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(), material);
const disposed = trackDisposals([normals, mirror, ...layers]);
disposeObject3D(mesh);
expect(disposed).toEqual(new Set([normals, mirror, ...layers]));
});
it("disposes a texture shared by a property and a uniform once", () => {
const video = new THREE.Texture();
const material = new THREE.ShaderMaterial({
uniforms: { map: { value: video } },
});
// ChromaKeyMaterial keeps its VideoTexture both here and in a uniform.
(material as unknown as { texture: THREE.Texture }).texture = video;
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(), material);
const onDispose = vi.fn();
video.addEventListener("dispose", onDispose);
disposeObject3D(mesh);
expect(onDispose).toHaveBeenCalledTimes(1);
});
it("skips objects without geometry or material", () => {
const root = new THREE.Group();
root.add(new THREE.DirectionalLight());
root.add(new THREE.PerspectiveCamera());
expect(() => disposeObject3D(root)).not.toThrow();
});
});
// Records which resources dispatched their "dispose" event.
function trackDisposals(
resources: Array<THREE.Texture | THREE.Material | THREE.BufferGeometry>,
): Set<unknown> {
const disposed = new Set<unknown>();
for (const resource of resources) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(resource as any).addEventListener("dispose", () => disposed.add(resource));
}
return disposed;
}
@@ -1,93 +0,0 @@
import * as THREE from "three";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
// Free the GPU resources held by an object tree that is leaving the scene
// for good: geometries, materials, the textures those materials reference
// (Material.dispose() does not free its maps), skeleton bone textures and
// Spark splat buffers. Call it after the tree has been removed.
//
// Textures are found both as material properties (map, normalMap, ...) and
// as ShaderMaterial uniforms (Water's normal and mirror samplers, Spark's
// splat textures). Water keeps its mirror render target in a closure, so
// only that target's color texture can be reached; its framebuffer and depth
// buffer stay allocated.
//
// Geometry, material and texture disposal is safe even if one of them is
// shared with an object that stays — three.js re-uploads it the next time
// it renders. Splat buffers are not re-creatable, but every SplatMesh owns
// its own (scene.ts builds one per load), and undo re-loads deleted
// objects from their snapshot rather than reusing the instance.
export function disposeObject3D(root: THREE.Object3D): void {
root.traverse((child) => {
if (child instanceof SplatMesh) child.dispose();
if (child instanceof SparkRenderer) disposeSparkRenderer(child);
if (child instanceof THREE.SkinnedMesh) child.skeleton?.dispose();
(child as Partial<THREE.Mesh>).geometry?.dispose();
});
const materials = collectMaterials(root);
const textures = collectMaterialTextures(materials);
for (const material of materials) material.dispose();
for (const texture of textures) texture.dispose();
}
// Every texture the tree's materials reference, as material properties or
// ShaderMaterial uniforms, deduplicated.
export function collectObjectTextures(
root: THREE.Object3D,
): Set<THREE.Texture> {
return collectMaterialTextures(collectMaterials(root));
}
// A SparkRenderer is added to the scene automatically the first time a
// splat renders. Its viewpoint render targets and splat accumulators are
// not reachable from its material, so free them explicitly. The viewpoint
// goes first: disposing it returns its accumulators to the free list.
function disposeSparkRenderer(spark: SparkRenderer): void {
spark.defaultView.dispose();
spark.active.splats.dispose();
const { freeAccumulators } = spark as unknown as {
freeAccumulators: SparkRenderer["active"][];
};
for (const accumulator of freeAccumulators) accumulator.splats.dispose();
}
function collectMaterials(root: THREE.Object3D): Set<THREE.Material> {
const materials = new Set<THREE.Material>();
root.traverse((child) => {
const { material } = child as Partial<THREE.Mesh>;
const childMaterials = Array.isArray(material) ? material : [material];
for (const childMaterial of childMaterials) {
if (childMaterial) materials.add(childMaterial);
}
});
return materials;
}
function collectMaterialTextures(
materials: Iterable<THREE.Material>,
): Set<THREE.Texture> {
const textures = new Set<THREE.Texture>();
for (const material of materials) {
addTextures(Object.values(material), textures);
if (material instanceof THREE.ShaderMaterial) {
addTextures(
Object.values(material.uniforms).map((uniform) => uniform?.value),
textures,
);
}
}
return textures;
}
function addTextures(values: unknown[], into: Set<THREE.Texture>): void {
for (const value of values) {
if (value instanceof THREE.Texture) {
into.add(value);
} else if (Array.isArray(value)) {
for (const item of value) {
if (item instanceof THREE.Texture) into.add(item);
}
}
}
}
@@ -2,23 +2,11 @@ import { render } from '@testing-library/react';
import StorytellerUiViewer3d from './viewer-3d';
// jsdom has no ResizeObserver; the viewer only needs it to exist.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
},
);
describe('StorytellerUiViewer3d', () => {
it('should render successfully', () => {
const { baseElement } = render(<StorytellerUiViewer3d />);
expect(baseElement).toBeTruthy();
});
it('unmounts cleanly before the scene ever initialized', () => {
const { unmount } = render(<StorytellerUiViewer3d />);
expect(() => unmount()).not.toThrow();
});
});
@@ -9,7 +9,6 @@ import {
NodeHierarchyHelper,
createRigHelper,
} from "./NodeHierarchyHelper";
import { disposeObject3D } from "./disposeObject3D";
// Dropdown value for the "no animation" choice (models rest in their bind /
// T-pose). Clip values are their stringified index.
@@ -278,14 +277,20 @@ export function Viewer3D({
) {
container.removeChild(rendererRef.current.domElement);
}
controlsRef.current?.dispose();
// Free everything still in the scene (placeholder cube, grid, any
// model, Spark's auto-added splat renderer), then the context itself.
// The canvas is ours and already detached, so forcing the context
// loss frees it now instead of whenever the canvas is collected.
if (sceneRef.current) disposeObject3D(sceneRef.current);
rendererRef.current?.dispose();
rendererRef.current?.forceContextLoss();
controlsRef.current?.dispose();
if (cubeRef.current) {
const cube = cubeRef.current;
if (cube.geometry) cube.geometry.dispose();
if (cube.material) {
if (Array.isArray(cube.material)) {
cube.material.forEach((m) => m.dispose());
} else {
cube.material.dispose();
}
}
}
};
}, []);
@@ -402,19 +407,11 @@ export function Viewer3D({
const SPLAT_EXTENSIONS = ["spz", "ply", "splat", "ksplat"];
const GLTF_EXTENSIONS = ["glb", "gltf", ""];
// Set by this effect's cleanup. A model that finishes loading after the
// URL changed or the viewer unmounted is freed instead of shown.
let cancelled = false;
if (SPLAT_EXTENSIONS.includes(urlExtension)) {
console.log(`[Viewer3D] splat format detected (.${urlExtension})`);
new SplatMesh({
url: modelUrl,
onLoad: (mesh) => {
if (cancelled) {
mesh.dispose();
return;
}
mesh.rotation.z = Math.PI;
mesh.position.y = 1;
scene.add(mesh);
@@ -453,10 +450,6 @@ export function Viewer3D({
loader.load(
modelUrl,
(gltf) => {
if (cancelled) {
disposeObject3D(gltf.scene);
return;
}
console.log("[Viewer3D] Model loaded successfully");
const model = gltf.scene;
onModelLoaded(model);
@@ -510,12 +503,10 @@ export function Viewer3D({
}
return () => {
cancelled = true;
stopAnimations();
removeSkeletonHelper();
if (loadedModelRef.current) {
loadedModelRef.current.removeFromParent();
disposeObject3D(loadedModelRef.current);
if (loadedModelRef.current && sceneRef.current) {
sceneRef.current.remove(loadedModelRef.current);
loadedModelRef.current = null;
}
};