mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
265 lines
7.6 KiB
JavaScript
265 lines
7.6 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export const DEFAULT_CAD_DIRECTORY = "models";
|
|
|
|
const SOURCE_EXTENSIONS = new Set([".step", ".stp", ".stl", ".dxf", ".urdf"]);
|
|
const SKIPPED_DIRECTORIES = new Set([".git", ".cache", ".viewer", "__pycache__", "node_modules"]);
|
|
const VIEWER_ARTIFACT_FILENAMES = new Set(["model.glb", "topology.json", "topology.bin"]);
|
|
|
|
function toPosixPath(value) {
|
|
return String(value || "").split(path.sep).join("/");
|
|
}
|
|
|
|
function encodeUrlPath(repoRelativePath) {
|
|
return `/${repoRelativePath.split("/").map((part) => encodeURIComponent(part)).join("/")}`;
|
|
}
|
|
|
|
export function normalizeCadDirectory(value = DEFAULT_CAD_DIRECTORY) {
|
|
const rawValue = String(value || "").trim() || DEFAULT_CAD_DIRECTORY;
|
|
const slashNormalized = rawValue.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
const normalized = path.posix.normalize(slashNormalized);
|
|
if (!normalized || normalized === ".") {
|
|
return DEFAULT_CAD_DIRECTORY;
|
|
}
|
|
if (normalized === ".." || normalized.startsWith("../")) {
|
|
throw new Error(`CAD directory must stay inside the repository: ${rawValue}`);
|
|
}
|
|
return normalized.replace(/\/+$/, "");
|
|
}
|
|
|
|
export function resolveCadDirectory(repoRoot, dir = DEFAULT_CAD_DIRECTORY) {
|
|
const normalizedDir = normalizeCadDirectory(dir);
|
|
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
const rootPath = path.resolve(resolvedRepoRoot, normalizedDir);
|
|
const relativePath = path.relative(resolvedRepoRoot, rootPath);
|
|
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
throw new Error(`CAD directory must stay inside the repository: ${normalizedDir}`);
|
|
}
|
|
return {
|
|
dir: normalizedDir,
|
|
rootPath,
|
|
rootName: path.basename(rootPath) || normalizedDir,
|
|
};
|
|
}
|
|
|
|
export function repoRelativePath(repoRoot, filePath) {
|
|
return toPosixPath(path.relative(path.resolve(repoRoot), path.resolve(filePath)));
|
|
}
|
|
|
|
function scanRelativePath(rootPath, filePath) {
|
|
return toPosixPath(path.relative(path.resolve(rootPath), path.resolve(filePath)));
|
|
}
|
|
|
|
function fileStats(filePath) {
|
|
try {
|
|
const stats = fs.statSync(filePath, { bigint: true });
|
|
return stats.isFile() ? stats : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function fileVersion(filePath) {
|
|
const stats = fileStats(filePath);
|
|
if (!stats) {
|
|
return "";
|
|
}
|
|
return `${stats.size.toString(36)}-${stats.mtimeNs.toString(36)}`;
|
|
}
|
|
|
|
function assetForPath(repoRoot, filePath) {
|
|
const version = fileVersion(filePath);
|
|
if (!version) {
|
|
return null;
|
|
}
|
|
const repoPath = repoRelativePath(repoRoot, filePath);
|
|
return {
|
|
url: `${encodeUrlPath(repoPath)}?v=${encodeURIComponent(version)}`,
|
|
hash: version,
|
|
};
|
|
}
|
|
|
|
function readJsonObject(filePath) {
|
|
try {
|
|
const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function stepKindFromTopology(topologyPath) {
|
|
const topology = readJsonObject(topologyPath);
|
|
return topology?.assembly?.root && typeof topology.assembly.root === "object"
|
|
? "assembly"
|
|
: "part";
|
|
}
|
|
|
|
function sourceFormatFromExtension(extension) {
|
|
const normalized = extension.toLowerCase().replace(/^\./, "");
|
|
return normalized === "stp" ? "stp" : normalized;
|
|
}
|
|
|
|
function isPerStepViewerDirectoryName(name) {
|
|
const normalized = String(name || "").toLowerCase();
|
|
return normalized.startsWith(".") && (normalized.endsWith(".step") || normalized.endsWith(".stp"));
|
|
}
|
|
|
|
function isPathInsidePerStepViewerDirectory(filePath) {
|
|
return String(filePath || "")
|
|
.split(path.sep)
|
|
.some((part) => isPerStepViewerDirectoryName(part));
|
|
}
|
|
|
|
function fileRefForSource(rootPath, sourcePath) {
|
|
return scanRelativePath(rootPath, sourcePath);
|
|
}
|
|
|
|
function cadPathForStepSource(repoRoot, sourcePath, extension) {
|
|
const relativePath = repoRelativePath(repoRoot, sourcePath);
|
|
return relativePath.slice(0, -extension.length);
|
|
}
|
|
|
|
function createStepEntry({ repoRoot, rootPath, sourcePath, extension }) {
|
|
const viewerDir = path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}`);
|
|
const glbPath = path.join(viewerDir, "model.glb");
|
|
const topologyPath = path.join(viewerDir, "topology.json");
|
|
const topologyBinaryPath = path.join(viewerDir, "topology.bin");
|
|
const assets = {};
|
|
|
|
for (const [key, assetPath] of [
|
|
["glb", glbPath],
|
|
["topology", topologyPath],
|
|
["topologyBinary", topologyBinaryPath],
|
|
]) {
|
|
const asset = assetForPath(repoRoot, assetPath);
|
|
if (asset) {
|
|
assets[key] = asset;
|
|
}
|
|
}
|
|
|
|
const sourceRelPath = fileRefForSource(rootPath, sourcePath);
|
|
return {
|
|
file: fileRefForSource(rootPath, sourcePath),
|
|
cadPath: cadPathForStepSource(repoRoot, sourcePath, extension),
|
|
kind: stepKindFromTopology(topologyPath),
|
|
name: path.basename(sourcePath),
|
|
source: {
|
|
kind: "file",
|
|
format: sourceFormatFromExtension(extension),
|
|
path: sourceRelPath,
|
|
},
|
|
assets,
|
|
step: {
|
|
path: sourceRelPath,
|
|
hash: fileVersion(sourcePath),
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSingleAssetEntry({ repoRoot, rootPath, sourcePath, extension }) {
|
|
const kind = sourceFormatFromExtension(extension);
|
|
const asset = assetForPath(repoRoot, sourcePath);
|
|
const assets = asset ? { [kind]: asset } : {};
|
|
return {
|
|
file: fileRefForSource(rootPath, sourcePath),
|
|
kind,
|
|
name: path.basename(sourcePath),
|
|
source: {
|
|
kind: "file",
|
|
format: kind,
|
|
path: repoRelativePath(repoRoot, sourcePath),
|
|
},
|
|
assets,
|
|
};
|
|
}
|
|
|
|
function shouldSkipDirectory(name) {
|
|
return SKIPPED_DIRECTORIES.has(name) || isPerStepViewerDirectoryName(name);
|
|
}
|
|
|
|
function collectCadSourceFiles(rootPath, result = []) {
|
|
let entries = [];
|
|
try {
|
|
entries = fs.readdirSync(rootPath, { withFileTypes: true });
|
|
} catch {
|
|
return result;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const entryPath = path.join(rootPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!shouldSkipDirectory(entry.name)) {
|
|
collectCadSourceFiles(entryPath, result);
|
|
}
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
const extension = path.extname(entry.name).toLowerCase();
|
|
if (SOURCE_EXTENSIONS.has(extension)) {
|
|
result.push(entryPath);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function compareEntries(a, b) {
|
|
return String(a.file || "").localeCompare(String(b.file || ""), undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
}
|
|
|
|
export function scanCadDirectory({ repoRoot, dir = DEFAULT_CAD_DIRECTORY } = {}) {
|
|
if (!repoRoot) {
|
|
throw new Error("repoRoot is required");
|
|
}
|
|
const resolved = resolveCadDirectory(repoRoot, dir);
|
|
const entries = collectCadSourceFiles(resolved.rootPath)
|
|
.map((sourcePath) => {
|
|
const extension = path.extname(sourcePath).toLowerCase();
|
|
if (extension === ".step" || extension === ".stp") {
|
|
return createStepEntry({
|
|
repoRoot,
|
|
rootPath: resolved.rootPath,
|
|
sourcePath,
|
|
extension,
|
|
});
|
|
}
|
|
return createSingleAssetEntry({
|
|
repoRoot,
|
|
rootPath: resolved.rootPath,
|
|
sourcePath,
|
|
extension,
|
|
});
|
|
})
|
|
.sort(compareEntries);
|
|
|
|
return {
|
|
schemaVersion: 3,
|
|
root: {
|
|
dir: resolved.dir,
|
|
name: resolved.rootName,
|
|
path: resolved.dir,
|
|
},
|
|
entries,
|
|
};
|
|
}
|
|
|
|
export function isServedCadAsset(filePath) {
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
if (SOURCE_EXTENSIONS.has(extension)) {
|
|
return true;
|
|
}
|
|
if (!isPathInsidePerStepViewerDirectory(filePath)) {
|
|
return false;
|
|
}
|
|
return extension === ".glb" || VIEWER_ARTIFACT_FILENAMES.has(path.basename(filePath));
|
|
}
|
|
|
|
export function isCatalogRelevantPath(filePath) {
|
|
return isServedCadAsset(filePath);
|
|
}
|