feat(viewer2d): 2.5D view, viewport fill transform and Z-aware fit box
- 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>
This commit is contained in:
1 parent
e51114dbfd
commit
aa27945d61
5 files changed
+477
-4
No files matched your search
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user