+ 도면은 이미 CAD 표고(Z)를 그대로 갖고 있다. 2.5D 는 같은 직교 카메라를
+ 남동 등각(SE Isometric)으로 돌려 그 높낮이를 보여준다 — 재파싱 없음.
+ 부지가 넓고 기복이 작아 평평하게 보이면 Z 과장을 올린다.
+ 왼쪽 드래그 = 회전, 가운데·오른쪽 드래그 = 이동.
+
+
diff --git a/src/main.ts b/src/main.ts
index 8ee57a2..353fa6f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -231,6 +231,7 @@ document.getElementById('theme')!.addEventListener('click', () => {
viewer.setTheme(dark);
fillLayers();
});
+
// ── 옵션 패널 ────────────────────────────────────────────────────
// 값은 IndexedDB(hmw-viewer/options)에 남는다. 선가중치 배율은 재파싱 없이
// 즉시 반영되므로 드래그하면서 화면을 보고 맞출 수 있다.
@@ -242,6 +243,10 @@ const optLwOut = document.getElementById('optLwOut')!;
const optGrid = document.getElementById('optGrid') as HTMLInputElement;
const optZoomSpeed = document.getElementById('optZoomSpeed') as HTMLInputElement;
const optZoomSpeedNum = document.getElementById('optZoomSpeedNum') as HTMLInputElement;
+const opt25D = document.getElementById('opt25D') as HTMLInputElement;
+const optZRow = document.getElementById('optZRow')!;
+const optZScale = document.getElementById('optZScale') as HTMLInputElement;
+const optZScaleNum = document.getElementById('optZScaleNum') as HTMLInputElement;
makePanelDraggableAndCollapsible(optionsPanel, {
collapseBtnEl: document.getElementById('collapseOptBtn')!,
@@ -295,11 +300,41 @@ function applyZoomSpeed(v: number, persist = true) {
optZoomSpeed.addEventListener('input', () => applyZoomSpeed(parseFloat(optZoomSpeed.value)));
optZoomSpeedNum.addEventListener('input', () => applyZoomSpeed(parseFloat(optZoomSpeedNum.value)));
+// ── 2.5D 보기 토글 ───────────────────────────────────────────────
+// Top 뷰를 풀고 남동 등각(SE Isometric)으로 기울인다. 도면 정점은 이미 CAD
+// 표고(Z)를 갖고 있으므로 재파싱·재생성 없이 카메라만 돌리면 등고선 같은
+// 높낮이가 그대로 드러난다. 다시 누르면 원래 Top 뷰(회전 잠금)로 돌아온다.
+// 뷰 상태라 IndexedDB 에 저장하지 않는다 — Z 과장 배율만 저장한다.
+const view25Btn = document.getElementById('view25Btn')!;
+
+function apply25D(on: boolean) {
+ viewer.set25D(on);
+ view25Btn.classList.toggle('on', on);
+ view25Btn.textContent = on ? '2.5D 보기 ✓' : '2.5D 보기';
+ opt25D.checked = on;
+ optZRow.classList.toggle('disabled', !on);
+ setStatus(on ? '2.5D 보기 · 왼쪽 드래그=회전, 가운데·오른쪽 드래그=이동' : 'Top 뷰');
+}
+view25Btn.addEventListener('click', () => apply25D(!viewer.is25D()));
+opt25D.addEventListener('change', () => apply25D(opt25D.checked));
+
+function applyZScale(v: number, persist = true) {
+ if (!Number.isFinite(v) || v <= 0) return;
+ viewer.setZScale(v);
+ optZScale.value = String(v);
+ optZScaleNum.value = String(v);
+ if (persist) void setOption('zScale', v);
+}
+optZScale.addEventListener('input', () => applyZScale(parseFloat(optZScale.value)));
+optZScaleNum.addEventListener('input', () => applyZScale(parseFloat(optZScaleNum.value)));
+
document.getElementById('optReset')!.addEventListener('click', () => {
viewer.setLineweightScale(null); // 뷰어 기본 배율
viewer.setLineweightEnabled(null); // 도면의 LWDISPLAY 를 다시 따름
viewer.setGrid(false);
applyZoomSpeed(1);
+ applyZScale(1);
+ apply25D(false);
optGrid.checked = false;
void setOption('lineweightScale', null);
void setOption('lineweightOn', null);
@@ -313,6 +348,7 @@ void getAllOptions().then((o) => {
if (typeof o.lineweightOn === 'boolean') viewer.setLineweightEnabled(o.lineweightOn);
if (o.grid === true) { viewer.setGrid(true); optGrid.checked = true; }
if (typeof o.zoomSpeed === 'number') applyZoomSpeed(o.zoomSpeed, false);
+ if (typeof o.zScale === 'number') applyZScale(o.zScale, false);
syncOptions();
});
syncOptions();
diff --git a/src/topProgress.ts b/src/topProgress.ts
new file mode 100644
index 0000000..b2a3db1
--- /dev/null
+++ b/src/topProgress.ts
@@ -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 {
+ return new Promise((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('.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 {
+ 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 {
+ 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;
+}
diff --git a/src/viewer2d/Viewer2D.d.ts b/src/viewer2d/Viewer2D.d.ts
index 30d7a7b..f18295e 100644
--- a/src/viewer2d/Viewer2D.d.ts
+++ b/src/viewer2d/Viewer2D.d.ts
@@ -18,6 +18,12 @@ export class Viewer2D {
setGrid(visible: boolean): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): number;
+ /** 2.5D (tilted 3D) view: orbit the drawing so entity elevation (Z) is visible. */
+ set25D(on: boolean, opts?: { keepView?: boolean }): void;
+ is25D(): boolean;
+ /** Height exaggeration for 2.5D (1 = true scale); ignored in top view. */
+ setZScale(k: number): void;
+ getZScale(): number;
/** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */
setLineweightEnabled(on: boolean | null): void;
getLineweightEnabled(): boolean;
diff --git a/src/viewer2d/Viewer2D.js b/src/viewer2d/Viewer2D.js
index d157ca8..0154861 100644
--- a/src/viewer2d/Viewer2D.js
+++ b/src/viewer2d/Viewer2D.js
@@ -108,6 +108,22 @@ export class Viewer2D {
this._lwDisplay = false;
this._curLwPx = 0;
this._lwScale = null; // null = LW_PX_PER_MM (setLineweightScale)
+ // ── 2.5D view ────────────────────────────────────────────────────────────
+ // Entities already carry their CAD elevation (Z) in the vertex buffers —
+ // the classic view simply stares straight down at them. 2.5D re-aims the
+ // same orthographic camera at an isometric angle and unlocks OrbitControls'
+ // rotate, so contours / TIN / 3D polylines show their relief. Nothing is
+ // re-tessellated: toggling is a camera + controls change only.
+ this._is25D = false;
+ this._zScale = 1; // height exaggeration (flat relief over a wide site)
+ this._need25Reset = false; // next _fit should snap back to the default angle
+ this._contentZ = null; // true Z range of the load (the fit box is flattened)
+ // Active viewport transform for filled geometry, set only while
+ // _emitModelThroughViewport runs. Lines reach the viewport through pushSeg,
+ // but SOLID/HATCH fills and dimension arrowheads hand a finished mesh built
+ // from model coordinates straight to _addObj — this is how that mesh finds
+ // out it belongs on a sheet. null outside the viewport pass.
+ this._fillXf = null;
SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure
this._scene = new THREE.Scene();
@@ -180,6 +196,104 @@ export class Viewer2D {
setZoomSpeed(speed) { if (this._controls) this._controls.zoomSpeed = speed; }
getZoomSpeed() { return this._controls ? this._controls.zoomSpeed : 1.0; }
+ // ── 2.5D view ──────────────────────────────────────────────────────────────
+
+ /**
+ * Toggle the 2.5D (tilted 3D) view.
+ *
+ * ON — camera moves to an SE-isometric angle (+X/−Y/+Z, the AutoCAD default),
+ * Z becomes the orbit pole and LEFT-drag orbits (MIDDLE/RIGHT still pan).
+ * OFF — straight-down top view with rotate locked; identical to before.
+ *
+ * Geometry is untouched, so layer masks, lineweights and picking survive.
+ * @param {boolean} on
+ * @param {{ keepView?: boolean }} [opts] keepView skips the zoom-extents re-fit
+ */
+ set25D(on, opts = {}) {
+ const want = !!on;
+ if (want === this._is25D) return;
+ this._is25D = want;
+ this._need25Reset = want;
+ const c = this._controls;
+ if (want) {
+ c.enableRotate = true;
+ c.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
+ c.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN };
+ // CAD elevation is +Z, so the orbit pole must be Z or dragging feels sideways.
+ this._camera.up.set(0, 0, 1);
+ } else {
+ c.enableRotate = false;
+ c.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN };
+ c.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN };
+ }
+ this._applyZScale();
+ if (opts.keepView) { c.update(); return; }
+ this.fit();
+ }
+
+ is25D() { return this._is25D; }
+
+ /**
+ * Height exaggeration for 2.5D. A survey sheet is often kilometres wide with
+ * only metres of relief, which reads as dead flat at any orbit angle —
+ * multiplying Z makes the terrain legible. Only applied in 2.5D (in top view
+ * it would be invisible anyway) and only as a scale on the scene group, so
+ * pick coordinates and the X/Y readout stay true to the drawing.
+ * @param {number} k >0; 1 = true scale
+ */
+ setZScale(k) {
+ const v = Number(k);
+ this._zScale = Number.isFinite(v) && v > 0 ? v : 1;
+ this._applyZScale();
+ this._refreshClip25();
+ }
+
+ getZScale() { return this._zScale; }
+
+ _applyZScale() {
+ this._group.scale.set(1, 1, this._is25D ? (this._zScale || 1) : 1);
+ this._group.updateMatrixWorld(true);
+ }
+
+ /** True Z extents of the drawing (the fit box is flattened to z=0). */
+ _zRange() {
+ const z = this._contentZ;
+ if (!z || !Number.isFinite(z.min) || !Number.isFinite(z.max)) return { min: 0, max: 0 };
+ return z;
+ }
+
+ /**
+ * Re-cut the orthographic clip slab around the (possibly Z-exaggerated)
+ * content without moving the camera — otherwise a big exaggeration pushes
+ * geometry through `near`/`far` and it vanishes mid-drag.
+ */
+ _refreshClip25() {
+ if (!this._is25D) return;
+ const box = this._box25(this._contentBox || this._fullContentBox);
+ if (!box) return;
+ const diag = Math.max(box.getSize(new THREE.Vector3()).length(), 1e-6);
+ const span = this._camera.position.distanceTo(this._controls.target) + diag + 1;
+ this._camera.near = -span;
+ this._camera.far = span;
+ this._camera.updateProjectionMatrix();
+ }
+
+ /** Fit box with the real (exaggerated) Z range restored. */
+ _box25(src) {
+ if (!src || src.isEmpty()) return null;
+ const b = src.clone();
+ if (b.max.z - b.min.z < 1e-9) {
+ const z = this._zRange();
+ b.min.z = z.min;
+ b.max.z = z.max;
+ }
+ const k = this._zScale || 1;
+ b.min.z *= k;
+ b.max.z *= k;
+ if (b.max.z < b.min.z) { const t = b.min.z; b.min.z = b.max.z; b.max.z = t; }
+ return b;
+ }
+
/**
* Subscribe to mouse world-coordinate updates (CAD WCS / drawing units).
* Callback receives `{ x, y, z }` or `null` when the pointer leaves the canvas.
@@ -213,6 +327,19 @@ export class Viewer2D {
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1;
+ // 2.5D: the view no longer lies in the drawing plane, so shoot the (parallel)
+ // ortho ray through the pixel and intersect the Z=0 plane. Exact for the
+ // drawing plane itself; entities lifted by their elevation read as the point
+ // of the plane under them, which is what a plan-coordinate readout means.
+ if (this._is25D) {
+ // Cached vectors: this runs on every pointermove.
+ const o = (this._ray25O ||= new THREE.Vector3()).set(ndcX, ndcY, -1).unproject(cam);
+ const d = cam.getWorldDirection(this._ray25D ||= new THREE.Vector3());
+ if (Math.abs(d.z) < 1e-9) return null;
+ const t = -o.z / d.z;
+ return { x: o.x + d.x * t, y: o.y + d.y * t, z: 0 };
+ }
+
// Orthographic frustum half-sizes in world units (respect zoom).
const zoom = cam.zoom || 1;
const halfW = ((cam.right - cam.left) * 0.5) / zoom;
@@ -531,6 +658,7 @@ export class Viewer2D {
setCurLayer,
box,
_tmp,
+ fillZ: VIEWPORT_Z, // fills ride the same underlay plane as the wires
underlay: true, // tag pending texts drawn under paper labels
});
}
@@ -1237,6 +1365,9 @@ export class Viewer2D {
const fitBox = box.isEmpty() ? null : (this._trimmedContentBox(fitSamples, box) || box);
this._fullContentBox = fitBox ? fitBox.clone() : null;
this._contentBox = fitBox ? fitBox.clone() : null;
+ // The trimmed fit box is flattened to z=0 (percentile trim is X/Y only), so
+ // keep the drawing's real elevation range for the 2.5D fit.
+ this._contentZ = box.isEmpty() ? null : { min: box.min.z, max: box.max.z };
// Geometry for every layer is in the scene now — switch off the hidden ones
// (also refreshes _contentBox to the visible subset).
this._applyLayerVisibility();
@@ -1274,8 +1405,10 @@ export class Viewer2D {
_rebuildGrid() {
if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; }
const zoom = this._camera.zoom || 1;
- const cx = this._camera.position.x;
- const cy = this._camera.position.y;
+ // Centre on the orbit target, not the camera: in 2.5D the camera sits off to
+ // the side of what it is looking at.
+ const cx = this._controls?.target?.x ?? this._camera.position.x;
+ const cy = this._controls?.target?.y ?? this._camera.position.y;
const hw = (this._camera.right - this._camera.left) / zoom / 2;
const hh = (this._camera.top - this._camera.bottom) / zoom / 2;
const ext = Math.max(hw, hh) * 3;
@@ -1414,6 +1547,14 @@ export class Viewer2D {
/** Track an entity-owned object so layer toggles can flip its visibility. */
_addObj(obj, layer) {
+ // Inside a paper-space VIEWPORT this mesh is still in model coordinates.
+ // Drop it when it maps outside the viewport frame, exactly as _clipSegToRect
+ // drops line work.
+ if (this._fillXf && !this._bakeFillXf(obj)) {
+ obj.geometry?.dispose();
+ if (obj.material) { obj.material.map?.dispose(); obj.material.dispose(); }
+ return;
+ }
const key = layer ?? this._curLayer;
if (key != null) {
obj.userData.cadLayer = key;
@@ -1656,6 +1797,53 @@ export class Viewer2D {
}
}
+ /**
+ * Bake the active viewport transform into a filled mesh and report whether it
+ * survives the viewport frame.
+ *
+ * _solidMesh / _hatchFill / _arrowMesh / _dotMesh build their own THREE.Mesh
+ * and never touch pushSeg, so the model→paper mapping has to happen here or
+ * not at all. Left unmapped they sit at raw survey coordinates: off the sheet,
+ * and dragging the content box out to the drawing's WCS origin — a sheet 18
+ * units wide framed inside a 480,000-unit box renders as a blank screen.
+ *
+ * The mesh's own transform is folded in first (arrowheads carry their position
+ * and rotation on the object, not in the buffer), then reset, so the paper
+ * coordinates end up in the geometry where the fit box can see them.
+ * @returns {boolean} false when the mesh lies wholly outside the viewport.
+ */
+ _bakeFillXf(obj) {
+ const { xf, clip, z } = this._fillXf;
+ const pos = obj.geometry?.attributes?.position;
+ if (!pos?.array) return true;
+ obj.updateMatrix();
+ const m = obj.matrix;
+ const v = (this._fillTmp ||= new THREE.Vector3());
+ const arr = pos.array;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let i = 0; i + 2 < arr.length; i += 3) {
+ v.set(arr[i], arr[i + 1], arr[i + 2]).applyMatrix4(m);
+ const p = xf(v.x, v.y);
+ arr[i] = p.x;
+ arr[i + 1] = p.y;
+ // Viewport content sits behind paper entities (see VIEWPORT_Z), but keep
+ // the helpers' own bias — arrowheads are lifted 0.5 above the dimension
+ // line they cap — scaled down so it can't cross into the paper layer.
+ arr[i + 2] = z + (v.z || 0) * 1e-3;
+ if (p.x < minX) minX = p.x;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.y > maxY) maxY = p.y;
+ }
+ pos.needsUpdate = true;
+ obj.position.set(0, 0, 0);
+ obj.rotation.set(0, 0, 0);
+ obj.scale.set(1, 1, 1);
+ obj.updateMatrix();
+ obj.geometry.computeBoundingSphere();
+ return !(maxX < clip.minX || minX > clip.maxX || maxY < clip.minY || minY > clip.maxY);
+ }
+
/**
* Emit model-space geometry into a paper VIEWPORT (transformed + clipped).
* Covers the entity types that dominate civil plan/profile sheets.
@@ -1686,6 +1874,11 @@ export class Viewer2D {
};
const inClip = (x, y) => x >= clip.minX && x <= clip.maxX && y >= clip.minY && y <= clip.maxY;
+ // Hand the mapping to _addObj for the fills that bypass pushSeg. Cleared at
+ // the end of the pass (and in _clear) so paper-space entities, which are
+ // already in sheet coordinates, are never re-mapped.
+ this._fillXf = { xf, clip, z: ctx.fillZ ?? 0 };
+
// Quick reject: model bbox far from view focus (optional optimisation skip for correctness)
for (const e of entities) {
@@ -1914,6 +2107,8 @@ export class Viewer2D {
}
}
+ this._fillXf = null;
+
// Transform any pending texts that are still in model space (from INSERT helpers).
// Entries created above already have paper pos. Detect model-space leftovers by
// checking if pos lies far outside the paper clip *and* inside a model-ish range —
@@ -4174,9 +4369,13 @@ export class Viewer2D {
// Pad slightly so content is not flush to the view edge
const padX = Math.max((maxX - minX) * 0.02, 1);
const padY = Math.max((maxY - minY) * 0.02, 1);
+ // Trimming is X/Y only — carry the full box's Z through. Pinning Z to 0 here
+ // and then clamping to fullBox below collapsed the box to empty whenever the
+ // drawing sat entirely off the z=0 plane, which is exactly where viewport
+ // underlay geometry lives (VIEWPORT_Z), and an empty box makes _fit a no-op.
const box = new THREE.Box3(
- new THREE.Vector3(minX - padX, minY - padY, 0),
- new THREE.Vector3(maxX + padX, maxY + padY, 0),
+ new THREE.Vector3(minX - padX, minY - padY, fullBox.min.z),
+ new THREE.Vector3(maxX + padX, maxY + padY, fullBox.max.z),
);
// Never expand beyond the true full box
box.min.max(fullBox.min);
@@ -4195,6 +4394,7 @@ export class Viewer2D {
*/
_fit(box) {
if (!box || box.isEmpty()) return;
+ if (this._is25D) { this._fit25(box); return; }
const c = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const w = this._container.clientWidth || 800;
@@ -4240,6 +4440,48 @@ export class Viewer2D {
this._controls.update();
}
+ /**
+ * Zoom-extents for the tilted view. The box is sized by its 3D diagonal so the
+ * drawing stays framed at *any* orbit angle — re-fitting on every rotation
+ * would fight the user's drag.
+ */
+ _fit25(box) {
+ const b = this._box25(box);
+ if (!b) return;
+ const c = b.getCenter(new THREE.Vector3());
+ const diag = Math.max(b.getSize(new THREE.Vector3()).length(), 1e-6);
+ const w = this._container.clientWidth || 800;
+ const h = this._container.clientHeight || 600;
+ const aspect = w / Math.max(h, 1);
+ const need = diag * 1.1;
+ const vw = aspect >= 1 ? need * aspect : need;
+ const vh = aspect >= 1 ? need : need / aspect;
+
+ // Entering 2.5D snaps to SE isometric (+X/−Y/+Z, AutoCAD's default view);
+ // a later Fit keeps whatever angle the user orbited to.
+ const dir = new THREE.Vector3();
+ if (this._need25Reset) dir.set(1, -1, 1);
+ else dir.subVectors(this._camera.position, this._controls.target);
+ if (dir.lengthSq() < 1e-12) dir.set(1, -1, 1);
+ dir.normalize();
+ this._need25Reset = false;
+
+ const dist = diag * 2 + 1; // ortho: only anchors the clip slab, not the scale
+ this._camera.zoom = 1;
+ this._camera.left = -vw / 2; this._camera.right = vw / 2;
+ this._camera.top = vh / 2; this._camera.bottom = -vh / 2;
+ const span = dist + diag + 1;
+ this._camera.near = -span;
+ this._camera.far = span;
+ this._camera.up.set(0, 0, 1);
+ this._controls.target.copy(c);
+ this._camera.position.copy(c).addScaledVector(dir, dist);
+ this._camera.lookAt(this._controls.target);
+ this._camera.updateProjectionMatrix();
+ this._camera.updateMatrixWorld(true);
+ this._controls.update();
+ }
+
_clear() {
for (let i = this._group.children.length - 1; i >= 0; i--) {
const o = this._group.children[i];
@@ -4253,6 +4495,7 @@ export class Viewer2D {
this._textGen++; // invalidate any in-flight async text flush
this._contentBox = null;
this._fullContentBox = null;
+ this._contentZ = null;
this._layerObjs.clear();
this._layerBoxes.clear();
this._layerSamples.clear();
@@ -4263,6 +4506,7 @@ export class Viewer2D {
this._curLayer = null;
this._curLayerId = -1;
this._layerInfoCache = null;
+ this._fillXf = null; // a load that threw mid-viewport must not leak its transform
}
_onResize() {