- Built-in code editor: edit working-copy source files in place (Edit → textarea → Save) before committing, no external IDE needed. Backend write_depot opens the file for edit, clears read-only, writes UTF-8; confined to the client root. - .uasset previews: pull the embedded Unreal thumbnail (PNG) server-side for both working copy and history; on-demand real 3D via a headless Unreal FBX export (commandlet mode, no editor window), temp file deleted right after — nothing cached on disk. Plugin content paths mapped to their mount point. - New app icon (brand mark on a theme tile) + theme-aware in-app logo. - Connection pill / Settings show the actual connected server (P4PORT), not the server's internal self-reported address. - Fix: the "select all" checkbox no longer renders as a giant empty square when not everything is selected (size pinned). - IDE picker chooses which installed editor opens code; AI replies in UI language. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
11 KiB
TypeScript
208 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import * as THREE from "three";
|
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
|
|
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";
|
|
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
|
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
|
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
|
|
import { ThreeMFLoader } from "three/examples/jsm/loaders/3MFLoader.js";
|
|
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
import { p4 } from "./p4";
|
|
import { t } from "./i18n";
|
|
|
|
type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void };
|
|
|
|
export default function ModelViewer({ depot, name, spec, bytes, forceExt }: { depot: string; name: string; spec?: string; bytes?: ArrayBuffer; forceExt?: string }) {
|
|
const mount = useRef<HTMLDivElement>(null);
|
|
const api = useRef<Api | null>(null);
|
|
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
|
|
const [err, setErr] = useState("");
|
|
const [stats, setStats] = useState({ tris: 0, verts: 0, meshes: 0, mats: 0 });
|
|
const [wire, setWire] = useState(false);
|
|
const [grid, setGrid] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
let raf = 0;
|
|
const el = mount.current!;
|
|
setStatus("loading"); setErr("");
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setSize(el.clientWidth || 600, el.clientHeight || 400);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.05;
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.shadowMap.enabled = true;
|
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
el.appendChild(renderer.domElement);
|
|
|
|
const scene = new THREE.Scene();
|
|
// image-based lighting (soft studio environment) — this is what makes PBR look good
|
|
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000);
|
|
|
|
// key light with shadows + soft fill
|
|
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
|
key.position.set(5, 8, 6);
|
|
key.castShadow = true;
|
|
key.shadow.mapSize.set(2048, 2048);
|
|
key.shadow.bias = -0.0004;
|
|
scene.add(key);
|
|
scene.add(new THREE.DirectionalLight(0x8b7bf7, 0.35).translateX(-6));
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true; controls.dampingFactor = 0.08;
|
|
controls.autoRotate = true; controls.autoRotateSpeed = 1.1;
|
|
renderer.domElement.addEventListener("pointerdown", () => (controls.autoRotate = false));
|
|
|
|
// ground + grid (added after fit, sized to model)
|
|
const ground = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(1, 1),
|
|
new THREE.ShadowMaterial({ opacity: 0.28 })
|
|
);
|
|
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
|
|
let gridHelper: THREE.GridHelper | null = null;
|
|
let home = { pos: new THREE.Vector3(3, 2, 3), tgt: new THREE.Vector3() };
|
|
|
|
const ext = (forceExt || name.toLowerCase().split(".").pop() || "");
|
|
|
|
function collectStats(obj: THREE.Object3D) {
|
|
let tris = 0, verts = 0, meshes = 0; const mats = new Set<THREE.Material>();
|
|
obj.traverse((c) => {
|
|
const m = c as THREE.Mesh;
|
|
if ((m as unknown as { isMesh?: boolean }).isMesh && m.geometry) {
|
|
meshes++; const pos = m.geometry.getAttribute("position");
|
|
verts += pos ? pos.count : 0;
|
|
tris += m.geometry.index ? m.geometry.index.count / 3 : (pos ? pos.count / 3 : 0);
|
|
(Array.isArray(m.material) ? m.material : [m.material]).forEach((x) => x && mats.add(x));
|
|
}
|
|
});
|
|
setStats({ tris: Math.round(tris), verts, meshes, mats: mats.size });
|
|
}
|
|
|
|
function place(obj: THREE.Object3D) {
|
|
obj.traverse((c) => {
|
|
const m = c as THREE.Mesh;
|
|
if ((m as unknown as { isMesh?: boolean }).isMesh) {
|
|
m.castShadow = true; m.receiveShadow = true;
|
|
const has = m.material && !(Array.isArray(m.material) && m.material.length === 0);
|
|
if (!has) m.material = new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, metalness: 0.05 });
|
|
}
|
|
});
|
|
const box = new THREE.Box3().setFromObject(obj);
|
|
const size = box.getSize(new THREE.Vector3());
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
|
obj.position.sub(center);
|
|
obj.position.y += size.y / 2; // sit on the ground
|
|
scene.add(obj);
|
|
|
|
// ground + grid
|
|
ground.scale.set(maxDim * 8, maxDim * 8, 1);
|
|
gridHelper = new THREE.GridHelper(maxDim * 6, 30, 0x7c6ef6, 0x333344);
|
|
(gridHelper.material as THREE.Material).transparent = true;
|
|
(gridHelper.material as THREE.Material).opacity = 0.35;
|
|
scene.add(gridHelper); gridHelper.visible = grid;
|
|
|
|
// shadow camera frustum
|
|
const s = maxDim * 2.5;
|
|
Object.assign(key.shadow.camera, { left: -s, right: s, top: s, bottom: -s, near: 0.1, far: maxDim * 40 });
|
|
key.shadow.camera.updateProjectionMatrix();
|
|
|
|
const dist = maxDim * 2.1;
|
|
home = { pos: new THREE.Vector3(dist * 0.8, dist * 0.65, dist), tgt: new THREE.Vector3(0, size.y / 2, 0) };
|
|
camera.position.copy(home.pos);
|
|
camera.near = maxDim / 200; camera.far = maxDim * 200; camera.updateProjectionMatrix();
|
|
controls.target.copy(home.tgt); controls.maxDistance = maxDim * 25; controls.update();
|
|
|
|
collectStats(obj);
|
|
setStatus("ok");
|
|
|
|
// expose controls to React buttons
|
|
api.current = {
|
|
toggleWire: (b) => obj.traverse((c) => {
|
|
const m = c as THREE.Mesh; if (!(m as unknown as { isMesh?: boolean }).isMesh) return;
|
|
(Array.isArray(m.material) ? m.material : [m.material]).forEach((mt) => { const s = mt as THREE.MeshStandardMaterial; if (s) s.wireframe = b; });
|
|
}),
|
|
toggleGrid: (b) => { if (gridHelper) gridHelper.visible = b; },
|
|
reset: () => { camera.position.copy(home.pos); controls.target.copy(home.tgt); controls.update(); controls.autoRotate = false; },
|
|
};
|
|
}
|
|
|
|
function fail(e: unknown) { if (!disposed) { setErr(String((e as { message?: string })?.message || e)); setStatus("error"); } }
|
|
|
|
(async () => {
|
|
try {
|
|
// pre-supplied bytes (e.g. an FBX exported from Unreal) skip the p4 fetch
|
|
const buf = bytes ? bytes : spec ? await p4.printDepot(spec) : await p4.readDepot(depot);
|
|
if (disposed) return;
|
|
const manager = new THREE.LoadingManager();
|
|
if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail);
|
|
else if (ext === "obj") place(new OBJLoader(manager).parse(new TextDecoder().decode(buf)));
|
|
else if (ext === "fbx") place(new FBXLoader(manager).parse(buf, ""));
|
|
else if (ext === "dae") { const c = new ColladaLoader(manager).parse(new TextDecoder().decode(buf), ""); if (c && c.scene) place(c.scene); else fail(t("Collada: empty scene")); }
|
|
else if (ext === "3mf") place(new ThreeMFLoader(manager).parse(buf));
|
|
else if (ext === "stl") place(new THREE.Mesh(new STLLoader().parse(buf), new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5 })));
|
|
else if (ext === "ply") { const g = new PLYLoader().parse(buf); g.computeVertexNormals(); place(new THREE.Mesh(g, new THREE.MeshStandardMaterial({ color: 0x9a8bf9, roughness: 0.5, vertexColors: !!g.getAttribute("color") }))); }
|
|
else { setErr(t("Format not supported by the viewer")); setStatus("error"); }
|
|
} catch (e) { fail(e); }
|
|
})();
|
|
|
|
const animate = () => { controls.update(); renderer.render(scene, camera); raf = requestAnimationFrame(animate); };
|
|
animate();
|
|
|
|
const onResize = () => {
|
|
const w = el.clientWidth || 600, h = el.clientHeight || 400;
|
|
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
|
|
};
|
|
const ro = new ResizeObserver(onResize); ro.observe(el);
|
|
|
|
return () => {
|
|
disposed = true; cancelAnimationFrame(raf); ro.disconnect(); controls.dispose(); pmrem.dispose();
|
|
scene.traverse((o) => {
|
|
const m = o as THREE.Mesh;
|
|
if (m.geometry) m.geometry.dispose();
|
|
const mat = m.material; if (mat) (Array.isArray(mat) ? mat : [mat]).forEach((x) => x && x.dispose());
|
|
});
|
|
renderer.dispose();
|
|
if (renderer.domElement.parentNode === el) el.removeChild(renderer.domElement);
|
|
api.current = null;
|
|
};
|
|
// eslint-disable-next-line
|
|
}, [depot, name, spec, bytes, forceExt]);
|
|
|
|
const fmt = (n: number) => n.toLocaleString("ru");
|
|
return (
|
|
<div className="asset">
|
|
<div className="stage" style={{ padding: 0, position: "relative" }}>
|
|
<div ref={mount} style={{ position: "absolute", inset: 0 }} />
|
|
<span className="lbl2">
|
|
<svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" /><path d="m3 7 9 5 9-5" stroke="currentColor" strokeWidth="1.6" /></svg>
|
|
3D · {(name.split(".").pop() || "").toUpperCase()}
|
|
</span>
|
|
{status === "ok" && (
|
|
<div className="v3d-tools">
|
|
<button className={"v3d-btn" + (wire ? " on" : "")} onClick={() => { setWire(!wire); api.current?.toggleWire(!wire); }} title={t("Wireframe")}>◫</button>
|
|
<button className={"v3d-btn" + (grid ? " on" : "")} onClick={() => { setGrid(!grid); api.current?.toggleGrid(!grid); }} title={t("Grid")}>▦</button>
|
|
<button className="v3d-btn" onClick={() => api.current?.reset()} title={t("Reset view")}>⟲</button>
|
|
</div>
|
|
)}
|
|
{status === "loading" && <div className="viewer-msg"><span className="ldr" />{t("Loading model…")}</div>}
|
|
{status === "error" && <div className="viewer-msg err">{err}</div>}
|
|
{status === "ok" && <span className="hint">{t("drag — rotate · wheel — zoom · right-click — pan")}</span>}
|
|
</div>
|
|
<div className="assetmeta">
|
|
<div className="mi"><span className="k">{t("Triangles")}</span><span className="v">{fmt(stats.tris)}</span></div>
|
|
<div className="mi"><span className="k">{t("Vertices")}</span><span className="v">{fmt(stats.verts)}</span></div>
|
|
<div className="mi"><span className="k">{t("Meshes · Materials")}</span><span className="v">{stats.meshes} · {stats.mats}</span></div>
|
|
<div className="mi"><span className="k">{t("Render")}</span><span className="v acc">three.js · PBR</span></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|