Fix resource lookup; re-export .dts and .dif with custom properties

This commit is contained in:
Brian Beck 2025-12-01 00:17:27 -08:00
parent 8147a1c418
commit 1182726bb4
1156 changed files with 126 additions and 114 deletions

View file

@ -1,6 +1,10 @@
import { memo, Suspense, useMemo } from "react";
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, shapeTextureToUrl, shapeToUrl } from "../loaders";
import {
FALLBACK_TEXTURE_URL,
shapeTextureToUrl,
shapeToUrl,
} from "../loaders";
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
import {
createAlphaAsRoughnessMaterial,
@ -12,8 +16,6 @@ import { useDebug } from "./SettingsProvider";
import { useShapeInfo } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
const FALLBACK_URL = `${BASE_URL}/black.png`;
/**
* Load a .glb file that was converted from a .dts, used for static shapes.
*/
@ -29,7 +31,22 @@ export function ShapeTexture({
material?: MeshStandardMaterial;
shapeName?: string;
}) {
const url = shapeTextureToUrl(material.name, FALLBACK_URL);
const url = useMemo(() => {
const flagNames = new Set(material.userData.flag_names ?? []);
const isIflMaterial = flagNames.has("IflMaterial");
const resourcePath = material.userData.resource_path;
if (!resourcePath) {
console.warn(
`Material index out of range on shape "${shapeName}" - rendering fallback.`,
);
}
return resourcePath && !isIflMaterial
? // Use custom `resource_path` added by forked io_dts3d Blender add-on
shapeTextureToUrl(resourcePath)
: // Not supported yet
FALLBACK_TEXTURE_URL;
}, [material]);
const isOrganic = shapeName && /borg|xorg|porg|dorg/i.test(shapeName);
const texture = useTexture(url, (texture) => {

View file

@ -1,5 +1,5 @@
import { Fragment, useMemo } from "react";
import { getMissionInfo, getMissionList, getSource } from "../manifest";
import { getMissionInfo, getMissionList, getSourceAndPath } from "../manifest";
import { useControls, useDebug, useSettings } from "./SettingsProvider";
import orderBy from "lodash.orderby";
@ -27,8 +27,8 @@ const sourceGroupNames = {
const groupedMissions = getMissionList().reduce(
(groupMap, missionName) => {
const missionInfo = getMissionInfo(missionName);
const source = getSource(missionInfo.resourcePath);
const groupName = sourceGroupNames[source] ?? null;
const [sourcePath] = getSourceAndPath(missionInfo.resourcePath);
const groupName = sourceGroupNames[sourcePath] ?? null;
const groupMissions = groupMap.get(groupName) ?? [];
if (!excludeMissions.has(missionName)) {
groupMissions.push({

View file

@ -2,15 +2,13 @@ import { memo, Suspense, useMemo } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Mesh } from "three";
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "../loaders";
import { interiorTextureToUrl, interiorToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
const FALLBACK_URL = `${BASE_URL}/black.png`;
/**
* Load a .gltf file that was converted from a .dif, used for "interior" models.
*/
@ -20,7 +18,7 @@ function useInterior(interiorFile: string) {
}
function InteriorTexture({ materialName }: { materialName: string }) {
const url = interiorTextureToUrl(materialName, FALLBACK_URL);
const url = interiorTextureToUrl(materialName);
const texture = useTexture(url, (texture) => setupColor(texture));
return <meshStandardMaterial map={texture} side={2} />;
@ -39,10 +37,15 @@ function InteriorMesh({ node }: { node: Mesh }) {
>
{Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<InteriorTexture key={index} materialName={mat.name} />
<InteriorTexture
key={index}
materialName={mat.userData.resource_path}
/>
))
) : (
<InteriorTexture materialName={node.material.name} />
<InteriorTexture
materialName={node.material.userData.resource_path}
/>
)}
</Suspense>
) : null}

View file

@ -1,67 +1,70 @@
import { parseImageFileList } from "./imageFileList";
import { getActualResourcePath, getMissionInfo, getSource } from "./manifest";
import {
getActualResourceKey,
getMissionInfo,
getSourceAndPath,
} from "./manifest";
import { parseMissionScript } from "./mission";
import { normalizePath } from "./stringUtils";
import { parseTerrainBuffer } from "./terrain";
export const BASE_URL = "/t2-mapper";
export const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
export const FALLBACK_TEXTURE_URL = `${BASE_URL}/magenta.png`;
export function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
resourcePath = getActualResourcePath(resourcePath);
let sourcePath: string;
let resourceKey;
try {
sourcePath = getSource(resourcePath);
resourceKey = getActualResourceKey(resourcePath);
} catch (err) {
if (fallbackUrl) {
// console.error(err);
console.warn(
`Resource "${resourcePath}" not found - rendering fallback.`,
);
return fallbackUrl;
} else {
throw err;
}
}
if (!sourcePath) {
return `${RESOURCE_ROOT_URL}${resourcePath}`;
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
if (sourcePath) {
return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${actualPath}`;
} else {
return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${resourcePath}`;
return `${RESOURCE_ROOT_URL}${actualPath}`;
}
}
export function interiorToUrl(name: string) {
const difUrl = getUrlForPath(`interiors/${name}`);
return difUrl.replace(/\.dif$/i, ".glb");
const url = getUrlForPath(`interiors/${name}`);
return url.replace(/\.dif$/i, ".glb");
}
export function shapeToUrl(name: string) {
const difUrl = getUrlForPath(`shapes/${name}`);
return difUrl.replace(/\.dts$/i, ".glb");
const url = getUrlForPath(`shapes/${name}`);
return url.replace(/\.dts$/i, ".glb");
}
export function terrainTextureToUrl(name: string) {
name = name.replace(/^terrain\./, "");
return getUrlForPath(`textures/terrain/${name}.png`, `${BASE_URL}/black.png`);
return getUrlForPath(`textures/terrain/${name}.png`, FALLBACK_TEXTURE_URL);
}
export function interiorTextureToUrl(name: string, fallbackUrl?: string) {
name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/${name}.png`, fallbackUrl);
export function interiorTextureToUrl(name: string) {
// name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/${name}.png`, FALLBACK_TEXTURE_URL);
}
export function textureFrameToUrl(fileName: string) {
return getUrlForPath(`textures/skins/${fileName}`);
return getUrlForPath(`textures/skins/${fileName}`, FALLBACK_TEXTURE_URL);
}
export function shapeTextureToUrl(name: string, fallbackUrl?: string) {
name = name.replace(/^skins\\/, "");
name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/skins/${name}.png`, fallbackUrl);
export function shapeTextureToUrl(name: string) {
// name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/${name}.png`, FALLBACK_TEXTURE_URL);
}
export function textureToUrl(name: string) {
try {
return getUrlForPath(`textures/${name}.png`);
} catch (err) {
return `${BASE_URL}/black.png`;
}
return getUrlForPath(`textures/${name}.png`, FALLBACK_TEXTURE_URL);
}
export function audioToUrl(fileName: string) {

View file

@ -2,12 +2,15 @@ import untypedManifest from "@/public/manifest.json";
import { normalizePath } from "./stringUtils";
// Source tuple: [sourcePath] or [sourcePath, actualPath] if casing differs
type SourceTuple = [string] | [string, string];
type SourceTuple =
| [sourcePath: string]
| [sourcePath: string, actualPath: string];
// Resource entry: [firstSeenPath, ...sourceTuples]
type ResourceEntry = [string, ...SourceTuple[]];
type ResourceEntry = [firstSeenPath: string, ...SourceTuple[]];
/**
* Manifest format: keys are normalized (lowercased) paths, values are
* Manifest format: keys are normalized (lowercased, forward-slash) paths,
* values are ResourceEntry arrays (see above):
* [firstSeenPath, ...sourceTuples] where each source tuple is either:
* - [sourcePath] if the file has the same casing as firstSeenPath
* - [sourcePath, actualPath] if the file has different casing in that source
@ -24,79 +27,68 @@ const manifest = untypedManifest as unknown as {
>;
};
function normalizeKey(resourcePath: string): string {
function getResourceKey(resourcePath: string): string {
return normalizePath(resourcePath).toLowerCase();
}
function getEntry(resourcePath: string): ResourceEntry | undefined {
return manifest.resources[normalizeKey(resourcePath)];
}
/**
* Get the source vl2 archive for a resource (or empty string for loose files).
* Returns the last/winning source since later vl2s override earlier ones.
*/
export function getSource(resourcePath: string): string {
const entry = getEntry(resourcePath);
if (entry && entry.length > 1) {
const lastSourceTuple = entry[entry.length - 1] as SourceTuple;
return lastSourceTuple[0];
} else {
throw new Error(`Resource not found in manifest: ${resourcePath}`);
}
export function getSourceAndPath(
resourceKey: string,
): [sourceName: string, pathInSource: string] {
const entry = manifest.resources[resourceKey];
const [firstSeenPath, ...sources] = entry;
const [sourcePath, actualPath] = sources[sources.length - 1];
return [sourcePath, actualPath ?? firstSeenPath];
}
/**
* Get the actual resource path with its original casing as seen in the filesystem.
* This handles case-insensitive lookups by normalizing the input path.
* Given a file path, check the manifest for an exact match (but case insensitive),
* followed by removing numeric suffixes and certain intermediate directories.
* Return the normalized resource path that was found.
*
* FIXME: Figure out how T2/Torque actually resolves these.
*/
export function getActualResourcePath(resourcePath: string): string {
const entry = getEntry(resourcePath);
if (entry) {
return entry[0]; // First element is the first-seen casing
export function getActualResourceKey(resourcePath: string): string {
const resourceKey = getResourceKey(resourcePath);
if (manifest.resources[resourceKey]) {
return resourceKey;
}
// Fallback: try stripping numeric suffixes (e.g., "generator0.png" -> "generator.png")
const pathWithoutNumber = resourcePath.replace(/\d+(\.(png))$/i, "$1");
if (pathWithoutNumber !== resourcePath) {
const entryWithoutNumber = getEntry(pathWithoutNumber);
if (entryWithoutNumber) {
return entryWithoutNumber[0];
}
const keyWithoutNumber = resourceKey.replace(/\d+(\.(png))$/i, "$1");
if (manifest.resources[keyWithoutNumber]) {
return keyWithoutNumber;
}
// Fallback: try nested texture paths
const normalized = normalizeKey(resourcePath);
if (normalized.startsWith("textures/")) {
for (const key of Object.keys(manifest.resources)) {
const stripped = key.replace(
/^(textures\/)((lush|desert|badlands|lava|ice|jaggedclaw|terraintiles)\/)/,
"$1",
);
if (stripped === normalized) {
return manifest.resources[key][0];
}
}
}
// // Fallback: try nested texture paths
// if (resourcePath.startsWith("textures/")) {
// for (const key of getResourceList()) {
// const stripped = key.replace(
// /^(textures\/)((lush|desert|badlands|lava|ice|jaggedclaw|terraintiles)\/)/,
// "$1",
// );
// if (stripped === normalized) {
// return manifest.resources[key][0];
// }
// }
// }
return resourcePath;
throw new Error(`Resource not found in manifest: ${resourcePath}`);
}
export function getResourceList(): string[] {
return Object.keys(manifest.resources);
}
export function getFilePath(resourcePath: string): string {
const entry = getEntry(resourcePath);
if (!entry) {
return `docs/base/${resourcePath}`;
}
const [firstSeenPath, ...sourceTuples] = entry;
const lastSourceTuple = sourceTuples[sourceTuples.length - 1];
const lastSource = lastSourceTuple[0];
const actualPath = lastSourceTuple[1] ?? firstSeenPath;
if (lastSource) {
return `docs/base/@vl2/${lastSource}/${actualPath}`;
export function getLocalFilePath(resourcePath: string): string {
const resourceKey = getResourceKey(resourcePath);
const [sourcePath, actualPath] = getSourceAndPath(resourceKey);
if (sourcePath) {
return `docs/base/@vl2/${sourcePath}/${actualPath}`;
} else {
return `docs/base/${actualPath}`;
}