feat(devtools): initial model editor (#112)

This commit is contained in:
/home/neo 2026-07-04 14:01:20 +03:00 committed by GitHub
parent 121a0bf4cf
commit d9441c9a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1758 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View file

@ -0,0 +1,354 @@
import { useEffect, useRef, useCallback, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { ModelFile } from "../../types/model";
interface ModelPreview3DProps {
model: ModelFile | null;
selectedPart?: string | null;
showBounds?: boolean;
className?: string;
}
const PART_COLORS = [
0xff5555, 0x55ff55, 0x5555ff, 0xffff55, 0xff55ff, 0x55ffff, 0xff8855,
0x88ff55, 0x5588ff, 0xff5588, 0x55ff88, 0x8855ff, 0xff8855, 0x55ffaa,
0xaa55ff,
];
interface FaceUV {
u0: number;
v0: number;
u1: number;
v1: number;
u2: number;
v2: number;
u3: number;
v3: number;
}
function getFaceUV(
face: number,
ux: number,
uy: number,
w: number,
h: number,
d: number,
tw: number,
th: number,
): FaceUV {
const toU = (px: number) => Math.max(0, px) / tw;
const toV = (py: number) => 1 - Math.max(0, py) / th;
switch (face) {
case 0: {
const l = ux + d;
const t = uy + d;
const r = ux + w;
const b = uy + h - d;
return {
u0: toU(l),
v0: toV(t),
u1: toU(r),
v1: toV(t),
u2: toU(l),
v2: toV(b),
u3: toU(r),
v3: toV(b),
};
}
case 1: {
const l = ux;
const t = uy + d;
const r = ux + d;
const b = uy + h - d;
return {
u0: toU(r),
v0: toV(t),
u1: toU(l),
v1: toV(t),
u2: toU(r),
v2: toV(b),
u3: toU(l),
v3: toV(b),
};
}
case 2: {
const l = ux + d;
const t = uy;
const r = ux + w - d;
const b = uy + d;
return {
u0: toU(l),
v0: toV(b),
v1: toV(b),
u1: toU(r),
u2: toU(l),
v2: toV(t),
u3: toU(r),
v3: toV(t),
};
}
case 3: {
const l = ux + w;
const t = uy;
const r = ux + w + d;
const b = uy + d;
return {
u0: toU(l),
v0: toV(t),
u1: toU(r),
v1: toV(t),
u2: toU(l),
v2: toV(b),
u3: toU(r),
v3: toV(b),
};
}
case 4: {
const l = ux + d;
const t = uy + d;
const r = ux + w - d;
const b = uy + h - d;
return {
u0: toU(r),
v0: toV(t),
u1: toU(l),
v1: toV(t),
u2: toU(r),
v2: toV(b),
u3: toU(l),
v3: toV(b),
};
}
case 5: {
const l = ux + d + w;
const t = uy + d;
const r = ux + d + w + d;
const b = uy + h - d;
return {
u0: toU(l),
v0: toV(t),
u1: toU(r),
v1: toV(t),
u2: toU(l),
v2: toV(b),
u3: toU(r),
v3: toV(b),
};
}
default:
return { u0: 0, v0: 0, u1: 1, v1: 0, u2: 0, v2: 1, u3: 1, v3: 1 };
}
}
export default function ModelPreview3D({
model,
selectedPart,
showBounds,
className,
}: ModelPreview3DProps) {
const mountRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const meshGroupRef = useRef<THREE.Group>(new THREE.Group());
const controlsRef = useRef<OrbitControls | null>(null);
const [texture, setTexture] = useState<THREE.Texture | null>(null);
useEffect(() => {
if (!model?.textures?.length) {
setTexture(null);
return;
}
const img = new Image();
img.onload = () => {
const tex = new THREE.Texture(img);
tex.colorSpace = THREE.SRGBColorSpace;
tex.needsUpdate = true;
setTexture(tex);
};
img.src = model.textures[0].data;
return () => {
img.onload = null;
};
}, [model]);
const buildModel = useCallback(() => {
const group = meshGroupRef.current;
while (group.children.length) {
const child = group.children[0];
group.remove(child);
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
}
if (child instanceof THREE.LineSegments) {
child.geometry.dispose();
child.material.dispose();
}
}
if (!model) return;
const atlas = texture;
const tw = model.textureSize.X;
const th = model.textureSize.Y;
const gridHelper = new THREE.GridHelper(30, 10, 0x444444, 0x222222);
gridHelper.position.y = -12;
group.add(gridHelper);
const axesHelper = new THREE.AxesHelper(5);
axesHelper.position.set(-20, -12, -20);
group.add(axesHelper);
let minX = Infinity,
minY = Infinity,
minZ = Infinity;
let maxX = -Infinity,
maxY = -Infinity,
maxZ = -Infinity;
model.parts.forEach((part, pi) => {
const color = PART_COLORS[pi % PART_COLORS.length];
const isSelected = selectedPart === part.name;
const tx = part.translation?.X ?? 0;
const ty = part.translation?.Y ?? 0;
const tz = part.translation?.Z ?? 0;
part.boxes.forEach((box) => {
const w = box.size.X;
const h = box.size.Y;
const d = box.size.Z;
const ux = box.uv.X;
const uy = box.uv.Y;
const cx = box.pos.X + w / 2 + tx;
const cy = box.pos.Y + h / 2 + ty;
const cz = box.pos.Z + d / 2 + tz;
minX = Math.min(minX, box.pos.X + tx);
minY = Math.min(minY, box.pos.Y + ty);
minZ = Math.min(minZ, box.pos.Z + tz);
maxX = Math.max(maxX, box.pos.X + w + tx);
maxY = Math.max(maxY, box.pos.Y + h + ty);
maxZ = Math.max(maxZ, box.pos.Z + d + tz);
const geo = new THREE.BoxGeometry(w, h, d);
if (atlas && tw && th) {
const uvAttr = geo.attributes.uv;
const uvs = uvAttr.array;
for (let f = 0; f < 6; f++) {
const faceUV = getFaceUV(f, ux, uy, w, h, d, tw, th);
uvs[f * 8 + 0] = faceUV.u0;
uvs[f * 8 + 1] = faceUV.v0;
uvs[f * 8 + 2] = faceUV.u1;
uvs[f * 8 + 3] = faceUV.v1;
uvs[f * 8 + 4] = faceUV.u2;
uvs[f * 8 + 5] = faceUV.v2;
uvs[f * 8 + 6] = faceUV.u3;
uvs[f * 8 + 7] = faceUV.v3;
}
uvAttr.needsUpdate = true;
}
const mat = new THREE.MeshPhongMaterial({
color: atlas ? 0xffffff : color,
map: atlas ?? undefined,
transparent: !atlas,
opacity: atlas ? 1 : isSelected ? 0.9 : 0.6,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(cx, cy, cz);
group.add(mesh);
const edgeGeo = new THREE.EdgesGeometry(geo);
const edgeMat = new THREE.LineBasicMaterial({
color: isSelected ? 0xffff55 : 0x888888,
transparent: true,
opacity: isSelected ? 1 : 0.4,
});
const edge = new THREE.LineSegments(edgeGeo, edgeMat);
edge.position.copy(mesh.position);
group.add(edge);
});
});
if (showBounds && minX !== Infinity) {
const bw = maxX - minX;
const bh = maxY - minY;
const bd = maxZ - minZ;
const bGeo = new THREE.BoxGeometry(bw, bh, bd);
const bMat = new THREE.MeshBasicMaterial({
color: 0xffff55,
wireframe: true,
transparent: true,
opacity: 0.3,
});
const bMesh = new THREE.Mesh(bGeo, bMat);
bMesh.position.set(
(minX + maxX) / 2,
(minY + maxY) / 2,
(minZ + maxZ) / 2,
);
group.add(bMesh);
}
}, [model, selectedPart, showBounds, texture]);
useEffect(() => {
if (!mountRef.current) return;
const width = mountRef.current.clientWidth;
const height = mountRef.current.clientHeight;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a2e);
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(30, 20, 30);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
mountRef.current.innerHTML = "";
mountRef.current.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.target.set(0, 0, 0);
controls.update();
controlsRef.current = controls;
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(10, 20, 10);
scene.add(dl);
const dl2 = new THREE.DirectionalLight(0xffffff, 0.3);
dl2.position.set(-10, -5, -10);
scene.add(dl2);
scene.add(meshGroupRef.current);
let animId: number;
const render = () => {
animId = requestAnimationFrame(render);
controls.update();
renderer.render(scene, camera);
};
render();
const resize = () => {
if (!mountRef.current) return;
const w = mountRef.current.clientWidth;
const h = mountRef.current.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener("resize", resize);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
controls.dispose();
renderer.dispose();
mountRef.current?.removeChild(renderer.domElement);
};
}, []);
useEffect(() => {
buildModel();
}, [buildModel]);
return (
<div
ref={mountRef}
className={className}
style={{ width: "100%", height: "100%", minHeight: "300px" }}
/>
);
}

View file

@ -16,7 +16,8 @@ const DEV_TOOLS: DevTool[] = [
{ id: "loc", name: "LOC Editor", view: "loc-editor", comingSoon: false },
{ id: "grf", name: "GRF Editor", view: "grf-editor", comingSoon: false },
{ id: "col", name: "COL Editor", view: "col-editor", comingSoon: false },
{ id: "options", name: "Options Editor", view: "options-editor", comingSoon: false }
{ id: "options", name: "Options Editor", view: "options-editor", comingSoon: false },
{ id: "model", name: "Model Editor", view: "model-editor", comingSoon: false }
];
export default function DevtoolsView() {
@ -111,6 +112,17 @@ export default function DevtoolsView() {
alt={tool.name}
className="w-12 h-12 object-contain opacity-50 grayscale"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
const parent = target.parentElement;
if (parent && !parent.querySelector(".tool-fallback")) {
const fallback = document.createElement("span");
fallback.className = "tool-fallback text-2xl text-white/30 mc-text-shadow uppercase font-bold";
fallback.textContent = tool.name.charAt(0);
parent.appendChild(fallback);
}
}}
/>
{tool.comingSoon && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ import LocEditorView from "../components/views/LocEditorView";
import GrfEditorView from "../components/views/GrfEditorView";
import ColEditorView from "../components/views/ColEditorView";
import OptionsEditorView from "../components/views/OptionsEditorView";
import ModelEditorView from "../components/views/ModelEditorView";
import ScreenshotsView from "../components/views/ScreenshotsView";
import SwfView from "../components/views/SwfView";
import LceLiveView from "../components/views/LceLiveView";
@ -607,6 +608,9 @@ export default function App() {
{activeView === "options-editor" && (
<OptionsEditorView key="options-editor-view" />
)}
{activeView === "model-editor" && (
<ModelEditorView key="model-editor-view" />
)}
{activeView === "swf-editor" && (
<SwfView key="swf-editor-view" />
)}

View file

@ -0,0 +1,243 @@
import { ModelFile, ModelPart, ModelBox, ModelTexture, Vec3 } from "../types/model";
interface BBElement {
name: string;
type: string;
from: number[];
to: number[];
uv_offset: number[];
inflate?: number;
mirror_uv?: boolean;
rotation?: number[];
origin?: number[];
}
interface BBTexture {
name: string;
source: string;
}
interface BBOutline {
name: string;
origin?: number[];
rotation?: number[];
children: (BBOutline | string)[];
}
interface BBModel {
name: string;
model_identifier: string;
resolution: { width: number; height: number };
elements: BBElement[];
textures: BBTexture[];
outliner: (BBOutline | string)[];
}
export class ModelService {
static async importFromBBModel(buffer: ArrayBuffer): Promise<ModelFile> {
const text = new TextDecoder("utf-8").decode(buffer);
const bb: BBModel = JSON.parse(text);
const textures: ModelTexture[] = (bb.textures || [])
.filter((t) => t.name && t.source)
.map((t) => ({ name: t.name, data: t.source }));
const model: ModelFile = {
name: bb.model_identifier || bb.name,
textureSize: { X: bb.resolution.width, Y: bb.resolution.height },
parts: [],
textures: textures.length > 0 ? textures : undefined,
};
const rootOutline = bb.outliner.find(
(o): o is BBOutline =>
typeof o === "object" && o.name === model.name,
);
const outlines = rootOutline?.children ?? bb.outliner;
for (const item of outlines) {
if (typeof item === "object") {
const parts = this.convertOutline(item, bb.elements);
model.parts.push(...parts);
}
}
return model;
}
private static convertOutline(
outline: BBOutline,
elements: BBElement[],
): ModelPart[] {
const parts: ModelPart[] = [];
const boxElements = outline.children.filter(
(c): c is string => typeof c === "string",
);
const childOutlines = outline.children.filter(
(c): c is BBOutline => typeof c === "object",
);
const boxes: ModelBox[] = [];
for (const uuid of boxElements) {
const el = elements.find((e) => e.name === uuid || this.guessUuid(e) === uuid);
if (el && el.type === "cube") {
boxes.push(this.elementToBox(el));
}
}
const translation: Vec3 = outline.origin
? { X: outline.origin[0], Y: outline.origin[1], Z: outline.origin[2] }
: { X: 0, Y: 0, Z: 0 };
const rotation: Vec3 = outline.rotation
? { X: outline.rotation[0], Y: outline.rotation[1], Z: outline.rotation[2] }
: { X: 0, Y: 0, Z: 0 };
if (boxes.length > 0 || childOutlines.length === 0) {
parts.push({
name: outline.name,
translation,
rotation,
boxes,
});
}
for (const child of childOutlines) {
parts.push(...this.convertOutline(child, elements));
}
return parts;
}
private static guessUuid(el: BBElement): string {
return el.name;
}
private static elementToBox(el: BBElement): ModelBox {
const pos: Vec3 = {
X: el.from[0],
Y: el.from[1],
Z: el.from[2],
};
const size: Vec3 = {
X: el.to[0] - el.from[0],
Y: el.to[1] - el.from[1],
Z: el.to[2] - el.from[2],
};
return {
pos,
size,
uv: { X: el.uv_offset?.[0] ?? 0, Y: el.uv_offset?.[1] ?? 0 },
inflate: el.inflate,
mirror: el.mirror_uv,
};
}
static exportToBBModel(model: ModelFile): ArrayBuffer {
const elements: BBElement[] = [];
const outliner: (BBOutline | string)[] = [];
for (const part of model.parts) {
const partOutlines = this.partToOutline(part, elements);
outliner.push(...partOutlines);
}
const bb: BBModel = {
name: model.name,
model_identifier: model.name,
resolution: { width: model.textureSize.X, height: model.textureSize.Y },
elements,
textures: (model.textures || []).map((t) => ({
name: t.name,
source: t.data,
})),
outliner: [{ name: model.name, children: outliner }],
};
const json = JSON.stringify(bb, null, 2);
return new TextEncoder().encode(json).buffer as ArrayBuffer;
}
private static partToOutline(
part: ModelPart,
elements: BBElement[],
): BBOutline[] {
const boxUuids: string[] = [];
for (const box of part.boxes) {
const uuid = `box_${elements.length}`;
boxUuids.push(uuid);
const to: number[] = [
box.pos.X + box.size.X,
box.pos.Y + box.size.Y,
box.pos.Z + box.size.Z,
];
elements.push({
name: uuid,
type: "cube",
from: [box.pos.X, box.pos.Y, box.pos.Z],
to,
uv_offset: [box.uv.X, box.uv.Y],
inflate: box.inflate,
mirror_uv: box.mirror,
});
}
return [
{
name: part.name,
origin: part.translation
? [part.translation.X, part.translation.Y, part.translation.Z]
: undefined,
rotation: undefined,
children: boxUuids,
},
];
}
static importFromJSON(json: string): ModelFile[] {
const data = JSON.parse(json);
return Object.entries(data).map(([name, val]: [string, any]) => ({
name,
textureSize: val.textureSize as { X: number; Y: number },
parts: (val.parts as any[]).map(
(p: any): ModelPart => ({
name: p.name,
translation: p.translation,
rotation: p.rotation,
boxes: (p.boxes as any[]).map(
(b: any): ModelBox => ({
pos: b.pos,
size: b.size,
uv: b.uv,
inflate: b.inflate,
mirror: b.mirror,
}),
),
}),
),
}));
}
static createDefaultModel(name: string): ModelFile {
return {
name,
textureSize: { X: 64, Y: 64 },
parts: [
{
name: "body",
boxes: [
{
pos: { X: -4, Y: 0, Z: -2 },
size: { X: 8, Y: 12, Z: 4 },
uv: { X: 0, Y: 0 },
},
],
},
],
};
}
}

41
src/types/model.ts Normal file
View file

@ -0,0 +1,41 @@
export interface Vec3 {
X: number;
Y: number;
Z: number;
}
export interface Vec2 {
X: number;
Y: number;
}
export interface ModelBox {
pos: Vec3;
size: Vec3;
uv: Vec2;
inflate?: number;
mirror?: boolean;
}
export interface ModelPart {
name: string;
translation?: Vec3;
rotation?: Vec3;
boxes: ModelBox[];
}
export interface ModelTexture {
name: string;
data: string;
}
export interface ModelFile {
name: string;
textureSize: Vec2;
parts: ModelPart[];
textures?: ModelTexture[];
}
export interface ModelContainer {
models: ModelFile[];
}