Fix editor regressions and add Blender scene and animated material import

This commit is contained in:
emil28092005
2026-09-12 00:53:20 +03:00
parent 1a0bf725fa
commit 6f497d1977
34 changed files with 1241 additions and 213 deletions
+69 -63
View File
@@ -70,8 +70,8 @@ import {
} from "../engine/archive.ts";
import { FormaRuntime } from "../engine/runtime.ts";
import { saveDraft } from "./persistence.ts";
import { loadEditorProject, type EditorStartupOptions } from "./startup.ts";
import { inspectModel } from "../engine/model.ts";
import { loadEditorProject, editorDraftKey, type EditorStartupOptions } from "./startup.ts";
import { portableModel, modelComponents, modelDataUri, modelProject } from "../engine/model-import.ts";
import "./editor.css";
import { BuildPanel } from "./BuildPanel.tsx";
const names: Record<string, string> = {
@@ -278,7 +278,7 @@ function Modal({
</div>
);
}
export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) {
export default function Editor({ initialProject, browserOnly = false, draftScope }: EditorStartupOptions = {}) {
const [project, setProject] = useState<Project | null>(null),
[selected, setSelected] = useState<string | null>(null),
[connection, setConnection] = useState<any>(null),
@@ -328,6 +328,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
toastTimer = useRef<any>(null),
file = useRef<HTMLInputElement | null>(null),
modelFile = useRef<HTMLInputElement | null>(null),
sceneFile = useRef<HTMLInputElement | null>(null),
viewport = useRef<HTMLDivElement | null>(null),
stickId = useRef<number | null>(null),
knob = useRef<HTMLDivElement | null>(null);
@@ -406,7 +407,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
let alive = true;
let unsubscribe: (() => void) | undefined;
(async () => {
const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly });
const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly, draftScope });
if (!alive) return;
store.current = new ProjectStore(startingProject);
setCached(restored);
@@ -427,7 +428,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(
() =>
saveDraft(store.current!.project)
saveDraft(store.current!.project, editorDraftKey({ initialProject, browserOnly, draftScope }))
.then(() => setCached(true))
.catch((e) => addLog("error", String(e))),
500,
@@ -492,7 +493,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
events.current?.close();
clearTimeout(saveTimer.current);
};
}, [accept, addLog, call, initialProject, browserOnly]);
}, [accept, addLog, call, initialProject, browserOnly, draftScope]);
useEffect(() => {
if (!ready || !canvas.current) return;
const r = new FormaRuntime(canvas.current, {
@@ -691,53 +692,51 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
setSelected(n.id);
}
};
const importFiles = async (files: FileList | File[]) => {
for (const f of Array.from(files)) {
const importFiles = async (files: FileList | File[], asScene = false) => {
const selectedFiles = Array.from(files);
const entries = new Map(selectedFiles.map(file => [file.webkitRelativePath || file.name, file]));
for (const f of selectedFiles.filter(file => /\.(forma|json|zip|glb|gltf)$/i.test(file.name))) {
setBusy("Импорт " + f.name);
try {
if (f.size > 80 * 1024 * 1024) throw Error("Лимит файла импорта: 80 МБ");
const bytes = new Uint8Array(await f.arrayBuffer());
if (/\.(forma|json|zip)$/i.test(f.name)) {
const p = unpackProject(new Uint8Array(await f.arrayBuffer()));
if (await cmd("project.replace", { project: p }, "Открыть проект")) {
setSelected(null);
notify("Проект открыт");
let imported: Project | undefined;
try { imported = unpackProject(bytes); }
catch (error) {
if (!/\.zip$/i.test(f.name) || !(error instanceof Error) || error.message !== "Нет project.forma.json") throw error;
}
} else if (/\.(glb|gltf)$/i.test(f.name)) {
if (f.size > 25 * 1024 * 1024) throw Error("Лимит модели: 25 МБ");
const bytes = new Uint8Array(await f.arrayBuffer());
const metadata = inspectModel(bytes, f.name);
const id = uid("asset"),
n = entity(f.name.replace(/\.(glb|gltf)$/i, ""), {
mesh: { type: "model", assetId: id },
});
if (
await execute(
[
{
op: "asset.upsert",
args: {
asset: {
id,
name: f.name,
kind: "model",
metadata,
uri:
"data:" + mimeFor(f.name) + ";base64," + base64(bytes),
},
},
},
{ op: "node.create", args: { entity: n } },
],
"Импорт " + f.name,
)
)
setSelected(n.id);
} else throw Error("Поддерживаются .forma, GLB, встроенный glTF");
} catch (e) {
notify(String(e));
addLog("error", String(e));
} finally {
setBusy("");
}
if (imported) {
if (await cmd("project.replace", { project: imported }, "Открыть проект")) { setSelected(null); notify("Проект открыт"); }
continue;
}
}
const model = await portableModel(bytes, f.webkitRelativePath || f.name, async path => {
const file = entries.get(path);
if (!file) throw Error("Отсутствует " + path + ". Упакуй glTF с ресурсами в ZIP.");
if (file.size > 25 * 1024 * 1024) throw Error("Лимит ресурса: 25 МБ");
return new Uint8Array(await file.arrayBuffer());
});
if (asScene) {
if (await cmd("project.replace", { project: modelProject(model) }, "Открыть сцену Blender")) {
setSelected(null); notify("Сцена Blender открыта; исходную сцену можно вернуть через Undo");
for (const warning of model.metadata.warnings) addLog("warning", warning);
}
continue;
}
const id = uid("asset");
const n = entity(model.name.replace(/\.(glb|gltf)$/i, ""), modelComponents(id, model.metadata));
const result = await execute([
{ op: "asset.upsert", args: { asset: { id, name: model.name, kind: "model", metadata: model.metadata, uri: modelDataUri(model) } } },
{ op: "node.create", args: { entity: n } },
], "Импорт " + model.name);
if (result) {
setSelected(n.id);
for (const warning of model.metadata.warnings) addLog("warning", warning, n.id);
notify(`Импортировано: ${model.metadata.nodes} узлов, ${model.metadata.clips.length} клипов, ${model.metadata.animatedProperties.length} анимированных свойств`);
}
} catch (error) { notify(String(error)); addLog("error", String(error)); }
finally { setBusy(""); }
}
};
const exportGame = async () => {
@@ -1086,12 +1085,16 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
type="file"
hidden
multiple
accept=".glb,.gltf"
accept=".glb,.gltf,.zip,.bin,.png,.jpg,.jpeg,.webp"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files);
e.target.value = "";
}}
/>
<input ref={sceneFile} type="file" hidden accept=".glb,.gltf,.zip" onChange={e => {
if (e.target.files) void importFiles(e.target.files, true);
e.target.value = "";
}} />
<header className="topbar">
<div className="brand">
<div className="brand-mark">
@@ -1359,18 +1362,6 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
ref={canvas}
aria-label="3D-сцена"
tabIndex={0}
onPointerDown={() => {
const r = runtime.current;
if (
r?.playing &&
!r.paused &&
r.state.some((n) => n.components.camera?.mode === "firstPerson")
) {
try {
void canvas.current?.requestPointerLock()?.catch(() => {});
} catch {}
}
}}
/>
{!playing && (
<>
@@ -1769,6 +1760,14 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
</>
) : type === "animator" ? (
<>
<div className="property">
<label>Автозапуск при Play</label>
<select aria-label="Клип автозапуска" value={c.autoplay || ""} onChange={e => void componentField(type, "autoplay", e.target.value)}>
<option value="">Не запускать</option>
{(runtime.current?.importInfo.get(node.components.mesh?.assetId)?.clips || []).map((clip: string) => <option key={clip} value={clip}>{clip}</option>)}
</select>
</div>
<button className="wide-button" onClick={() => runtime.current?.previewAnimation(node.id, c.autoplay || runtime.current?.importInfo.get(node.components.mesh?.assetId)?.clips?.[0])}>Предпросмотр клипа</button>
{["idle", "run", "attack", "death"].map((k) => (
<div className="property" key={k}>
<label>{k}</label>
@@ -1806,9 +1805,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
>
<option value="follow">Следование</option>
<option value="fixed">Фиксированная</option>
{node.components.mesh?.type === "model" && <option value="imported">Из Blender / glTF</option>}
<option value="firstPerson">От первого лица</option>
</select>
</div>
{c.mode === "imported" && <div className="property"><label>Камера модели</label><select aria-label="Камера модели" value={c.cameraName || ""} onChange={e => void componentField(type, "cameraName", e.target.value)}><option value="">Первая камера</option>{(runtime.current?.importInfo.get(node.components.mesh?.assetId)?.cameraNames || []).map((name: string) => <option key={name} value={name}>{name}</option>)}</select></div>}
{c.mode !== "imported" && <>
<div className="property">
<label>Проекция</label>
<select
@@ -1873,6 +1875,7 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
onChange={(v) => void componentField(type, "fov", v)}
/>
</div>
</>}
</>
) : type === "character" ? (
<>
@@ -2082,9 +2085,12 @@ export default function Editor({ initialProject, browserOnly = false }: EditorSt
))}
</div>
<div className="bottom-actions">
<button onClick={() => sceneFile.current?.click()} title="Открыть GLB или ZIP Blender как новый проект с камерами, светом и анимацией">
<Upload size={13} /> Открыть сцену Blender
</button>
<button onClick={() => modelFile.current?.click()}>
<Upload size={13} />
Импорт GLB
Импорт модели
</button>
<Icon
icon={HelpCircle}
+4 -4
View File
@@ -7,10 +7,10 @@ function db(): Promise<IDBDatabase> {
req.onerror = () => reject(req.error);
});
}
export async function loadDraft() {
export async function loadDraft(key = "active") {
const d = await db();
return new Promise<Project | null>((resolve, reject) => {
const r = d.transaction("projects").objectStore("projects").get("active");
const r = d.transaction("projects").objectStore("projects").get(key);
r.onsuccess = () => {
d.close();
if (r.result) {
@@ -25,11 +25,11 @@ export async function loadDraft() {
r.onerror = () => reject(r.error);
});
}
export async function saveDraft(p: Project) {
export async function saveDraft(p: Project, key = "active") {
const d = await db();
return new Promise<void>((resolve, reject) => {
const tx = d.transaction("projects", "readwrite");
tx.objectStore("projects").put(p, "active");
tx.objectStore("projects").put(p, key);
tx.oncomplete = () => {
d.close();
resolve();
+8 -3
View File
@@ -2,10 +2,15 @@ import { type Project, clone, validateProject } from '../engine/schema.ts';
import { defaultProject } from '../engine/templates.ts';
import { loadDraft } from './persistence.ts';
export interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; }
export interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; draftScope?: string; }
export function editorDraftKey(options: EditorStartupOptions, base = typeof document === "undefined" ? "http://localhost/" : document.baseURI) {
if (!options.browserOnly) return "active";
const scope = options.draftScope ?? new URL(".", base).pathname;
return `hosted:${scope}:${options.initialProject?.id ?? "blank"}`;
}
export async function loadEditorProject(
options: EditorStartupOptions = {},
dependencies: { request: typeof fetch; draft: () => Promise<Project | null> } = { request: fetch, draft: loadDraft },
dependencies: { request: typeof fetch; draft: (key: string) => Promise<Project | null> } = { request: fetch, draft: loadDraft },
) {
if (!options.browserOnly) {
try {
@@ -23,7 +28,7 @@ export async function loadEditorProject(
} catch {}
}
try {
const project = await dependencies.draft();
const project = await dependencies.draft(editorDraftKey(options));
if (project) {
validateProject(project);
return { project, status: null, restored: true };