From d9441c9a52bbb0460e0c0dd002af377e032cc7a5 Mon Sep 17 00:00:00 2001 From: /home/neo <158327205+neoapps-dev@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:01:20 +0300 Subject: [PATCH] feat(devtools): initial model editor (#112) --- public/images/tools/model.png | Bin 0 -> 41376 bytes src/components/common/ModelPreview3D.tsx | 354 +++++++ src/components/views/DevtoolsView.tsx | 14 +- src/components/views/ModelEditorView.tsx | 1103 ++++++++++++++++++++++ src/pages/App.tsx | 4 + src/services/ModelService.ts | 243 +++++ src/types/model.ts | 41 + 7 files changed, 1758 insertions(+), 1 deletion(-) create mode 100644 public/images/tools/model.png create mode 100644 src/components/common/ModelPreview3D.tsx create mode 100644 src/components/views/ModelEditorView.tsx create mode 100644 src/services/ModelService.ts create mode 100644 src/types/model.ts diff --git a/public/images/tools/model.png b/public/images/tools/model.png new file mode 100644 index 0000000000000000000000000000000000000000..da1f1f0ddc53fcee9131325c91b54046771c93a0 GIT binary patch literal 41376 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 L_t)Fo9D@H3F|P7I literal 0 HcmV?d00001 diff --git a/src/components/common/ModelPreview3D.tsx b/src/components/common/ModelPreview3D.tsx new file mode 100644 index 0000000..7e7bad7 --- /dev/null +++ b/src/components/common/ModelPreview3D.tsx @@ -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(null); + const sceneRef = useRef(null); + const meshGroupRef = useRef(new THREE.Group()); + const controlsRef = useRef(null); + const [texture, setTexture] = useState(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 ( +
+ ); +} diff --git a/src/components/views/DevtoolsView.tsx b/src/components/views/DevtoolsView.tsx index 0b9956f..fd09a51 100644 --- a/src/components/views/DevtoolsView.tsx +++ b/src/components/views/DevtoolsView.tsx @@ -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 && (
diff --git a/src/components/views/ModelEditorView.tsx b/src/components/views/ModelEditorView.tsx new file mode 100644 index 0000000..ba2aca5 --- /dev/null +++ b/src/components/views/ModelEditorView.tsx @@ -0,0 +1,1103 @@ +import { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; +import { ModelFile, ModelPart, ModelBox, Vec3, Vec2 } from "../../types/model"; +import { ModelService } from "../../services/ModelService"; +import ModelPreview3D from "../common/ModelPreview3D"; +type SelectionType = "model" | "part" | "box"; +interface Selection { + type: SelectionType; + modelIdx: number; + partIdx?: number; + boxIdx?: number; +} + +export default function ModelEditorView() { + const { setActiveView } = useUI(); + const { playPressSound, playBackSound } = useAudio(); + const { animationsEnabled } = useConfig(); + const [models, setModels] = useState([]); + const [selection, setSelection] = useState(null); + const [selectedTexture, setSelectedTexture] = useState(null); + const [showBounds, setShowBounds] = useState(false); + const [notification, setNotification] = useState<{ + message: string; + type: "success" | "error"; + } | null>(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + items: { label: string; action: () => void }[]; + } | null>(null); + const fileInputRef = useRef(null); + const textureInputRef = useRef(null); + const [expandedModels, setExpandedModels] = useState>(new Set()); + const [expandedParts, setExpandedParts] = useState>(new Set()); + const [editingPart, setEditingPart] = useState<{ + modelIdx: number; + partIdx: number; + } | null>(null); + const [editingBox, setEditingBox] = useState<{ + modelIdx: number; + partIdx: number; + boxIdx: number; + } | null>(null); + + const showNotify = ( + message: string, + type: "success" | "error" = "success", + ) => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 3000); + }; + + const activeModel = useMemo(() => { + if (!selection) return null; + return models[selection.modelIdx] || null; + }, [selection, models]); + + const activeTextures = useMemo(() => { + if (!activeModel?.textures) return []; + return activeModel.textures; + }, [activeModel]); + + const handleFileLoad = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + try { + if (file.name.endsWith(".bbmodel")) { + const model = await ModelService.importFromBBModel(buffer); + setModels((prev) => [...prev, model]); + const idx = models.length; + setExpandedModels((prev) => new Set(prev).add(idx)); + setSelection({ type: "model", modelIdx: idx }); + showNotify(`Imported: ${model.name}`); + } else { + const text = new TextDecoder("utf-8").decode(buffer); + const imported = ModelService.importFromJSON(text); + setModels((prev) => [...prev, ...imported]); + showNotify(`Loaded ${imported.length} model(s)`); + } + } catch (err: unknown) { + showNotify( + err instanceof Error ? err.message : "Failed to parse", + "error", + ); + } + e.target.value = ""; + }; + + const handleAddTexture = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !selection) return; + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsDataURL(file); + }); + const name = file.name.replace(/\.[^.]+$/, ""); + const mi = selection.modelIdx; + setModels((prev) => { + const next = [...prev]; + const model = { ...next[mi] }; + const textures = [...(model.textures ?? []), { name, data: dataUrl }]; + next[mi] = { ...model, textures }; + return next; + }); + showNotify(`Added texture: ${name}`); + e.target.value = ""; + }; + + const handleNewModel = () => { + playPressSound(); + const name = `model_${models.length + 1}`; + const model = ModelService.createDefaultModel(name); + const idx = models.length; + setModels((prev) => [...prev, model]); + setExpandedModels((prev) => new Set(prev).add(idx)); + setSelection({ type: "model", modelIdx: idx }); + showNotify(`Created: ${name}`); + }; + + const handleExport = () => { + if (activeModel === null) return; + playPressSound(); + try { + const buffer = ModelService.exportToBBModel(activeModel); + const blob = new Blob([buffer]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${activeModel.name}.bbmodel`; + a.click(); + URL.revokeObjectURL(url); + showNotify(`Exported: ${activeModel.name}.bbmodel`); + } catch { + showNotify("Export failed", "error"); + } + }; + + const handleSave = () => { + if (!activeModel) return; + handleExport(); + }; + + const handleDeleteModel = (idx: number) => { + playBackSound(); + setModels((prev) => prev.filter((_, i) => i !== idx)); + if (selection?.modelIdx === idx) setSelection(null); + showNotify("Model deleted"); + }; + + const handleDeletePart = (modelIdx: number, partIdx: number) => { + playBackSound(); + setModels((prev) => { + const next = [...prev]; + next[modelIdx] = { + ...next[modelIdx], + parts: next[modelIdx].parts.filter((_, i) => i !== partIdx), + }; + return next; + }); + if ( + selection?.type === "part" && + selection.modelIdx === modelIdx && + selection.partIdx === partIdx + ) + setSelection({ type: "model", modelIdx }); + }; + + const handleDeleteBox = ( + modelIdx: number, + partIdx: number, + boxIdx: number, + ) => { + playBackSound(); + setModels((prev) => { + const next = [...prev]; + const parts = [...next[modelIdx].parts]; + parts[partIdx] = { + ...parts[partIdx], + boxes: parts[partIdx].boxes.filter((_, i) => i !== boxIdx), + }; + next[modelIdx] = { ...next[modelIdx], parts }; + return next; + }); + if ( + selection?.type === "box" && + selection.modelIdx === modelIdx && + selection.partIdx === partIdx && + selection.boxIdx === boxIdx + ) + setSelection({ type: "part", modelIdx, partIdx }); + }; + + const handleAddPart = (modelIdx: number) => { + playPressSound(); + setModels((prev) => { + const next = [...prev]; + next[modelIdx] = { + ...next[modelIdx], + parts: [ + ...next[modelIdx].parts, + { name: `part_${next[modelIdx].parts.length + 1}`, boxes: [] }, + ], + }; + return next; + }); + }; + + const handleAddBox = (modelIdx: number, partIdx: number) => { + playPressSound(); + setModels((prev) => { + const next = [...prev]; + const parts = [...next[modelIdx].parts]; + parts[partIdx] = { + ...parts[partIdx], + boxes: [ + ...parts[partIdx].boxes, + { + pos: { X: -4, Y: 0, Z: -4 }, + size: { X: 8, Y: 8, Z: 8 }, + uv: { X: 0, Y: 0 }, + }, + ], + }; + next[modelIdx] = { ...next[modelIdx], parts }; + return next; + }); + }; + + const updatePart = ( + modelIdx: number, + partIdx: number, + field: keyof ModelPart, + value: string | Vec3 | undefined, + ) => { + setModels((prev) => { + const next = [...prev]; + const parts = [...next[modelIdx].parts]; + parts[partIdx] = { ...parts[partIdx], [field]: value }; + next[modelIdx] = { ...next[modelIdx], parts }; + return next; + }); + }; + + const updateBox = ( + modelIdx: number, + partIdx: number, + boxIdx: number, + field: keyof ModelBox, + value: Vec3 | Vec2 | number | boolean | undefined, + ) => { + setModels((prev) => { + const next = [...prev]; + const parts = [...next[modelIdx].parts]; + const boxes = [...parts[partIdx].boxes]; + boxes[boxIdx] = { ...boxes[boxIdx], [field]: value }; + parts[partIdx] = { ...parts[partIdx], boxes }; + next[modelIdx] = { ...next[modelIdx], parts }; + return next; + }); + }; + + const handleContextMenu = useCallback( + (e: React.MouseEvent, items: { label: string; action: () => void }[]) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, items }); + }, + [], + ); + + useEffect(() => { + const close = () => setContextMenu(null); + window.addEventListener("click", close); + return () => window.removeEventListener("click", close); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (document.activeElement?.tagName === "INPUT") return; + if (e.key === "Escape" || e.key === "Backspace") { + playBackSound(); + setActiveView("devtools"); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [playBackSound, setActiveView]); + + const toggleModel = (idx: number) => { + setExpandedModels((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }; + + const togglePart = (key: string) => { + setExpandedParts((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + return ( + + + + +
+

+ Model Editor +

+
+ + + +
+
+ + {models.length === 0 ? ( +
+

+ Import a .bbmodel or .json file to begin +

+ +
+ ) : ( +
+
+
+ + Models ({models.length}) + + +
+
+ {models.map((model, mi) => ( +
+
{ + toggleModel(mi); + setSelection({ type: "model", modelIdx: mi }); + }} + onContextMenu={(e) => + handleContextMenu(e, [ + { + label: "Export", + action: () => { + setSelection({ type: "model", modelIdx: mi }); + setTimeout(handleExport, 0); + }, + }, + { + label: "Remove", + action: () => handleDeleteModel(mi), + }, + ]) + } + className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${selection?.type === "model" && selection.modelIdx === mi ? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]" : "border-transparent text-white"}`} + > + +
+
+
+ + {model.name} + + +
+ {expandedModels.has(mi) && ( +
+
handleAddPart(mi)} + > + + Parts ({model.parts.length}) + + + + Add + +
+ {model.parts.map((part, pi) => { + const partKey = `${mi}-${pi}`; + const isPartExpanded = expandedParts.has(partKey); + return ( +
+
{ + togglePart(partKey); + setSelection({ + type: "part", + modelIdx: mi, + partIdx: pi, + }); + }} + onContextMenu={(e) => + handleContextMenu(e, [ + { + label: "Edit", + action: () => + setEditingPart({ + modelIdx: mi, + partIdx: pi, + }), + }, + { + label: "Remove", + action: () => handleDeletePart(mi, pi), + }, + ]) + } + className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${selection?.type === "part" && selection.modelIdx === mi && selection.partIdx === pi ? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]" : "border-transparent text-white/80"}`} + > + + + {part.name} + + +
+ {isPartExpanded && ( +
+
handleAddBox(mi, pi)} + > + + Boxes ({part.boxes.length}) + + + + Add + +
+ {part.boxes.map((_box, bi) => ( +
+ setSelection({ + type: "box", + modelIdx: mi, + partIdx: pi, + boxIdx: bi, + }) + } + onContextMenu={(e) => + handleContextMenu(e, [ + { + label: "Edit", + action: () => + setEditingBox({ + modelIdx: mi, + partIdx: pi, + boxIdx: bi, + }), + }, + { + label: "Remove", + action: () => + handleDeleteBox(mi, pi, bi), + }, + ]) + } + className={`flex items-center gap-2 p-1.5 cursor-pointer transition-all border-l-2 ${selection?.type === "box" && selection.modelIdx === mi && selection.partIdx === pi && selection.boxIdx === bi ? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]" : "border-transparent text-white/60"}`} + > +
+ + Box {bi + 1} + + +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ))} +
+
+ +
+ {activeModel ? ( + <> +
+ +
+
+
+ + Textures ({activeTextures.length}) + + +
+ {activeTextures.length === 0 ? ( +
+ + No textures + +
+ ) : ( +
+ {activeTextures.map((tex, ti) => ( +
setSelectedTexture(tex.name)} + className={`flex items-center gap-2 p-2 cursor-pointer transition-all border ${selectedTexture === tex.name ? "border-[#FFFF55] bg-[#FFFF55]/10" : "border-transparent hover:border-[#373737]"}`} + > +
+ {tex.name} +
+ + {tex.name} + +
+ ))} +
+ )} +
+ + ) : ( +
+ + Select a model to preview + +
+ )} +
+
+ )} + +
+ +
+ + + {editingPart && ( + setEditingPart(null)} + onConfirm={(updated) => { + updatePart( + editingPart.modelIdx, + editingPart.partIdx, + "name", + updated.name, + ); + updatePart( + editingPart.modelIdx, + editingPart.partIdx, + "translation", + updated.translation, + ); + setEditingPart(null); + }} + /> + )} + + + {editingBox && ( + setEditingBox(null)} + onConfirm={(updated) => { + updateBox( + editingBox.modelIdx, + editingBox.partIdx, + editingBox.boxIdx, + "pos", + updated.pos, + ); + updateBox( + editingBox.modelIdx, + editingBox.partIdx, + editingBox.boxIdx, + "size", + updated.size, + ); + updateBox( + editingBox.modelIdx, + editingBox.partIdx, + editingBox.boxIdx, + "uv", + updated.uv, + ); + updateBox( + editingBox.modelIdx, + editingBox.partIdx, + editingBox.boxIdx, + "inflate", + updated.inflate, + ); + updateBox( + editingBox.modelIdx, + editingBox.partIdx, + editingBox.boxIdx, + "mirror", + updated.mirror, + ); + setEditingBox(null); + }} + /> + )} + + {contextMenu && ( +
+ {contextMenu.items.map((item, i) => ( + + ))} +
+ )} + + + {notification && ( + + + {notification.message} + + + )} + + + ); +} + +function PartEditModal({ + part, + onClose, + onConfirm, +}: { + part: ModelPart; + onClose: () => void; + onConfirm: (updated: { name: string; translation: Vec3 | undefined }) => void; +}) { + const [name, setName] = useState(part.name); + const [tx, setTx] = useState(part.translation?.X ?? 0); + const [ty, setTy] = useState(part.translation?.Y ?? 0); + const [tz, setTz] = useState(part.translation?.Z ?? 0); + return ( +
+ + +

+ Edit Part +

+
+
+ + setName(e.target.value)} + className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" + autoFocus + /> +
+
+ +
+ {(["X", "Y", "Z"] as const).map((axis) => ( +
+ + {axis} + + { + const v = parseFloat(e.target.value) || 0; + if (axis === "X") setTx(v); + else if (axis === "Y") setTy(v); + else setTz(v); + }} + className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm" + /> +
+ ))} +
+
+
+
+ + +
+
+
+ ); +} + +function BoxEditModal({ + box, + onClose, + onConfirm, +}: { + box: ModelBox; + onClose: () => void; + onConfirm: (updated: ModelBox) => void; +}) { + const [posX, setPosX] = useState(box.pos.X); + const [posY, setPosY] = useState(box.pos.Y); + const [posZ, setPosZ] = useState(box.pos.Z); + const [sizeX, setSizeX] = useState(box.size.X); + const [sizeY, setSizeY] = useState(box.size.Y); + const [sizeZ, setSizeZ] = useState(box.size.Z); + const [uvX, setUvX] = useState(box.uv.X); + const [uvY, setUvY] = useState(box.uv.Y); + const [inflate, setInflate] = useState(box.inflate ?? 0); + const [mirror, setMirror] = useState(box.mirror ?? false); + return ( +
+ + +

+ Edit Box +

+
+
+ +
+ {(["X", "Y", "Z"] as const).map((axis) => ( +
+ + {axis} + + { + const v = parseFloat(e.target.value) || 0; + if (axis === "X") setPosX(v); + else if (axis === "Y") setPosY(v); + else setPosZ(v); + }} + className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm" + /> +
+ ))} +
+
+
+ +
+ {(["X", "Y", "Z"] as const).map((axis) => ( +
+ + {axis} + + { + const v = Math.max(0.01, parseFloat(e.target.value) || 0); + if (axis === "X") setSizeX(v); + else if (axis === "Y") setSizeY(v); + else setSizeZ(v); + }} + className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm" + /> +
+ ))} +
+
+
+ +
+ {(["X", "Y"] as const).map((axis) => ( +
+ + {axis} + + { + const v = parseInt(e.target.value) || 0; + if (axis === "X") setUvX(v); + else setUvY(v); + }} + className="w-full bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm" + /> +
+ ))} +
+
+
+ + setInflate(parseFloat(e.target.value) || 0)} + className="w-20 bg-black/40 border border-[#373737] text-white px-2 py-1 outline-none focus:border-[#FFFF55] text-sm" + /> +
+
+ +
setMirror(!mirror)} + className={`w-10 h-6 border-2 transition-colors cursor-pointer ${mirror ? "bg-[#FFFF55] border-[#FFFF55]" : "bg-black/40 border-[#373737]"}`} + /> +
+
+
+ + +
+ +
+ ); +} diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 05c25e1..bb47a53 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -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" && ( )} + {activeView === "model-editor" && ( + + )} {activeView === "swf-editor" && ( )} diff --git a/src/services/ModelService.ts b/src/services/ModelService.ts new file mode 100644 index 0000000..e057af9 --- /dev/null +++ b/src/services/ModelService.ts @@ -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 { + 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 }, + }, + ], + }, + ], + }; + } +} diff --git a/src/types/model.ts b/src/types/model.ts new file mode 100644 index 0000000..7248231 --- /dev/null +++ b/src/types/model.ts @@ -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[]; +}