Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<details>
<summary>Native Windows (advanced, unsupported)</summary>

```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.

</details>

### 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.
Expand Down
8 changes: 7 additions & 1 deletion client/packages/editor-oss/src/EngineRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
50 changes: 38 additions & 12 deletions client/packages/editor-oss/src/agent/handlers/SettingsHandlers.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -48,34 +49,51 @@ const DEFAULT_RENDERING = {
export class SettingsHandlers {
constructor(private engine: EngineRuntime) {}

private async resolveImageSource(source?: string): Promise<string | undefined> {
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://")
|| source.startsWith("data:")
|| 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}};
}

/**
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,41 @@ export function autoResolveImports(
const claimed = new Set<string>();

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) {
Expand Down
6 changes: 4 additions & 2 deletions client/packages/editor-oss/src/agent/script-tool/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/** 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 */
Expand Down
Loading
Loading