summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--public/App.js25
-rw-r--r--public/index.html48
-rw-r--r--public/styles.css45
-rw-r--r--src/SomhairlesDream.Frontend/App.fs38
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs9
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunApi.fs3
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 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#080b0d">
<title>Somhairle's Dream · 实时重建台</title>
- <link rel="stylesheet" href="/styles.css">
+ <link rel="stylesheet" href="/styles.css?v=ui5">
<script type="importmap">
{
"imports": {
@@ -17,12 +17,8 @@
<script type="module" src="/vendor/three-setup.js?v=orbit1"></script>
</head>
<body>
- <main class="shell" aria-labelledby="page-title">
- <header class="masthead">
- <div class="brand-lockup">
- <span class="eyebrow">SOMHAIRLE'S DREAM / CONTROL SURFACE</span>
- <h1 id="page-title">实时重建台</h1>
- </div>
+ <main class="shell">
+ <header class="masthead masthead-compact">
<div class="connection-readout" aria-live="polite">
<span class="signal-mark" aria-hidden="true"></span>
<span id="stream-state">实时链路 · 等待启动</span>
@@ -102,41 +98,13 @@
<canvas id="viewport-canvas" aria-label="设计几何三维视图"></canvas>
<div class="axis-readout" aria-hidden="true">X / Y / Z</div>
<div id="viewport-note" class="viewport-note">实时几何 · V00</div>
+ <div id="viewport-loading" class="viewport-loading is-hidden" aria-hidden="true">
+ <span class="loading-ring"></span>
+ <span class="loading-text">几何加载中</span>
+ </div>
</div>
</section>
- <aside class="event-panel panel" aria-labelledby="event-title">
- <div class="panel-heading">
- <div>
- <span class="eyebrow">EVENT STREAM</span>
- <h2 id="event-title">运行记录</h2>
- </div>
- <span class="event-count">LIVE</span>
- </div>
- <ol class="event-list">
- <li class="event-item event-item-active">
- <span class="event-index">01</span>
- <div>
- <strong>实时监视</strong>
- <span>服务端心跳与版本状态</span>
- </div>
- </li>
- <li class="event-item">
- <span class="event-index">02</span>
- <div>
- <strong>目标检查</strong>
- <span>等候下一次设计运行</span>
- </div>
- </li>
- <li class="event-item">
- <span class="event-index">03</span>
- <div>
- <strong>发布闸门</strong>
- <span>验证通过后写入快照</span>
- </div>
- </li>
- </ol>
- </aside>
</section>
<section class="timeline panel" aria-labelledby="timeline-title">
@@ -179,6 +147,6 @@
<span>heritage 参考线保留占位演示 · F# / OPEN CODE</span>
</footer>
</main>
- <script type="module" src="/App.js?v=detail1"></script>
+ <script type="module" src="/App.js?v=ui6"></script>
</body>
</html>
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
[<Emit("$0.toneMappingExposure = $1")>]
let setToneMappingExposure (renderer: obj, exposure: float) : unit = jsNative
+[<Emit("new THREE.GridHelper($0, $1, $2, $3)")>]
+let createGridHelper (size: float, divisions: int, centerColor: string, edgeColor: string) : ThreeNode = jsNative
+
+[<Emit("$0.position.y = $1")>]
+let setNodePositionY (node: ThreeNode, y: float) : unit = jsNative
+
+[<Emit("document.getElementById($0).classList.toggle('is-hidden', $1)")>]
+let toggleHidden (id: string, hidden: bool) : unit = jsNative
+
[<Emit("$0.outputColorSpace = THREE.SRGBColorSpace")>]
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"