From 8432233f0f25b8f460019e98ced165d89defb111 Mon Sep 17 00:00:00 2001 From: Coasterpete Design Date: Sat, 20 Jun 2026 19:32:40 -0400 Subject: [PATCH] Add preview viewer asset and style foundation --- Quantum.PreviewViewer/Program.cs | 334 +++++++++++++++- Quantum.PreviewViewer/README.md | 36 +- Quantum.PreviewViewer/preview-styles.json | 12 + Quantum.PreviewViewer/wwwroot/app.js | 378 +++++++++++++++++- Quantum.PreviewViewer/wwwroot/index.html | 5 + Quantum.PreviewViewer/wwwroot/styles.css | 11 + .../StyleManifestCatalogTests.cs | 172 ++++++++ 7 files changed, 932 insertions(+), 16 deletions(-) create mode 100644 Quantum.PreviewViewer/preview-styles.json create mode 100644 Quantum.Tests/PreviewViewer/StyleManifestCatalogTests.cs diff --git a/Quantum.PreviewViewer/Program.cs b/Quantum.PreviewViewer/Program.cs index ac476f8..d195422 100644 --- a/Quantum.PreviewViewer/Program.cs +++ b/Quantum.PreviewViewer/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); @@ -6,6 +7,13 @@ string repositoryRoot = PreviewViewerPaths.FindRepositoryRoot(app.Environment.ContentRootPath); string configuredSnapshotRoot = app.Configuration["SnapshotRoot"] ?? Path.Combine("artifacts", "debug-viewport"); string snapshotRoot = PreviewViewerPaths.ResolveRepoPath(repositoryRoot, configuredSnapshotRoot); +string configuredStyleManifest = app.Configuration["PreviewStyleManifest"] ?? + Path.Combine("Quantum.PreviewViewer", "preview-styles.json"); +string styleManifestPath = PreviewViewerPaths.ResolveRepoPath(repositoryRoot, configuredStyleManifest); +string configuredStyleAssetRoot = app.Configuration["PreviewStyleAssetRoot"] ?? + Path.GetDirectoryName(styleManifestPath) ?? + repositoryRoot; +string styleAssetRoot = PreviewViewerPaths.ResolveRepoPath(repositoryRoot, configuredStyleAssetRoot); app.UseDefaultFiles(); app.UseStaticFiles(); @@ -36,6 +44,36 @@ return Results.Text(json, "application/json"); }); +app.MapGet("/api/styles", () => +{ + JsonObject manifest = PreviewStyleManifestCatalog.LoadManifest( + repositoryRoot, + styleManifestPath, + styleAssetRoot, + "/style-assets"); + + return Results.Json(manifest); +}); + +app.MapGet("/style-assets/{**assetPath}", (string assetPath) => +{ + if (!PreviewStyleAssetFiles.TryResolveStyleAssetFile( + styleAssetRoot, + assetPath, + out string resolvedPath, + out string contentType, + out string error)) + { + int statusCode = error.Contains("not found", StringComparison.OrdinalIgnoreCase) + ? StatusCodes.Status404NotFound + : StatusCodes.Status400BadRequest; + + return Results.Problem(error, statusCode: statusCode); + } + + return Results.File(resolvedPath, contentType, enableRangeProcessing: true); +}); + app.MapGet("/api/health", () => Results.Json(new { status = "ok" })); app.MapFallbackToFile("index.html"); @@ -85,8 +123,7 @@ public static bool TryResolveRepositoryFile( } string candidate = ResolveRepoPath(repositoryRoot, requestedPath); - string normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(repositoryRoot)); - if (!candidate.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + if (!IsPathInsideDirectory(repositoryRoot, candidate)) { error = "Snapshot path must resolve inside the repository. Use Open JSON for external files."; return false; @@ -108,6 +145,15 @@ public static string ToRepositoryRelativePath(string repositoryRoot, string path return relativePath.Replace(Path.DirectorySeparatorChar, '/'); } + public static bool IsPathInsideDirectory(string rootDirectory, string candidatePath) + { + string normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootDirectory)); + string normalizedCandidate = Path.GetFullPath(candidatePath); + string normalizedRootWithoutTrailingSeparator = normalizedRoot.TrimEnd(Path.DirectorySeparatorChar); + return string.Equals(normalizedCandidate, normalizedRootWithoutTrailingSeparator, StringComparison.OrdinalIgnoreCase) || + normalizedCandidate.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase); + } + private static string EnsureTrailingSeparator(string path) { if (path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) @@ -119,6 +165,290 @@ private static string EnsureTrailingSeparator(string path) } } +public static class PreviewStyleManifestCatalog +{ + public const int CurrentVersion = 1; + public const string DebugBoxesTrainStyleId = "debug-boxes"; + + public static JsonObject LoadManifest( + string repositoryRoot, + string styleManifestPath, + string styleAssetRoot, + string assetRequestPathPrefix) + { + JsonObject manifest; + if (!File.Exists(styleManifestPath)) + { + manifest = CreateDefaultManifest("Style manifest was not found. Using debug train boxes."); + AddRepositoryMetadata(manifest, repositoryRoot, styleManifestPath, styleAssetRoot); + return manifest; + } + + try + { + using FileStream stream = File.OpenRead(styleManifestPath); + manifest = JsonNode.Parse(stream) as JsonObject ?? + CreateDefaultManifest("Style manifest root must be a JSON object. Using debug train boxes."); + } + catch (IOException) + { + manifest = CreateDefaultManifest("Style manifest could not be read. Using debug train boxes."); + } + catch (JsonException) + { + manifest = CreateDefaultManifest("Style manifest is invalid JSON. Using debug train boxes."); + } + catch (UnauthorizedAccessException) + { + manifest = CreateDefaultManifest("Style manifest was not accessible. Using debug train boxes."); + } + + AddRepositoryMetadata(manifest, repositoryRoot, styleManifestPath, styleAssetRoot); + EnsureDefaults(manifest); + AddAssetUrls(manifest, styleAssetRoot, assetRequestPathPrefix.TrimEnd('/'), GetDiagnostics(manifest)); + return manifest; + } + + private static void AddRepositoryMetadata( + JsonObject manifest, + string repositoryRoot, + string styleManifestPath, + string styleAssetRoot) + { + manifest["manifestPath"] = PreviewViewerPaths.IsPathInsideDirectory(repositoryRoot, styleManifestPath) + ? PreviewViewerPaths.ToRepositoryRelativePath(repositoryRoot, styleManifestPath) + : styleManifestPath; + manifest["assetRoot"] = PreviewViewerPaths.IsPathInsideDirectory(repositoryRoot, styleAssetRoot) + ? PreviewViewerPaths.ToRepositoryRelativePath(repositoryRoot, styleAssetRoot) + : styleAssetRoot; + } + + private static void EnsureDefaults(JsonObject manifest) + { + manifest["version"] ??= CurrentVersion; + JsonArray trainStyles = GetOrCreateArray(manifest, "trainStyles"); + if (trainStyles.Count == 0) + { + trainStyles.Add(CreateDebugBoxesTrainStyle()); + } + + JsonObject? firstTrainStyle = trainStyles[0] as JsonObject; + string defaultTrainStyle = TryGetString(manifest, "defaultTrainStyle") ?? + TryGetString(firstTrainStyle, "id") ?? + DebugBoxesTrainStyleId; + manifest["defaultTrainStyle"] = defaultTrainStyle; + manifest["trackStyles"] ??= new JsonArray(); + + foreach (JsonNode? trainStyle in trainStyles) + { + if (trainStyle is JsonObject trainStyleObject) + { + trainStyleObject["roles"] ??= new JsonObject(); + } + } + } + + private static void AddAssetUrls( + JsonNode? node, + string styleAssetRoot, + string assetRequestPathPrefix, + JsonArray diagnostics) + { + if (node is JsonObject jsonObject) + { + if (TryGetString(jsonObject, "asset") is { Length: > 0 } assetPath && + TryBuildStyleAssetUrl(styleAssetRoot, assetPath, assetRequestPathPrefix, diagnostics, out string assetUrl)) + { + jsonObject["assetUrl"] = assetUrl; + } + + foreach (KeyValuePair property in jsonObject.ToList()) + { + AddAssetUrls(property.Value, styleAssetRoot, assetRequestPathPrefix, diagnostics); + } + } + else if (node is JsonArray jsonArray) + { + foreach (JsonNode? item in jsonArray) + { + AddAssetUrls(item, styleAssetRoot, assetRequestPathPrefix, diagnostics); + } + } + } + + private static bool TryBuildStyleAssetUrl( + string styleAssetRoot, + string assetPath, + string assetRequestPathPrefix, + JsonArray diagnostics, + out string assetUrl) + { + assetUrl = string.Empty; + string extension = Path.GetExtension(assetPath); + if (!PreviewStyleAssetFiles.IsPrimaryModelExtension(extension)) + { + diagnostics.Add(CreateDiagnostic( + "warning", + $"Style asset '{assetPath}' is not a supported .glb or .gltf model.")); + return false; + } + + string resolvedPath = PreviewViewerPaths.ResolveRepoPath(styleAssetRoot, assetPath); + if (!PreviewViewerPaths.IsPathInsideDirectory(styleAssetRoot, resolvedPath)) + { + diagnostics.Add(CreateDiagnostic( + "warning", + $"Style asset '{assetPath}' must resolve inside the configured preview asset root.")); + return false; + } + + if (!File.Exists(resolvedPath)) + { + diagnostics.Add(CreateDiagnostic( + "warning", + $"Style asset '{assetPath}' was not found. Matching train boxes will use debug rendering.")); + } + + string relativePath = Path.GetRelativePath(styleAssetRoot, resolvedPath) + .Replace(Path.DirectorySeparatorChar, '/') + .Replace(Path.AltDirectorySeparatorChar, '/'); + assetUrl = $"{assetRequestPathPrefix}/{EscapePathSegments(relativePath)}"; + return true; + } + + private static JsonObject CreateDefaultManifest(string diagnosticMessage) + { + return new JsonObject + { + ["version"] = CurrentVersion, + ["defaultTrainStyle"] = DebugBoxesTrainStyleId, + ["trainStyles"] = new JsonArray(CreateDebugBoxesTrainStyle()), + ["trackStyles"] = new JsonArray(), + ["diagnostics"] = new JsonArray(CreateDiagnostic("warning", diagnosticMessage)) + }; + } + + private static JsonObject CreateDebugBoxesTrainStyle() + { + return new JsonObject + { + ["id"] = DebugBoxesTrainStyleId, + ["name"] = "Debug boxes", + ["roles"] = new JsonObject() + }; + } + + private static JsonArray GetDiagnostics(JsonObject manifest) + { + return GetOrCreateArray(manifest, "diagnostics"); + } + + private static JsonArray GetOrCreateArray(JsonObject manifest, string propertyName) + { + if (manifest[propertyName] is JsonArray array) + { + return array; + } + + array = new JsonArray(); + manifest[propertyName] = array; + return array; + } + + private static JsonObject CreateDiagnostic(string severity, string message) + { + return new JsonObject + { + ["severity"] = severity, + ["message"] = message + }; + } + + private static string? TryGetString(JsonObject? jsonObject, string propertyName) + { + if (jsonObject == null || + jsonObject[propertyName] is not JsonValue value || + !value.TryGetValue(out string? result) || + string.IsNullOrWhiteSpace(result)) + { + return null; + } + + return result; + } + + private static string EscapePathSegments(string path) + { + return string.Join( + '/', + path.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString)); + } +} + +public static class PreviewStyleAssetFiles +{ + private static readonly IReadOnlyDictionary ContentTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [".glb"] = "model/gltf-binary", + [".gltf"] = "model/gltf+json", + [".bin"] = "application/octet-stream", + [".png"] = "image/png", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".webp"] = "image/webp", + [".ktx2"] = "image/ktx2" + }; + + public static bool TryResolveStyleAssetFile( + string styleAssetRoot, + string requestedAssetPath, + out string resolvedPath, + out string contentType, + out string error) + { + resolvedPath = string.Empty; + contentType = string.Empty; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(requestedAssetPath)) + { + error = "Style asset path is required."; + return false; + } + + string extension = Path.GetExtension(requestedAssetPath); + if (!ContentTypes.TryGetValue(extension, out string? resolvedContentType)) + { + error = "Style assets must be .glb, .gltf, .bin, or common image texture files."; + return false; + } + + string candidate = PreviewViewerPaths.ResolveRepoPath(styleAssetRoot, requestedAssetPath); + if (!PreviewViewerPaths.IsPathInsideDirectory(styleAssetRoot, candidate)) + { + error = "Style asset path must resolve inside the configured preview asset root."; + return false; + } + + if (!File.Exists(candidate)) + { + error = "Style asset file was not found."; + return false; + } + + resolvedPath = candidate; + contentType = resolvedContentType; + return true; + } + + public static bool IsPrimaryModelExtension(string extension) + { + return string.Equals(extension, ".glb", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".gltf", StringComparison.OrdinalIgnoreCase); + } +} + public static class SnapshotCatalog { public const string ContractName = "quantum.debug_viewport_snapshot"; diff --git a/Quantum.PreviewViewer/README.md b/Quantum.PreviewViewer/README.md index ef7edac..cd1b68a 100644 --- a/Quantum.PreviewViewer/README.md +++ b/Quantum.PreviewViewer/README.md @@ -6,6 +6,7 @@ Technology: - ASP.NET Core `net8.0` static host plus tiny repo-local snapshot API. - Three.js in the browser for the 3D viewport and orbit controls. +- Viewer-local `preview-styles.json` manifest for optional train and future track styling. - No Unity, Unreal, Avalonia, Silk.NET, or editor architecture commitment. Run: @@ -19,19 +20,52 @@ Then open the printed localhost URL. The viewer lists valid generated snapshots Current capabilities: - Shows catalog/file load status, empty/error states, snapshot metadata, scene counts, train metrics, and layer visibility state. +- Loads the viewer style manifest from `Quantum.PreviewViewer/preview-styles.json`. +- Serves repo-local style assets through `/style-assets/...` with `.glb` and `.gltf` model support. - Renders centerline polylines from `centerlinePoints`. - Renders frame axes from `frames`. - Renders `lines` as colored diagnostics. - Renders oriented train/debug boxes from `boxes`. +- Can replace configured train box roles with GLTF/GLB assets while preserving debug boxes as the fallback. - Scrubs and plays a visual lead distance through the exported sampled frames with orbit and follow-train camera modes. - Repositions train placeholder boxes by preserving their exported spacing offsets. - Supports orbit, pan, zoom, camera reset, and optional follow camera. - Captures PNG screenshots and WebM recordings through browser canvas APIs. - Caps selected helper visuals such as sample markers, frame axes, and labels for responsiveness on larger snapshots. +Style manifest shape: + +```json +{ + "version": 1, + "defaultTrainStyle": "debug-boxes", + "trainStyles": [ + { + "id": "custom-train", + "name": "Custom train", + "roles": { + "train.body": { + "asset": "assets/trains/custom-car.glb", + "fitToBox": true, + "fitMode": "uniform", + "scale": 1.0, + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "rotationDegrees": { "x": 0.0, "y": 0.0, "z": 0.0 } + } + } + } + ], + "trackStyles": [] +} +``` + +Asset paths are resolved under the configured preview asset root, which defaults to the manifest directory. `PreviewStyleManifest` and `PreviewStyleAssetRoot` can be set through app configuration if the manifest or assets need to live elsewhere in the repository. + Limitations: - This is not a production editor. -- No authoring tools, supports, track styles, mesh import, or material system. +- No authoring tools, supports, generated track mesh, or material editing. +- `trackStyles` is reserved in the manifest but not rendered yet. +- Styled train assets are viewer-side visuals only; snapshot contracts and backend train placement are unchanged. - Moving train boxes are a viewer-side inspection aid based on exported sample interpolation, not a backend simulation change. - Three.js modules are loaded from a pinned CDN URL for the spike; vendor or package them later if the viewer needs offline operation. diff --git a/Quantum.PreviewViewer/preview-styles.json b/Quantum.PreviewViewer/preview-styles.json new file mode 100644 index 0000000..5ef672f --- /dev/null +++ b/Quantum.PreviewViewer/preview-styles.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "defaultTrainStyle": "debug-boxes", + "trainStyles": [ + { + "id": "debug-boxes", + "name": "Debug boxes", + "roles": {} + } + ], + "trackStyles": [] +} diff --git a/Quantum.PreviewViewer/wwwroot/app.js b/Quantum.PreviewViewer/wwwroot/app.js index ffb9e60..9e95066 100644 --- a/Quantum.PreviewViewer/wwwroot/app.js +++ b/Quantum.PreviewViewer/wwwroot/app.js @@ -1,5 +1,6 @@ import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; const ui = { viewport: document.getElementById("viewport"), @@ -10,6 +11,7 @@ const ui = { overlayTitle: document.getElementById("overlayTitle"), overlayMessage: document.getElementById("overlayMessage"), snapshotSelect: document.getElementById("snapshotSelect"), + styleSelect: document.getElementById("styleSelect"), reloadButton: document.getElementById("reloadButton"), fileButton: document.getElementById("fileButton"), fileInput: document.getElementById("fileInput"), @@ -81,6 +83,12 @@ const state = { playing: false, playSpeed: 8, dynamicBoxes: [], + styleManifest: null, + trainStyles: [], + selectedTrainStyleId: "", + assetCache: new Map(), + trainVisualGeneration: 0, + trainVisualStats: createEmptyTrainVisualStats(), axisScale: 1, bounds: new THREE.Box3(), boundsCenter: new THREE.Vector3(), @@ -102,6 +110,7 @@ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; ui.viewport.appendChild(renderer.domElement); +const gltfLoader = new GLTFLoader(); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; @@ -133,6 +142,11 @@ ui.snapshotSelect.addEventListener("change", () => { } }); +ui.styleSelect.addEventListener("change", () => { + state.selectedTrainStyleId = ui.styleSelect.value; + rebuildTrainVisuals(); +}); + ui.reloadButton.addEventListener("click", () => loadCatalog({ reloadCurrent: true })); ui.fileInput.addEventListener("change", loadFileSnapshot); ui.playButton.addEventListener("click", () => setPlaying(!state.playing)); @@ -156,9 +170,72 @@ ui.recordButton.addEventListener("click", toggleRecording); }); resizeRenderer(); -loadCatalog(); +loadStyles().finally(() => loadCatalog()); requestAnimationFrame(tick); +async function loadStyles() { + try { + const response = await fetch("/api/styles"); + if (!response.ok) { + throw new Error(`Style manifest request failed (${response.status})`); + } + + state.styleManifest = normalizeStyleManifest(await response.json()); + } catch (error) { + state.styleManifest = normalizeStyleManifest({ + version: 1, + defaultTrainStyle: "debug-boxes", + trainStyles: [{ id: "debug-boxes", name: "Debug boxes", roles: {} }], + trackStyles: [], + diagnostics: [{ severity: "warning", message: error instanceof Error ? error.message : String(error) }] + }); + } + + state.trainStyles = state.styleManifest.trainStyles; + state.selectedTrainStyleId = state.styleManifest.defaultTrainStyle; + populateStyleSelect(); +} + +function normalizeStyleManifest(manifest) { + const trainStyles = Array.isArray(manifest?.trainStyles) + ? manifest.trainStyles.filter((style) => getString(style, "id")) + : []; + if (trainStyles.length === 0) { + trainStyles.push({ id: "debug-boxes", name: "Debug boxes", roles: {} }); + } + + trainStyles.forEach((style) => { + style.roles = style.roles && typeof style.roles === "object" ? style.roles : {}; + }); + + const defaultTrainStyle = getString(manifest, "defaultTrainStyle") || getString(trainStyles[0], "id"); + const hasDefault = trainStyles.some((style) => getString(style, "id") === defaultTrainStyle); + + return { + version: getNumber(manifest ?? {}, "version") || 1, + defaultTrainStyle: hasDefault ? defaultTrainStyle : getString(trainStyles[0], "id"), + manifestPath: getString(manifest ?? {}, "manifestPath"), + assetRoot: getString(manifest ?? {}, "assetRoot"), + trainStyles, + trackStyles: Array.isArray(manifest?.trackStyles) ? manifest.trackStyles : [], + diagnostics: Array.isArray(manifest?.diagnostics) ? manifest.diagnostics : [] + }; +} + +function populateStyleSelect() { + ui.styleSelect.replaceChildren(); + + state.trainStyles.forEach((style) => { + const option = document.createElement("option"); + option.value = getString(style, "id"); + option.textContent = getString(style, "name") || getString(style, "id"); + ui.styleSelect.appendChild(option); + }); + + ui.styleSelect.value = state.selectedTrainStyleId; + ui.styleSelect.disabled = state.trainStyles.length === 0; +} + async function loadCatalog(options = {}) { const requestedPath = options.reloadCurrent ? ui.snapshotSelect.value || (state.snapshotSummary ? getSummaryPath(state.snapshotSummary) : "") @@ -290,6 +367,8 @@ function loadSnapshotFromText(json, label) { state.frames = getFrames(snapshot); state.frameDistances = state.frames.map((frame) => frame.distance); state.dynamicBoxes = []; + state.trainVisualGeneration += 1; + state.trainVisualStats = createEmptyTrainVisualStats(); state.centerlineMarkerStride = 1; state.frameAxisStride = 1; @@ -349,6 +428,8 @@ function clearSnapshot() { state.frames = []; state.frameDistances = []; state.dynamicBoxes = []; + state.trainVisualGeneration += 1; + state.trainVisualStats = createEmptyTrainVisualStats(); setPlaying(false); Object.values(groups).forEach(clearGroup); @@ -486,7 +567,7 @@ function buildTrainBoxes(snapshot) { const shouldLabel = Boolean(box.label) && isTrainRole(box.role) && labelCount < renderLimits.maxTrainLabels; - const group = createBoxGroup(size, box.role, shouldLabel ? box.label : "", isLead); + const group = createTrainVisualGroup(size, box.role, shouldLabel ? box.label : "", isLead); if (shouldLabel) { labelCount += 1; } @@ -507,6 +588,154 @@ function buildTrainBoxes(snapshot) { }); } +function rebuildTrainVisuals() { + if (!state.snapshot) { + updateMetrics(); + updateControlAvailability(); + return; + } + + state.trainVisualGeneration += 1; + state.trainVisualStats = createEmptyTrainVisualStats(); + state.dynamicBoxes = []; + clearGroup(groups.train); + clearGroup(groups.cursor); + buildTrainBoxes(state.snapshot); + buildCursor(); + configureScrubber(state.snapshot); + updateDynamicObjects(); + updateLayerVisibility(); + updateMetrics(); + setStatus(`Train style: ${getSelectedTrainStyleName()}`, "ready"); +} + +function createTrainVisualGroup(size, role, label, isLead) { + if (!isTrainRole(role)) { + return createBoxGroup(size, role, label, isLead); + } + + state.trainVisualStats.trainBoxes += 1; + const roleStyle = getTrainRoleStyle(role); + const assetUrl = getString(roleStyle, "assetUrl"); + if (!assetUrl) { + return createBoxGroup(size, role, label, isLead); + } + + state.trainVisualStats.assetRequested += 1; + const generation = state.trainVisualGeneration; + const group = new THREE.Group(); + group.name = label || role || "train asset"; + group.userData.assetStatus = "loading"; + group.userData.assetUrl = assetUrl; + group.add(createBoxGroup(size, role, label, isLead)); + + loadStyleAsset(assetUrl) + .then((asset) => { + if (generation !== state.trainVisualGeneration || group.parent !== groups.train) { + return; + } + + replaceFallbackWithAsset(group, asset, roleStyle, size, label, isLead); + state.trainVisualStats.assetLoaded += 1; + updateMetrics(); + }) + .catch((error) => { + if (generation !== state.trainVisualGeneration || group.parent !== groups.train) { + return; + } + + group.userData.assetStatus = "fallback"; + group.userData.assetError = error instanceof Error ? error.message : String(error); + state.trainVisualStats.assetFailed += 1; + updateMetrics(); + setStatus(`Train style asset unavailable; using debug boxes for ${label || role}`, "warning"); + }); + + return group; +} + +function replaceFallbackWithAsset(group, asset, roleStyle, size, label, isLead) { + disposeChildren(group); + const assetGroup = createAssetVisualGroup(asset, roleStyle, size); + group.add(assetGroup); + if (label) { + const labelSprite = createLabelSprite(label, isLead); + labelSprite.position.set(0, size.height * 0.9, 0); + group.add(labelSprite); + } + + group.userData.assetStatus = "loaded"; +} + +async function loadStyleAsset(assetUrl) { + if (!state.assetCache.has(assetUrl)) { + const loadPromise = gltfLoader.loadAsync(assetUrl) + .then((gltf) => ({ url: assetUrl, scene: gltf.scene })) + .catch((error) => { + state.assetCache.delete(assetUrl); + throw error; + }); + state.assetCache.set(assetUrl, loadPromise); + } + + return state.assetCache.get(assetUrl); +} + +function createAssetVisualGroup(asset, roleStyle, size) { + const wrapper = new THREE.Group(); + wrapper.name = getString(roleStyle, "name") || asset.url.split("/").pop() || "style asset"; + const instance = asset.scene.clone(true); + markAssetResourcesPreserved(instance); + wrapper.add(instance); + + if (roleStyle.fitToBox !== false) { + fitAssetToBox(instance, size, getString(roleStyle, "fitMode") || "uniform"); + } + + const rotation = vectorFromStyle(roleStyle.rotationDegrees); + wrapper.rotation.set( + THREE.MathUtils.degToRad(rotation.x), + THREE.MathUtils.degToRad(rotation.y), + THREE.MathUtils.degToRad(rotation.z)); + + const offset = vectorFromStyle(roleStyle.offset); + wrapper.position.set(offset.x, offset.y, offset.z); + + const scale = scaleFromStyle(roleStyle.scale); + wrapper.scale.set(scale.x, scale.y, scale.z); + return wrapper; +} + +function fitAssetToBox(instance, size, fitMode) { + const bounds = new THREE.Box3().setFromObject(instance); + if (bounds.isEmpty()) { + return; + } + + const modelSize = bounds.getSize(new THREE.Vector3()); + if (modelSize.x <= 1e-9 || modelSize.y <= 1e-9 || modelSize.z <= 1e-9) { + return; + } + + const targetSize = new THREE.Vector3(size.length, size.height, size.width); + if (fitMode === "stretch") { + instance.scale.multiply(new THREE.Vector3( + targetSize.x / modelSize.x, + targetSize.y / modelSize.y, + targetSize.z / modelSize.z)); + } else { + const uniformScale = Math.min( + targetSize.x / modelSize.x, + targetSize.y / modelSize.y, + targetSize.z / modelSize.z); + instance.scale.multiplyScalar(uniformScale); + } + + const fittedBounds = new THREE.Box3().setFromObject(instance); + const center = fittedBounds.getCenter(new THREE.Vector3()); + instance.position.sub(center); +} + function createBoxGroup(size, role, label, isLead) { const group = new THREE.Group(); group.name = label || role || "box"; @@ -1057,13 +1286,24 @@ function updateMetrics() { const trainBoxes = (Array.isArray(snapshot.boxes) ? snapshot.boxes : []).filter((box) => isTrainRole(box?.role)); const trainRoles = summarizeTrainRoles(trainBoxes); const offsetRange = getDynamicOffsetRange(); + const visualStats = state.trainVisualStats; + const loadingAssets = Math.max( + visualStats.assetRequested - visualStats.assetLoaded - visualStats.assetFailed, + 0); + const fallbackVisuals = Math.max(visualStats.trainBoxes - visualStats.assetLoaded, 0); appendMetricRows(ui.trainMetrics, [ + ["Train style", getSelectedTrainStyleName()], ["Pose cars", trainPoseCars > 0 ? String(trainPoseCars) : "none"], ["Lead distance", Number.isFinite(finiteNumber(snapshot.trainPose?.leadDistance, Number.NaN)) ? formatDistance(finiteNumber(snapshot.trainPose.leadDistance)) : "not exported"], ["Train boxes", String(trainBoxes.length)], ["Dynamic boxes", String(state.dynamicBoxes.length)], + ["Asset visuals", visualStats.assetRequested > 0 + ? `${visualStats.assetLoaded}/${visualStats.assetRequested}` + : "none"], + ["Loading assets", loadingAssets > 0 ? String(loadingAssets) : "none"], + ["Debug fallbacks", String(fallbackVisuals)], ["Spacing span", offsetRange], ["Roles", trainRoles] ]); @@ -1241,6 +1481,77 @@ function makeCaptureName(extension) { return `${stem}-${timestamp}.${extension}`; } +function createEmptyTrainVisualStats() { + return { + trainBoxes: 0, + assetRequested: 0, + assetLoaded: 0, + assetFailed: 0 + }; +} + +function getSelectedTrainStyle() { + return state.trainStyles.find((style) => getString(style, "id") === state.selectedTrainStyleId) ?? + state.trainStyles[0] ?? + null; +} + +function getSelectedTrainStyleName() { + const style = getSelectedTrainStyle(); + return getString(style, "name") || getString(style, "id") || "Debug boxes"; +} + +function getTrainRoleStyle(role) { + const style = getSelectedTrainStyle(); + const roles = style?.roles; + if (!roles || typeof roles !== "object") { + return null; + } + + if (roles[role]) { + return roles[role]; + } + + if (role === "train.body.banking-profile" && roles["train.body"]) { + return roles["train.body"]; + } + + if (roles["train.*"]) { + return roles["train.*"]; + } + + return null; +} + +function vectorFromStyle(value) { + if (!value || typeof value !== "object") { + return new THREE.Vector3(); + } + + return new THREE.Vector3( + finiteNumber(value.x ?? value.X), + finiteNumber(value.y ?? value.Y), + finiteNumber(value.z ?? value.Z) + ); +} + +function scaleFromStyle(value) { + if (typeof value === "number") { + const uniformScale = Number.isFinite(value) && value > 0 ? value : 1; + return new THREE.Vector3(uniformScale, uniformScale, uniformScale); + } + + if (!value || typeof value !== "object") { + return new THREE.Vector3(1, 1, 1); + } + + return new THREE.Vector3( + positiveNumber(value.x ?? value.X, 1), + positiveNumber(value.y ?? value.Y, 1), + positiveNumber(value.z ?? value.Z, 1) + ); +} + function colorForLineKind(kind) { switch (kind) { case "frame.axis.tangent": @@ -1289,27 +1600,53 @@ function finiteNumber(value, fallback = 0) { return Number.isFinite(number) ? number : fallback; } +function positiveNumber(value, fallback = 1) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } +function disposeChildren(group) { + group.children.slice().forEach((child) => { + disposeObjectTree(child); + group.remove(child); + }); +} + +function disposeObjectTree(object) { + object.traverse((child) => { + disposeObjectResources(child); + }); +} + function clearGroup(group) { group.traverse((object) => { - if (object.geometry) { - object.geometry.dispose(); - } - - if (object.material) { - if (Array.isArray(object.material)) { - object.material.forEach(disposeMaterial); - } else { - disposeMaterial(object.material); - } - } + disposeObjectResources(object); }); group.clear(); } +function disposeObjectResources(object) { + if (object.userData?.preserveAssetResources) { + return; + } + + if (object.geometry) { + object.geometry.dispose(); + } + + if (object.material) { + if (Array.isArray(object.material)) { + object.material.forEach(disposeMaterial); + } else { + disposeMaterial(object.material); + } + } +} + function disposeMaterial(material) { if (material.map) { material.map.dispose(); @@ -1318,6 +1655,12 @@ function disposeMaterial(material) { material.dispose(); } +function markAssetResourcesPreserved(object) { + object.traverse((child) => { + child.userData.preserveAssetResources = true; + }); +} + function setBusy(busy) { state.busy = busy; updateControlAvailability(); @@ -1328,6 +1671,7 @@ function updateControlAvailability() { const canMove = hasSnapshot && hasMotionSamples() && !state.busy; ui.snapshotSelect.disabled = state.busy || state.summaries.length === 0; + ui.styleSelect.disabled = state.busy || state.trainStyles.length === 0; ui.reloadButton.disabled = state.busy; ui.fileInput.disabled = state.busy; ui.fileButton.classList.toggle("disabled", state.busy); @@ -1476,10 +1820,18 @@ function getSummaryPath(summary) { } function getString(object, propertyName) { + if (!object) { + return ""; + } + return object[propertyName] ?? object[toPascalCase(propertyName)] ?? ""; } function getNumber(object, propertyName) { + if (!object) { + return 0; + } + return Number(object[propertyName] ?? object[toPascalCase(propertyName)] ?? 0); } diff --git a/Quantum.PreviewViewer/wwwroot/index.html b/Quantum.PreviewViewer/wwwroot/index.html index 3d1dd87..f4cf285 100644 --- a/Quantum.PreviewViewer/wwwroot/index.html +++ b/Quantum.PreviewViewer/wwwroot/index.html @@ -35,6 +35,11 @@ + +