Add FxNode render graph playground
Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup. Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+515
-17
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
compileFxNodeRenderGraph,
|
||||
createRenderGraphEditor,
|
||||
renderGraphNodeGroups,
|
||||
renderGraphPresets,
|
||||
} from "@yawn/fxnode-rg";
|
||||
|
||||
const renderGraphComment = "/* Render graph loadout:";
|
||||
|
||||
const defaultSource = `import {
|
||||
AmbientLight,
|
||||
ArcRotateCamera,
|
||||
@@ -11,7 +20,11 @@ const defaultSource = `import {
|
||||
SpotLight,
|
||||
} from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||||
const scene = new Scene(canvas, {
|
||||
hdr: true,
|
||||
fps: 60,
|
||||
clearColor: [0.018, 0.035, 0.055, 1],
|
||||
});
|
||||
await scene.ready;
|
||||
await scene.reserve({ nodes: 6, materials: 2 });
|
||||
|
||||
@@ -62,15 +75,16 @@ const lights = [
|
||||
outerAngle: 0.72,
|
||||
}),
|
||||
new PointLight(scene, {
|
||||
position: [2.2, 0.9, 1.8],
|
||||
color: [1, 0.02, 0.45],
|
||||
intensity: 16,
|
||||
range: 6,
|
||||
position: [3.4, 1.8, 3.6],
|
||||
color: [0.9, 0.04, 0.38],
|
||||
intensity: 45,
|
||||
range: 14,
|
||||
radius: 1.5,
|
||||
}),
|
||||
];
|
||||
await Promise.all(lights.map((light) => light.ready));
|
||||
|
||||
log("Icosphere ready — teal ambience, orange rim, and magenta accent are active.");
|
||||
log("Icosphere ready — teal ambience, orange rim, and soft magenta fill are active.");
|
||||
|
||||
export default {
|
||||
scene,
|
||||
@@ -121,19 +135,27 @@ await scene.batchGraphUpdates(async () => {
|
||||
const elements = Object.fromEntries(
|
||||
[
|
||||
"canvas",
|
||||
"apply-loadout-button",
|
||||
"clear-console",
|
||||
"console-count",
|
||||
"console-empty",
|
||||
"console-lines",
|
||||
"copy-loadout-code-button",
|
||||
"cursor-status",
|
||||
"dirty-indicator",
|
||||
"editor",
|
||||
"editor-diagnostics",
|
||||
"editor-mode-status",
|
||||
"encoding-status",
|
||||
"format-button",
|
||||
"fps-status",
|
||||
"fullscreen-button",
|
||||
"graph-tab-dirty",
|
||||
"inspector",
|
||||
"inspector-button",
|
||||
"loadout-name",
|
||||
"loadout-select",
|
||||
"loadout-status",
|
||||
"preview",
|
||||
"preview-message",
|
||||
"profile-adapter",
|
||||
@@ -143,6 +165,9 @@ const elements = Object.fromEntries(
|
||||
"profile-results",
|
||||
"profile-total",
|
||||
"project-title",
|
||||
"render-graph-canvas",
|
||||
"render-graph-editor",
|
||||
"render-graph-hint",
|
||||
"reset-button",
|
||||
"resize-handle",
|
||||
"resolution-status",
|
||||
@@ -156,6 +181,7 @@ const elements = Object.fromEntries(
|
||||
"tab-dirty",
|
||||
"toast",
|
||||
"type-status",
|
||||
"workspace-language",
|
||||
"workbench",
|
||||
].map((id) => [id, document.getElementById(id)]),
|
||||
);
|
||||
@@ -222,14 +248,29 @@ let currentID = "";
|
||||
let currentRevision = 0;
|
||||
let initialSource = defaultSource;
|
||||
let generation = 0;
|
||||
let lastRunSource = "";
|
||||
let dirty = false;
|
||||
let activePanel = "console";
|
||||
let activeCodingTab = "scene";
|
||||
let stopProfile;
|
||||
let profileCore;
|
||||
let toastTimer;
|
||||
let frameRequest;
|
||||
let sampledFrame = 0;
|
||||
let sampledAt = 0;
|
||||
let renderGraphEditor;
|
||||
let renderGraphMenu;
|
||||
let renderGraphMenuOutsideListener;
|
||||
let graphDirty = false;
|
||||
let loadoutsDirty = false;
|
||||
let suppressGraphChanges = false;
|
||||
let customLoadouts = [];
|
||||
let currentLoadoutID = "beauty";
|
||||
let compiledRenderGraph = "";
|
||||
|
||||
localStorage.removeItem("yawn:playground:loadouts");
|
||||
localStorage.removeItem("yawn:playground:active-loadout");
|
||||
localStorage.removeItem("yawn:playground:draft");
|
||||
|
||||
addEventListener("yawn-theme-change", (event) => {
|
||||
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
|
||||
@@ -242,6 +283,29 @@ function savedLocation() {
|
||||
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
||||
}
|
||||
|
||||
function validCustomLoadouts(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(loadout) =>
|
||||
loadout &&
|
||||
typeof loadout.id === "string" &&
|
||||
/^custom-[a-z0-9-]+$/u.test(loadout.id) &&
|
||||
typeof loadout.name === "string" &&
|
||||
loadout.name.trim() &&
|
||||
loadout.layout &&
|
||||
Array.isArray(loadout.layout.nodes) &&
|
||||
Array.isArray(loadout.layout.links),
|
||||
)
|
||||
.slice(0, 40)
|
||||
.map((loadout) => ({
|
||||
id: loadout.id,
|
||||
name: loadout.name.trim().slice(0, 64),
|
||||
builtIn: false,
|
||||
layout: loadout.layout,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadInitialPlayground() {
|
||||
const saved = savedLocation();
|
||||
if (saved) {
|
||||
@@ -258,6 +322,7 @@ async function loadInitialPlayground() {
|
||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||
elements["share-button"].hidden = false;
|
||||
initialSource = result.code;
|
||||
customLoadouts = validCustomLoadouts(result.loadouts);
|
||||
document.title = `${result.title} — Yawn Playground`;
|
||||
return result.code;
|
||||
} catch (error) {
|
||||
@@ -266,7 +331,8 @@ async function loadInitialPlayground() {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
return localStorage.getItem("yawn:playground:draft") || defaultSource;
|
||||
customLoadouts = [];
|
||||
return defaultSource;
|
||||
}
|
||||
|
||||
function loadMonaco() {
|
||||
@@ -367,13 +433,12 @@ async function createEditor(source) {
|
||||
run,
|
||||
});
|
||||
editor.onDidChangeCursorPosition(({ position }) => {
|
||||
if (activeCodingTab !== "scene") return;
|
||||
elements["cursor-status"].textContent =
|
||||
`Ln ${position.lineNumber}, Col ${position.column}`;
|
||||
});
|
||||
model.onDidChangeContent(() => {
|
||||
markDirty(true);
|
||||
if (!currentID)
|
||||
localStorage.setItem("yawn:playground:draft", model.getValue());
|
||||
});
|
||||
monaco.editor.onDidChangeMarkers(([resource]) => {
|
||||
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
||||
@@ -384,6 +449,7 @@ async function createEditor(source) {
|
||||
|
||||
function updateDiagnostics() {
|
||||
if (!monaco || !model) return;
|
||||
if (activeCodingTab !== "scene") return;
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
const errors = markers.filter(
|
||||
(marker) => marker.severity === monaco.MarkerSeverity.Error,
|
||||
@@ -401,10 +467,403 @@ function updateDiagnostics() {
|
||||
: "TypeScript language service ready";
|
||||
}
|
||||
|
||||
function markDirty(value) {
|
||||
function markDirty(value, target = "code") {
|
||||
dirty = value;
|
||||
elements["dirty-indicator"].classList.toggle("visible", value);
|
||||
elements["tab-dirty"].classList.toggle("visible", value);
|
||||
if (!value) {
|
||||
elements["tab-dirty"].classList.remove("visible");
|
||||
elements["graph-tab-dirty"].classList.remove("visible");
|
||||
graphDirty = false;
|
||||
loadoutsDirty = false;
|
||||
} else if (target === "graph") {
|
||||
elements["graph-tab-dirty"].classList.add("visible");
|
||||
} else {
|
||||
elements["tab-dirty"].classList.add("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function allLoadouts() {
|
||||
return [...renderGraphPresets, ...customLoadouts];
|
||||
}
|
||||
|
||||
function activeLoadout() {
|
||||
return allLoadouts().find((loadout) => loadout.id === currentLoadoutID);
|
||||
}
|
||||
|
||||
function loadoutLayout(loadout = activeLoadout()) {
|
||||
if (!loadout) throw new Error("The selected render graph loadout is missing");
|
||||
return loadout.layout;
|
||||
}
|
||||
|
||||
function renderLoadoutOptions() {
|
||||
const builtIn = document.createElement("optgroup");
|
||||
const saved = document.createElement("optgroup");
|
||||
builtIn.label = "Prepared loadouts";
|
||||
saved.label = "Playground loadouts";
|
||||
for (const loadout of renderGraphPresets) {
|
||||
const option = document.createElement("option");
|
||||
option.value = loadout.id;
|
||||
option.textContent = loadout.name;
|
||||
builtIn.append(option);
|
||||
}
|
||||
for (const loadout of customLoadouts) {
|
||||
const option = document.createElement("option");
|
||||
option.value = loadout.id;
|
||||
option.textContent = loadout.name;
|
||||
saved.append(option);
|
||||
}
|
||||
elements["loadout-select"].replaceChildren(
|
||||
builtIn,
|
||||
...(customLoadouts.length ? [saved] : []),
|
||||
);
|
||||
elements["loadout-select"].value = currentLoadoutID;
|
||||
const currentLoadout = activeLoadout();
|
||||
elements["loadout-name"].value = currentLoadout?.name ?? "";
|
||||
}
|
||||
|
||||
function setLoadoutStatus(message, state = "ready") {
|
||||
elements["loadout-status"].className =
|
||||
state === "busy" ? "busy" : state === "failed" ? "failed" : "";
|
||||
elements["loadout-status"].title = message;
|
||||
elements["loadout-status"].lastChild.textContent = ` ${message}`;
|
||||
}
|
||||
|
||||
function markGraphChanged() {
|
||||
if (suppressGraphChanges) return;
|
||||
compiledRenderGraph = "";
|
||||
graphDirty = true;
|
||||
loadoutsDirty = true;
|
||||
markDirty(true, "graph");
|
||||
setLoadoutStatus("Unsaved graph changes");
|
||||
}
|
||||
|
||||
function stageActiveLoadout(layout) {
|
||||
const currentLoadout = activeLoadout();
|
||||
if (!currentLoadout)
|
||||
throw new Error("The selected render graph loadout is missing");
|
||||
const name =
|
||||
elements["loadout-name"].value.trim() ||
|
||||
`${currentLoadout.name || "Render graph"} custom`;
|
||||
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
|
||||
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const changed =
|
||||
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
|
||||
const staged = changed
|
||||
? {
|
||||
id,
|
||||
name: name.slice(0, 64),
|
||||
builtIn: false,
|
||||
layout,
|
||||
}
|
||||
: currentLoadout;
|
||||
if (changed) {
|
||||
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
|
||||
if (index < 0) customLoadouts.push(staged);
|
||||
else customLoadouts[index] = staged;
|
||||
loadoutsDirty = true;
|
||||
}
|
||||
currentLoadoutID = id;
|
||||
graphDirty = false;
|
||||
renderLoadoutOptions();
|
||||
if (changed) markDirty(true, "graph");
|
||||
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||
return staged;
|
||||
}
|
||||
|
||||
function generatedRenderGraphBlock(name, serialized) {
|
||||
const safeName = name.replace(/[\r\n]+/gu, " ").replaceAll("*/", "* /");
|
||||
return `${renderGraphComment} ${safeName} */ { const renderGraph = ${JSON.stringify(serialized)}; const loadoutId = await scene.compileRenderGraph(renderGraph); await scene.setLoadout(loadoutId); }`;
|
||||
}
|
||||
|
||||
function withAppendedRenderGraph(source, name, serialized) {
|
||||
const block = generatedRenderGraphBlock(name, serialized);
|
||||
return `${source.trimEnd()}\n\n${block}\n`;
|
||||
}
|
||||
|
||||
async function compileLoadout(layout) {
|
||||
const scene = current?.scene;
|
||||
if (!scene?.getRenderGraph)
|
||||
throw new Error("Run a Scene before compiling a render graph loadout");
|
||||
return compileFxNodeRenderGraph(layout, await scene.getRenderGraph());
|
||||
}
|
||||
|
||||
function closeRenderGraphMenu() {
|
||||
if (renderGraphMenuOutsideListener) {
|
||||
document.removeEventListener(
|
||||
"pointerdown",
|
||||
renderGraphMenuOutsideListener,
|
||||
true,
|
||||
);
|
||||
renderGraphMenuOutsideListener = undefined;
|
||||
}
|
||||
renderGraphMenu?.remove();
|
||||
renderGraphMenu = undefined;
|
||||
}
|
||||
|
||||
function renderGraphOption(item, request, groupTitle) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "rg-menu-option";
|
||||
button.dataset.typeId = item.typeId;
|
||||
button.dataset.search =
|
||||
`${item.title} ${item.description} ${item.keywords || ""} ${groupTitle}`.toLowerCase();
|
||||
const title = document.createElement("span");
|
||||
const add = document.createElement("b");
|
||||
const description = document.createElement("small");
|
||||
title.textContent = item.title;
|
||||
add.textContent = "+";
|
||||
description.textContent = item.description;
|
||||
button.append(title, add, description);
|
||||
button.addEventListener("click", async () => {
|
||||
closeRenderGraphMenu();
|
||||
try {
|
||||
await request.add(item.typeId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function openRenderGraphMenu(request) {
|
||||
closeRenderGraphMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "rg-node-menu";
|
||||
menu.dataset.renderGraphMenu = "";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Add render graph node");
|
||||
const search = document.createElement("label");
|
||||
search.className = "rg-menu-search";
|
||||
search.innerHTML = '<span aria-hidden="true">⌕</span>';
|
||||
const input = document.createElement("input");
|
||||
input.type = "search";
|
||||
input.placeholder = "Search nodes";
|
||||
input.setAttribute("aria-label", "Search render graph nodes");
|
||||
search.append(input);
|
||||
const groups = document.createElement("div");
|
||||
groups.className = "rg-menu-groups";
|
||||
const results = document.createElement("div");
|
||||
results.className = "rg-menu-results";
|
||||
results.hidden = true;
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "rg-menu-empty";
|
||||
empty.textContent = "No render graph nodes match.";
|
||||
empty.hidden = true;
|
||||
|
||||
const openGroup = (target) => {
|
||||
for (const group of groups.children)
|
||||
group.classList.toggle("open", group === target);
|
||||
};
|
||||
for (const group of renderGraphNodeGroups) {
|
||||
const host = document.createElement("div");
|
||||
host.className = "rg-menu-group";
|
||||
host.dataset.group = group.id;
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "rg-menu-group-trigger";
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.innerHTML = `<span class="rg-menu-group-icon">${group.icon}</span><span>${group.title}<br><small>${group.items.length} nodes</small></span><span class="rg-menu-chevron">›</span>`;
|
||||
const submenu = document.createElement("div");
|
||||
submenu.className = "rg-menu-submenu";
|
||||
submenu.setAttribute("role", "menu");
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = group.title;
|
||||
submenu.append(
|
||||
heading,
|
||||
...group.items.map((item) =>
|
||||
renderGraphOption(item, request, group.title),
|
||||
),
|
||||
);
|
||||
trigger.addEventListener("pointerenter", () => openGroup(host));
|
||||
trigger.addEventListener("click", () => openGroup(host));
|
||||
host.append(trigger, submenu);
|
||||
groups.append(host);
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
groups.hidden = Boolean(query);
|
||||
results.hidden = !query;
|
||||
results.replaceChildren();
|
||||
if (query) {
|
||||
const matches = renderGraphNodeGroups.flatMap((group) =>
|
||||
group.items
|
||||
.map((item) => renderGraphOption(item, request, group.title))
|
||||
.filter((button) => button.dataset.search.includes(query)),
|
||||
);
|
||||
results.append(...matches);
|
||||
empty.hidden = matches.length > 0;
|
||||
} else {
|
||||
empty.hidden = true;
|
||||
}
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeRenderGraphMenu();
|
||||
elements["render-graph-canvas"].focus();
|
||||
}
|
||||
});
|
||||
menu.append(search, groups, results, empty);
|
||||
document.body.append(menu);
|
||||
const canvasBounds = elements["render-graph-canvas"].getBoundingClientRect();
|
||||
const menuBounds = menu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const x = canvasBounds.left + request.viewPosition.x;
|
||||
const y = canvasBounds.top + request.viewPosition.y;
|
||||
const needsLeftSubmenu = x + menuBounds.width + 270 > innerWidth - margin;
|
||||
menu.classList.toggle("open-left", needsLeftSubmenu);
|
||||
menu.style.left = `${Math.max(margin, Math.min(x, innerWidth - menuBounds.width - margin))}px`;
|
||||
menu.style.top = `${Math.max(margin, Math.min(y, innerHeight - menuBounds.height - margin))}px`;
|
||||
menu.style.visibility = "visible";
|
||||
renderGraphMenu = menu;
|
||||
input.focus();
|
||||
renderGraphMenuOutsideListener = (event) => {
|
||||
if (!menu.contains(event.target)) closeRenderGraphMenu();
|
||||
};
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
renderGraphMenuOutsideListener,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function switchLoadout(id) {
|
||||
const next = allLoadouts().find((loadout) => loadout.id === id);
|
||||
if (!next || !renderGraphEditor) return;
|
||||
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
|
||||
suppressGraphChanges = true;
|
||||
try {
|
||||
currentLoadoutID = next.id;
|
||||
await renderGraphEditor.load(loadoutLayout(next));
|
||||
graphDirty = false;
|
||||
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||
renderLoadoutOptions();
|
||||
setLoadoutStatus(`${next.name} selected · Apply to update scene.ts`);
|
||||
window.__yawnRenderGraph.selected = next.id;
|
||||
window.__yawnRenderGraph.layout = loadoutLayout(next);
|
||||
} finally {
|
||||
suppressGraphChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyLoadout() {
|
||||
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
|
||||
elements["apply-loadout-button"].disabled = true;
|
||||
try {
|
||||
const layout = await renderGraphEditor.save();
|
||||
const staged = stageActiveLoadout(layout);
|
||||
if (model.getValue() !== lastRunSource && !(await run()))
|
||||
throw new Error(
|
||||
"The Scene must run successfully before applying a loadout",
|
||||
);
|
||||
setLoadoutStatus(`Compiling ${staged.name}…`, "busy");
|
||||
compiledRenderGraph = await compileLoadout(layout);
|
||||
const source = withAppendedRenderGraph(
|
||||
model.getValue(),
|
||||
staged.name,
|
||||
compiledRenderGraph,
|
||||
);
|
||||
if (source !== model.getValue()) model.setValue(source);
|
||||
window.__yawnRenderGraph.selected = staged.id;
|
||||
window.__yawnRenderGraph.layout = structuredClone(layout);
|
||||
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||||
if (!(await run()))
|
||||
throw new Error("The generated render graph loadout could not run");
|
||||
setLoadoutStatus(`${staged.name} applied from scene.ts`);
|
||||
showToast(`Appended ${staged.name} to scene.ts and reran the playground`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
} finally {
|
||||
elements["apply-loadout-button"].disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLoadoutCode() {
|
||||
const button = elements["copy-loadout-code-button"];
|
||||
if (!renderGraphEditor || button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const layout = await renderGraphEditor.save();
|
||||
compiledRenderGraph = await compileLoadout(layout);
|
||||
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
|
||||
const code = generatedRenderGraphBlock(name, compiledRenderGraph);
|
||||
await navigator.clipboard.writeText(code);
|
||||
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||||
showToast(`${activeLoadout().name} loadout code copied`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRenderGraphWorkspace() {
|
||||
renderLoadoutOptions();
|
||||
renderGraphEditor = await createRenderGraphEditor({
|
||||
canvas: elements["render-graph-canvas"],
|
||||
initialLayout: loadoutLayout(),
|
||||
workerUrl: "/pkg/fxnode-worker.js",
|
||||
onChange: markGraphChanged,
|
||||
onAddNodeMenu: openRenderGraphMenu,
|
||||
onError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
},
|
||||
});
|
||||
window.__yawnRenderGraph = {
|
||||
editor: renderGraphEditor,
|
||||
layout: loadoutLayout(),
|
||||
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
|
||||
selected: currentLoadoutID,
|
||||
serialized: compiledRenderGraph,
|
||||
switchLoadout,
|
||||
};
|
||||
setLoadoutStatus(`${activeLoadout().name} selected`);
|
||||
}
|
||||
|
||||
function activateCodingTab(tab) {
|
||||
activeCodingTab = tab;
|
||||
closeRenderGraphMenu();
|
||||
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||||
const active = button.dataset.editorTab === tab;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
}
|
||||
elements.editor.hidden = tab !== "scene";
|
||||
elements["render-graph-editor"].hidden = tab !== "render-graph";
|
||||
for (const target of document.querySelectorAll("[data-code-tool]"))
|
||||
target.hidden = tab !== "scene";
|
||||
for (const target of document.querySelectorAll("[data-graph-tool]"))
|
||||
target.hidden = tab !== "render-graph";
|
||||
if (tab === "render-graph") {
|
||||
elements["workspace-language"].textContent = "Render graph";
|
||||
elements["type-status"].textContent = "FxNode worker ready";
|
||||
elements["cursor-status"].textContent = "Node canvas";
|
||||
elements["editor-mode-status"].textContent = "FxNode";
|
||||
elements["encoding-status"].textContent = "S-expression v1";
|
||||
elements["editor-diagnostics"].lastChild.textContent =
|
||||
" Graph valid on save";
|
||||
elements["editor-diagnostics"].classList.remove("has-errors");
|
||||
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
|
||||
} else {
|
||||
elements["workspace-language"].textContent = "TypeScript";
|
||||
const position = editor?.getPosition();
|
||||
elements["cursor-status"].textContent = position
|
||||
? `Ln ${position.lineNumber}, Col ${position.column}`
|
||||
: "Ln 1, Col 1";
|
||||
elements["editor-mode-status"].textContent = "Spaces: 2";
|
||||
elements["encoding-status"].textContent = "UTF-8";
|
||||
updateDiagnostics();
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function transpile() {
|
||||
@@ -414,10 +873,11 @@ async function transpile() {
|
||||
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
|
||||
if (emitted.emitSkipped || !output)
|
||||
throw new Error("TypeScript could not emit this scene.");
|
||||
return output.text
|
||||
const code = output.text
|
||||
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
|
||||
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
|
||||
.replace(/\bexport\s+default\s+/, "return ");
|
||||
.replace(/\bexport\s+default\s+/, "__yawnPlaygroundDefaultExport = ");
|
||||
return `let __yawnPlaygroundDefaultExport;\n${code}\nreturn __yawnPlaygroundDefaultExport;`;
|
||||
}
|
||||
|
||||
async function disposeCurrent() {
|
||||
@@ -459,7 +919,7 @@ function replaceCanvas() {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!editor || elements["run-button"].disabled) return;
|
||||
if (!editor || elements["run-button"].disabled) return false;
|
||||
clearPlaygroundTimers();
|
||||
const runID = ++generation;
|
||||
elements["run-button"].disabled = true;
|
||||
@@ -500,16 +960,18 @@ async function run() {
|
||||
);
|
||||
if (runID !== generation) {
|
||||
if (result?.dispose) await result.dispose();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
current = result;
|
||||
window.__yawnPlayground = result;
|
||||
lastRunSource = model.getValue();
|
||||
elements["preview-message"].hidden = true;
|
||||
setRunStatus(
|
||||
`Running · ${Math.round(performance.now() - started)} ms`,
|
||||
"ready",
|
||||
);
|
||||
if (activePanel === "profile") await attachProfiler();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (runID === generation) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -518,6 +980,7 @@ async function run() {
|
||||
showPreviewMessage("Scene could not start", message);
|
||||
openInspector("console");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (runID === generation) {
|
||||
elements["run-button"].disabled = false;
|
||||
@@ -641,6 +1104,10 @@ async function save() {
|
||||
if (!editor || elements["save-button"].disabled) return;
|
||||
elements["save-button"].disabled = true;
|
||||
try {
|
||||
const hadLoadoutChanges = loadoutsDirty;
|
||||
let stagedLoadout;
|
||||
if (graphDirty && renderGraphEditor)
|
||||
stagedLoadout = stageActiveLoadout(await renderGraphEditor.save());
|
||||
const response = await fetch("/api/playgrounds", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -648,6 +1115,7 @@ async function save() {
|
||||
id: currentID,
|
||||
title: elements["project-title"].value.trim() || "Untitled playground",
|
||||
code: model.getValue(),
|
||||
loadouts: customLoadouts,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
@@ -656,11 +1124,15 @@ async function save() {
|
||||
currentID = result.id;
|
||||
currentRevision = result.revision;
|
||||
initialSource = result.code;
|
||||
customLoadouts = validCustomLoadouts(result.loadouts);
|
||||
renderLoadoutOptions();
|
||||
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||
elements["share-button"].hidden = false;
|
||||
localStorage.removeItem("yawn:playground:draft");
|
||||
markDirty(false);
|
||||
if (stagedLoadout) setLoadoutStatus("Saved · Apply to update scene.ts");
|
||||
else if (hadLoadoutChanges)
|
||||
setLoadoutStatus("Applied code · saved with playground");
|
||||
showToast(`Saved revision ${result.revision}`);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error));
|
||||
@@ -718,6 +1190,23 @@ function sampleFPS(now = performance.now()) {
|
||||
|
||||
elements["run-button"].addEventListener("click", run);
|
||||
elements["save-button"].addEventListener("click", save);
|
||||
elements["apply-loadout-button"].addEventListener("click", applyLoadout);
|
||||
elements["copy-loadout-code-button"].addEventListener("click", copyLoadoutCode);
|
||||
elements["loadout-name"].addEventListener("input", markGraphChanged);
|
||||
elements["loadout-select"].addEventListener(
|
||||
"change",
|
||||
(event) =>
|
||||
void switchLoadout(event.target.value).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
}),
|
||||
);
|
||||
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||||
button.addEventListener("click", () =>
|
||||
activateCodingTab(button.dataset.editorTab),
|
||||
);
|
||||
}
|
||||
elements["share-button"].addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
showToast("Playground URL copied");
|
||||
@@ -786,14 +1275,23 @@ elements["resize-handle"].addEventListener("pointerup", (event) => {
|
||||
elements["resize-handle"].classList.remove("dragging");
|
||||
});
|
||||
addEventListener("popstate", () => location.reload());
|
||||
addEventListener("beforeunload", () => {
|
||||
addEventListener("beforeunload", (event) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
addEventListener("pagehide", (event) => {
|
||||
if (event.persisted) return;
|
||||
cancelAnimationFrame(frameRequest);
|
||||
closeRenderGraphMenu();
|
||||
void renderGraphEditor?.destroy();
|
||||
void disposeCurrent();
|
||||
});
|
||||
|
||||
try {
|
||||
const source = await loadInitialPlayground();
|
||||
await createEditor(source);
|
||||
await initializeRenderGraphWorkspace();
|
||||
markDirty(false);
|
||||
sampleFPS();
|
||||
await run();
|
||||
|
||||
Reference in New Issue
Block a user