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

+24
View File
@@ -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 @@
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
<div id="spaces" title="Model Space / Paper Space (Layout)"></div>
<button id="fit" type="button">Fit (F)</button>
<button id="view25Btn" type="button" title="2.5D 보기 — Top 뷰를 풀고 기울여서 등고선 등의 높낮이(Z)를 본다. 왼쪽 드래그=회전, 가운데/오른쪽 드래그=이동">2.5D 보기</button>
<button id="theme" type="button">Theme</button>
<button id="optionsBtn" type="button" title="표시 옵션 (선가중치 · 그리드 · 줌)">옵션</button>
<button id="layersBtn" type="button">Layers</button>
@@ -311,6 +315,26 @@
<input id="optZoomSpeedNum" type="number" min="0.1" max="5" step="0.1" value="1" />
</div>
</div>
<div class="opt-sec">
<h4>2.5D 보기</h4>
<div class="opt-row">
<label for="opt25D">기울여 보기 (Top 해제)</label>
<input id="opt25D" type="checkbox" />
</div>
<div id="optZRow" class="opt-row disabled">
<label for="optZScale">높이 과장 (Z)</label>
<input id="optZScale" type="range" min="1" max="50" step="0.5" value="1" />
<input id="optZScaleNum" type="number" min="1" max="200" step="0.5" value="1" />
<span class="opt-unit">×</span>
</div>
<p class="opt-help">
도면은 이미 CAD 표고(Z)를 그대로 갖고 있다. 2.5D 는 같은 직교 카메라를
남동 등각(SE Isometric)으로 돌려 그 높낮이를 보여준다 — 재파싱 없음.
부지가 넓고 기복이 작아 평평하게 보이면 Z 과장을 올린다.
<b>왼쪽 드래그 = 회전, 가운데·오른쪽 드래그 = 이동.</b>
</p>
</div>
</div>
</div>
+36
View File
@@ -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();
+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;
}
+6
View File
@@ -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;
+248 -4
View File
@@ -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() {