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]"}`} />
); }