From aa27945d6146c8fd43d2c09e86adab090eae3cf6 Mon Sep 17 00:00:00 2001 From: minsung Date: Mon, 19 Oct 2026 19:48:19 +0900 Subject: [PATCH] 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 --- index.html | 24 ++++ src/main.ts | 36 ++++++ src/topProgress.ts | 163 ++++++++++++++++++++++++ src/viewer2d/Viewer2D.d.ts | 6 + src/viewer2d/Viewer2D.js | 252 ++++++++++++++++++++++++++++++++++++- 5 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 src/topProgress.ts diff --git a/index.html b/index.html index 7b532e0..b856fc3 100644 --- a/index.html +++ b/index.html @@ -65,6 +65,9 @@ button:hover, label.btn:hover { border-color: var(--accent); } button.primary { background: #238636; border-color: #2ea043; } button.wasm { border-color: var(--wasm); color: var(--wasm); } + /* 켜져 있는 토글 버튼 (2.5D 보기 등) */ + #toolbar > button.on { background: #1f6feb; border-color: #58a6ff; color: #fff; } + .opt-row.disabled { opacity: .45; pointer-events: none; } label.btn input { display: none; } /* 옵션 패널 — 값은 IndexedDB(hmw-viewer/options)에 저장된다 */ #optBody { padding: 12px 14px; overflow: auto; font-size: 12px; color: var(--ink); } @@ -232,6 +235,7 @@
+ @@ -311,6 +315,26 @@ + +
+

2.5D 보기

+
+ + +
+
+ + + + × +
+

+ 도면은 이미 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() {