From 374afdcd7172402a0be0371964fd6d4040610d5a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 20:31:37 +0800 Subject: feat(ui): loading overlay with instant mesh swap, no-store GLB, full-width viewport, ground grid, drop masthead title --- public/App.js | 25 ++++++++++++++- public/index.html | 48 +++++----------------------- public/styles.css | 45 +++++++++++++++++++++++++- src/SomhairlesDream.Frontend/App.fs | 38 ++++++++++++++++++++-- src/SomhairlesDream.Frontend/Bindings.fs | 9 ++++++ src/SomhairlesDream.Server/ArtifactRunApi.fs | 3 ++ 6 files changed, 124 insertions(+), 44 deletions(-) diff --git a/public/App.js b/public/App.js index eb47dbc..7aac7f0 100644 --- a/public/App.js +++ b/public/App.js @@ -330,6 +330,9 @@ export function initializeView() { scene.add(rimLight); const bounceLight = new THREE.HemisphereLight("#3d4a56", "#141a1e", 0.9); scene.add(bounceLight); + const grid = new THREE.GridHelper(24, 48, "#5a4a2a", "#2a2f33"); + grid.position.y = -1.35; + scene.add(grid); const controls = new OrbitControls(camera, canvas); controls.target.copy({ x: 0, y: 0, z: 0 }); controls.enableDamping = true; @@ -430,12 +433,17 @@ export function startStepLoad(view, owner, historical) { viewMode(new ViewMode(1, [])); failedLoad(undefined); setNoteRetryable(false); + clearGroup(view.Group); + displayedNode(undefined); + loadedStep(undefined); + document.getElementById("viewport-loading").classList.toggle('is-hidden', false); const loader = new GLTFLoader(); const projectId = encodeURIComponent(owner.ProjectId); const runId = encodeURIComponent(owner.RunId); + const stamp = ~~(Date.now() % 100000000) | 0; let url; const arg_2 = encodeURIComponent(owner.Path); - url = toText(printf("/api/artifacts/steps?projectId=%s&runId=%s&path=%s"))(projectId)(runId)(arg_2); + url = toText(printf("/api/artifacts/steps?projectId=%s&runId=%s&path=%s&ts=%d"))(projectId)(runId)(arg_2)(stamp); if (historical) { setText("viewport-note", toText(printf("历史几何加载中 · %s"))(owner.Path)); } @@ -450,6 +458,7 @@ export function startStepLoad(view, owner, historical) { displayedNode(gltf.scene); loadedStep(owner); frameObject(view, gltf.scene); + document.getElementById("viewport-loading").classList.toggle('is-hidden', true); view.Renderer.render(view.Scene, view.Camera); if (historical) { setText("viewport-note", toText(printf("历史几何 · %s"))(owner.Path)); @@ -466,6 +475,7 @@ export function startStepLoad(view, owner, historical) { pendingLoad(undefined); loadedStep(undefined); failedLoad([owner, historical]); + document.getElementById("viewport-loading").classList.toggle('is-hidden', true); setNoteRetryable(true); if (historical) { setText("viewport-note", toText(printf("历史几何加载失败 · %s"))(owner.Path)); @@ -965,10 +975,14 @@ export function loadHistoryFor(selection) { } } else { + document.getElementById("viewport-loading").classList.toggle('is-hidden', true); + setText("viewport-note", "该运行尚无可用历史几何"); return singleton.Zero(); } }) : singleton.Zero(); })), (_arg_2) => { + document.getElementById("viewport-loading").classList.toggle('is-hidden', true); + setText("viewport-note", "历史几何加载失败 · 清单不可用"); return singleton.Zero(); }))); } @@ -1032,6 +1046,15 @@ export function selectModel(card) { activeModel(selection); highlightActiveCard(selection); setText("run-message", "载入模型历史"); + if (viewState() == null) { + } + else { + clearGroup(viewState().Group); + displayedNode(undefined); + loadedStep(undefined); + } + document.getElementById("viewport-loading").classList.toggle('is-hidden', false); + setText("viewport-note", "历史几何加载中"); loadHistoryFor(selection); } diff --git a/public/index.html b/public/index.html index 267d21b..d58853c 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ Somhairle's Dream · 实时重建台 - + -
-
-
- SOMHAIRLE'S DREAM / CONTROL SURFACE -

实时重建台

