mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-18 00:07:09 +00:00
feat(devtools): initial model editor (#112)
This commit is contained in:
parent
121a0bf4cf
commit
d9441c9a52
BIN
public/images/tools/model.png
Normal file
BIN
public/images/tools/model.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
354
src/components/common/ModelPreview3D.tsx
Normal file
354
src/components/common/ModelPreview3D.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
1103
src/components/views/ModelEditorView.tsx
Normal file
1103
src/components/views/ModelEditorView.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
243
src/services/ModelService.ts
Normal file
243
src/services/ModelService.ts
Normal 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
41
src/types/model.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Reference in a new issue