diff --git a/src/ModManUi/src/components/assets/image/ImagePreview.vue b/src/ModManUi/src/components/assets/image/ImagePreview.vue
new file mode 100644
index 00000000..30063a51
--- /dev/null
+++ b/src/ModManUi/src/components/assets/image/ImagePreview.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
diff --git a/src/ModManUi/src/components/assets/xmodel/GltfLoader.ts b/src/ModManUi/src/components/assets/xmodel/GltfLoader.ts
new file mode 100644
index 00000000..cf3638b7
--- /dev/null
+++ b/src/ModManUi/src/components/assets/xmodel/GltfLoader.ts
@@ -0,0 +1,61 @@
+import { computed, type MaybeRefOrGetter, toValue } from "vue";
+import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
+import { DDSLoader } from "three/examples/jsm/loaders/DDSLoader.js";
+import { getModManUrl } from "@/meta/ModManUrl.ts";
+import { Loader, LoadingManager } from "three";
+export { type GLTF } from "three/addons/loaders/GLTFLoader.js";
+
+const IMAGE_REGEX = /\.\.[\\/]images[\\/](.+)\.(.+)$/m;
+class ProxyImageLoader extends Loader {
+ private ddsLoader: DDSLoader;
+ private readonly zoneName: string;
+
+ constructor(zoneName: string) {
+ super();
+ this.ddsLoader = new DDSLoader();
+ this.zoneName = zoneName;
+ }
+ load(
+ url: string,
+ onLoad: (data: unknown) => void,
+ onProgress?: (event: ProgressEvent) => void,
+ onError?: (err: unknown) => void,
+ ) {
+ const match = IMAGE_REGEX.exec(url);
+ if (!match) {
+ onError?.("invalid url");
+ return;
+ }
+
+ this.ddsLoader.load(
+ getModManUrl(
+ `/image/dds?zone=${encodeURIComponent(this.zoneName)}&name=${encodeURIComponent(match[1])}`,
+ ),
+ onLoad,
+ onProgress,
+ onError,
+ );
+ }
+ loadAsync(url: string, onProgress?: (event: ProgressEvent) => void): Promise {
+ const match = IMAGE_REGEX.exec(url);
+ if (!match) {
+ return Promise.reject("invalid url");
+ }
+
+ return this.ddsLoader.loadAsync(
+ getModManUrl(
+ `/image/dds?zone=${encodeURIComponent(this.zoneName)}&name=${encodeURIComponent(match[1])}`,
+ ),
+ onProgress,
+ );
+ }
+}
+
+export function useGltfLoader(zoneName: MaybeRefOrGetter) {
+ return computed(() => {
+ const manager = new LoadingManager();
+ manager.addHandler(IMAGE_REGEX, new ProxyImageLoader(toValue(zoneName)));
+
+ return new GLTFLoader(manager);
+ });
+}
diff --git a/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue b/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue
index 1f82db69..e4413ed9 100644
--- a/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue
+++ b/src/ModManUi/src/components/assets/xmodel/XModelPreview.vue
@@ -1,45 +1,36 @@
-
+
-
-
diff --git a/src/ModManUi/src/components/assets/xmodel/ThreeResourceTracker.ts b/src/ModManUi/src/components/three/ThreeResourceTracker.ts
similarity index 85%
rename from src/ModManUi/src/components/assets/xmodel/ThreeResourceTracker.ts
rename to src/ModManUi/src/components/three/ThreeResourceTracker.ts
index b5cc8548..d355b80b 100644
--- a/src/ModManUi/src/components/assets/xmodel/ThreeResourceTracker.ts
+++ b/src/ModManUi/src/components/three/ThreeResourceTracker.ts
@@ -10,6 +10,7 @@ function getMaterialsOfObject(object: Object3D) {
return [];
}
+export type ResourceTrackedType = Texture | Material | Object3D | BufferGeometry;
export class ThreeResourceTracker {
private readonly textures: Record;
private readonly materials: Record;
@@ -23,6 +24,30 @@ export class ThreeResourceTracker {
this.objects = {};
}
+ ref(value: ResourceTrackedType) {
+ if (value instanceof Texture) {
+ this.refTexture(value);
+ } else if (value instanceof Material) {
+ this.refMaterial(value);
+ } else if (value instanceof BufferGeometry) {
+ this.refGeometry(value);
+ } else if (value instanceof Object3D) {
+ this.refObject(value);
+ }
+ }
+
+ unref(value: ResourceTrackedType) {
+ if (value instanceof Texture) {
+ this.unrefTexture(value);
+ } else if (value instanceof Material) {
+ this.unrefMaterial(value);
+ } else if (value instanceof BufferGeometry) {
+ this.unrefGeometry(value);
+ } else if (value instanceof Object3D) {
+ this.unrefObject(value);
+ }
+ }
+
refTexture(texture: Texture) {
if (!this.textures[texture.id]) {
this.textures[texture.id] = 1;
diff --git a/src/ModManUi/src/components/three/ThreeScene.vue b/src/ModManUi/src/components/three/ThreeScene.vue
new file mode 100644
index 00000000..864d6d9e
--- /dev/null
+++ b/src/ModManUi/src/components/three/ThreeScene.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
diff --git a/src/ModManUi/src/components/three/useOrbitControls.ts b/src/ModManUi/src/components/three/useOrbitControls.ts
new file mode 100644
index 00000000..45376e5f
--- /dev/null
+++ b/src/ModManUi/src/components/three/useOrbitControls.ts
@@ -0,0 +1,26 @@
+import { type ComponentInstance, type MaybeRefOrGetter, shallowRef, toValue, watch } from "vue";
+import { OrbitControls } from "three/addons/controls/OrbitControls.js";
+import type { Camera } from "three";
+import ThreeScene from "@/components/three/ThreeScene.vue";
+
+export function useOrbitControls(
+ sceneComponent: MaybeRefOrGetter | null>,
+ camera: MaybeRefOrGetter,
+) {
+ const controls = shallowRef(undefined);
+
+ watch(
+ () => toValue(sceneComponent)?.canvasRef ?? null,
+ (canvasRefValue) => {
+ if (canvasRefValue) {
+ const cameraValue = toValue(camera);
+ controls.value = new OrbitControls(cameraValue, canvasRefValue);
+ controls.value.target.set(0, 0, 0);
+ controls.value.update();
+ }
+ },
+ { immediate: true },
+ );
+
+ return { controls };
+}
diff --git a/src/ModManUi/src/components/three/useThreeRenderer.ts b/src/ModManUi/src/components/three/useThreeRenderer.ts
new file mode 100644
index 00000000..77323aa2
--- /dev/null
+++ b/src/ModManUi/src/components/three/useThreeRenderer.ts
@@ -0,0 +1,167 @@
+import {
+ type ComponentInstance,
+ computed,
+ type MaybeRefOrGetter,
+ ref,
+ shallowRef,
+ toValue,
+ watch,
+} from "vue";
+import { OrthographicCamera, PerspectiveCamera, WebGLRenderer } from "three";
+import { useResizeObserver } from "@vueuse/core";
+import ThreeScene from "@/components/three/ThreeScene.vue";
+import { useThreeSceneOrThrow } from "@/components/three/useThreeScene.ts";
+
+export type CameraType = PerspectiveCamera | OrthographicCamera;
+export interface UseThreeRendererOptions {
+ cameraType?: "perspective" | "orthographic";
+ fov?: MaybeRefOrGetter;
+ orthoAdjustForAspect?: boolean;
+ orthoWidth?: MaybeRefOrGetter;
+ orthoHeight?: MaybeRefOrGetter;
+ zNear?: MaybeRefOrGetter;
+ zFar?: MaybeRefOrGetter;
+}
+
+export function useThreeRenderer(
+ sceneComponent: MaybeRefOrGetter | null>,
+ options?: UseThreeRendererOptions,
+) {
+ const cameraType = options?.cameraType ?? "perspective";
+ const fov = options?.fov ?? 75;
+ const zNear = options?.zNear ?? 0.1;
+ const zFar = options?.zFar ?? 1000;
+ const orthoWidth = options?.orthoWidth ?? 1;
+ const orthoHeight = options?.orthoHeight ?? 1;
+
+ const { scene } = useThreeSceneOrThrow();
+ const canvas = computed(() => toValue(sceneComponent)?.canvasRef ?? null);
+ const canvasWrapper = computed(() => toValue(sceneComponent)?.canvasWrapperRef ?? null);
+
+ function createNewCamera() {
+ if (cameraType === "perspective") {
+ let aspect: number = 1;
+ const canvasWrapperValue = canvasWrapper.value;
+ if (canvasWrapperValue) {
+ const canvasWrapperBounds = canvasWrapperValue.getBoundingClientRect();
+ aspect = canvasWrapperBounds.width / canvasWrapperBounds.height;
+ }
+
+ return new PerspectiveCamera(toValue(fov), aspect, toValue(zNear), toValue(zFar));
+ }
+
+ const orthoWidthValue = toValue(orthoWidth);
+ const orthoHeightValue = toValue(orthoHeight);
+ return new OrthographicCamera(
+ orthoWidthValue / -2,
+ orthoWidthValue / 2,
+ orthoHeightValue / 2,
+ orthoHeightValue / -2,
+ toValue(zNear),
+ toValue(zFar),
+ );
+ }
+
+ const camera = shallowRef(createNewCamera());
+ const aspect = ref(1);
+ let renderer: WebGLRenderer | undefined = undefined;
+
+ watch(
+ () => [canvas.value, canvasWrapper.value],
+ ([canvasValue, canvasWrapperValue]) => {
+ if (!canvasValue || !canvasWrapperValue) return;
+
+ renderer = new WebGLRenderer({ canvas: canvasValue, alpha: true });
+ const canvasWrapperBounds = canvasWrapperValue.getBoundingClientRect();
+ renderer.setSize(canvasWrapperBounds.width, canvasWrapperBounds.height);
+ aspect.value = canvasWrapperBounds.width / canvasWrapperBounds.height;
+
+ if (camera.value instanceof PerspectiveCamera) {
+ camera.value.aspect = aspect.value;
+ }
+
+ function animate() {
+ renderer!.render(toValue(scene), camera.value);
+ }
+ renderer.setAnimationLoop(animate);
+ },
+ { immediate: true },
+ );
+
+ useResizeObserver(canvasWrapper, () => {
+ const canvasWrapperBounds = canvasWrapper.value!.getBoundingClientRect();
+ renderer?.setSize(canvasWrapperBounds.width, canvasWrapperBounds.height);
+ aspect.value = canvasWrapperBounds.width / canvasWrapperBounds.height;
+ });
+
+ watch(aspect, (newValue) => {
+ if (camera.value instanceof PerspectiveCamera) {
+ camera.value.aspect = newValue;
+ camera.value.updateProjectionMatrix();
+ }
+ });
+
+ watch(
+ () => toValue(fov),
+ (fovValue) => {
+ const cameraValue = camera.value;
+ if (cameraValue instanceof PerspectiveCamera) {
+ cameraValue.fov = fovValue;
+ cameraValue.updateProjectionMatrix();
+ }
+ },
+ );
+
+ if (options?.orthoAdjustForAspect === true) {
+ watch(
+ () => [toValue(orthoWidth), toValue(orthoHeight), aspect.value],
+ ([orthoWidthValue, orthoHeightValue, aspect]) => {
+ const cameraValue = camera.value;
+
+ if (orthoWidthValue > orthoHeightValue) {
+ orthoHeightValue = orthoWidthValue / aspect;
+ } else {
+ orthoWidthValue = orthoHeightValue * aspect;
+ }
+
+ if (cameraValue instanceof OrthographicCamera) {
+ cameraValue.left = -orthoWidthValue;
+ cameraValue.right = orthoWidthValue;
+ cameraValue.top = orthoHeightValue;
+ cameraValue.bottom = -orthoHeightValue;
+ cameraValue.updateProjectionMatrix();
+ }
+ },
+ );
+ } else {
+ watch(
+ () => [toValue(orthoWidth), toValue(orthoHeight)],
+ ([orthoWidthValue, orthoHeightValue]) => {
+ const cameraValue = camera.value;
+ if (cameraValue instanceof OrthographicCamera) {
+ cameraValue.left = -orthoWidthValue;
+ cameraValue.right = orthoWidthValue;
+ cameraValue.top = orthoHeightValue;
+ cameraValue.bottom = -orthoHeightValue;
+ cameraValue.updateProjectionMatrix();
+ }
+ },
+ );
+ }
+
+ watch(
+ () => toValue(zNear),
+ (nearValue) => {
+ camera.value.near = nearValue;
+ },
+ );
+
+ watch(
+ () => toValue(zFar),
+ (farValue) => {
+ camera.value.far = farValue;
+ },
+ );
+
+ return { camera };
+}
diff --git a/src/ModManUi/src/components/three/useThreeScene.ts b/src/ModManUi/src/components/three/useThreeScene.ts
new file mode 100644
index 00000000..e2421ea7
--- /dev/null
+++ b/src/ModManUi/src/components/three/useThreeScene.ts
@@ -0,0 +1,67 @@
+import { createInjectionState } from "@vueuse/core";
+import { type Object3D, Scene } from "three";
+import {
+ type ResourceTrackedType,
+ ThreeResourceTracker,
+} from "@/components/three/ThreeResourceTracker.ts";
+import { onScopeDispose, shallowRef, watch } from "vue";
+
+export const [provideThreeScene, useThreeScene] = createInjectionState(() => {
+ const scene = new Scene();
+ const resourceTracker = new ThreeResourceTracker();
+
+ return { scene, resourceTracker };
+});
+
+export function useThreeSceneOrThrow() {
+ const value = useThreeScene();
+ if (!value) throw new Error("ThreeScene must be provided");
+ return value;
+}
+
+export function createResourceTrackedObject(initialValue?: T) {
+ const { resourceTracker } = useThreeSceneOrThrow();
+ const refObj = shallowRef(initialValue);
+
+ watch(
+ refObj,
+ (newValue, oldValue) => {
+ if (newValue === oldValue) return;
+
+ if (newValue) {
+ resourceTracker.ref(newValue);
+ }
+
+ if (oldValue) {
+ resourceTracker.unref(oldValue);
+ }
+ },
+ { immediate: true },
+ );
+
+ onScopeDispose(() => {
+ const remainingValue = refObj.value;
+ if (remainingValue) {
+ resourceTracker.unref(remainingValue);
+ refObj.value = undefined;
+ }
+ });
+
+ return refObj;
+}
+
+export function createSceneObject(initialValue?: T) {
+ const { scene } = useThreeSceneOrThrow();
+ const refObj = createResourceTrackedObject(initialValue);
+
+ watch(refObj, (newValue, oldValue) => {
+ if (newValue) {
+ scene.add(newValue);
+ }
+ if (oldValue) {
+ scene.remove(oldValue);
+ }
+ });
+
+ return refObj;
+}
diff --git a/src/ModManUi/src/meta/ModManUrl.ts b/src/ModManUi/src/meta/ModManUrl.ts
new file mode 100644
index 00000000..5fa7f035
--- /dev/null
+++ b/src/ModManUi/src/meta/ModManUrl.ts
@@ -0,0 +1,3 @@
+export function getModManUrl(path: string) {
+ return new URL(path, "modman://localhost").toString();
+}
diff --git a/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue b/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue
index 7906fd6e..e5eafa09 100644
--- a/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue
+++ b/src/ModManUi/src/view/inspect_details/components/InspectPreview.vue
@@ -1,6 +1,7 @@