diff --git a/public/images/tools/model.png b/public/images/tools/model.png new file mode 100644 index 0000000..da1f1f0 Binary files /dev/null and b/public/images/tools/model.png differ 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[]; +}