diff --git a/CLAUDE.md b/CLAUDE.md
index 25333264..50518132 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -103,6 +103,19 @@ When you add a new feature that needs to write data, route it through
- Reach the engine from inside a behavior via `this.erth.*` and
`this.gameObject`. Old `EventBus` and `this.target` style code is
deprecated.
+- **Editor lifecycle hooks run without `init()`.** `onEditorAdded`,
+ `onEditorAttributesUpdated`, and `onEditorUpdate` fire in the editor
+ (e.g. at import/attach time) where `init(game)` is never called. A
+ common bug class: a behavior caches `const erth = this.erth` (or
+ `game`) *only* inside `init()`, then dereferences that module-local in
+ an editor hook — yielding `Cannot read properties of undefined (reading
+ 'asset')` and silently failing to load textures/assets. Always read
+ engine handles from `this.erth` / `this.gameObject` directly, or
+ re-derive the locals at the top of *every* lifecycle entry point —
+ never assume `init()` already ran. Note `this.gameObject` exposes
+ `uuid`/`position`/`rotation`/`scale`/`visible`/`physics`/`_internal.three`
+ only — there is **no** `gameObject.game`; in the editor `game` is
+ typically `undefined`, so guard any `game.renderer.*` access.
- Lifecycle docs: `docs/behaviors/`.
## Lambdas (ECS)
diff --git a/README.md b/README.md
index 9265cff6..858ad6c6 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ If StemStudio is useful to you or your organization, please consider sponsoring
## Quick start
-Prerequisites: [Bun](https://bun.sh) 1.0+, [Go](https://go.dev) 1.21+, [Node.js](https://nodejs.org) 20+.
+Prerequisites: [Bun](https://bun.sh) 1.0+, [Go](https://go.dev) 1.21+, [Node.js](https://nodejs.org) 20+. New machine? See [Setting up your dev environment](#setting-up-your-dev-environment) for per-OS install steps (macOS, Windows, Linux).
```bash
git clone https://github.com/your-org/stemstudio.git
@@ -60,6 +60,72 @@ ANYTHING_WORLD_API_KEY=...
Any key you omit makes that provider unavailable — the editor will prompt you for it when you first try a feature that needs it.
+## Setting up your dev environment
+
+You need three tools on your `PATH`: **[Bun](https://bun.sh) 1.0+** (package manager + task runner), **[Go](https://go.dev) 1.21+** (the AI proxy server), and **[Node.js](https://nodejs.org) 20+** with `npm` (the multiplayer sidecar). Pick your OS below, then continue with [Quick start](#quick-start).
+
+### macOS
+
+Using [Homebrew](https://brew.sh):
+
+```bash
+brew install oven-sh/bun/bun go node git
+```
+
+Or install each from its official site (links above). Apple Silicon and Intel are both supported.
+
+### Linux
+
+```bash
+# Bun
+curl -fsSL https://bun.sh/install | bash
+
+# Node.js 20+ — via nvm (recommended; distro packages are often older)
+curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
+nvm install 20
+
+# Go 1.21+ — prefer the official tarball; distro `golang-go` may lag
+# https://go.dev/doc/install (or: sudo apt install golang-go / sudo dnf install golang)
+
+# Build essentials for any native dependency compilation
+sudo apt install -y build-essential git # Debian/Ubuntu
+# sudo dnf groupinstall "Development Tools" # Fedora/RHEL
+```
+
+Open a new shell (or `source ~/.bashrc`) so the freshly installed tools land on your `PATH`.
+
+### Windows
+
+**Use [WSL2](https://learn.microsoft.com/windows/wsl/install) (recommended).** Several `package.json` scripts use Unix-shell idioms — inline env vars like `BUILD_MODE=oss …` and `.sh` deploy scripts — that do not run under native `cmd`/PowerShell. WSL2 gives you a real Linux shell where everything works as documented.
+
+```powershell
+wsl --install # installs Ubuntu; reboot if prompted
+```
+
+Then open the **Ubuntu** terminal and follow the **Linux** instructions above. Clone the repo *inside* the WSL filesystem (e.g. `~/code/…`, not `/mnt/c/…`) for usable file-watch and build performance.
+
+
+Native Windows (advanced, unsupported)
+
+```powershell
+winget install OpenJS.NodeJS.LTS GoLang.Go Git.Git
+powershell -c "irm bun.sh/install.ps1 | iex"
+```
+
+You will still need to work around the Unix-style scripts (e.g. run the editor, AI server, and MP sidecar separately and set `BUILD_MODE` via `$env:BUILD_MODE`). WSL2 is strongly preferred.
+
+
+
+### Verify
+
+```bash
+bun --version # 1.0+
+go version # go1.21+
+node --version # v20+
+```
+
+If all three print a version, you're ready for [Quick start](#quick-start).
+
## What's in the box
- Editor, player, runtime, behaviors, lambdas, physics, rendering, scheduler, asset loading.
diff --git a/client/packages/editor-oss/src/EngineRuntime.ts b/client/packages/editor-oss/src/EngineRuntime.ts
index 0fc9cc5b..8a9c3050 100644
--- a/client/packages/editor-oss/src/EngineRuntime.ts
+++ b/client/packages/editor-oss/src/EngineRuntime.ts
@@ -285,7 +285,11 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext {
global.app = this;
global.three$1 = THREE;
-
+ // TEMP debug: mirror the engine onto window so it can be inspected from
+ // the browser console (`global` is a module export, not window.app).
+ // Lets us read e.g. `app.scene.userData._matchStarted`. Remove later.
+ (window as unknown as {app?: unknown}).app = this;
+
this.viewport = undefined;
this.width = this.container.clientWidth;
this.height = this.container.clientHeight;
@@ -1877,6 +1881,8 @@ export class EngineRuntime extends AppRuntime implements RuntimeContext {
this.playerMask.hide();
//global.app.setAutoSave(this.autoSaveState);
+ // NOTE: game.reset() tears down the HUD DOM root via `this.hud?.clear()`,
+ // so the `hud-view-container` created in startPlayer is removed here.
this.game?.reset();
this.clock.stop();
diff --git a/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts b/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts
index 45d48d04..17c39478 100644
--- a/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts
+++ b/client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts
@@ -1,9 +1,10 @@
import * as THREE from "three";
import {PCFShadowMap, PCFSoftShadowMap} from "three";
-import {Asset, AssetType, getAssetRevisionData} from "@stem/network/api/asset";
+import {Asset, AssetType} from "@stem/network/api/asset";
import {updateSceneThumbnail} from "@stem/network/api/scene/thumbnail";
import {getAssetResolutionContext, resolveAssetRevisionId} from "../../asset-management/AssetResolutionContext";
+import type {AssetRef} from "../../asset-management/AssetRef";
import EngineRuntime from "../../EngineRuntime";
import {
normalizeBackgroundGradient,
@@ -48,8 +49,24 @@ const DEFAULT_RENDERING = {
export class SettingsHandlers {
constructor(private engine: EngineRuntime) {}
- private async resolveImageSource(source?: string): Promise {
- if (!source) return source;
+ /**
+ * Resolves a background image source to the value that should be PERSISTED
+ * in the scene's rendering config: the original source string (a literal
+ * URL/path, or the asset name) plus, when it names a scene image asset, the
+ * stable {@link AssetRef} for that asset.
+ *
+ * Crucially this never returns a `blob:` object URL. The old code persisted
+ * `URL.createObjectURL(blob)` as the background texture, which is the cause
+ * of the "scene goes dark on reload" bug: object URLs are revoked when the
+ * page reloads, so the saved background/environment texture could no longer
+ * be fetched — the scene lost both its skybox and its image-based
+ * environment lighting, and the loader logged "Failed to load texture:
+ * blob:…". The AssetRef is what survives a reload;
+ * {@link EnvironmentSettingsManager.applyBackgroundSettings} prefers it and
+ * re-fetches the texture through the asset loader.
+ */
+ private async resolveImageAsset(source?: string): Promise<{value?: string; assetRef?: AssetRef}> {
+ if (!source) return {value: source};
if (
source.startsWith("http://")
|| source.startsWith("https://")
@@ -57,25 +74,26 @@ export class SettingsHandlers {
|| source.startsWith("blob:")
|| source.startsWith("/")
) {
- return source;
+ return {value: source};
}
const assetSource = this.engine.editor?.assetSource;
if (!assetSource) {
- return source;
+ return {value: source};
}
const {assets} = await assetSource.getAssets({types: [AssetType.Image]});
const match = assets?.find((asset: Asset) => asset.name.toLowerCase() === source.toLowerCase());
if (!match) {
- return source;
+ return {value: source};
}
const context = getAssetResolutionContext(this.engine.scene);
const revisionId = context ? resolveAssetRevisionId(match.id, context) : undefined;
const finalRevisionId = revisionId || match.headRevisionId;
- const blob = await getAssetRevisionData(match.id, finalRevisionId, "blob");
- return URL.createObjectURL(blob);
+ // Keep the human-readable source (the asset name) as the persisted
+ // `texture` value; the AssetRef is the durable handle used to re-fetch.
+ return {value: source, assetRef: {assetId: match.id, revisionId: finalRevisionId}};
}
/**
@@ -247,18 +265,26 @@ export class SettingsHandlers {
const current = scene.userData.rendering.background || {...DEFAULT_BACKGROUND};
const normalizedGradient = normalizeBackgroundGradient(gradient, current.gradient);
const normalizedGradientMode = normalizeGradientMode(gradientMode, current.gradientMode);
- const resolvedTexture = texture ? await this.resolveImageSource(texture) : undefined;
+ // Resolve to a displayable URL AND a stable AssetRef. The AssetRef is
+ // what survives a reload (the `blob:` URL does not), so persisting it
+ // keeps the skybox + environment lighting after the project is saved
+ // and reopened.
+ const resolvedTexture = texture ? await this.resolveImageAsset(texture) : undefined;
const resolvedCubemap = cubemap
- ? await Promise.all(cubemap.map(face => this.resolveImageSource(face)))
+ ? await Promise.all(cubemap.map(face => this.resolveImageAsset(face)))
: undefined;
scene.userData.rendering.background = {
...current,
type: type ?? current.type,
color: color ?? current.color,
- texture: resolvedTexture ?? current.texture,
+ texture: resolvedTexture?.value ?? current.texture,
+ textureAsset: texture ? resolvedTexture?.assetRef : current.textureAsset,
cubemap: resolvedCubemap
- ? (resolvedCubemap as [string, string, string, string, string, string])
+ ? (resolvedCubemap.map(face => face.value ?? "") as [string, string, string, string, string, string])
: current.cubemap,
+ cubemapAssets: resolvedCubemap
+ ? resolvedCubemap.map(face => face.assetRef)
+ : current.cubemapAssets,
gradient: normalizedGradient,
gradientMode: normalizedGradientMode,
rotation: rotation ?? current.rotation,
diff --git a/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.test.ts b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.test.ts
new file mode 100644
index 00000000..b4f8d3e4
--- /dev/null
+++ b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.test.ts
@@ -0,0 +1,69 @@
+// @vitest-environment jsdom
+import {describe, expect, it} from "vitest";
+
+import {autoResolveImports, findByFilepath} from "./ImportBatchDialog";
+import type {ImportRequest} from "./ScriptExecutor";
+
+/** Build a File whose webkitRelativePath encodes a subfolder path. */
+const fileAt = (relPath: string, bytes = "x"): File => {
+ const name = relPath.split("/").pop()!;
+ const f = new File([bytes], name, {type: "application/octet-stream"});
+ Object.defineProperty(f, "webkitRelativePath", {value: relPath, configurable: true});
+ return f;
+};
+
+const imageReq = (index: number, filepath: string, name: string): ImportRequest => ({
+ index,
+ type: "image",
+ name,
+ filepath,
+ extensions: [".png", ".jpg", ".jpeg", ".webp"],
+});
+
+describe("autoResolveImports — explicit filepath resolution", () => {
+ it("resolves a filepath whose file has a NON-STANDARD extension (regression)", () => {
+ // Repro of the pirate-ship hang: a generator emitted duplicate textures
+ // as `PIR_Water.png-2 … -5`. Those filenames end in `.png-2`, not a known
+ // image extension. The resolver used to filter candidates by extension
+ // BEFORE matching the filepath, dropping these files → the import stayed
+ // "unresolved" → the blocking batch-import dialog opened → headless runs
+ // hung forever. An explicit filepath must resolve regardless of ext.
+ const folder = [
+ fileAt("textures/PIR_Water.png"),
+ fileAt("textures/PIR_Water.png-2"),
+ fileAt("textures/PIR_Water.png-3"),
+ fileAt("textures/PIR_Water.png-4"),
+ fileAt("textures/PIR_Water.png-5"),
+ ];
+ const imports: ImportRequest[] = [
+ imageReq(0, "textures/PIR_Water.png", "PIR_Water.png"),
+ imageReq(1, "textures/PIR_Water.png-2", "PIR_Water.png 2"),
+ imageReq(2, "textures/PIR_Water.png-3", "PIR_Water.png 3"),
+ imageReq(3, "textures/PIR_Water.png-4", "PIR_Water.png 4"),
+ imageReq(4, "textures/PIR_Water.png-5", "PIR_Water.png 5"),
+ ];
+
+ const {files} = autoResolveImports(imports, folder);
+
+ // EVERY import resolves — nothing is left for the dialog.
+ expect(files.size).toBe(5);
+ expect(files.get(1)?.name).toBe("PIR_Water.png-2");
+ expect(files.get(4)?.name).toBe("PIR_Water.png-5");
+ });
+
+ it("findByFilepath matches an odd-extension file by exact basename", () => {
+ const folder = [fileAt("textures/PIR_Water.png-2")];
+ expect(findByFilepath(folder, "textures/PIR_Water.png-2")?.name).toBe("PIR_Water.png-2");
+ });
+
+ it("still resolves normal model filepaths and reuses one file for several imports", () => {
+ // Four wheels all reference the one wheel.glb.
+ const folder = [fileAt("models/wheel.glb")];
+ const wheel = (i: number): ImportRequest => ({
+ index: i, type: "model", name: `wheel ${i}`, filepath: "models/wheel.glb", extensions: [".glb", ".gltf", ".fbx"],
+ });
+ const {files} = autoResolveImports([wheel(0), wheel(1), wheel(2), wheel(3)], folder);
+ expect(files.size).toBe(4);
+ expect([...files.values()].every(f => f.name === "wheel.glb")).toBe(true);
+ });
+});
diff --git a/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts
index 30ad48ed..89a335bc 100644
--- a/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts
+++ b/client/packages/editor-oss/src/agent/script-tool/ImportBatchDialog.ts
@@ -50,19 +50,41 @@ export function autoResolveImports(
const claimed = new Set();
for (const req of imports) {
- const extMatches = folderFiles.filter(f =>
- //if multiple objects are created from the same model, this check filters out this file after first use
- /*!claimed.has(fileKey(f)) && */req.extensions.some(ext => f.name.toLowerCase().endsWith(ext)),
- );
- if (extMatches.length === 0) continue;
+ // Extension predicate reused below for both filepath and fuzzy matching.
+ // origin's variant filtered `folderFiles` into a local `extMatches` here
+ // and removed the claimed-filter so one model file can back several
+ // imports; that concern is already handled below where filepath matches
+ // resolve against the full (unfiltered) list. The fuzzy fallback still
+ // redeclares its own claimed-filtered `extMatches`, so keep the shared
+ // predicate form to avoid a duplicate declaration.
+ const extOf = (f: File) => req.extensions.some(ext => f.name.toLowerCase().endsWith(ext));
let match: File | null = null;
+ // An explicit filepath wins and may reuse a file already claimed by
+ // another import: one asset file can legitimately back several imports
+ // (e.g. four wheels all referencing wheel.glb, or one image reused as a
+ // texture and the scene thumbnail). Matching only *unclaimed* files left
+ // those duplicates unresolved, which popped a blocking import dialog and
+ // hung headless / automated imports. So resolve filepath against the
+ // full file list, not the claimed-filtered subset.
if (req.filepath) {
- match = findByFilepath(extMatches, req.filepath);
+ // Match an explicit filepath against the FULL file list, not the
+ // extension-filtered subset. A generator can emit a source file
+ // whose name carries a non-standard extension — e.g. duplicate
+ // textures saved as `PIR_Water.png-2`, `…-3`. Gating filepath
+ // resolution by `extOf` silently drops those files, leaving the
+ // import "unresolved", which pops the blocking batch-import dialog
+ // and hangs any headless/automated run forever (no user to click).
+ // The explicit filepath is already precise (exact path / basename),
+ // so it does not need — and is actively harmed by — the ext guard.
+ match = findByFilepath(folderFiles, req.filepath);
}
if (!match) {
+ // Fuzzy fallback only considers still-unclaimed files so two
+ // ambiguous imports don't grab the same file.
+ const extMatches = folderFiles.filter(f => !claimed.has(fileKey(f)) && extOf(f));
if (extMatches.length === 1) {
match = extMatches[0]!;
} else if (req.name) {
diff --git a/client/packages/editor-oss/src/agent/script-tool/builtins.ts b/client/packages/editor-oss/src/agent/script-tool/builtins.ts
index 9843edbf..619e06d5 100644
--- a/client/packages/editor-oss/src/agent/script-tool/builtins.ts
+++ b/client/packages/editor-oss/src/agent/script-tool/builtins.ts
@@ -24,8 +24,10 @@ export interface BuiltinContext {
commandBuffer: string[];
/** Callback to clear terminal output */
clearOutput: () => void;
- /** Callback to run a .stemscript file, optionally with folder files for auto-resolve */
- runScript?: (content: string, folderFiles?: File[]) => Promise;
+ /** Callback to run a .stemscript file, optionally with folder files for auto-resolve.
+ * Resolves to a run summary (`{executedCommands, successCount, failCount}`) or
+ * `undefined` for early-return paths (cancelled / no imports). */
+ runScript?: (content: string, folderFiles?: File[]) => Promise<{executedCommands: number; successCount: number; failCount: number} | undefined>;
/** Last script executed through the terminal, if any */
getLastScript?: () => {content: string; label?: string} | null;
/** Callback to validate a script against current scene state */
diff --git a/client/packages/editor-oss/src/agent/script-tool/importHandler.ts b/client/packages/editor-oss/src/agent/script-tool/importHandler.ts
index 54a8b617..f755c0b6 100644
--- a/client/packages/editor-oss/src/agent/script-tool/importHandler.ts
+++ b/client/packages/editor-oss/src/agent/script-tool/importHandler.ts
@@ -265,6 +265,14 @@ export function getSupportedImportTypes(): string[] {
* @param name
* @param companionFiles
*/
+/** Hex SHA-256 of a byte buffer, used to content-address imported model files. */
+async function sha256Hex(buffer: ArrayBuffer): Promise {
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
+ return Array.from(new Uint8Array(digest))
+ .map(b => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
export async function processImportedFile(
file: File,
type: string,
@@ -279,6 +287,16 @@ export async function processImportedFile(
* the behavior is imported but never attached to any object.
*/
behaviorIdOverride?: string,
+ /**
+ * Run-scoped cache keyed by source-file content hash → the asset created for
+ * it. A stemscript that places the same model many times (e.g. pirate-ship's
+ * 77 `import model … filepath=models/rocks-a.glb`) otherwise creates a fresh
+ * multi-MB inline asset per placement. With this cache, identical source
+ * bytes import once and every later placement reuses that one asset (a new
+ * scene object still gets created per placement). Pass the same Map for an
+ * entire import batch; omit it for one-off imports.
+ */
+ modelAssetDedupCache?: Map,
): Promise<{success: boolean; message: string}> {
// Dynamic imports to keep module resolution lightweight for tests
const [{default: global}, {setAssetRevision, resolveAssetRevisionId, getAssetResolutionContext}, {AssetType, ModelFormat, createAssetRevisionWithData, isNoChangesError, getAsset}, {createAsset}] = await Promise.all([
@@ -331,14 +349,27 @@ export async function processImportedFile(
);
}
}
+ let survivorAssetId: string | undefined;
+ let resultMessage = `Behavior "${config.name}" imported`;
if (existingBhvConfig) {
const assetId = existingBhvConfig.id || originalConfigId;
// Always fetch the asset's actual HEAD revision to avoid stale-parent 409s
let headRevisionId: string;
try {
headRevisionId = (await getAsset(assetId)).headRevisionId;
- } catch {
- // Fall back to scene-pinned revision if getAsset fails
+ } catch (err) {
+ // We matched this asset in the scene's behavior configs, so
+ // it exists — a getAsset failure here is unexpected. Falling
+ // back to the scene-pinned revision silently risks building
+ // a revision on a STALE parent (the merge-on-stale path can
+ // then drop the user's first edit). Surface it loudly; the
+ // pinned revision is only a last resort, not a quiet default.
+ console.error(
+ `[ScriptImport] getAsset("${assetId}") failed while resolving HEAD for ` +
+ `behavior "${config.name}"; falling back to scene-pinned revision. ` +
+ `A stale parent here can drop the next edit.`,
+ err,
+ );
const context = getAssetResolutionContext(scene);
headRevisionId = (context ? resolveAssetRevisionId(assetId, context) : undefined) as string;
}
@@ -355,18 +386,59 @@ export async function processImportedFile(
aliasId,
retryOnConflict: true,
});
- return {success: true, message: `Behavior "${config.name}" updated (new revision)`};
+ survivorAssetId = assetId;
+ resultMessage = `Behavior "${config.name}" updated (new revision)`;
}
}
- const newBehavior = await createBehavior({
- assetSource: global.app?.editor?.assetSource,
- name: config.name,
- code,
- config,
- aliasId: originalConfigId !== config.name ? originalConfigId : undefined,
- });
- return {success: true, message: `Behavior "${config.name}" imported (${newBehavior.id})`};
+ if (!survivorAssetId) {
+ const newBehavior = await createBehavior({
+ assetSource: global.app?.editor?.assetSource,
+ name: config.name,
+ code,
+ config,
+ aliasId: originalConfigId !== config.name ? originalConfigId : undefined,
+ });
+ survivorAssetId = newBehavior.id;
+ resultMessage = `Behavior "${config.name}" imported (${newBehavior.id})`;
+ }
+
+ // OSS de-duplication (on import, per user request). OSS has no
+ // revision history — there is only the latest version — so earlier
+ // imports of the same behavior leave orphan asset records that pile
+ // up in the Behaviors panel (the reported 3× copies). Collapse every
+ // other same-named behavior record down to the survivor we just
+ // imported. Scene objects attach by the logical/alias id, which
+ // resolves to this survivor, so dropping the other records is safe
+ // for attachments. Gated to OSS; integrated keeps server-side history.
+ const {IS_OSS} = await import("../../mode/buildMode");
+ if (IS_OSS) {
+ const {getOssAssetsForProject, unregisterOssAsset} = await import("@stem/network/api/asset");
+ const projectId = editor.sceneID;
+ if (projectId) {
+ const configRegistry = editor.behaviorConfigRegistry;
+ const scriptRegistry = editor.behaviorScriptRegistry;
+ const dupes = getOssAssetsForProject(projectId).filter(r =>
+ r.type === AssetType.Behavior &&
+ r.name === config.name &&
+ r.assetId !== survivorAssetId &&
+ r.assetId !== originalConfigId,
+ );
+ for (const dup of dupes) {
+ unregisterOssAsset(dup.assetId);
+ configRegistry?.unregisterConfig(dup.assetId, true);
+ scriptRegistry?.unregisterScript(dup.assetId, true);
+ }
+ if (dupes.length) {
+ console.info(
+ `[ScriptImport] OSS dedup: collapsed ${dupes.length} duplicate ` +
+ `"${config.name}" behavior record(s) into ${survivorAssetId}`,
+ );
+ }
+ }
+ }
+
+ return {success: true, message: resultMessage};
}
case "lambda": {
@@ -485,14 +557,63 @@ export async function processImportedFile(
return {success: true, message: `Model "${modelName}" already in scene, skipping re-import`};
}
+ // Content-addressed dedup. If an identical source file was already
+ // imported in this batch, reuse that asset and just place a new
+ // scene object — skipping the whole load/convert/texture-bake
+ // pipeline AND avoiding a duplicate multi-MB inline asset.
+ let srcHash: string | undefined;
+ if (modelAssetDedupCache) {
+ try {
+ srcHash = await sha256Hex(await file.arrayBuffer());
+ } catch {
+ srcHash = undefined; // crypto unavailable — fall through to a normal import
+ }
+ const cached = srcHash ? modelAssetDedupCache.get(srcHash) : undefined;
+ if (cached) {
+ setAssetRevision(scene, cached.id, cached.headRevisionId);
+ const {loadModel} = await import("../../model/load-util");
+ const context = scene.userData?.assetResolutionContext || {};
+ const object = await loadModel(cached.id, context);
+ if (name) object.name = name;
+ editor.addObject(object);
+ app?.call("objectChanged", null, scene);
+ return {success: true, message: `Model "${modelName}" placed (reused shared asset ${cached.id})`};
+ }
+ }
+
const abortController = new AbortController();
const abortSignal = abortController.signal;
// 1. Load model into Three.js (needed for thumbnail + texture cleanup)
let model;
+ let loadedFormat: string | undefined;
+ let loadedRootFile: File | Blob | undefined;
+ let loadedAtlas: unknown;
+ let loadedTextureOverrides: unknown;
try {
- //all models are ZIP archives
- ({model} = await loadModelFromFile(file, abortSignal, companionFiles, "application/zip"));
+ // Sniff the real container. A model import can be EITHER a
+ // ZIP archive bundling model + textures (e.g. pirate-ship's
+ // `.glb` files are actually `PK\x03\x04` zips) OR a raw
+ // model file (`.glb`/`.gltf`/`.fbx`/`.obj`, e.g. 100-cars,
+ // 3d-chess). Hardcoding "application/zip" here forced JSZip
+ // onto raw files, which threw "Can't find end of central
+ // directory" and silently dropped every non-zipped model
+ // from the scene. Detect by magic bytes so both shapes work.
+ const head = new Uint8Array(await file.slice(0, 4).arrayBuffer());
+ const isZipArchive =
+ head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04;
+ ({
+ model,
+ format: loadedFormat,
+ rootFile: loadedRootFile,
+ atlasData: loadedAtlas,
+ textureOverrides: loadedTextureOverrides,
+ } = await loadModelFromFile(
+ file,
+ abortSignal,
+ companionFiles,
+ isZipArchive ? "application/zip" : "",
+ ));
} catch (loadErr) {
if (loadErr instanceof AnimationOnlyModelError) {
return {success: true, message: `Skipped "${modelName}": This file contains only animations (no 3D geometry). Animation files should be imported via the Animation Combiner tool.`};
@@ -503,8 +624,23 @@ export async function processImportedFile(
// 2. Fix broken textures (especially FBX)
await cleanupInvalidTextures(model);
- // 3. Convert to GLB
- const sourceGlbBuffer = await convertToGlb(model, abortSignal, {});
+ // 3. Produce the GLB buffer to store.
+ // Fast path: a self-contained GLB source (no atlas, no loose
+ // texture remapping) is already a valid GLB — reuse its bytes
+ // and skip the GLTFExporter round-trip. `convertToGlb` here runs
+ // with empty options, so for GLB sources it would only re-encode
+ // the same data. The exporter is the dominant synchronous cost
+ // per model; skipping it for the common all-GLB asset pack case
+ // is what keeps large imports (e.g. 100+ models) from blocking
+ // the main thread for many minutes. FBX/OBJ/gltf-with-loose-
+ // textures/atlas still need the exporter to normalize into one
+ // GLB, so they take the fallback.
+ let sourceGlbBuffer: ArrayBuffer;
+ if (loadedFormat === "glb" && !loadedAtlas && !loadedTextureOverrides && loadedRootFile) {
+ sourceGlbBuffer = await loadedRootFile.arrayBuffer();
+ } else {
+ sourceGlbBuffer = await convertToGlb(model, abortSignal, {});
+ }
// 4. Create LODs with meshopt compression + texture compression (best effort).
// Skip in OSS: derivatives don't persist (no upload endpoint), and the
@@ -539,6 +675,9 @@ export async function processImportedFile(
lods: modelLods,
thumbnail: thumbnailParam,
});
+ if (modelAssetDedupCache && srcHash) {
+ modelAssetDedupCache.set(srcHash, {id: asset.id, headRevisionId: asset.headRevisionId});
+ }
setAssetRevision(scene, asset.id, asset.headRevisionId);
const {loadModel} = await import("../../model/load-util");
const context = scene.userData?.assetResolutionContext || {};
diff --git a/client/packages/editor-oss/src/behaviors/game/GameManager.ts b/client/packages/editor-oss/src/behaviors/game/GameManager.ts
index 0bd39d3d..db15ce61 100644
--- a/client/packages/editor-oss/src/behaviors/game/GameManager.ts
+++ b/client/packages/editor-oss/src/behaviors/game/GameManager.ts
@@ -860,7 +860,7 @@ class GameManager {
const promises: Promise[] = [];
for (const behavior of behaviors) {
const target = behaviorToTargetMap.get(behavior.uuid)!;
- console.log(
+ console.debug(
`[GameManager] About to add behavior "${behavior.id}" (uuid: ${behavior.uuid}) to object "${target.name}" (uuid: ${target.uuid})`,
);
@@ -871,7 +871,7 @@ class GameManager {
};
const promise = this.addBehaviorToObject(target, behavior.id, options)
.then(() => {
- console.log(
+ console.debug(
`[GameManager] ✓ Successfully added behavior "${behavior.id}" to object "${target.name}"`,
);
})
@@ -1504,7 +1504,7 @@ class GameManager {
behaviorId: string,
behaviorOptions?: CreateBehaviorOptions,
): Promise {
- console.log(
+ console.debug(
`[GameManager] addBehaviorToObject called with behaviorId: "${behaviorId}", target: "${target.name || target.uuid}", options:`,
behaviorOptions,
);
@@ -1540,7 +1540,7 @@ class GameManager {
}
}
- console.log(`[GameManager] BehaviorManager exists, calling createBehavior for "${behaviorKey}"`);
+ console.debug(`[GameManager] BehaviorManager exists, calling createBehavior for "${behaviorKey}"`);
const behavior = await this.behaviorManager.createBehavior(target, behaviorKey, behaviorOptions);
if (!behavior) {
@@ -1549,7 +1549,7 @@ class GameManager {
return Promise.reject(error);
}
- console.log(
+ console.debug(
`[GameManager] Successfully created behavior "${behaviorKey}" for object "${target.name || target.uuid}"`,
);
@@ -1557,7 +1557,7 @@ class GameManager {
const enableAtStart =
typeof target.userData.enableAtStart === "boolean" ? target.userData.enableAtStart : true; // default to true if not set
- console.log(`[GameManager] Object "${target.name || target.uuid}" enableAtStart: ${enableAtStart}`);
+ console.debug(`[GameManager] Object "${target.name || target.uuid}" enableAtStart: ${enableAtStart}`);
if (!enableAtStart) {
this.pauseObject(target, false); // Pause behaviors without cascading to children
}
diff --git a/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts b/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts
index 82c6dc78..8ce7e07c 100644
--- a/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts
+++ b/client/packages/editor-oss/src/behaviors/uikit/UIKitPointerEvents.ts
@@ -71,9 +71,11 @@ let initRefCount = 0;
const activeRoots = new Set();
let hasConfiguredTransparentSort = false;
-// Diagnostic logging for UIKit layout/sizing issues. Bounded so a stuck
-// scene doesn't spam the console forever.
-const UIKIT_DIAG = true;
+// Diagnostic logging for UIKit layout/sizing issues. OFF by default — this is
+// developer instrumentation that emits per-frame `[UIKitDiag]` snapshots for the
+// first few frames of every registered root, which is console noise in normal
+// use. Flip to true locally when debugging UIKit sizing/positioning.
+const UIKIT_DIAG = false;
const diagFramesRemaining = new Map();
const DIAG_MAX_FRAMES = 6;
@@ -423,7 +425,21 @@ function initializePointerEvents(): void {
return;
}
- pointerEventsInstance = forwardHtmlEvents(canvas, camera, scene, {
+ // The camera controls call setPointerCapture() on the scene container
+ // (#scene-container) on pointerdown for drag handling. That capture
+ // redirects every subsequent pointermove/pointerup for the gesture to the
+ // container, so a listener bound to the