- 2.5D view: a toolbar toggle (plus an options-panel checkbox) re-aims the orthographic camera at an SE-isometric angle and unlocks orbit, so the CAD elevation already in the vertex buffers (contours, TIN, 3D polylines) becomes visible. Nothing is re-tessellated; switching back restores the locked top view. A Z exaggeration slider (persisted in the options store) makes kilometre-wide sheets with metres of relief legible; picking and the X/Y readout intersect the Z=0 plane. - Viewport fills: SOLID/HATCH fills and dimension arrowheads drawn through a paper-space VIEWPORT now get the model-to-paper transform (_fillXf / _bakeFillXf), as lines already did via pushSeg. - The trimmed content box keeps the drawing's real Z range instead of collapsing to z=0, so the 2.5D fit and clip slab cover the relief. - src/topProgress.ts: driver for the existing #progress loading bar. It is not imported from main.ts yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
5.1 KiB
TypeScript
164 lines
5.1 KiB
TypeScript
/**
|
||
* Top loading bar (NProgress-style) for the DWG/DXF load pipeline.
|
||
*
|
||
* Why two layers:
|
||
* The expensive stages — `parse_dwg_json` on the WASM heap and
|
||
* `Viewer2D.load()` — are SYNCHRONOUS. While they run, the main thread is
|
||
* blocked: no rAF ticks, no `width` transitions, no repaint. A JS-driven bar
|
||
* simply freezes for the seconds that matter most.
|
||
*
|
||
* · `.bar` real progress, driven by `width` (accurate, main-thread bound).
|
||
* · `.sweep` liveness, a `transform` keyframe animation. Transform keyframes
|
||
* are handed to the compositor thread, so the stripe keeps moving
|
||
* across a blocked main thread. That is the only feedback the page
|
||
* can give while the parser is inside WASM.
|
||
*
|
||
* The markup and CSS live in index.html (`#progress`), same as the other
|
||
* panels; this module just drives them.
|
||
*/
|
||
|
||
/** Wait until the browser has actually painted the pending style change. */
|
||
function nextPaint(): Promise<void> {
|
||
return new Promise<void>((resolve) => {
|
||
let settled = false;
|
||
const finish = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
resolve();
|
||
};
|
||
// Two frames: the first callback still runs BEFORE paint, the second one
|
||
// only fires once the frame carrying our change has been composited.
|
||
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||
// Backgrounded tabs never fire rAF — don't stall the load behind it.
|
||
setTimeout(finish, 150);
|
||
});
|
||
}
|
||
|
||
export class TopProgress {
|
||
private root: HTMLElement;
|
||
private bar: HTMLElement;
|
||
private value = 0;
|
||
private hideTimer = 0;
|
||
|
||
constructor(root: HTMLElement) {
|
||
this.root = root;
|
||
this.bar = root.querySelector<HTMLElement>('.bar') ?? root;
|
||
}
|
||
|
||
/** Show the bar and reset it to zero (no rewind animation). */
|
||
start(): void {
|
||
window.clearTimeout(this.hideTimer);
|
||
this.root.classList.remove('err');
|
||
this.value = 0;
|
||
this.jumpTo(0);
|
||
this.root.classList.add('on');
|
||
}
|
||
|
||
/**
|
||
* Move to `v` (0–1). Monotonic — a lower value is ignored, so out-of-order
|
||
* stage callbacks can never make the bar walk backwards.
|
||
*/
|
||
set(v: number): void {
|
||
const next = Math.max(this.value, Math.min(1, Math.max(0, v)));
|
||
if (next === this.value) return;
|
||
this.value = next;
|
||
this.bar.style.width = `${(next * 100).toFixed(2)}%`;
|
||
}
|
||
|
||
/** Toggle the compositor-driven sweep for a stage that blocks the main thread. */
|
||
busy(on: boolean): void {
|
||
this.root.classList.toggle('busy', on);
|
||
}
|
||
|
||
/** Resolve once the pending width/class change is on screen. */
|
||
paint(): Promise<void> {
|
||
return nextPaint();
|
||
}
|
||
|
||
/** Fill to 100% and fade out. */
|
||
done(): void {
|
||
this.finish();
|
||
}
|
||
|
||
/** Fill to 100% in the error color and fade out. */
|
||
fail(): void {
|
||
this.root.classList.add('err');
|
||
this.finish();
|
||
}
|
||
|
||
private finish(): void {
|
||
this.busy(false);
|
||
this.set(1);
|
||
window.clearTimeout(this.hideTimer);
|
||
this.hideTimer = window.setTimeout(() => {
|
||
this.root.classList.remove('on');
|
||
// Rewind only after the fade-out has finished, so the bar never visibly
|
||
// slides back to zero.
|
||
this.hideTimer = window.setTimeout(() => {
|
||
this.root.classList.remove('err');
|
||
this.value = 0;
|
||
this.jumpTo(0);
|
||
}, 320);
|
||
}, 260);
|
||
}
|
||
|
||
/** Set the width with the transition suppressed for one frame. */
|
||
private jumpTo(v: number): void {
|
||
const prev = this.bar.style.transition;
|
||
this.bar.style.transition = 'none';
|
||
this.bar.style.width = `${(v * 100).toFixed(2)}%`;
|
||
void this.bar.offsetWidth; // force reflow so the next change animates again
|
||
this.bar.style.transition = prev;
|
||
}
|
||
}
|
||
|
||
/** 1.4 MB / 812 kB — short byte label for the download readout. */
|
||
export function formatBytes(n: number): string {
|
||
if (!Number.isFinite(n) || n < 0) return '?';
|
||
if (n < 1024) return `${n} B`;
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} kB`;
|
||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
/**
|
||
* fetch() that reports download progress.
|
||
*
|
||
* Falls back to a plain `arrayBuffer()` when the response has no body stream
|
||
* or no Content-Length (then `total` is 0 and the caller should show an
|
||
* indeterminate state). `loaded` is decompressed bytes while Content-Length is
|
||
* the compressed size, so the ratio is clamped to 1.
|
||
*/
|
||
export async function fetchWithProgress(
|
||
url: string,
|
||
onProgress: (loaded: number, total: number) => void,
|
||
): Promise<ArrayBuffer> {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const total = Number(res.headers.get('content-length')) || 0;
|
||
if (!res.body || !total) {
|
||
const buf = await res.arrayBuffer();
|
||
onProgress(buf.byteLength, buf.byteLength);
|
||
return buf;
|
||
}
|
||
|
||
const reader = res.body.getReader();
|
||
const chunks: Uint8Array[] = [];
|
||
let loaded = 0;
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
if (!value) continue;
|
||
chunks.push(value);
|
||
loaded += value.length;
|
||
onProgress(loaded, total);
|
||
}
|
||
|
||
const out = new Uint8Array(loaded);
|
||
let offset = 0;
|
||
for (const c of chunks) {
|
||
out.set(c, offset);
|
||
offset += c.length;
|
||
}
|
||
return out.buffer;
|
||
}
|