-
+
+
实时链路 · 等待启动 @@ -102,41 +98,13 @@
实时几何 · V00
+
-
@@ -179,6 +147,6 @@ heritage 参考线保留占位演示 · F# / OPEN CODE
- + diff --git a/public/styles.css b/public/styles.css index d6cfbb1..bd4e3be 100644 --- a/public/styles.css +++ b/public/styles.css @@ -67,6 +67,10 @@ button:focus-visible { justify-content: space-between; } +.masthead-compact { + justify-content: flex-end; +} + .masthead { gap: 24px; padding-bottom: 30px; @@ -161,7 +165,7 @@ button:focus-visible { .workspace { display: grid; - grid-template-columns: minmax(200px, 0.72fr) minmax(360px, 1.8fr) minmax(220px, 0.82fr); + grid-template-columns: minmax(200px, 0.68fr) minmax(420px, 2.4fr); gap: 12px; } @@ -780,3 +784,42 @@ button:focus-visible { cursor: pointer; pointer-events: auto; } + +/* ---- viewport loading overlay ---- */ +.viewport-loading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + gap: 10px; + align-content: center; + background: rgba(8, 11, 13, 0.55); + backdrop-filter: blur(2px); + z-index: 3; +} + +.viewport-loading.is-hidden { + display: none; +} + +.loading-ring { + width: 42px; + height: 42px; + border: 2px solid rgba(210, 175, 95, 0.18); + border-top-color: #d2af5f; + border-radius: 50%; + animation: loading-spin 0.9s linear infinite; +} + +.loading-text { + color: var(--muted); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.62rem; + letter-spacing: 0.08em; +} + +@keyframes loading-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs index 315a0fc..3665c43 100644 --- a/src/SomhairlesDream.Frontend/App.fs +++ b/src/SomhairlesDream.Frontend/App.fs @@ -269,6 +269,12 @@ let initializeView () = let bounceLight = createHemisphereLight("#3d4a56", "#141a1e", 0.9) scene.add(bounceLight) + // Coordinate ground grid so the model reads as anchored in a workspace + // instead of floating in a void. + let grid = createGridHelper (24.0, 48, "#5a4a2a", "#2a2f33") + setNodePositionY (grid, -1.35) + scene.add(grid) + let controls = createOrbitControls camera canvas setControlsTarget controls 0.0 0.0 0.0 setControlsDamping controls true @@ -361,10 +367,18 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) (historical: bool) = viewMode <- StepMode failedLoad <- None setNoteRetryable false + // Swap the viewport to a loading state immediately: drop the previous + // mesh so the old geometry never lingers under the spinner. + clearGroup view.Group + displayedNode <- None + loadedStep <- None + toggleHidden ("viewport-loading", false) let loader = createGLTFLoader () let projectId = JS.encodeURIComponent owner.ProjectId let runId = JS.encodeURIComponent owner.RunId - let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s" projectId runId (JS.encodeURIComponent owner.Path) + // Cache-buster: the GLB is always re-fetched from the server. + let stamp = int (JS.Constructors.Date.now () % 100000000.0) + let url = sprintf "/api/artifacts/steps?projectId=%s&runId=%s&path=%s&ts=%d" projectId runId (JS.encodeURIComponent owner.Path) stamp if historical then setText "viewport-note" (sprintf "历史几何加载中 · %s" owner.Path) @@ -380,6 +394,7 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) (historical: bool) = displayedNode <- Some gltf.scene loadedStep <- Some owner frameObject view gltf.scene + toggleHidden ("viewport-loading", true) view.Renderer.render(view.Scene, view.Camera) if historical then @@ -393,6 +408,7 @@ let startStepLoad (view: ViewState) (owner: LoadOwner) (historical: bool) = pendingLoad <- None loadedStep <- None failedLoad <- Some(owner, historical) + toggleHidden ("viewport-loading", true) setNoteRetryable true if historical then @@ -790,8 +806,15 @@ let loadHistoryFor (selection: RunSelection) = match viewState with | Some view -> loadStepArtifact view steps[steps.Length - 1] true | None -> () + else + // Manifest answered but nothing is replayable: close the + // spinner instead of leaving it spinning forever. + toggleHidden ("viewport-loading", true) + setText "viewport-note" "该运行尚无可用历史几何" with _ -> - () + // Manifest unreachable: fail honestly rather than spin forever. + toggleHidden ("viewport-loading", true) + setText "viewport-note" "历史几何加载失败 · 清单不可用" } |> Async.StartImmediate let startRun (selection: RunSelection) = @@ -859,6 +882,17 @@ let selectModel (card: HTMLElement) = activeModel <- selection highlightActiveCard selection setText "run-message" "载入模型历史" + // Enter the loading state before the manifest round-trip: the previous + // mesh is dropped and the spinner appears the instant a card is clicked. + (match viewState with + | Some view -> + clearGroup view.Group + displayedNode <- None + loadedStep <- None + | None -> ()) + + toggleHidden ("viewport-loading", false) + setText "viewport-note" "历史几何加载中" loadHistoryFor selection let boot () = diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs index b6c4353..7f0ce31 100644 --- a/src/SomhairlesDream.Frontend/Bindings.fs +++ b/src/SomhairlesDream.Frontend/Bindings.fs @@ -163,6 +163,15 @@ let setToneMappingACES (renderer: obj) : unit = jsNative [] let setToneMappingExposure (renderer: obj, exposure: float) : unit = jsNative +[] +let createGridHelper (size: float, divisions: int, centerColor: string, edgeColor: string) : ThreeNode = jsNative + +[] +let setNodePositionY (node: ThreeNode, y: float) : unit = jsNative + +[] +let toggleHidden (id: string, hidden: bool) : unit = jsNative + [] let setSRGBOutput (renderer: obj) : unit = jsNative diff --git a/src/SomhairlesDream.Server/ArtifactRunApi.fs b/src/SomhairlesDream.Server/ArtifactRunApi.fs index 6c00d66..3a29d99 100644 --- a/src/SomhairlesDream.Server/ArtifactRunApi.fs +++ b/src/SomhairlesDream.Server/ArtifactRunApi.fs @@ -129,6 +129,9 @@ module ArtifactRunApi = | Error message, _ -> error options 400 message | _, None -> error options 400 "missing query parameter 'path'" | Ok(projectId, runId), Some relativePath -> + // Step geometry must always be re-fetched: browsers and CDNs + // never cache GLB responses from this endpoint. + context.Response.Headers.["Cache-Control"] <- "no-store" match options.Coordinator.StepArtifact(projectId, runId, relativePath) with | Ok file -> streamArtifact context file | Error RunNotFound -> error options 404 "run not found" -- cgit v1.2.3