forked from kimminsung/dwg-dxf-viewer-sample
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
+24
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+6
@@ -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
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user