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:
minsungandClaude Opus 5.5 committed 2026-10-19 19:48:19 +09:00
1 parent e51114dbfd
commit aa27945d61
5 files changed
+477 -4

No files matched your search

+163
View File
@@ -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;
}