diff --git a/Quantum.PreviewViewer/Program.cs b/Quantum.PreviewViewer/Program.cs index d195422..6fa509f 100644 --- a/Quantum.PreviewViewer/Program.cs +++ b/Quantum.PreviewViewer/Program.cs @@ -449,6 +449,97 @@ public static bool IsPrimaryModelExtension(string extension) } } +public static class PreviewTrainStyleRoles +{ + public const string TrainLeadRole = "train.lead"; + public const string TrainMiddleRole = "train.middle"; + public const string TrainRearRole = "train.rear"; + public const string TrainBodyRole = "train.body"; + public const string TrainBodyBankingProfileRole = "train.body.banking-profile"; + public const string TrainWildcardRole = "train.*"; + + public static bool IsTrainBodyRole(string? snapshotRole) + { + return string.Equals(snapshotRole, TrainBodyRole, StringComparison.Ordinal) || + string.Equals(snapshotRole, TrainBodyBankingProfileRole, StringComparison.Ordinal); + } + + public static string ResolveTrainBodyStyleRole(int carIndex, int carCount) + { + if (carCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(carCount), "Train car count must be positive."); + } + + if (carIndex < 0 || carIndex >= carCount) + { + throw new ArgumentOutOfRangeException(nameof(carIndex), "Train car index must be inside the train."); + } + + if (carIndex == 0) + { + return TrainLeadRole; + } + + return carIndex == carCount - 1 + ? TrainRearRole + : TrainMiddleRole; + } + + public static string? ResolveConfiguredStyleRole( + string? snapshotRole, + int carIndex, + int carCount, + IReadOnlyCollection configuredRoles) + { + if (string.IsNullOrWhiteSpace(snapshotRole)) + { + return null; + } + + if (IsTrainBodyRole(snapshotRole)) + { + string variantRole = ResolveTrainBodyStyleRole(carIndex, carCount); + if (ContainsRole(configuredRoles, variantRole)) + { + return variantRole; + } + + if (ContainsRole(configuredRoles, TrainBodyRole)) + { + return TrainBodyRole; + } + + if (ContainsRole(configuredRoles, snapshotRole)) + { + return snapshotRole; + } + } + else if (ContainsRole(configuredRoles, snapshotRole)) + { + return snapshotRole; + } + + return snapshotRole.StartsWith("train.", StringComparison.Ordinal) && + ContainsRole(configuredRoles, TrainWildcardRole) + ? TrainWildcardRole + : null; + } + + private static bool ContainsRole(IReadOnlyCollection configuredRoles, string role) + { + foreach (string configuredRole in configuredRoles) + { + if (string.Equals(configuredRole, role, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} + 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 cd1b68a..9642e7b 100644 --- a/Quantum.PreviewViewer/README.md +++ b/Quantum.PreviewViewer/README.md @@ -26,7 +26,7 @@ Current capabilities: - 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. +- Can replace configured train box roles with GLTF/GLB assets while preserving debug boxes as the fallback and optional overlay. - 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. @@ -43,9 +43,28 @@ Style manifest shape: { "id": "custom-train", "name": "Custom train", + "debugBoxOverlay": false, "roles": { + "train.lead": { + "asset": "assets/trains/custom-lead.glb", + "fitToBox": true, + "fitMode": "uniform", + "scale": 1.0, + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "rotationDegrees": { "x": 0.0, "y": 90.0, "z": 0.0 } + }, + "train.middle": { + "asset": "assets/trains/custom-middle.glb", + "fitToBox": true, + "fitMode": "uniform" + }, + "train.rear": { + "asset": "assets/trains/custom-rear.glb", + "fitToBox": true, + "fitMode": "uniform" + }, "train.body": { - "asset": "assets/trains/custom-car.glb", + "asset": "assets/trains/custom-fallback-car.glb", "fitToBox": true, "fitMode": "uniform", "scale": 1.0, @@ -59,6 +78,8 @@ Style manifest shape: } ``` +Train body boxes are still exported as `train.body` by `DebugViewportSnapshotV1`. The viewer resolves the visual asset role per car: first car uses `train.lead`, last car uses `train.rear`, interior cars use `train.middle`, and any missing variant falls back to `train.body`. A one-car train uses `train.lead`. See [preview-viewer-train-styles.md](../docs/visualization/preview-viewer-train-styles.md) for configuration examples. + 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: diff --git a/Quantum.PreviewViewer/wwwroot/app.js b/Quantum.PreviewViewer/wwwroot/app.js index 9e95066..0f7cb9b 100644 --- a/Quantum.PreviewViewer/wwwroot/app.js +++ b/Quantum.PreviewViewer/wwwroot/app.js @@ -547,6 +547,7 @@ function buildTrainBoxes(snapshot) { } const trainBoxes = boxes.filter((box) => isTrainRole(box.role) && hasFrame(box.frame)); + const trainBodyStyleRoles = resolveTrainBodyStyleRoles(trainBoxes); const leadReference = trainBoxes.length === 0 ? 0 : Math.max(...trainBoxes.map((box) => finiteNumber(box.frame.distance))); @@ -563,11 +564,15 @@ function buildTrainBoxes(snapshot) { height: Math.max(finiteNumber(box.size.height), 0.01) }; - const isLead = isTrainRole(box.role) && Math.abs(finiteNumber(box.frame.distance) - leadReference) <= 1e-6; + const styleRole = trainBodyStyleRoles.get(box) || box.role; + const isLead = styleRole === "train.lead" || + (isTrainRole(box.role) && + !isTrainBodyRole(box.role) && + Math.abs(finiteNumber(box.frame.distance) - leadReference) <= 1e-6); const shouldLabel = Boolean(box.label) && isTrainRole(box.role) && labelCount < renderLimits.maxTrainLabels; - const group = createTrainVisualGroup(size, box.role, shouldLabel ? box.label : "", isLead); + const group = createTrainVisualGroup(size, box.role, styleRole, shouldLabel ? box.label : "", isLead); if (shouldLabel) { labelCount += 1; } @@ -609,13 +614,63 @@ function rebuildTrainVisuals() { setStatus(`Train style: ${getSelectedTrainStyleName()}`, "ready"); } -function createTrainVisualGroup(size, role, label, isLead) { +function resolveTrainBodyStyleRoles(trainBoxes) { + const bodyEntries = trainBoxes + .map((box, index) => ({ + box, + index, + carIndex: parseCarIndex(box.label), + distance: finiteNumber(box.frame?.distance, Number.NaN) + })) + .filter((entry) => isTrainBodyRole(entry.box.role)); + + if (bodyEntries.length === 0) { + return new Map(); + } + + if (bodyEntries.some((entry) => entry.carIndex !== null)) { + bodyEntries.sort((left, right) => { + const leftIndex = left.carIndex ?? Number.MAX_SAFE_INTEGER; + const rightIndex = right.carIndex ?? Number.MAX_SAFE_INTEGER; + return leftIndex === rightIndex + ? left.index - right.index + : leftIndex - rightIndex; + }); + } else if (bodyEntries.every((entry) => Number.isFinite(entry.distance))) { + bodyEntries.sort((left, right) => right.distance === left.distance + ? left.index - right.index + : right.distance - left.distance); + } + + const roles = new Map(); + bodyEntries.forEach((entry, carIndex) => { + roles.set(entry.box, resolveTrainBodyStyleRole(carIndex, bodyEntries.length)); + }); + + return roles; +} + +function resolveTrainBodyStyleRole(carIndex, carCount) { + if (carIndex === 0) { + return "train.lead"; + } + + return carIndex === carCount - 1 ? "train.rear" : "train.middle"; +} + +function parseCarIndex(label) { + const match = String(label ?? "").match(/(?:^|-)car-(\d+)$/i); + return match ? Number(match[1]) : null; +} + +function createTrainVisualGroup(size, role, styleRole, label, isLead) { if (!isTrainRole(role)) { return createBoxGroup(size, role, label, isLead); } state.trainVisualStats.trainBoxes += 1; - const roleStyle = getTrainRoleStyle(role); + incrementRoleCount(state.trainVisualStats.styleRoles, styleRole); + const roleStyle = getTrainRoleStyle(styleRole, role); const assetUrl = getString(roleStyle, "assetUrl"); if (!assetUrl) { return createBoxGroup(size, role, label, isLead); @@ -627,6 +682,8 @@ function createTrainVisualGroup(size, role, label, isLead) { group.name = label || role || "train asset"; group.userData.assetStatus = "loading"; group.userData.assetUrl = assetUrl; + group.userData.snapshotRole = role; + group.userData.styleRole = styleRole; group.add(createBoxGroup(size, role, label, isLead)); loadStyleAsset(assetUrl) @@ -635,7 +692,7 @@ function createTrainVisualGroup(size, role, label, isLead) { return; } - replaceFallbackWithAsset(group, asset, roleStyle, size, label, isLead); + replaceFallbackWithAsset(group, asset, roleStyle, size, role, label, isLead); state.trainVisualStats.assetLoaded += 1; updateMetrics(); }) @@ -654,10 +711,15 @@ function createTrainVisualGroup(size, role, label, isLead) { return group; } -function replaceFallbackWithAsset(group, asset, roleStyle, size, label, isLead) { +function replaceFallbackWithAsset(group, asset, roleStyle, size, role, label, isLead) { disposeChildren(group); const assetGroup = createAssetVisualGroup(asset, roleStyle, size); group.add(assetGroup); + if (shouldShowDebugBoxOverlay(roleStyle)) { + group.add(createDebugBoxOverlayGroup(size, role, isLead)); + state.trainVisualStats.debugOverlays += 1; + } + if (label) { const labelSprite = createLabelSprite(label, isLead); labelSprite.position.set(0, size.height * 0.9, 0); @@ -684,20 +746,25 @@ async function loadStyleAsset(assetUrl) { function createAssetVisualGroup(asset, roleStyle, size) { const wrapper = new THREE.Group(); wrapper.name = getString(roleStyle, "name") || asset.url.split("/").pop() || "style asset"; + const modelRoot = new THREE.Group(); const instance = asset.scene.clone(true); markAssetResourcesPreserved(instance); - wrapper.add(instance); - - if (roleStyle.fitToBox !== false) { - fitAssetToBox(instance, size, getString(roleStyle, "fitMode") || "uniform"); - } + modelRoot.add(instance); + wrapper.add(modelRoot); const rotation = vectorFromStyle(roleStyle.rotationDegrees); - wrapper.rotation.set( + modelRoot.rotation.set( THREE.MathUtils.degToRad(rotation.x), THREE.MathUtils.degToRad(rotation.y), THREE.MathUtils.degToRad(rotation.z)); + const fitMode = normalizeFitMode(getString(roleStyle, "fitMode") || "uniform"); + if (roleStyle.fitToBox !== false && fitMode !== "none") { + fitAssetToBox(modelRoot, size, fitMode); + } else if (roleStyle.center !== false) { + centerAssetAtOrigin(modelRoot); + } + const offset = vectorFromStyle(roleStyle.offset); wrapper.position.set(offset.x, offset.y, offset.z); @@ -706,8 +773,8 @@ function createAssetVisualGroup(asset, roleStyle, size) { return wrapper; } -function fitAssetToBox(instance, size, fitMode) { - const bounds = new THREE.Box3().setFromObject(instance); +function fitAssetToBox(object, size, fitMode) { + const bounds = new THREE.Box3().setFromObject(object); if (bounds.isEmpty()) { return; } @@ -719,21 +786,64 @@ function fitAssetToBox(instance, size, fitMode) { const targetSize = new THREE.Vector3(size.length, size.height, size.width); if (fitMode === "stretch") { - instance.scale.multiply(new THREE.Vector3( + object.scale.multiply(new THREE.Vector3( targetSize.x / modelSize.x, targetSize.y / modelSize.y, targetSize.z / modelSize.z)); } else { - const uniformScale = Math.min( + const scales = [ targetSize.x / modelSize.x, targetSize.y / modelSize.y, - targetSize.z / modelSize.z); - instance.scale.multiplyScalar(uniformScale); + targetSize.z / modelSize.z + ]; + const uniformScale = fitMode === "cover" + ? Math.max(...scales) + : Math.min(...scales); + object.scale.multiplyScalar(uniformScale); + } + + centerAssetAtOrigin(object); +} + +function centerAssetAtOrigin(object) { + const fittedBounds = new THREE.Box3().setFromObject(object); + if (fittedBounds.isEmpty()) { + return; } - const fittedBounds = new THREE.Box3().setFromObject(instance); const center = fittedBounds.getCenter(new THREE.Vector3()); - instance.position.sub(center); + object.position.sub(center); +} + +function normalizeFitMode(value) { + const mode = String(value || "").toLowerCase(); + return mode === "stretch" || mode === "cover" || mode === "none" + ? mode + : "uniform"; +} + +function createDebugBoxOverlayGroup(size, role, isLead) { + const group = new THREE.Group(); + group.name = `${role || "box"} debug overlay`; + const geometry = new THREE.BoxGeometry(size.length, size.height, size.width); + const material = new THREE.MeshStandardMaterial({ + color: isLead ? colors.accent : colorForBoxRole(role), + roughness: 0.8, + metalness: 0, + transparent: true, + opacity: 0.12, + depthWrite: false + }); + group.add(new THREE.Mesh(geometry, material)); + group.add(new THREE.LineSegments( + new THREE.EdgesGeometry(geometry), + new THREE.LineBasicMaterial({ + color: isLead ? colors.accentStrong : 0xffffff, + transparent: true, + opacity: 0.72 + }) + )); + return group; } function createBoxGroup(size, role, label, isLead) { @@ -1304,7 +1414,9 @@ function updateMetrics() { : "none"], ["Loading assets", loadingAssets > 0 ? String(loadingAssets) : "none"], ["Debug fallbacks", String(fallbackVisuals)], + ["Debug overlays", visualStats.debugOverlays > 0 ? String(visualStats.debugOverlays) : "off"], ["Spacing span", offsetRange], + ["Style roles", summarizeRoleCounts(visualStats.styleRoles)], ["Roles", trainRoles] ]); @@ -1486,7 +1598,9 @@ function createEmptyTrainVisualStats() { trainBoxes: 0, assetRequested: 0, assetLoaded: 0, - assetFailed: 0 + assetFailed: 0, + debugOverlays: 0, + styleRoles: {} }; } @@ -1501,28 +1615,49 @@ function getSelectedTrainStyleName() { return getString(style, "name") || getString(style, "id") || "Debug boxes"; } -function getTrainRoleStyle(role) { +function getTrainRoleStyle(styleRole, snapshotRole = styleRole) { const style = getSelectedTrainStyle(); const roles = style?.roles; if (!roles || typeof roles !== "object") { return null; } - if (roles[role]) { - return roles[role]; + if (roles[styleRole]) { + return roles[styleRole]; } - if (role === "train.body.banking-profile" && roles["train.body"]) { + if (isTrainBodyVariantRole(styleRole) && roles["train.body"]) { return roles["train.body"]; } - if (roles["train.*"]) { + if (roles[snapshotRole]) { + return roles[snapshotRole]; + } + + if (snapshotRole === "train.body.banking-profile" && roles["train.body"]) { + return roles["train.body"]; + } + + if (isTrainRole(snapshotRole) && roles["train.*"]) { return roles["train.*"]; } return null; } +function shouldShowDebugBoxOverlay(roleStyle) { + const style = getSelectedTrainStyle(); + return roleStyle?.debugBoxOverlay === true || style?.debugBoxOverlay === true; +} + +function incrementRoleCount(target, role) { + if (!role) { + return; + } + + target[role] = (target[role] ?? 0) + 1; +} + function vectorFromStyle(value) { if (!value || typeof value !== "object") { return new THREE.Vector3(); @@ -1583,6 +1718,14 @@ function isTrainRole(role) { return typeof role === "string" && role.startsWith("train."); } +function isTrainBodyRole(role) { + return role === "train.body" || role === "train.body.banking-profile"; +} + +function isTrainBodyVariantRole(role) { + return role === "train.lead" || role === "train.middle" || role === "train.rear"; +} + function hasFrame(frame) { return Boolean(frame && frame.position && frame.tangent && frame.normal && frame.binormal); } @@ -1792,6 +1935,18 @@ function summarizeTrainRoles(trainBoxes) { .join(", "); } +function summarizeRoleCounts(roleCounts) { + const entries = Object.entries(roleCounts ?? {}); + if (entries.length === 0) { + return "none"; + } + + return entries + .sort(([left], [right]) => left.localeCompare(right)) + .map(([role, count]) => `${role.replace(/^train\./, "")}: ${count}`) + .join(", "); +} + function getDynamicOffsetRange() { if (state.dynamicBoxes.length === 0) { return "none"; diff --git a/Quantum.Tests/PreviewViewer/StyleManifestCatalogTests.cs b/Quantum.Tests/PreviewViewer/StyleManifestCatalogTests.cs index 0551b11..3909f3e 100644 --- a/Quantum.Tests/PreviewViewer/StyleManifestCatalogTests.cs +++ b/Quantum.Tests/PreviewViewer/StyleManifestCatalogTests.cs @@ -91,6 +91,111 @@ public void LoadManifest_MissingManifestReturnsDebugBoxesStyle() } } + [Fact] + public void LoadManifest_AddsAssetUrlsForTrainRoleVariantAssets() + { + string tempDirectory = CreateTempDirectoryPath(); + string repositoryRoot = Path.Combine(tempDirectory, "repo"); + string viewerRoot = Path.Combine(repositoryRoot, "Quantum.PreviewViewer"); + string manifestPath = Path.Combine(viewerRoot, "preview-styles.json"); + + try + { + Directory.CreateDirectory(Path.Combine(viewerRoot, "assets", "trains")); + File.WriteAllText(Path.Combine(repositoryRoot, "QuantumCoasterWorks.sln"), string.Empty); + File.WriteAllText(Path.Combine(viewerRoot, "assets", "trains", "lead.glb"), "lead"); + File.WriteAllText(Path.Combine(viewerRoot, "assets", "trains", "middle.glb"), "middle"); + File.WriteAllText(Path.Combine(viewerRoot, "assets", "trains", "rear.glb"), "rear"); + File.WriteAllText(Path.Combine(viewerRoot, "assets", "trains", "body.glb"), "body"); + File.WriteAllText( + manifestPath, + """ + { + "version": 1, + "defaultTrainStyle": "variants", + "trainStyles": [ + { + "id": "variants", + "name": "Variants", + "roles": { + "train.lead": { "asset": "assets/trains/lead.glb" }, + "train.middle": { "asset": "assets/trains/middle.glb" }, + "train.rear": { "asset": "assets/trains/rear.glb" }, + "train.body": { "asset": "assets/trains/body.glb" } + } + } + ], + "trackStyles": [] + } + """); + + JsonObject manifest = PreviewStyleManifestCatalog.LoadManifest( + repositoryRoot, + manifestPath, + viewerRoot, + "/style-assets"); + + Assert.Equal("/style-assets/assets/trains/lead.glb", GetRole(manifest, "train.lead")["assetUrl"]!.GetValue()); + Assert.Equal("/style-assets/assets/trains/middle.glb", GetRole(manifest, "train.middle")["assetUrl"]!.GetValue()); + Assert.Equal("/style-assets/assets/trains/rear.glb", GetRole(manifest, "train.rear")["assetUrl"]!.GetValue()); + Assert.Equal("/style-assets/assets/trains/body.glb", GetRole(manifest, "train.body")["assetUrl"]!.GetValue()); + } + finally + { + DeleteDirectoryIfPresent(tempDirectory); + } + } + + [Fact] + public void ResolveConfiguredStyleRole_UsesTrainPositionVariantsThenBodyFallback() + { + string[] variantRoles = + { + PreviewTrainStyleRoles.TrainLeadRole, + PreviewTrainStyleRoles.TrainMiddleRole, + PreviewTrainStyleRoles.TrainRearRole, + PreviewTrainStyleRoles.TrainBodyRole + }; + + Assert.Equal( + PreviewTrainStyleRoles.TrainLeadRole, + PreviewTrainStyleRoles.ResolveConfiguredStyleRole( + PreviewTrainStyleRoles.TrainBodyRole, + carIndex: 0, + carCount: 4, + variantRoles)); + Assert.Equal( + PreviewTrainStyleRoles.TrainMiddleRole, + PreviewTrainStyleRoles.ResolveConfiguredStyleRole( + PreviewTrainStyleRoles.TrainBodyRole, + carIndex: 1, + carCount: 4, + variantRoles)); + Assert.Equal( + PreviewTrainStyleRoles.TrainRearRole, + PreviewTrainStyleRoles.ResolveConfiguredStyleRole( + PreviewTrainStyleRoles.TrainBodyRole, + carIndex: 3, + carCount: 4, + variantRoles)); + Assert.Equal( + PreviewTrainStyleRoles.TrainLeadRole, + PreviewTrainStyleRoles.ResolveConfiguredStyleRole( + PreviewTrainStyleRoles.TrainBodyRole, + carIndex: 0, + carCount: 1, + variantRoles)); + + string[] fallbackRoles = { PreviewTrainStyleRoles.TrainBodyRole }; + Assert.Equal( + PreviewTrainStyleRoles.TrainBodyRole, + PreviewTrainStyleRoles.ResolveConfiguredStyleRole( + PreviewTrainStyleRoles.TrainBodyBankingProfileRole, + carIndex: 1, + carCount: 3, + fallbackRoles)); + } + [Fact] public void TryResolveStyleAssetFile_AllowsGltfSidecarBinFiles() { @@ -120,6 +225,59 @@ public void TryResolveStyleAssetFile_AllowsGltfSidecarBinFiles() } } + [Fact] + public void LoadManifest_DoesNotAddAssetUrlForAssetsOutsideAssetRoot() + { + string tempDirectory = CreateTempDirectoryPath(); + string repositoryRoot = Path.Combine(tempDirectory, "repo"); + string viewerRoot = Path.Combine(repositoryRoot, "Quantum.PreviewViewer"); + string manifestPath = Path.Combine(viewerRoot, "preview-styles.json"); + + try + { + Directory.CreateDirectory(viewerRoot); + File.WriteAllText(Path.Combine(repositoryRoot, "QuantumCoasterWorks.sln"), string.Empty); + File.WriteAllText( + manifestPath, + """ + { + "version": 1, + "defaultTrainStyle": "unsafe", + "trainStyles": [ + { + "id": "unsafe", + "name": "Unsafe", + "roles": { + "train.body": { + "asset": "../outside.glb" + } + } + } + ], + "trackStyles": [] + } + """); + + JsonObject manifest = PreviewStyleManifestCatalog.LoadManifest( + repositoryRoot, + manifestPath, + viewerRoot, + "/style-assets"); + + JsonObject role = GetTrainBodyRole(manifest); + Assert.Null(role["assetUrl"]); + JsonArray diagnostics = Assert.IsType(manifest["diagnostics"]); + Assert.Contains( + diagnostics, + diagnostic => ((JsonObject)diagnostic!)["message"]!.GetValue() + .Contains("inside the configured preview asset root", StringComparison.Ordinal)); + } + finally + { + DeleteDirectoryIfPresent(tempDirectory); + } + } + [Fact] public void TryResolveStyleAssetFile_RejectsPathsOutsideAssetRoot() { @@ -147,11 +305,16 @@ public void TryResolveStyleAssetFile_RejectsPathsOutsideAssetRoot() } private static JsonObject GetTrainBodyRole(JsonObject manifest) + { + return GetRole(manifest, "train.body"); + } + + private static JsonObject GetRole(JsonObject manifest, string roleName) { JsonArray trainStyles = Assert.IsType(manifest["trainStyles"]); JsonObject style = Assert.IsType(trainStyles[0]); JsonObject roles = Assert.IsType(style["roles"]); - return Assert.IsType(roles["train.body"]); + return Assert.IsType(roles[roleName]); } private static string CreateTempDirectoryPath() diff --git a/docs/visualization/preview-viewer-train-styles.md b/docs/visualization/preview-viewer-train-styles.md new file mode 100644 index 0000000..081e432 --- /dev/null +++ b/docs/visualization/preview-viewer-train-styles.md @@ -0,0 +1,109 @@ +# Preview Viewer Train Styles + +`Quantum.PreviewViewer` train styles are viewer-side only. They do not change +`DebugViewportSnapshotV1`, train placement, physics, or backend domain projects. + +The style manifest defaults to `Quantum.PreviewViewer/preview-styles.json`. +Asset paths are resolved under the configured preview asset root, which defaults +to the manifest directory. Assets must stay inside that root and primary train +models must be `.glb` or `.gltf`. + +## Train Role Variants + +Snapshot train body boxes keep the stable exported role `train.body`. The viewer +resolves a styling role per car: + +- First car: `train.lead` +- Interior cars: `train.middle` +- Last car: `train.rear` +- Missing variant: `train.body` + +A one-car train uses `train.lead`. Existing `train.body.banking-profile` debug +boxes also participate in the same lead/middle/rear resolution and can fall back +to `train.body`. + +## Lead, Middle, Rear Example + +```json +{ + "version": 1, + "defaultTrainStyle": "role-variant-train", + "trainStyles": [ + { + "id": "role-variant-train", + "name": "Role variant train", + "roles": { + "train.lead": { + "asset": "assets/trains/lead-car.glb", + "fitToBox": true, + "fitMode": "uniform", + "rotationDegrees": { "x": 0.0, "y": 90.0, "z": 0.0 }, + "offset": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "scale": 1.0 + }, + "train.middle": { + "asset": "assets/trains/middle-car.glb", + "fitToBox": true, + "fitMode": "uniform" + }, + "train.rear": { + "asset": "assets/trains/rear-car.glb", + "fitToBox": true, + "fitMode": "uniform" + }, + "train.body": { + "asset": "assets/trains/fallback-car.glb", + "fitToBox": true, + "fitMode": "uniform" + } + } + } + ], + "trackStyles": [] +} +``` + +## Fallback-Only Example + +Use only `train.body` when every car can share the same model or when you want a +safe fallback before adding variants: + +```json +{ + "version": 1, + "defaultTrainStyle": "simple-train", + "trainStyles": [ + { + "id": "simple-train", + "name": "Simple train", + "roles": { + "train.body": { + "asset": "assets/trains/shared-car.glb", + "fitToBox": true, + "fitMode": "uniform" + } + } + } + ], + "trackStyles": [] +} +``` + +## Fitting And Alignment + +Each role entry can use: + +- `fitToBox`: defaults to `true`; set `false` to keep model size. +- `fitMode`: `uniform`, `stretch`, `cover`, or `none`. +- `rotationDegrees`: local model rotation before bounds fitting. +- `scale`: uniform number or `{ "x": 1.0, "y": 1.0, "z": 1.0 }` after fitting. +- `offset`: local `{ "x": 0.0, "y": 0.0, "z": 0.0 }` offset in meters. +- `center`: defaults to `true`; set `false` to preserve the model origin when fitting is disabled. +- `debugBoxOverlay`: set `true` on a role or train style to keep a thin debug box over loaded assets. + +Quantum local train axes are `+X` forward along the tangent, `+Y` up along the +normal, and `+Z` across the binormal. Use `rotationDegrees` when a GLTF asset has +a different forward or up axis. + +If an asset cannot be loaded, the viewer keeps the generated debug box for that +car. This fallback is intentional and does not mark the snapshot invalid.