feat(engine): port the viewer engine and panel logic unchanged
Copy the vanilla engine from dwg-dxf-viewer-sample verbatim (#1): - src/engine/viewer2d/* (Viewer2D.js incl. the 2.5D view, parsers, acadrust-dwg WASM, cadSpaces, slugText, linParser/linData, fixDwgKoreanText, ...) - src/engine/{layerPanel,panelUtils,propertyInspector,optionsStore}.ts The relative layout is kept (engine/viewer2d next to the panel files), so not even import paths changed; `diff -r --strip-trailing-cr` against the sample is empty. public/ (NanumGothic, samples, licenses, module map) is copied as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
948021c7ac
commit
bc8875770b
38 files changed
+8480
No files matched your search
@@ -0,0 +1,361 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DWG/DXF · 모듈 조합 레시피 & 샘플</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #eef1f4; --panel: #fff; --ink: #1a2230; --ink-soft: #4a5568; --ink-faint: #8592a3;
|
||||
--line: #d3dae2; --line-strong: #b7c1cd; --accent: #0e7c86; --accent-soft: #d3ecef;
|
||||
--wasm: #b45309; --wasm-soft: #fbe8cf; --js: #0e7c86;
|
||||
--grid: rgba(14,124,134,.06);
|
||||
--shadow: 0 1px 2px rgba(26,34,48,.06), 0 8px 24px rgba(26,34,48,.06);
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1219; --panel: #141b25; --ink: #e6ecf3; --ink-soft: #a3b0c0; --ink-faint: #6b7a8d;
|
||||
--line: #243040; --line-strong: #33445a; --accent: #3fd0dc; --accent-soft: #123038;
|
||||
--wasm: #f0a03c; --wasm-soft: #33260f; --js: #3fd0dc; --grid: rgba(63,208,220,.05);
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||
var(--bg);
|
||||
color: var(--ink); font-family: var(--sans); line-height: 1.6;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px 96px; }
|
||||
header { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 36px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--accent); display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent); }
|
||||
h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 12px; letter-spacing: -.01em; }
|
||||
h1 .dim { color: var(--ink-faint); font-weight: 400; }
|
||||
.lede { font-size: 16px; color: var(--ink-soft); max-width: 64ch; margin: 0; }
|
||||
.cta {
|
||||
display: inline-flex; align-items: center; gap: 10px; margin-top: 20px;
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; text-decoration: none;
|
||||
color: var(--accent); background: var(--panel); border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
h2 {
|
||||
font-size: 12px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-faint); margin: 48px 0 16px; display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
h2 .num { color: var(--accent); }
|
||||
h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-soft); margin: -4px 0 18px; }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.dot { width: 11px; height: 11px; border-radius: 3px; }
|
||||
.dot.wasm { background: var(--wasm); }
|
||||
.dot.js { background: var(--js); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; box-shadow: var(--shadow); position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tag); }
|
||||
.card.w { --tag: var(--wasm); }
|
||||
.card.j { --tag: var(--js); }
|
||||
.card .k {
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 6px;
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.badge.w { background: var(--wasm-soft); color: var(--wasm); }
|
||||
.badge.j { background: var(--accent-soft); color: var(--js); }
|
||||
.card .d { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
|
||||
.card .path { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 8px; word-break: break-all; }
|
||||
.flow {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 22px 16px; box-shadow: var(--shadow); overflow-x: auto;
|
||||
}
|
||||
.pipe { display: flex; align-items: stretch; min-width: 720px; }
|
||||
.node {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--line-strong); border-radius: 9px;
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||
}
|
||||
.node.hl { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.node.w { border-color: var(--wasm); }
|
||||
.node .tier { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
|
||||
.node.hl .tier { color: var(--accent); }
|
||||
.node.w .tier { color: var(--wasm); }
|
||||
.node .name { font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; }
|
||||
.node .sub { font-size: 12px; color: var(--ink-soft); }
|
||||
.arrow { flex: 0 0 32px; display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; }
|
||||
.flow-note { font-size: 12.5px; color: var(--ink-faint); margin-top: 14px; font-family: var(--mono); }
|
||||
.flow-note b { color: var(--accent); }
|
||||
pre {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px;
|
||||
overflow-x: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); line-height: 1.55;
|
||||
box-shadow: var(--shadow); margin: 0;
|
||||
}
|
||||
pre .c { color: var(--accent); }
|
||||
pre .w { color: var(--wasm); }
|
||||
pre .cm { color: var(--ink-faint); }
|
||||
.steps { display: grid; gap: 12px; }
|
||||
.step {
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 16px 18px; box-shadow: var(--shadow); display: grid; grid-template-columns: 36px 1fr; gap: 12px;
|
||||
}
|
||||
.step .n {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--accent);
|
||||
width: 36px; height: 36px; border-radius: 8px; background: var(--accent-soft);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.step h3 { margin: 0 0 4px; font-size: 15px; }
|
||||
.step p { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
||||
.step code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line);
|
||||
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
|
||||
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="eyebrow">compose · don’t invent</div>
|
||||
<h1>DWG · DXF 모듈 조합 <span class="dim">레시피</span></h1>
|
||||
<p class="lede">
|
||||
전체 제품이 아니라, 지도에 있는 <b>재사용 모듈만</b> 골라 조립하면 2D CAD 뷰어가 됩니다.
|
||||
아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다.
|
||||
</p>
|
||||
<a class="cta" href="/">
|
||||
▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev)
|
||||
</a>
|
||||
<p style="margin:10px 0 0; font-family:var(--mono); font-size:12px; color:var(--ink-faint)">
|
||||
cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 01 modules -->
|
||||
<h2><span class="num">01</span> 조합에 쓰는 모듈 (지도 카드)</h2>
|
||||
<div class="legend">
|
||||
<span><i class="dot wasm"></i> WASM 파서</span>
|
||||
<span><i class="dot js"></i> JS 모듈 · 렌더러</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">acadrust-dwg <span class="badge w">wasm</span></div>
|
||||
<div class="d">DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.</div>
|
||||
<div class="path">src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dwgParser / acadrustParser <span class="badge j">wrap</span></div>
|
||||
<div class="d">WASM 1회 init + <code>parseDwgBuffer(bytes)</code>.</div>
|
||||
<div class="path">dwgParser.ts · acadrustParser.ts</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxf-parser <span class="badge j">npm</span></div>
|
||||
<div class="d">DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.</div>
|
||||
<div class="path">npm: dxf-parser</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfHatchHandler <span class="badge j">patch</span></div>
|
||||
<div class="d"><code>registerEntityHandler</code>로 HATCH 보강.</div>
|
||||
<div class="path">dxfHatchHandler.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">dxfAdapter <span class="badge j">adapt</span></div>
|
||||
<div class="d">dxf-parser 출력 → Viewer2D 공통 스키마.</div>
|
||||
<div class="path">dxfAdapter.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">Viewer2D <span class="badge j">render</span></div>
|
||||
<div class="d">Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…</div>
|
||||
<div class="path">Viewer2D.js</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">three <span class="badge j">peer</span></div>
|
||||
<div class="d">WebGLRenderer + OrbitControls (pan/zoom).</div>
|
||||
<div class="path">npm: three ≥0.160</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">slugText + font <span class="badge j">text</span></div>
|
||||
<div class="d">opentype.js → GPU 텍스트. TTF를 호스트가 서빙.</div>
|
||||
<div class="path">slugText.ts · /fonts/*.ttf</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">ext2d <span class="badge j">util</span></div>
|
||||
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
||||
<div class="path">ext2d.ts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 02 pipeline -->
|
||||
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
||||
<div class="flow">
|
||||
<div class="pipe">
|
||||
<div class="node">
|
||||
<div class="tier">Host</div>
|
||||
<div class="name">File · URL · Drop</div>
|
||||
<div class="sub">.dwg / .dxf</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">ext2d</div>
|
||||
<div class="name">isDwg / isDxf</div>
|
||||
<div class="sub">분기</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node w">
|
||||
<div class="tier">Parser A or B</div>
|
||||
<div class="name">acadrust · dxf-parser</div>
|
||||
<div class="sub">(+ hatch · adapter)</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node hl">
|
||||
<div class="tier">Contract</div>
|
||||
<div class="name">CadParseResult</div>
|
||||
<div class="sub">공통 JSON</div>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="node">
|
||||
<div class="tier">Viewer2D</div>
|
||||
<div class="name">.load(result)</div>
|
||||
<div class="sub">three 씬</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-note">
|
||||
<b>핵심 계약</b>: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다.
|
||||
중간은 항상 <code>CadParseResult</code> 하나.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 03 recipe steps -->
|
||||
<h2><span class="num">03</span> 다른 프로젝트에 옮기는 순서</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="n">1</div>
|
||||
<div>
|
||||
<h3>모듈 소스 복사</h3>
|
||||
<p>
|
||||
이 샘플의 <code>src/viewer2d/</code> 전체를 복사.
|
||||
npm: <code>three</code>, <code>dxf-parser</code>, <code>opentype.js</code>.
|
||||
(원본 소스는 형제 폴더 <code>hmwebviewer/src/viewer2d</code>.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">2</div>
|
||||
<div>
|
||||
<h3>정적 자산</h3>
|
||||
<p>
|
||||
<code>public/fonts/NanumGothic-Regular.ttf</code> (한글 TEXT).
|
||||
WASM은 소스가 <code>?url</code>로 import — Vite면 <code>assetsInclude: ['**/*.wasm']</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">3</div>
|
||||
<div>
|
||||
<h3>아래 스니펫처럼 조합</h3>
|
||||
<p>
|
||||
래퍼 클래스 없이 모듈을 직접 import 해도 됩니다.
|
||||
실행 가능한 전체 예: 이 프로젝트 <code>src/main.ts</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="n">4</div>
|
||||
<div>
|
||||
<h3>호스트 UI만 붙이기</h3>
|
||||
<p>
|
||||
드롭존 · 툴바 · 레이어 패널은 모듈이 아님.
|
||||
샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 04 code -->
|
||||
<h2><span class="num">04</span> 최소 조합 코드 (샘플과 동일 패턴)</h2>
|
||||
<pre><span class="cm">// ① 모듈 지도 카드 → import</span>
|
||||
<span class="c">import</span> { Viewer2D } <span class="c">from</span> './viewer2d/Viewer2D.js';
|
||||
<span class="c">import</span> { parseDwgBuffer } <span class="c">from</span> './viewer2d/dwgParser'; <span class="cm">// acadrust-dwg</span>
|
||||
<span class="c">import</span> { parseDxfBuffer } <span class="c">from</span> './viewer2d/parseDxf'; <span class="cm">// dxf-parser+hatch+adapter</span>
|
||||
<span class="c">import</span> { isDwg, isDxf } <span class="c">from</span> './viewer2d/ext2d';
|
||||
<span class="c">import</span> { setDefaultFontUrl } <span class="c">from</span> './viewer2d/slugText';
|
||||
|
||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||
|
||||
<span class="cm">// ② 렌더러</span>
|
||||
<span class="c">const</span> viewer = <span class="c">new</span> Viewer2D(document.getElementById('app')!);
|
||||
|
||||
<span class="cm">// ③ 파서 조합</span>
|
||||
<span class="c">async function</span> parseCad(buf: ArrayBuffer, name: string) {
|
||||
<span class="c">if</span> (isDxf(name)) <span class="c">return</span> parseDxfBuffer(buf); <span class="cm">// dxf-parser 경로</span>
|
||||
<span class="c">if</span> (isDwg(name)) <span class="c">return</span> parseDwgBuffer(<span class="c">new</span> Uint8Array(buf)); <span class="cm">// WASM 경로</span>
|
||||
<span class="c">throw new</span> Error('need .dwg or .dxf name');
|
||||
}
|
||||
|
||||
<span class="cm">// ④ 계약: parseResult → load</span>
|
||||
<span class="c">async function</span> open(file: File) {
|
||||
<span class="c">const</span> result = <span class="c">await</span> parseCad(<span class="c">await</span> file.arrayBuffer(), file.name);
|
||||
viewer.load(result);
|
||||
viewer.resize();
|
||||
viewer.fit();
|
||||
}</pre>
|
||||
|
||||
<h2><span class="num">05</span> DWG 경로 vs DXF 경로</h2>
|
||||
<div class="grid">
|
||||
<div class="card w">
|
||||
<div class="k">.dwg 경로 <span class="badge w">wasm</span></div>
|
||||
<div class="d">
|
||||
<code>parseDwgBuffer</code><br>
|
||||
→ acadrustParser.init<br>
|
||||
→ <span style="color:var(--wasm)">parse_dwg(bytes)</span><br>
|
||||
→ CadParseResult
|
||||
</div>
|
||||
<div class="path">Sample DWG 버튼으로 검증</div>
|
||||
</div>
|
||||
<div class="card j">
|
||||
<div class="k">.dxf 경로 <span class="badge j">js</span></div>
|
||||
<div class="d">
|
||||
<code>parseDxfBuffer</code><br>
|
||||
→ decodeDxf (UTF-8 / CP949…)<br>
|
||||
→ DxfParser + HatchHandler<br>
|
||||
→ dxfToParseResult
|
||||
</div>
|
||||
<div class="path">Sample DXF 버튼으로 검증</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="num">06</span> 샘플 위치</h2>
|
||||
<pre style="white-space:pre-wrap"><span class="c">dwg-dxf-viewer-sample/</span> ← hmwebviewer 형제 폴더
|
||||
src/main.ts ← 모듈을 직접 import 해서 조립 (핵심)
|
||||
src/viewer2d/ ← acadrust-dwg · Viewer2D · dxf-*
|
||||
index.html ← 툴바 + Module recipe 패널
|
||||
public/fonts/ ← NanumGothic (slugText)
|
||||
public/samples/ ← BasicSample.dwg · simple.dxf
|
||||
docs/modules-dwg-dxf.html
|
||||
|
||||
<span class="c">실행</span>
|
||||
cd ../dwg-dxf-viewer-sample # (hmwebviewer 기준 형제)
|
||||
npm install
|
||||
npm run dev → http://localhost:5173</pre>
|
||||
|
||||
<footer>
|
||||
<span>DWG/DXF only · sibling of hmwebviewer</span>
|
||||
<span>no 3D / SHP / R2</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
# Third-party notices
|
||||
|
||||
This viewer bundles third-party code. The notices below apply to the source
|
||||
tree **and** to the built site (`dist2/`, deployed at
|
||||
`https://dwg-dxf-viewer-sample.pages.dev/`), because the build embeds the
|
||||
components listed here.
|
||||
|
||||
Served copies of the license texts live under
|
||||
[`public/licenses/`](./public/licenses/) → `/licenses/…` on the deployed site.
|
||||
|
||||
---
|
||||
|
||||
## acadrust 0.4.1 — Mozilla Public License 2.0 — **MODIFIED**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Component | `acadrust` (DWG/DXF parser, Rust) |
|
||||
| Version | 0.4.1 |
|
||||
| License | MPL-2.0 — full text: [`public/licenses/acadrust-MPL-2.0.txt`](./public/licenses/acadrust-MPL-2.0.txt) (`/licenses/acadrust-MPL-2.0.txt`) |
|
||||
| Upstream | https://github.com/hakanaktt/acadrust · https://crates.io/crates/acadrust/0.4.1 |
|
||||
| Upstream commit | `f249c2f816acf36ee51cd5533716bdd443c2517e` (from the crate's `.cargo_vcs_info.json`) |
|
||||
| Author | Hakan AK |
|
||||
| Shipped as | compiled into `assets/acadrust_dwg_bg-*.wasm` (Executable Form) |
|
||||
|
||||
**This copy is modified.** One file differs from the published crate:
|
||||
|
||||
```
|
||||
src/io/dwg/dwg_stream_readers/object_reader/entities.rs (read_mesh array-count bounds)
|
||||
```
|
||||
|
||||
The modification bounds `read_mesh`'s array counts by the bits remaining in the
|
||||
object stream instead of the blanket 100 000-item guard, which truncated and
|
||||
thereby corrupted large SubD meshes.
|
||||
|
||||
### How to obtain the Source Code Form (MPL-2.0 §3.2)
|
||||
|
||||
1. Get the unmodified crate source — `cargo vendor`, or
|
||||
`https://crates.io/api/v1/crates/acadrust/0.4.1/download`, or the upstream
|
||||
commit above.
|
||||
2. Apply [`public/licenses/acadrust-0.4.1-read_mesh.patch`](./public/licenses/acadrust-0.4.1-read_mesh.patch)
|
||||
(`/licenses/acadrust-0.4.1-read_mesh.patch` on the deployed site):
|
||||
|
||||
```bash
|
||||
tar xf acadrust-0.4.1.crate && cd acadrust-0.4.1
|
||||
patch -p1 < acadrust-0.4.1-read_mesh.patch
|
||||
```
|
||||
|
||||
The result is the exact Source Code Form of the acadrust code compiled into the
|
||||
shipped `.wasm`. Both the original and the modified file remain under MPL-2.0.
|
||||
|
||||
---
|
||||
|
||||
## dwg-wasm — MIT
|
||||
|
||||
The wasm-bindgen wrapper that turns acadrust output into this viewer's
|
||||
`parseResult` (`hmwebviewer/rust/dwg-wasm/`). Our own code, MIT. MPL-2.0 §3.3
|
||||
permits distributing this Larger Work under different terms as long as the
|
||||
MPL-covered files above keep their license — which they do.
|
||||
|
||||
---
|
||||
|
||||
## Other components
|
||||
|
||||
| Component | License |
|
||||
|---|---|
|
||||
| three.js | MIT |
|
||||
| dxf-parser | MIT |
|
||||
| opentype.js | MIT |
|
||||
| NanumGothic (`public/fonts/`) | SIL Open Font License 1.1 |
|
||||
| Viewer2D / sample glue | MIT |
|
||||
@@ -0,0 +1,111 @@
|
||||
--- a/src/io/dwg/dwg_stream_readers/object_reader/entities.rs
|
||||
+++ b/src/io/dwg/dwg_stream_readers/object_reader/entities.rs
|
||||
@@ -1682,20 +1682,49 @@
|
||||
MLineData { scale_factor, justification, start_point, normal, openclosed, lines_in_style, vertex_count, vertices, style_handle }
|
||||
}
|
||||
|
||||
+/// Cap an array count against the bits actually left in the object's main
|
||||
+/// stream instead of the blanket [`MAX_ARRAY_COUNT`].
|
||||
+///
|
||||
+/// MESH is the one entity whose arrays legitimately run past 100 000 items: a
|
||||
+/// road-surface mesh in a civil drawing carries hundreds of thousands of
|
||||
+/// vertices. Clamping those to the blanket cap does not merely truncate the
|
||||
+/// mesh — the reader then continues from the wrong bit offset, so the face,
|
||||
+/// edge and crease lists that follow decode as garbage (observed: a 100 000
|
||||
+/// vertex clamp left a 14-face mesh with face sizes `[64, 0, 64, 23, 0, …]`).
|
||||
+/// Bounding by the remaining bits keeps the corrupt-data protection — no
|
||||
+/// unbounded allocation is possible — without mis-parsing a valid drawing.
|
||||
+///
|
||||
+/// `min_bits_per_item` is the shortest legal encoding of one element: a
|
||||
+/// BitDouble is 2 bits at minimum (the 0.0 / 1.0 forms), so a 3BD vertex is 6
|
||||
+/// and a BitLong is 2.
|
||||
+fn stream_bounded_count(reader: &DwgMergedReader, raw: i32, min_bits_per_item: i64) -> i32 {
|
||||
+ let raw = raw.max(0) as i64;
|
||||
+ let total_bits = (reader.main().data_len() as i64).saturating_mul(8);
|
||||
+ // The handle stream follows the main stream; anything past its start is not
|
||||
+ // array data. handle_start() is 0 when the framing did not supply it.
|
||||
+ let handle_start = reader.handle_start();
|
||||
+ let end_bits = if handle_start > 0 { handle_start.min(total_bits) } else { total_bits };
|
||||
+ let remaining = (end_bits - reader.position_in_bits()).max(0);
|
||||
+ raw.min(remaining / min_bits_per_item.max(1)).min(i32::MAX as i64) as i32
|
||||
+}
|
||||
+
|
||||
pub fn read_mesh(reader: &mut DwgMergedReader) -> MeshData {
|
||||
let version = reader.read_bit_short();
|
||||
let blend_crease = reader.read_bit();
|
||||
let subdivision_level = reader.read_bit_long();
|
||||
|
||||
- let num_verts = safe_count(reader.read_bit_long());
|
||||
+ let raw_verts = reader.read_bit_long();
|
||||
+ let num_verts = stream_bounded_count(reader, raw_verts, 6);
|
||||
let mut vertices = Vec::with_capacity(num_verts as usize);
|
||||
for _ in 0..num_verts { vertices.push(reader.read_3bit_double()); }
|
||||
|
||||
- let total_face_data = safe_count(reader.read_bit_long());
|
||||
+ let raw_face_data = reader.read_bit_long();
|
||||
+ let total_face_data = stream_bounded_count(reader, raw_face_data, 2);
|
||||
let mut faces = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < total_face_data {
|
||||
- let n = safe_count(reader.read_bit_long());
|
||||
+ let raw_n = reader.read_bit_long();
|
||||
+ let n = stream_bounded_count(reader, raw_n, 2);
|
||||
i += 1;
|
||||
let mut face = Vec::new();
|
||||
for _ in 0..n {
|
||||
@@ -1705,7 +1734,8 @@
|
||||
faces.push(face);
|
||||
}
|
||||
|
||||
- let num_edges = safe_count(reader.read_bit_long());
|
||||
+ let raw_edges = reader.read_bit_long();
|
||||
+ let num_edges = stream_bounded_count(reader, raw_edges, 4);
|
||||
let mut edges = Vec::with_capacity(num_edges as usize);
|
||||
for _ in 0..num_edges {
|
||||
let s = reader.read_bit_long();
|
||||
@@ -1713,7 +1743,8 @@
|
||||
edges.push((s, e));
|
||||
}
|
||||
|
||||
- let num_creases = safe_count(reader.read_bit_long());
|
||||
+ let raw_creases = reader.read_bit_long();
|
||||
+ let num_creases = stream_bounded_count(reader, raw_creases, 2);
|
||||
let mut crease_values = Vec::with_capacity(num_creases as usize);
|
||||
for _ in 0..num_creases { crease_values.push(reader.read_bit_double()); }
|
||||
|
||||
@@ -2350,14 +2381,31 @@
|
||||
pub fn read_ole2frame(reader: &mut DwgMergedReader, version: DwgVersion) -> Ole2FrameData {
|
||||
let ver = reader.read_bit_short();
|
||||
let mode = if version.r2000_plus() { reader.read_bit_short() } else { 0 };
|
||||
- // OLE binary data can be very large (embedded images/documents),
|
||||
- // so don't use safe_count (100 KB cap). Use a generous 10 MB cap instead.
|
||||
let data_len = (reader.read_bit_long().max(0) as usize).min(10_000_000);
|
||||
let data = reader.read_bytes(data_len);
|
||||
let trailing_byte = if version.r2000_plus() { reader.read_byte() } else { 3 };
|
||||
- let upper_left = reader.read_3bit_double();
|
||||
- let lower_right = reader.read_3bit_double();
|
||||
+
|
||||
+ let mut upper_left = Vector3::new(0.0, 0.0, 0.0);
|
||||
+ let mut lower_right = Vector3::new(0.0, 0.0, 0.0);
|
||||
+
|
||||
+ // Extract 4-corner coordinates stored in the OLE binary data header starting at offset 2
|
||||
+ if data.len() >= 66 {
|
||||
+ let ul_x = f64::from_le_bytes(data[2..10].try_into().unwrap());
|
||||
+ let ul_y = f64::from_le_bytes(data[10..18].try_into().unwrap());
|
||||
+ let ul_z = f64::from_le_bytes(data[18..26].try_into().unwrap());
|
||||
+ let lr_x = f64::from_le_bytes(data[50..58].try_into().unwrap());
|
||||
+ let lr_y = f64::from_le_bytes(data[58..66].try_into().unwrap());
|
||||
+ let lr_z = if data.len() >= 74 { f64::from_le_bytes(data[66..74].try_into().unwrap()) } else { 0.0 };
|
||||
+
|
||||
+ if ul_x.is_finite() && ul_y.is_finite() && lr_x.is_finite() && lr_y.is_finite()
|
||||
+ && (ul_x != 0.0 || ul_y != 0.0 || lr_x != 0.0 || lr_y != 0.0) {
|
||||
+ upper_left = Vector3::new(ul_x, ul_y, if ul_z.is_finite() { ul_z } else { 0.0 });
|
||||
+ lower_right = Vector3::new(lr_x, lr_y, if lr_z.is_finite() { lr_z } else { 0.0 });
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
let ole_type = if version.r2000_plus() { reader.read_byte() } else { 2 };
|
||||
+
|
||||
Ole2FrameData { version: ver, mode, data, trailing_byte, upper_left, lower_right, ole_type }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under the name "LEGAL", and included in all Covered Software
|
||||
distributed in Source Code Form. Except to the extent prohibited by
|
||||
statute or regulation, such description must be sufficiently detailed
|
||||
for a recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,146 @@
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
HEADER
|
||||
9
|
||||
$ACADVER
|
||||
1
|
||||
AC1015
|
||||
9
|
||||
$INSUNITS
|
||||
70
|
||||
4
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
TABLES
|
||||
0
|
||||
TABLE
|
||||
2
|
||||
LAYER
|
||||
0
|
||||
LAYER
|
||||
2
|
||||
0
|
||||
70
|
||||
0
|
||||
62
|
||||
7
|
||||
0
|
||||
LAYER
|
||||
2
|
||||
OUTLINE
|
||||
70
|
||||
0
|
||||
62
|
||||
1
|
||||
0
|
||||
ENDTAB
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
BLOCKS
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
SECTION
|
||||
2
|
||||
ENTITIES
|
||||
0
|
||||
LINE
|
||||
8
|
||||
OUTLINE
|
||||
10
|
||||
0.0
|
||||
20
|
||||
0.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
100.0
|
||||
21
|
||||
0.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
OUTLINE
|
||||
10
|
||||
100.0
|
||||
20
|
||||
0.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
100.0
|
||||
21
|
||||
60.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
OUTLINE
|
||||
10
|
||||
100.0
|
||||
20
|
||||
60.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
0.0
|
||||
21
|
||||
60.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
LINE
|
||||
8
|
||||
OUTLINE
|
||||
10
|
||||
0.0
|
||||
20
|
||||
60.0
|
||||
30
|
||||
0.0
|
||||
11
|
||||
0.0
|
||||
21
|
||||
0.0
|
||||
31
|
||||
0.0
|
||||
0
|
||||
CIRCLE
|
||||
8
|
||||
0
|
||||
10
|
||||
50.0
|
||||
20
|
||||
30.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
15.0
|
||||
0
|
||||
TEXT
|
||||
8
|
||||
0
|
||||
10
|
||||
10.0
|
||||
20
|
||||
70.0
|
||||
30
|
||||
0.0
|
||||
40
|
||||
5.0
|
||||
1
|
||||
Hello DWG/DXF package
|
||||
0
|
||||
ENDSEC
|
||||
0
|
||||
EOF
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Layer Panel manager for dwg-dxf-viewer-sample.
|
||||
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
|
||||
*/
|
||||
|
||||
import type { Viewer2D } from './viewer2d/Viewer2D';
|
||||
|
||||
export interface LayerItem {
|
||||
name: string;
|
||||
colorHex: string;
|
||||
count: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export class LayerPanelManager {
|
||||
private viewer: Viewer2D;
|
||||
private listEl: HTMLElement;
|
||||
private countEl: HTMLElement | null;
|
||||
private searchInputEl: HTMLInputElement | null;
|
||||
private hiddenLayers: Set<string> = new Set();
|
||||
private searchQuery: string = '';
|
||||
|
||||
constructor(
|
||||
viewer: Viewer2D,
|
||||
listEl: HTMLElement,
|
||||
options?: {
|
||||
countEl?: HTMLElement | null;
|
||||
searchInputEl?: HTMLInputElement | null;
|
||||
showAllBtnEl?: HTMLElement | null;
|
||||
hideAllBtnEl?: HTMLElement | null;
|
||||
}
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this.listEl = listEl;
|
||||
this.countEl = options?.countEl || null;
|
||||
this.searchInputEl = options?.searchInputEl || null;
|
||||
|
||||
if (this.searchInputEl) {
|
||||
this.searchInputEl.addEventListener('input', (e) => {
|
||||
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.showAllBtnEl) {
|
||||
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
|
||||
}
|
||||
|
||||
if (options?.hideAllBtnEl) {
|
||||
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
|
||||
}
|
||||
|
||||
// Delegated click handling: bound once, so render() never has to re-attach
|
||||
// a listener per row (that was O(layers) DOM work on every single toggle).
|
||||
this.listEl.addEventListener('click', (e) => {
|
||||
const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
const layerName = row.dataset.name;
|
||||
if (layerName) this.toggleLayer(layerName);
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh and re-render layer list from Viewer2D state */
|
||||
public update(): void {
|
||||
this.hiddenLayers.clear();
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
if (!l.visible) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
public showAllLayers(): void {
|
||||
this.hiddenLayers.clear();
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRows();
|
||||
}
|
||||
|
||||
public hideAllLayers(): void {
|
||||
const layers = this.viewer.getLayerInfo() || [];
|
||||
for (const l of layers) {
|
||||
this.hiddenLayers.add(l.name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRows();
|
||||
}
|
||||
|
||||
public toggleLayer(name: string): void {
|
||||
if (this.hiddenLayers.has(name)) {
|
||||
this.hiddenLayers.delete(name);
|
||||
} else {
|
||||
this.hiddenLayers.add(name);
|
||||
}
|
||||
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||
this.syncRow(name);
|
||||
}
|
||||
|
||||
/** Patch one row in place — a full render() would rebuild the whole list. */
|
||||
private syncRow(name: string): void {
|
||||
const row = this.listEl?.querySelector<HTMLElement>(
|
||||
`.layer-row[data-name="${cssEscape(name)}"]`
|
||||
);
|
||||
if (!row) return;
|
||||
this.paintRow(row, !this.hiddenLayers.has(name));
|
||||
}
|
||||
|
||||
/** Patch every row (show-all / hide-all) without touching the DOM structure. */
|
||||
private syncRows(): void {
|
||||
if (!this.listEl) return;
|
||||
this.listEl.querySelectorAll<HTMLElement>('.layer-row').forEach((row) => {
|
||||
const name = row.dataset.name;
|
||||
if (name != null) this.paintRow(row, !this.hiddenLayers.has(name));
|
||||
});
|
||||
}
|
||||
|
||||
private paintRow(row: HTMLElement, visible: boolean): void {
|
||||
row.style.opacity = visible ? '1' : '0.4';
|
||||
const btn = row.querySelector<HTMLElement>('.eye-btn');
|
||||
if (btn) {
|
||||
btn.textContent = visible ? '👁️' : '🕶️';
|
||||
btn.title = visible ? '숨기기' : '표시하기';
|
||||
}
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
if (!this.listEl) return;
|
||||
|
||||
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
|
||||
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = `${layers.length}개`;
|
||||
}
|
||||
|
||||
if (!layers.length) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||
도면에 레이어 정보가 없습니다
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = layers.filter((l) =>
|
||||
l.name.toLowerCase().includes(this.searchQuery)
|
||||
);
|
||||
|
||||
if (!filtered.length) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = filtered
|
||||
.map((l) => {
|
||||
const isVisible = !this.hiddenLayers.has(l.name);
|
||||
const opacity = isVisible ? '1' : '0.4';
|
||||
const eyeIcon = isVisible ? '👁️' : '🕶️';
|
||||
const eyeTitle = isVisible ? '숨기기' : '표시하기';
|
||||
|
||||
return `
|
||||
<div class="layer-row" data-name="${escapeHtml(l.name)}"
|
||||
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
|
||||
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
|
||||
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
|
||||
${escapeHtml(l.name)}
|
||||
</span>
|
||||
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
|
||||
${l.count}
|
||||
</span>
|
||||
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
|
||||
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
|
||||
${eyeIcon}
|
||||
</button>
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
// Clicks are handled by the delegated listener bound in the constructor.
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape a layer name for use inside a double-quoted CSS attribute selector. */
|
||||
function cssEscape(str: string): string {
|
||||
return str.replace(/["\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 뷰어 옵션 영구 저장소 (IndexedDB).
|
||||
*
|
||||
* localStorage 대신 IndexedDB 를 쓰는 이유: 값이 문자열로 강제되지 않고(숫자/불린/
|
||||
* 객체 그대로), 용량 제한이 사실상 없으며, 나중에 도면별 설정처럼 구조가 커져도
|
||||
* 같은 저장소를 그대로 쓸 수 있다.
|
||||
*
|
||||
* DB 하나(`hmw-viewer`) 안에 key-value 스토어(`options`) 하나만 둔다.
|
||||
* 실패(사생활 보호 모드, 스토리지 차단 등)해도 앱이 죽지 않도록 모든 API 는
|
||||
* 조용히 기본값으로 떨어진다.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'hmw-viewer';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'options';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase | null> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase | null> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve) => {
|
||||
if (typeof indexedDB === 'undefined') return resolve(null);
|
||||
let req: IDBOpenDBRequest;
|
||||
try {
|
||||
req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
} catch {
|
||||
return resolve(null);
|
||||
}
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => resolve(null);
|
||||
req.onblocked = () => resolve(null);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function tx<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T | null> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T | null>((resolve) => {
|
||||
if (!db) return resolve(null);
|
||||
try {
|
||||
const t = db.transaction(STORE, mode);
|
||||
const req = run(t.objectStore(STORE));
|
||||
req.onsuccess = () => resolve(req.result as T);
|
||||
req.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 저장된 옵션 하나. 없으면 `fallback`. */
|
||||
export async function getOption<T>(key: string, fallback: T): Promise<T> {
|
||||
const v = await tx<T>('readonly', (s) => s.get(key));
|
||||
return v === null || v === undefined ? fallback : v;
|
||||
}
|
||||
|
||||
/** 옵션 하나 저장. 저장이 막혀 있어도 예외를 던지지 않는다. */
|
||||
export async function setOption(key: string, value: unknown): Promise<void> {
|
||||
await tx('readwrite', (s) => s.put(value, key));
|
||||
}
|
||||
|
||||
/** 전체 옵션을 한 번에 읽는다 (기동 시 1회 호출용). */
|
||||
export async function getAllOptions(): Promise<Record<string, unknown>> {
|
||||
const keys = (await tx<IDBValidKey[]>('readonly', (s) => s.getAllKeys())) ?? [];
|
||||
const vals = (await tx<unknown[]>('readonly', (s) => s.getAll())) ?? [];
|
||||
const out: Record<string, unknown> = {};
|
||||
keys.forEach((k, i) => { out[String(k)] = vals[i]; });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Utility helper to make floating UI panels draggable and collapsible.
|
||||
*/
|
||||
|
||||
let globalMaxZIndex = 30;
|
||||
|
||||
export interface PanelControlOptions {
|
||||
handleEl?: HTMLElement | null;
|
||||
collapseBtnEl?: HTMLElement | null;
|
||||
bodyEl?: HTMLElement | null;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function makePanelDraggableAndCollapsible(
|
||||
panelEl: HTMLElement,
|
||||
options?: PanelControlOptions
|
||||
): { toggleCollapse: () => void; isCollapsed: () => boolean } {
|
||||
if (!panelEl) return { toggleCollapse: () => {}, isCollapsed: () => false };
|
||||
|
||||
const handleEl = (options?.handleEl || panelEl.querySelector('.hdr') || panelEl) as HTMLElement;
|
||||
const collapseBtnEl = (options?.collapseBtnEl || panelEl.querySelector('.collapse-btn')) as HTMLElement | null;
|
||||
const bodyEl = (options?.bodyEl || panelEl.querySelector('#propBody') || panelEl.querySelector('#layerBody') || panelEl.children[1]) as HTMLElement | null;
|
||||
|
||||
let isCollapsed = false;
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let initialLeft = 0;
|
||||
let initialTop = 0;
|
||||
|
||||
// Set initial drag cursor
|
||||
if (handleEl) {
|
||||
handleEl.style.cursor = 'grab';
|
||||
handleEl.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
// Focus panel to top z-index when clicked anywhere
|
||||
panelEl.addEventListener('pointerdown', () => {
|
||||
globalMaxZIndex++;
|
||||
panelEl.style.zIndex = String(globalMaxZIndex);
|
||||
});
|
||||
|
||||
// Dragging handler
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
// Ignore clicks on buttons, inputs, or interactive controls inside the header
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button, input, select, a, label, .hdr-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
globalMaxZIndex++;
|
||||
panelEl.style.zIndex = String(globalMaxZIndex);
|
||||
if (handleEl) handleEl.style.cursor = 'grabbing';
|
||||
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
initialLeft = rect.left;
|
||||
initialTop = rect.top;
|
||||
|
||||
// Convert CSS right/bottom to explicit left/top positioning on first drag
|
||||
panelEl.style.left = `${initialLeft}px`;
|
||||
panelEl.style.top = `${initialTop}px`;
|
||||
panelEl.style.right = 'auto';
|
||||
panelEl.style.bottom = 'auto';
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
|
||||
let newLeft = initialLeft + dx;
|
||||
let newTop = initialTop + dy;
|
||||
|
||||
// Clamp within window boundaries so panel doesn't disappear off-screen
|
||||
const maxLeft = Math.max(10, window.innerWidth - panelEl.offsetWidth - 10);
|
||||
const maxTop = Math.max(10, window.innerHeight - panelEl.offsetHeight - 10);
|
||||
|
||||
newLeft = Math.max(10, Math.min(newLeft, maxLeft));
|
||||
newTop = Math.max(10, Math.min(newTop, maxTop));
|
||||
|
||||
panelEl.style.left = `${newLeft}px`;
|
||||
panelEl.style.top = `${newTop}px`;
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
if (handleEl) handleEl.style.cursor = 'grab';
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
|
||||
if (handleEl) {
|
||||
handleEl.addEventListener('pointerdown', onPointerDown as EventListener);
|
||||
|
||||
// Double-click on header to toggle collapse/expand
|
||||
handleEl.addEventListener('dblclick', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button, input, select, a, label')) return;
|
||||
toggleCollapseState();
|
||||
});
|
||||
}
|
||||
|
||||
let lastHeight = '';
|
||||
|
||||
// Collapse / Expand toggle function
|
||||
const toggleCollapseState = () => {
|
||||
isCollapsed = !isCollapsed;
|
||||
panelEl.classList.toggle('collapsed', isCollapsed);
|
||||
|
||||
if (isCollapsed) {
|
||||
lastHeight = panelEl.style.height || `${panelEl.offsetHeight}px`;
|
||||
panelEl.style.height = '42px';
|
||||
if (bodyEl) bodyEl.style.display = 'none';
|
||||
} else {
|
||||
panelEl.style.height = lastHeight || '480px';
|
||||
if (bodyEl) bodyEl.style.display = 'flex';
|
||||
}
|
||||
|
||||
if (collapseBtnEl) {
|
||||
collapseBtnEl.textContent = isCollapsed ? '▸' : '▾';
|
||||
collapseBtnEl.setAttribute('title', isCollapsed ? '펼치기' : '접기');
|
||||
}
|
||||
|
||||
if (options?.onCollapseToggle) {
|
||||
options.onCollapseToggle(isCollapsed);
|
||||
}
|
||||
};
|
||||
|
||||
if (collapseBtnEl) {
|
||||
collapseBtnEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleCollapseState();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
toggleCollapse: toggleCollapseState,
|
||||
isCollapsed: () => isCollapsed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Entity Property Inspector module for dwg-dxf-viewer-sample.
|
||||
* Formats CAD entity properties and renders detailed property UI panels.
|
||||
*/
|
||||
|
||||
export interface FormattedProperties {
|
||||
id: string;
|
||||
type: string;
|
||||
handle: string;
|
||||
layer: string;
|
||||
general: [string, string][];
|
||||
geometry: [string, string][];
|
||||
}
|
||||
|
||||
const f2 = (v: any): string => (typeof v === 'number' ? v.toFixed(2) : (v ?? '-'));
|
||||
const f3 = (v: any): string => (typeof v === 'number' ? v.toFixed(3) : (v ?? '-'));
|
||||
const deg = (r: any): string => {
|
||||
if (typeof r !== 'number') return '-';
|
||||
let d = (r * 180) / Math.PI;
|
||||
while (d < 0) d += 360;
|
||||
while (d >= 360) d -= 360;
|
||||
return d.toFixed(1) + '°';
|
||||
};
|
||||
|
||||
const pt = (p: any): string => {
|
||||
if (!p) return '-';
|
||||
const x = typeof p.x === 'number' ? f2(p.x) : '0.00';
|
||||
const y = typeof p.y === 'number' ? f2(p.y) : '0.00';
|
||||
const z = typeof p.z === 'number' && Math.abs(p.z) > 1e-6 ? `, ${f2(p.z)}` : '';
|
||||
return `${x}, ${y}${z}`;
|
||||
};
|
||||
|
||||
function formatColor(colorVal: any): string {
|
||||
if (colorVal == null) return 'BYLAYER';
|
||||
if (typeof colorVal === 'number') {
|
||||
if (colorVal === 256) return 'BYLAYER (256)';
|
||||
if (colorVal === 0) return 'BYBLOCK (0)';
|
||||
return `ACI ${colorVal}`;
|
||||
}
|
||||
return String(colorVal);
|
||||
}
|
||||
|
||||
/** DWG lineweight: i16 in 1/100 mm, with -1/-2/-3 reserved for the inherit modes. */
|
||||
function formatLineweight(raw: unknown): string {
|
||||
if (typeof raw !== 'number') return 'BYLAYER';
|
||||
if (raw === -1) return 'BYLAYER';
|
||||
if (raw === -2) return 'BYBLOCK';
|
||||
if (raw === -3) return 'DEFAULT (0.25mm)';
|
||||
if (raw < 0) return String(raw);
|
||||
return `${(raw / 100).toFixed(2)}mm`;
|
||||
}
|
||||
|
||||
export function formatEntityProperties(entity: any): FormattedProperties {
|
||||
if (!entity) {
|
||||
return { id: '', type: '', handle: '', layer: '', general: [], geometry: [] };
|
||||
}
|
||||
|
||||
const d = entity.data || entity;
|
||||
const type = (entity.type || entity.typeName || 'ENTITY').toUpperCase();
|
||||
const handle = entity.handle?.value ?? entity.handle ?? '-';
|
||||
const layer = entity.layer ?? entity.layerName ?? '0';
|
||||
const color = formatColor(entity.color ?? d.color);
|
||||
const linetype = entity.lineType ?? entity.linetype ?? d.lineType ?? 'BYLAYER';
|
||||
const lineweight = formatLineweight(
|
||||
entity.entityHeader?.lineWeight ?? entity.lineWeight ?? entity.lineweight ?? d.lineweight,
|
||||
);
|
||||
|
||||
const general: [string, string][] = [
|
||||
['유형 (Type)', type],
|
||||
['핸들 (Handle)', String(handle)],
|
||||
['레이어 (Layer)', String(layer)],
|
||||
['색상 (Color)', color],
|
||||
['선종류 (Linetype)', String(linetype)],
|
||||
['선가중치 (Lineweight)', lineweight],
|
||||
];
|
||||
|
||||
const geometry: [string, string][] = [];
|
||||
|
||||
switch (type) {
|
||||
case 'LINE': {
|
||||
if (d.start && d.end) {
|
||||
const dx = d.end.x - d.start.x;
|
||||
const dy = d.end.y - d.start.y;
|
||||
const dz = (d.end.z || 0) - (d.start.z || 0);
|
||||
const len = Math.hypot(dx, dy, dz);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
geometry.push(['시작점 (Start)', pt(d.start)]);
|
||||
geometry.push(['끝점 (End)', pt(d.end)]);
|
||||
geometry.push(['증분 (ΔX, ΔY)', `${f2(dx)}, ${f2(dy)}`]);
|
||||
geometry.push(['길이 (Length)', f3(len)]);
|
||||
geometry.push(['각도 (Angle)', deg(angle)]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CIRCLE': {
|
||||
if (d.center && d.radius != null) {
|
||||
const r = d.radius;
|
||||
const diameter = r * 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const area = Math.PI * r * r;
|
||||
|
||||
geometry.push(['중심점 (Center)', pt(d.center)]);
|
||||
geometry.push(['반지름 (Radius)', f3(r)]);
|
||||
geometry.push(['지름 (Diameter)', f3(diameter)]);
|
||||
geometry.push(['둘레 (Circumference)', f3(circumference)]);
|
||||
geometry.push(['넓이 (Area)', f3(area)]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ARC': {
|
||||
if (d.center && d.radius != null) {
|
||||
const r = d.radius;
|
||||
const sa = d.startAngle ?? 0;
|
||||
const ea = d.endAngle ?? Math.PI * 2;
|
||||
let sweep = ea - sa;
|
||||
while (sweep < 0) sweep += Math.PI * 2;
|
||||
const arcLen = sweep * r;
|
||||
|
||||
geometry.push(['중심점 (Center)', pt(d.center)]);
|
||||
geometry.push(['반지름 (Radius)', f3(r)]);
|
||||
geometry.push(['시작각 (Start Angle)', deg(sa)]);
|
||||
geometry.push(['끝각 (End Angle)', deg(ea)]);
|
||||
geometry.push(['중심각 (Sweep Angle)', deg(sweep)]);
|
||||
geometry.push(['호 길이 (Arc Length)', f3(arcLen)]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'LWPOLYLINE':
|
||||
case 'POLYLINE': {
|
||||
const rawPts = d.points ?? d.vertices ?? [];
|
||||
const isClosed = !!(d.closed || (d.flags & 1));
|
||||
let totalLen = 0;
|
||||
|
||||
for (let i = 0; i < rawPts.length - (isClosed ? 0 : 1); i++) {
|
||||
const p1 = rawPts[i];
|
||||
const p2 = rawPts[(i + 1) % rawPts.length];
|
||||
if (p1 && p2) {
|
||||
totalLen += Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
||||
}
|
||||
}
|
||||
|
||||
geometry.push(['정점 개수 (Vertices)', String(rawPts.length)]);
|
||||
geometry.push(['닫힘 여부 (Closed)', isClosed ? '예 (Closed)' : '아니오 (Open)']);
|
||||
geometry.push(['총 길이 (Total Length)', f3(totalLen)]);
|
||||
|
||||
if (isClosed && rawPts.length >= 3) {
|
||||
let shoelace = 0;
|
||||
for (let i = 0; i < rawPts.length; i++) {
|
||||
const p1 = rawPts[i];
|
||||
const p2 = rawPts[(i + 1) % rawPts.length];
|
||||
shoelace += p1.x * p2.y - p2.x * p1.y;
|
||||
}
|
||||
geometry.push(['면적 (Area)', f3(Math.abs(shoelace) / 2)]);
|
||||
}
|
||||
|
||||
if (rawPts.length > 0) {
|
||||
const sampleCount = Math.min(rawPts.length, 4);
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
geometry.push([`정점 P${i + 1}`, pt(rawPts[i])]);
|
||||
}
|
||||
if (rawPts.length > sampleCount) {
|
||||
geometry.push(['...', `외 ${rawPts.length - sampleCount}개 정점`]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// MESH (AcDbSubDMesh). dwg-wasm emits flat arrays: vertices [x,y,z,…],
|
||||
// faceList [n, i0 … i(n-1)] repeated, edges [a,b,…].
|
||||
case 'MESH': {
|
||||
const verts: number[] = d.vertices ?? [];
|
||||
const vCount = d.vertexCount ?? Math.floor(verts.length / 3);
|
||||
const fCount = d.faceCount ?? 0;
|
||||
const eCount = Math.floor((d.edges?.length ?? 0) / 2);
|
||||
const sub = d.subdivisionLevel ?? 0;
|
||||
|
||||
geometry.push(['정점 개수 (Vertices)', String(vCount)]);
|
||||
geometry.push(['면 개수 (Faces)', String(fCount)]);
|
||||
geometry.push(['모서리 개수 (Edges)', String(eCount)]);
|
||||
geometry.push(['세분 레벨 (Subdivision)', String(sub)]);
|
||||
geometry.push(['크리스 혼합 (Blend Crease)', d.blendCrease ? '예 (Yes)' : '아니오 (No)']);
|
||||
|
||||
if (vCount > 0) {
|
||||
let minX = Infinity, minY = Infinity, minZ = Infinity;
|
||||
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
|
||||
for (let i = 0; i + 2 < verts.length; i += 3) {
|
||||
const x = verts[i], y = verts[i + 1], z = verts[i + 2];
|
||||
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
||||
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
|
||||
}
|
||||
geometry.push(['최소점 (Min)', pt({ x: minX, y: minY, z: minZ })]);
|
||||
geometry.push(['최대점 (Max)', pt({ x: maxX, y: maxY, z: maxZ })]);
|
||||
geometry.push(['표고 범위 (Z Range)', `${f3(minZ)} ~ ${f3(maxZ)}`]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEXT':
|
||||
case 'MTEXT':
|
||||
case 'ATTRIB': {
|
||||
const content = d.text ?? entity.text ?? d.textValue ?? '-';
|
||||
const height = d.textHeight ?? d.height ?? entity.textHeight;
|
||||
const rot = d.rotationAngle ?? d.rotation ?? 0;
|
||||
const pos = d.insertionPoint ?? d.insertionPt ?? d.alignmentPt ?? d.pos;
|
||||
|
||||
geometry.push(['텍스트 내용 (Content)', String(content)]);
|
||||
if (height != null) geometry.push(['높이 (Height)', f3(height)]);
|
||||
geometry.push(['회전각 (Rotation)', deg(rot)]);
|
||||
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'INSERT': {
|
||||
const name = d.name ?? d.blockName ?? entity.name ?? '-';
|
||||
const pos = d.insertionPoint ?? d.insertionPt ?? entity.insertionPt;
|
||||
const sx = d.xScale ?? d.scale?.x ?? 1;
|
||||
const sy = d.yScale ?? d.scale?.y ?? 1;
|
||||
const sz = d.zScale ?? d.scale?.z ?? 1;
|
||||
const rot = d.rotation ?? 0;
|
||||
|
||||
geometry.push(['블록 이름 (Block)', String(name)]);
|
||||
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
|
||||
geometry.push(['축척 (Scale X,Y,Z)', `${f2(sx)}, ${f2(sy)}, ${f2(sz)}`]);
|
||||
geometry.push(['회전각 (Rotation)', deg(rot)]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'HATCH': {
|
||||
const isSolid = d.solidFill ?? entity.solidFill ?? false;
|
||||
const pattern = d.patternName ?? d.name ?? (isSolid ? 'SOLID' : 'HATCH');
|
||||
const paths = d.paths ?? entity.paths ?? [];
|
||||
|
||||
geometry.push(['패턴 (Pattern)', String(pattern)]);
|
||||
geometry.push(['채우기 유형 (Type)', isSolid ? '단색 채우기 (Solid)' : '패턴 (Pattern)']);
|
||||
geometry.push(['경계 루프 수 (Loops)', String(paths.length)]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DIMENSION':
|
||||
case 'DIMENSION_LINEAR':
|
||||
case 'DIMENSION_ALIGNED':
|
||||
case 'DIMENSION_RADIUS':
|
||||
case 'DIMENSION_DIAMETER':
|
||||
case 'DIMENSION_ANG_3PT':
|
||||
case 'DIMENSION_ANG_2LN':
|
||||
case 'DIMENSION_ORDINATE': {
|
||||
const meas = d.actualMeasurement ?? entity.actualMeasurement;
|
||||
const textVal = d.userText || (meas != null ? f3(meas) : '-');
|
||||
const midPt = d.textMidPt ?? entity.textMidPt;
|
||||
|
||||
geometry.push(['치수 유형 (Dim Type)', type]);
|
||||
if (meas != null) geometry.push(['측정값 (Measurement)', f3(meas)]);
|
||||
geometry.push(['표시 텍스트 (Text)', String(textVal)]);
|
||||
if (midPt) geometry.push(['텍스트 위치 (Text Mid)', pt(midPt)]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ELLIPSE': {
|
||||
if (d.center) {
|
||||
geometry.push(['중심점 (Center)', pt(d.center)]);
|
||||
if (d.majorAxis) geometry.push(['주축 벡터 (Major Axis)', pt(d.majorAxis)]);
|
||||
if (d.axisRatio != null || d.ratio != null) {
|
||||
geometry.push(['단축 비율 (Ratio)', f3(d.axisRatio ?? d.ratio)]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
let count = 0;
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
if (typeof v === 'object' || v == null || typeof v === 'function') continue;
|
||||
geometry.push([k, String(v)]);
|
||||
count++;
|
||||
if (count >= 12) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = `${type}${handle !== '-' ? ' · #' + handle : ''}`;
|
||||
return { id, type, handle: String(handle), layer: String(layer), general, geometry };
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function renderPropertyInspector(
|
||||
bodyEl: HTMLElement,
|
||||
entity: any,
|
||||
options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null }
|
||||
): void {
|
||||
if (!bodyEl) return;
|
||||
|
||||
if (!entity) {
|
||||
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none';
|
||||
bodyEl.innerHTML = `
|
||||
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6">
|
||||
<div style="font-size:24px;margin-bottom:8px;opacity:.5">🔍</div>
|
||||
도면에서 객체(엔티티)를 클릭하면<br/>상세 속성이 표시됩니다
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex';
|
||||
|
||||
const prop = formatEntityProperties(entity);
|
||||
|
||||
const renderRows = (rows: [string, string][]) =>
|
||||
rows
|
||||
.map(
|
||||
([k, v]) => `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)">
|
||||
<span style="font-size:12px;color:var(--muted);flex-shrink:0">${escapeHtml(k)}</span>
|
||||
<span style="font-family:var(--mono);font-size:12px;color:var(--ink);text-align:right;word-break:break-all;user-select:all">${escapeHtml(v)}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
bodyEl.innerHTML = `
|
||||
<div style="padding:14px">
|
||||
<!-- Entity ID Tag -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25);padding:8px 10px;border-radius:6px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
|
||||
<span style="font-family:var(--mono);font-size:13px;font-weight:700;color:var(--accent)">${escapeHtml(prop.id)}</span>
|
||||
</div>
|
||||
|
||||
<!-- General Section -->
|
||||
<div style="margin-bottom:14px">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px">
|
||||
일반 정보 (General)
|
||||
</div>
|
||||
${renderRows(prop.general)}
|
||||
</div>
|
||||
|
||||
<!-- Geometry Section -->
|
||||
${
|
||||
prop.geometry.length > 0
|
||||
? `
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">
|
||||
기하 속성 (Geometry)
|
||||
</div>
|
||||
${renderRows(prop.geometry)}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
|
||||
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
|
||||
import type { CadSpace } from './cadSpaces';
|
||||
|
||||
export class Viewer2D {
|
||||
constructor(container: HTMLElement);
|
||||
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
|
||||
load(result: unknown, opts?: { keepView?: boolean; keepTheme?: boolean; spaceHandle?: string | number | null }): void;
|
||||
/** Zoom-extents to the loaded drawing. */
|
||||
fit(): void;
|
||||
/** Resync renderer/camera to the container size (call after un-hiding). */
|
||||
resize(): void;
|
||||
onSelect(cb: (entity: unknown) => void): void;
|
||||
getSelectedEntity(): unknown;
|
||||
zoomToSelection(): void;
|
||||
setAccent(hex: string): void;
|
||||
setTheme(dark: boolean): void;
|
||||
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;
|
||||
/** Lineweight display scale in px per mm (AutoCAD's display-scale slider). Default 20. */
|
||||
setLineweightScale(pxPerMm: number | null): void;
|
||||
getLineweightScale(): number;
|
||||
/** Distinct lineweights (mm) present in the loaded drawing, ascending. */
|
||||
getLineweightClasses(): number[];
|
||||
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
|
||||
setHiddenLayers(nameSet: Set<string>): void;
|
||||
/** Model / Paper (Layout) spaces for the loaded drawing. */
|
||||
getSpaces(): CadSpace[];
|
||||
getActiveSpace(): string | null;
|
||||
/** Switch Model ↔ Layout; re-renders (fit unless keepView). */
|
||||
setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void;
|
||||
getZoomPercent(): number;
|
||||
onViewChange(cb: () => void): void;
|
||||
/** Screen client coords → drawing world (Z=0 plane). */
|
||||
screenToWorld(clientX: number, clientY: number): { x: number; y: number; z: number } | null;
|
||||
/** Live CAD coords under the cursor; null when pointer leaves the canvas. */
|
||||
onPointerWorld(cb: ((p: { x: number; y: number; z: number } | null) => void) | null): void;
|
||||
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
|
||||
stopMeasure(): void;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,53 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function parse_dwg(bytes: Uint8Array): any;
|
||||
|
||||
/**
|
||||
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
|
||||
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
|
||||
*
|
||||
* Materializing the whole result as a `serde_json::Value` is what makes big
|
||||
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
|
||||
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
|
||||
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
|
||||
* that takes 2.5 s natively. Converting one entity at a time and appending its
|
||||
* JSON text keeps the live heap flat and the cost linear.
|
||||
*/
|
||||
export function parse_dwg_json(bytes: Uint8Array): string;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly parse_dwg: (a: number, b: number) => [number, number, number];
|
||||
readonly parse_dwg_json: (a: number, b: number) => [number, number, number, number];
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
@@ -0,0 +1,320 @@
|
||||
/* @ts-self-types="./acadrust_dwg.d.ts" */
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parse_dwg(bytes) {
|
||||
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_dwg(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same parseResult as [`parse_dwg`], returned as a JSON string for the
|
||||
* caller to hand to `JSON.parse`. Prefer this on anything but small drawings.
|
||||
*
|
||||
* Materializing the whole result as a `serde_json::Value` is what makes big
|
||||
* files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing
|
||||
* peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the
|
||||
* live heap, so conversion goes quadratic — measured 352 s in wasm for work
|
||||
* that takes 2.5 s natively. Converting one entity at a time and appending its
|
||||
* JSON text keeps the live heap flat and the cost linear.
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function parse_dwg_json(bytes) {
|
||||
let deferred3_0;
|
||||
let deferred3_1;
|
||||
try {
|
||||
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_dwg_json(ptr0, len0);
|
||||
var ptr2 = ret[0];
|
||||
var len2 = ret[1];
|
||||
if (ret[3]) {
|
||||
ptr2 = 0; len2 = 0;
|
||||
throw takeFromExternrefTable0(ret[2]);
|
||||
}
|
||||
deferred3_0 = ptr2;
|
||||
deferred3_1 = len2;
|
||||
return getStringFromWasm0(ptr2, len2);
|
||||
} finally {
|
||||
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
|
||||
}
|
||||
}
|
||||
function __wbg_get_imports() {
|
||||
const import0 = {
|
||||
__proto__: null,
|
||||
__wbg_Error_92b29b0548f8b746: function(arg0, arg1) {
|
||||
const ret = Error(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
},
|
||||
__wbg_String_8564e559799eccda: function(arg0, arg1) {
|
||||
const ret = String(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) {
|
||||
const ret = typeof(arg0) === 'string';
|
||||
return ret;
|
||||
},
|
||||
__wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
},
|
||||
__wbg_new_32b398fb48b6d94a: function() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_7796ffc7ed656783: function() {
|
||||
const ret = new Map();
|
||||
return ret;
|
||||
},
|
||||
__wbg_new_da52cf8fe3429cb2: function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
},
|
||||
__wbg_set_575dd786d51585f8: function(arg0, arg1, arg2) {
|
||||
const ret = arg0.set(arg1, arg2);
|
||||
return ret;
|
||||
},
|
||||
__wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
},
|
||||
__wbg_set_8a16b38e4805b298: function(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
},
|
||||
__wbindgen_cast_0000000000000001: function(arg0) {
|
||||
// Cast intrinsic for `F64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0) {
|
||||
// Cast intrinsic for `I64 -> Externref`.
|
||||
const ret = arg0;
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0) {
|
||||
// Cast intrinsic for `U64 -> Externref`.
|
||||
const ret = BigInt.asUintN(64, arg0);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_init_externref_table: function() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
},
|
||||
};
|
||||
return {
|
||||
__proto__: null,
|
||||
"./acadrust_dwg_bg.js": import0,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
let wasmModule, wasmInstance, wasm;
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasmInstance = instance;
|
||||
wasm = instance.exports;
|
||||
wasmModule = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
} catch (e) {
|
||||
const validResponse = module.ok && expectedResponseType(module.type);
|
||||
|
||||
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else { throw e; }
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedResponseType(type) {
|
||||
switch (type) {
|
||||
case 'basic': case 'cors': case 'default': return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module !== undefined) {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (module_or_path !== undefined) {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('acadrust_dwg_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync, __wbg_init as default };
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const parse_dwg: (a: number, b: number) => [number, number, number];
|
||||
export const parse_dwg_json: (a: number, b: number) => [number, number, number, number];
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dwg-wasm",
|
||||
"type": "module",
|
||||
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"acadrust_dwg_bg.wasm",
|
||||
"acadrust_dwg.js",
|
||||
"acadrust_dwg.d.ts"
|
||||
],
|
||||
"main": "acadrust_dwg.js",
|
||||
"types": "acadrust_dwg.d.ts",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
|
||||
* (wasm-pack --target web, committed under ./acadrust-dwg).
|
||||
*/
|
||||
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
|
||||
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
|
||||
import type { CadParseResult } from './dwgParser';
|
||||
|
||||
let ready: Promise<void> | null = null;
|
||||
|
||||
/** Initialize the acadrust WASM module once (idempotent). */
|
||||
export function initAcadrustParser(): Promise<void> {
|
||||
if (!ready) ready = init({ module_or_path: wasmUrl }).then(() => undefined);
|
||||
return ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DWG buffer. Requires initAcadrustParser() first.
|
||||
*
|
||||
* Goes through `parse_dwg_json` + `JSON.parse` rather than the `parse_dwg`
|
||||
* export that hands back a JsValue. The latter keeps the entire result live on
|
||||
* the wasm heap as it builds, and the allocator's cost grows with that heap —
|
||||
* a 19 MB drawing took ~354 s and froze the tab. Streaming the result out as
|
||||
* text keeps it linear: the same file now lands in ~2.5 s.
|
||||
*/
|
||||
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
|
||||
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* AutoCAD Color Index (ACI) → hex RGB integer.
|
||||
* Returns null for BYBLOCK (0) and BYLAYER (256) — caller uses layer/default color.
|
||||
*/
|
||||
|
||||
function hsl2hex(h, s, l) {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r, g, b;
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
return Math.round((r + m) * 255) * 65536
|
||||
+ Math.round((g + m) * 255) * 256
|
||||
+ Math.round((b + m) * 255);
|
||||
}
|
||||
|
||||
// ACI 0-9: exact fixed colors
|
||||
const FIXED = [
|
||||
null, // 0: BYBLOCK
|
||||
0xFF0000, // 1: red
|
||||
0xFFFF00, // 2: yellow
|
||||
0x00FF00, // 3: green
|
||||
0x00FFFF, // 4: cyan
|
||||
0x0000FF, // 5: blue
|
||||
0xFF00FF, // 6: magenta
|
||||
0xFFFFFF, // 7: white
|
||||
0x414141, // 8: dark gray
|
||||
0x808080, // 9: gray
|
||||
];
|
||||
|
||||
// ACI 10-249: 24 hue groups × 10 saturation/lightness variants
|
||||
const VARIANTS_SL = [
|
||||
[1.00, 0.50], // 0: full
|
||||
[1.00, 0.28], // 1: dark
|
||||
[0.65, 0.65], // 2: light pastel
|
||||
[0.65, 0.38], // 3: pastel dark
|
||||
[0.35, 0.80], // 4: pale
|
||||
[0.35, 0.48], // 5: pale mid
|
||||
[0.18, 0.68], // 6: faint light
|
||||
[0.18, 0.38], // 7: faint dark
|
||||
[0.00, 0.55], // 8: light gray
|
||||
[0.00, 0.30], // 9: dark gray
|
||||
];
|
||||
|
||||
export function aciToHex(aci, isDark = true) {
|
||||
if (aci == null || aci === 0 || aci === 256) return null;
|
||||
if (aci === 7) return isDark ? 0xFFFFFF : 0x000000;
|
||||
if (aci <= 9) return FIXED[aci] ?? null;
|
||||
if (aci >= 250 && aci <= 255) {
|
||||
const v = Math.round(40 + (aci - 250) * 43);
|
||||
return v * 65536 + v * 256 + v;
|
||||
}
|
||||
if (aci >= 10 && aci <= 249) {
|
||||
const idx = aci - 10;
|
||||
const [s, l] = VARIANTS_SL[idx % 10];
|
||||
return hsl2hex(Math.floor(idx / 10) * 15, s, l);
|
||||
}
|
||||
return 0xC9D1D9;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Model Space / Paper Space (Layout) discovery for CadParseResult.
|
||||
*
|
||||
* DWG entities are owned by a block header handle. System blocks
|
||||
* *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces
|
||||
* AutoCAD switches between. Viewer2D must render only one at a time —
|
||||
* drawing both stacks survey-model geometry on top of layout sheet
|
||||
* geometry and produces the "all text piled in the center" glitch.
|
||||
*/
|
||||
|
||||
export type CadSpaceKind = 'model' | 'paper' | 'other';
|
||||
|
||||
export type CadSpace = {
|
||||
/** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */
|
||||
id: string;
|
||||
/** Display label, e.g. "Model" / "Layout" / raw block name. */
|
||||
label: string;
|
||||
/** Raw block name from tables.blocks. */
|
||||
name: string;
|
||||
/** Block handle as number when available. */
|
||||
handle: number | null;
|
||||
/** Hex string matching entity ownerHandle.value.toString(16). */
|
||||
handleHex: string;
|
||||
kind: CadSpaceKind;
|
||||
entityCount: number;
|
||||
};
|
||||
|
||||
function handleToHex(handle: unknown): string | null {
|
||||
if (handle == null) return null;
|
||||
if (typeof handle === 'object' && handle !== null && 'value' in handle) {
|
||||
const v = (handle as { value?: unknown }).value;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16);
|
||||
if (typeof v === 'string' && v.length) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n.toString(16) : v.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16);
|
||||
if (typeof handle === 'string' && handle.length) {
|
||||
const n = Number(handle);
|
||||
return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ownerHex(entity: { ownerHandle?: unknown }): string | null {
|
||||
return handleToHex(entity.ownerHandle);
|
||||
}
|
||||
|
||||
function classifySpaceName(raw: string): CadSpaceKind | null {
|
||||
const name = raw.toLowerCase().replace(/\*/g, '').trim();
|
||||
if (!name) return null;
|
||||
if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model';
|
||||
if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper';
|
||||
return null;
|
||||
}
|
||||
|
||||
function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string {
|
||||
if (kind === 'model') return 'Model';
|
||||
if (kind === 'paper') {
|
||||
// First paper space → "Layout"; further ones keep a disambiguator.
|
||||
if (paperIndex <= 1) return 'Layout';
|
||||
return `Layout (${name.replace(/^\*/, '')})`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* List model/paper space blocks that own at least zero entities.
|
||||
* Always includes Model/Paper headers found in tables.blocks.
|
||||
*/
|
||||
export function listCadSpaces(result: {
|
||||
entities?: unknown[];
|
||||
tables?: { blocks?: Array<{ name?: string; handle?: unknown }> };
|
||||
} | null | undefined): CadSpace[] {
|
||||
const blocks = result?.tables?.blocks ?? [];
|
||||
const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const e of entities) {
|
||||
const hex = ownerHex(e);
|
||||
if (!hex) continue;
|
||||
counts.set(hex, (counts.get(hex) || 0) + 1);
|
||||
}
|
||||
|
||||
const spaces: CadSpace[] = [];
|
||||
let paperIndex = 0;
|
||||
for (const b of blocks) {
|
||||
const kind = classifySpaceName(b.name ?? '');
|
||||
if (!kind) continue;
|
||||
const hex = handleToHex(b.handle);
|
||||
if (!hex) continue;
|
||||
if (kind === 'paper') paperIndex += 1;
|
||||
const handleNum =
|
||||
typeof b.handle === 'number'
|
||||
? b.handle
|
||||
: typeof b.handle === 'object' && b.handle && 'value' in b.handle
|
||||
? Number((b.handle as { value: unknown }).value)
|
||||
: null;
|
||||
spaces.push({
|
||||
id: hex,
|
||||
label: spaceLabel(kind, b.name ?? '', paperIndex),
|
||||
name: b.name ?? '',
|
||||
handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null,
|
||||
handleHex: hex,
|
||||
kind,
|
||||
entityCount: counts.get(hex) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Stable order: Model first, then papers in table order.
|
||||
spaces.sort((a, b) => {
|
||||
if (a.kind !== b.kind) {
|
||||
if (a.kind === 'model') return -1;
|
||||
if (b.kind === 'model') return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return spaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tab when a file is opened:
|
||||
* - Paper/Layout with entities if any (civil sheet drawings often leave Layout active)
|
||||
* - else Model
|
||||
* - else first listed space
|
||||
*/
|
||||
export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null {
|
||||
if (!spaces.length) return null;
|
||||
const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0);
|
||||
if (paperWithContent) return paperWithContent;
|
||||
const model = spaces.find((s) => s.kind === 'model');
|
||||
if (model) return model;
|
||||
return spaces[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owners allowed when filtering to one space: the space handle itself plus
|
||||
* handles of entities that live under it (e.g. INSERT → ATTRIB children).
|
||||
*/
|
||||
export function buildAllowedOwnerHexes(
|
||||
entities: Array<{ handle?: unknown; ownerHandle?: unknown }>,
|
||||
activeSpaceHex: string,
|
||||
rounds = 3,
|
||||
): Set<string> {
|
||||
const allowed = new Set<string>([activeSpaceHex]);
|
||||
for (let pass = 0; pass < rounds; pass++) {
|
||||
let grew = false;
|
||||
for (const ent of entities) {
|
||||
const oh = ownerHex(ent);
|
||||
if (!oh || !allowed.has(oh)) continue;
|
||||
const h = handleToHex(ent.handle);
|
||||
if (h && !allowed.has(h)) {
|
||||
allowed.add(h);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
if (!grew) break;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export function isSystemSpaceBlockName(name: string): boolean {
|
||||
return classifySpaceName(name) != null;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Decode a DXF buffer to text.
|
||||
*
|
||||
* Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE
|
||||
* (e.g. ANSI_949) that no longer matches the bytes — so the header is
|
||||
* unreliable. Strategy: try UTF-8 strictly (self-validating); on failure
|
||||
* fall back to the header's single-byte codepage. The DXF group/value
|
||||
* structure is ASCII regardless, so this only affects TEXT/MTEXT strings.
|
||||
*/
|
||||
export function decodeDxf(buf: ArrayBuffer): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
||||
} catch {
|
||||
/* not valid UTF-8 → legacy single-byte codepage */
|
||||
}
|
||||
const head = new TextDecoder('latin1').decode(
|
||||
new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)),
|
||||
);
|
||||
const cp = head
|
||||
.match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
let label = 'windows-1252';
|
||||
if (cp) {
|
||||
if (/949/.test(cp)) label = 'euc-kr';
|
||||
else if (/932/.test(cp)) label = 'shift_jis';
|
||||
else if (/936/.test(cp)) label = 'gbk';
|
||||
else if (/950/.test(cp)) label = 'big5';
|
||||
}
|
||||
try {
|
||||
return new TextDecoder(label).decode(buf);
|
||||
} catch {
|
||||
return new TextDecoder('utf-8').decode(buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0,
|
||||
* vendored with a local read_mesh patch; see hmwebviewer rust/dwg-wasm). Emits
|
||||
* the parseResult shape Viewer2D.load() consumes. Lazy-imported so the wasm is
|
||||
* only fetched when a DWG is actually opened.
|
||||
*
|
||||
* History: until 2026-07-10 this module selected between acadrust and
|
||||
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
|
||||
* after side-by-side validation (PROGRESS.md Phase 15).
|
||||
*
|
||||
* Post-process: fixDwgKoreanText repairs CP949→Latin-1 mojibake common in
|
||||
* older Korean DWGs (see fixDwgKoreanText.ts). Unicode-correct drawings
|
||||
* (e.g. BasicSample) are left unchanged.
|
||||
*/
|
||||
|
||||
import { fixDwgKoreanText } from './fixDwgKoreanText';
|
||||
|
||||
export type CadParseResult = {
|
||||
version?: string;
|
||||
vars?: Record<string, unknown>;
|
||||
entities?: unknown[];
|
||||
tables?: Record<string, unknown>;
|
||||
stats?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */
|
||||
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
|
||||
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
|
||||
await initAcadrustParser();
|
||||
return fixDwgKoreanText(parseDwgAcadrust(bytes));
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */
|
||||
export function dxfToParseResult(dxf: unknown): {
|
||||
version: string;
|
||||
vars: Record<string, unknown>;
|
||||
entities: unknown[];
|
||||
tables: { blocks: unknown[]; layers: unknown[] };
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that
|
||||
* Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension
|
||||
* block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF.
|
||||
*
|
||||
* Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the
|
||||
* raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD
|
||||
* handle its child entities are owned by, and INSERT/DIMENSION reference their
|
||||
* block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the
|
||||
* incoherent handles and synthesize one consistent integer handle PER BLOCK NAME:
|
||||
* - block table entry handle = {value: H}
|
||||
* - every child entity ownerHandle = {value: H}
|
||||
* - INSERT.blockHeaderHandle = {value: H of insert.name}
|
||||
* - DIMENSION.blockHeaderHandle = {value: H of dim.block}
|
||||
* Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so
|
||||
* Viewer2D's space-handle filter keeps them visible.
|
||||
*
|
||||
* Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but
|
||||
* INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here.
|
||||
*
|
||||
* Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent.
|
||||
*/
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
|
||||
const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p);
|
||||
|
||||
/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */
|
||||
function adaptEntity(e, nameToH) {
|
||||
const o = { ...e };
|
||||
delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI
|
||||
const t = (e.type || '').toUpperCase();
|
||||
|
||||
switch (t) {
|
||||
case 'LINE':
|
||||
if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); }
|
||||
break;
|
||||
|
||||
case 'POLYLINE':
|
||||
case 'LWPOLYLINE':
|
||||
o.type = 'LWPOLYLINE';
|
||||
o.points = (e.vertices || []).map(vec);
|
||||
o.bulges = (e.vertices || []).map((v) => v.bulge || 0);
|
||||
o.closed = !!e.shape || ((e.flags || 0) & 1) === 1;
|
||||
break;
|
||||
|
||||
case 'ARC':
|
||||
case 'CIRCLE':
|
||||
o.center = vec(e.center); // radius + (radian) angles already match
|
||||
break;
|
||||
|
||||
case 'ELLIPSE':
|
||||
o.center = vec(e.center);
|
||||
o.majorAxis = vec(e.majorAxisEndPoint);
|
||||
o.ratio = e.axisRatio;
|
||||
o.startParam = e.startAngle;
|
||||
o.endParam = e.endAngle;
|
||||
break;
|
||||
|
||||
case 'POINT':
|
||||
o.position = vec(e.position);
|
||||
break;
|
||||
|
||||
case 'SOLID':
|
||||
case '3DFACE': {
|
||||
const p = e.points || [];
|
||||
if (p[0]) o.corner1 = vec(p[0]);
|
||||
if (p[1]) o.corner2 = vec(p[1]);
|
||||
if (p[2]) o.corner3 = vec(p[2]);
|
||||
if (p[3]) o.corner4 = vec(p[3]);
|
||||
o.type = 'SOLID';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEXT':
|
||||
case 'ATTRIB':
|
||||
case 'ATTDEF':
|
||||
o.insertionPoint = vec(e.startPoint || e.position);
|
||||
o.alignmentPt = vec(e.endPoint);
|
||||
o.rotationAngle = (e.rotation || 0) * DEG2RAD;
|
||||
o.horizAlignment = e.halign || 0;
|
||||
o.vertAlignment = e.valign || 0;
|
||||
o.textHeight = e.textHeight ?? e.height;
|
||||
break;
|
||||
|
||||
case 'MTEXT':
|
||||
o.insertionPoint = vec(e.position);
|
||||
o.textHeight = e.height ?? e.textHeight;
|
||||
if (e.directionVector) o.xAxisDir = vec(e.directionVector);
|
||||
else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) };
|
||||
break;
|
||||
|
||||
case 'INSERT': {
|
||||
o.insertionPoint = vec(e.position);
|
||||
o.scaleX = e.xScale ?? 1;
|
||||
o.scaleY = e.yScale ?? 1;
|
||||
o.rotation = (e.rotation || 0) * DEG2RAD;
|
||||
const h = nameToH.get(e.name);
|
||||
if (h != null) o.blockHeaderHandle = { value: h };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DIMENSION': {
|
||||
const h = nameToH.get(e.block);
|
||||
if (h != null) o.blockHeaderHandle = { value: h };
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a full dxf-parser result → Viewer2D parseResult.
|
||||
* @param {object} dxf output of `new DxfParser().parseSync(text)`
|
||||
* @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}}
|
||||
*/
|
||||
export function dxfToParseResult(dxf) {
|
||||
const blocks = dxf.blocks || {};
|
||||
const names = Object.keys(blocks);
|
||||
const nameToH = new Map();
|
||||
names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles
|
||||
|
||||
const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n));
|
||||
const msH = msName != null ? nameToH.get(msName) : 0;
|
||||
|
||||
const entities = [];
|
||||
// Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle.
|
||||
for (const e of (dxf.entities || [])) {
|
||||
const o = adaptEntity(e, nameToH);
|
||||
o.ownerHandle = { value: msH };
|
||||
entities.push(o);
|
||||
}
|
||||
// Block-definition entities → owned by their block's synthetic handle.
|
||||
for (const n of names) {
|
||||
const H = nameToH.get(n);
|
||||
for (const e of (blocks[n].entities || [])) {
|
||||
const o = adaptEntity(e, nameToH);
|
||||
o.ownerHandle = { value: H };
|
||||
entities.push(o);
|
||||
}
|
||||
}
|
||||
|
||||
const blockTable = names.map((n) => ({
|
||||
name: n,
|
||||
handle: { value: nameToH.get(n) },
|
||||
basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 },
|
||||
}));
|
||||
|
||||
const layerSrc = dxf.tables?.layer?.layers || {};
|
||||
const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex }));
|
||||
|
||||
return {
|
||||
version: 'dxf',
|
||||
vars: dxf.header || {},
|
||||
entities,
|
||||
tables: { blocks: blockTable, layers: layerTable },
|
||||
};
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/** HATCH entity handler for dxf-parser's registerEntityHandler(). */
|
||||
export default class HatchHandler {
|
||||
ForEntityName: string;
|
||||
parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops
|
||||
* HATCH silently). Registered via the library's public `registerEntityHandler()`,
|
||||
* so dxf-parser itself stays unmodified (MIT-clean).
|
||||
*
|
||||
* Emits the shape Viewer2D's HATCH renderer consumes:
|
||||
* entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] }
|
||||
* Viewer2D fills solid hatches (with island/hole nesting) and draws boundary
|
||||
* outlines; bulge arcs on polyline boundaries are honored.
|
||||
*
|
||||
* Boundary kinds handled:
|
||||
* - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42)
|
||||
* - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points
|
||||
* ELLIPSE(72=3), SPLINE(72=4, control-point polyline)
|
||||
*
|
||||
* Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the
|
||||
* boundary outline as a proxy, matching its DWG behavior.
|
||||
*/
|
||||
const DEG2RAD = Math.PI / 180;
|
||||
const ARC_STEPS = 48;
|
||||
|
||||
function tessArc(cx, cy, r, a0, a1, ccw, out) {
|
||||
let span = a1 - a0;
|
||||
if (ccw) { while (span <= 0) span += Math.PI * 2; }
|
||||
else { while (span >= 0) span -= Math.PI * 2; }
|
||||
const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const a = a0 + (span * i) / steps;
|
||||
out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
|
||||
}
|
||||
}
|
||||
|
||||
export default class HatchHandler {
|
||||
constructor() {
|
||||
this.ForEntityName = 'HATCH';
|
||||
}
|
||||
|
||||
parseEntity(scanner, curr) {
|
||||
const entity = { type: curr.value, paths: [], solidFill: false };
|
||||
curr = scanner.next();
|
||||
while (!scanner.isEOF()) {
|
||||
if (curr.code === 0) break;
|
||||
switch (curr.code) {
|
||||
case 70: // solid fill flag
|
||||
entity.solidFill = curr.value !== 0;
|
||||
break;
|
||||
case 2: // pattern name
|
||||
entity.patternName = curr.value;
|
||||
break;
|
||||
case 8: // layer
|
||||
entity.layer = curr.value;
|
||||
break;
|
||||
case 62: // color index
|
||||
entity.colorIndex = curr.value;
|
||||
break;
|
||||
case 91: { // number of boundary paths → parse them by count
|
||||
const n = curr.value;
|
||||
let g = scanner.next();
|
||||
for (let i = 0; i < n && !scanner.isEOF(); i++) {
|
||||
g = this._readPath(scanner, entity, g);
|
||||
}
|
||||
curr = g; // g is the group following the last path's data
|
||||
continue; // curr already advanced — skip the trailing next()
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
curr = scanner.next();
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Parse one boundary path starting at its 92 group; return the next group after it. */
|
||||
_readPath(scanner, entity, g) {
|
||||
// g.code should be 92 (path type flag); if not, skip until we find it.
|
||||
while (!scanner.isEOF() && g.code !== 92) g = scanner.next();
|
||||
const flag = g.value | 0;
|
||||
const isPolyline = (flag & 2) !== 0;
|
||||
const path = { points: [], bulges: [] };
|
||||
let cur = scanner.next();
|
||||
|
||||
if (isPolyline) {
|
||||
let hasBulge = false;
|
||||
while (cur.code === 72 || cur.code === 73) {
|
||||
if (cur.code === 72) hasBulge = cur.value !== 0;
|
||||
cur = scanner.next();
|
||||
}
|
||||
let numVerts = 0;
|
||||
if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); }
|
||||
for (let v = 0; v < numVerts && !scanner.isEOF(); v++) {
|
||||
let x = 0, y = 0, b = 0;
|
||||
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
|
||||
if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); }
|
||||
path.points.push({ x, y });
|
||||
path.bulges.push(b);
|
||||
}
|
||||
} else {
|
||||
let numEdges = 0;
|
||||
if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); }
|
||||
for (let e = 0; e < numEdges && !scanner.isEOF(); e++) {
|
||||
let edgeType = 0;
|
||||
if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); }
|
||||
cur = this._readEdge(scanner, path, edgeType, cur);
|
||||
}
|
||||
}
|
||||
|
||||
// Consume the per-path trailing "source boundary objects" block (97 + 330×count).
|
||||
if (cur.code === 97) {
|
||||
const srcN = cur.value | 0;
|
||||
cur = scanner.next();
|
||||
for (let s = 0; s < srcN && !scanner.isEOF(); s++) {
|
||||
if (cur.code === 330) cur = scanner.next();
|
||||
else break;
|
||||
}
|
||||
}
|
||||
if (path.points.length >= 2) entity.paths.push(path);
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Tessellate one edge into path.points; return the next group. */
|
||||
_readEdge(scanner, path, edgeType, cur) {
|
||||
if (edgeType === 1) {
|
||||
// LINE: 10/20 start, 11/21 end
|
||||
let sx = 0, sy = 0, ex = 0, ey = 0;
|
||||
if (cur.code === 10) { sx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { sy = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 11) { ex = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 21) { ey = cur.value; cur = scanner.next(); }
|
||||
path.points.push({ x: sx, y: sy });
|
||||
path.points.push({ x: ex, y: ey }); // last edge's end closes the loop
|
||||
} else if (edgeType === 2) {
|
||||
// ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw
|
||||
let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1;
|
||||
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 40) { r = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
|
||||
tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points);
|
||||
} else if (edgeType === 3) {
|
||||
// ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw
|
||||
let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1;
|
||||
if (cur.code === 10) { cx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { cy = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 11) { mx = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 21) { my = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); }
|
||||
if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); }
|
||||
const major = Math.hypot(mx, my);
|
||||
const minor = major * ratio;
|
||||
const rot = Math.atan2(my, mx);
|
||||
let span = a1 - a0;
|
||||
if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; }
|
||||
else { while (span >= 0) span -= Math.PI * 2; }
|
||||
const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = a0 + (span * i) / steps;
|
||||
const ex = major * Math.cos(t), ey = minor * Math.sin(t);
|
||||
path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) });
|
||||
}
|
||||
} else if (edgeType === 4) {
|
||||
// SPLINE: approximate by its control points (10/20). Skip knots/weights.
|
||||
// 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights
|
||||
let numKnots = 0, numCtrl = 0;
|
||||
const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; };
|
||||
while (!scanner.isEOF()) {
|
||||
if (cur.code === 95) { numKnots = readInt(); }
|
||||
else if (cur.code === 96) { numCtrl = readInt(); }
|
||||
else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); }
|
||||
else break;
|
||||
}
|
||||
for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next();
|
||||
for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) {
|
||||
let x = 0, y = 0;
|
||||
if (cur.code === 10) { x = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 20) { y = cur.value; cur = scanner.next(); }
|
||||
if (cur.code === 42) cur = scanner.next(); // weight
|
||||
path.points.push({ x, y });
|
||||
}
|
||||
} else {
|
||||
// Unknown edge type: advance one group to avoid stalling.
|
||||
cur = scanner.next();
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 2D file-format detection for the 2D+3D merge dispatch.
|
||||
*
|
||||
* 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser.
|
||||
* Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer.
|
||||
* Kept dependency-free so both the dropzone and the controller can import it
|
||||
* without pulling in either viewer.
|
||||
*/
|
||||
export const DWG_EXT = /\.dwg$/i;
|
||||
export const DXF_EXT = /\.dxf$/i;
|
||||
|
||||
/** True if the name is a binary DWG (parsed by the WASM parser). */
|
||||
export function isDwg(name: string): boolean {
|
||||
return DWG_EXT.test(name);
|
||||
}
|
||||
|
||||
/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */
|
||||
export function isDxf(name: string): boolean {
|
||||
return DXF_EXT.test(name);
|
||||
}
|
||||
|
||||
/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */
|
||||
export function is2D(name: string): boolean {
|
||||
return DWG_EXT.test(name) || DXF_EXT.test(name);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR.
|
||||
*
|
||||
* Older Korean DWGs store strings in code page 949. When those bytes are
|
||||
* decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin
|
||||
* (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings
|
||||
* (already containing Hangul syllables) are left unchanged.
|
||||
*
|
||||
* Apply after parse_dwg, before Viewer2D.load().
|
||||
*/
|
||||
|
||||
const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/;
|
||||
const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/;
|
||||
|
||||
/** Labels tried in order; first supported decoder wins. */
|
||||
const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const;
|
||||
|
||||
let koreanDecoder: TextDecoder | null | undefined;
|
||||
|
||||
function getKoreanDecoder(): TextDecoder | null {
|
||||
if (koreanDecoder !== undefined) return koreanDecoder;
|
||||
for (const label of KOREAN_LABELS) {
|
||||
try {
|
||||
koreanDecoder = new TextDecoder(label);
|
||||
return koreanDecoder;
|
||||
} catch {
|
||||
/* label not available in this runtime */
|
||||
}
|
||||
}
|
||||
koreanDecoder = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `s` looks like CP949 bytes misread as Latin-1, return the Hangul
|
||||
* form; otherwise return `s` unchanged.
|
||||
*/
|
||||
export function fixCp949Mojibake(s: string): string {
|
||||
if (!s) return s;
|
||||
// Already real Hangul → leave alone (e.g. BasicSample.dwg).
|
||||
if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s;
|
||||
|
||||
let hasHigh = false;
|
||||
const bytes = new Uint8Array(s.length);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
if (code > 255) return s; // true Unicode beyond Latin-1
|
||||
if (code > 127) hasHigh = true;
|
||||
bytes[i] = code;
|
||||
}
|
||||
if (!hasHigh) return s; // pure ASCII
|
||||
|
||||
const decoder = getKoreanDecoder();
|
||||
if (!decoder) return s;
|
||||
|
||||
let fixed: string;
|
||||
try {
|
||||
fixed = decoder.decode(bytes);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Accept only when we gain Hangul and the decode is clean.
|
||||
if (
|
||||
(HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) &&
|
||||
!fixed.includes('\uFFFD')
|
||||
) {
|
||||
return fixed;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields
|
||||
* in place. Returns the same object for chaining.
|
||||
*/
|
||||
export function fixDwgKoreanText<T>(root: T): T {
|
||||
if (root == null || typeof root !== 'object') return root;
|
||||
const seen = new WeakSet<object>();
|
||||
|
||||
const walk = (value: unknown): void => {
|
||||
if (value == null || typeof value !== 'object') return;
|
||||
if (seen.has(value as object)) return;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const item = value[i];
|
||||
if (typeof item === 'string') value[i] = fixCp949Mojibake(item);
|
||||
else walk(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === 'imageDataUrl') continue;
|
||||
const item = obj[key];
|
||||
if (typeof item === 'string') obj[key] = fixCp949Mojibake(item);
|
||||
else walk(item);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Auto-generated standard LIN file definitions for Icad.lin and KOSDIC.lin
|
||||
export const ICAD_LIN = "; IntelliCAD Linetypes\r\n;\r\n; icad.lin\r\n; Release 1.0 \r\n; \r\n; .............................................................................\r\n;\r\n; Copyright (c) 1998 by Visio Corporation. All rights reserved.\r\n;\r\n; The Software is subject to the license agreement that accompanies \r\n; or is included with the Software, which specifies the permitted \r\n; and prohibited uses of the Software. Any unauthorized duplication \r\n; or use of Visio Corporation Software, in whole or in part, in print, \r\n; or in any other storage and retrieval system is prohibited.\r\n; \r\n; To the maximum extent permitted by applicable law, Visio Corporation\r\n; and its suppliers disclaim any and all warranties and conditions,\r\n; either express or implied, including, without limitation, implied\r\n; warranties of merchantability, fitness for a particular purpose,\r\n; title, and non-infringement, and those arising out of usage of trade\r\n; or course of dealing, concerning these materials. These materials\r\n; are provided \"as is\" without warranty of any kind.\r\n; .............................................................................\r\n\r\n\r\n\r\n*BORDER,__ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . \r\nA,0.50,-0.250,0.50,-0.250,0,-0.250\r\n*BORDER2,__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__ \r\nA,0.25,-0.125,0.25,-0.125,0,-0.125\r\n*BORDERX2,____ ____ . ____ ____ . ____ ____ . ____ ____ . \r\nA,1.0,-0.5,1.0,-0.5,0,-0.5\r\n\r\n*CENTER,____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _\r\nA,1.250,-0.25,0.25,-0.25\r\n*CENTER2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.1250,0.1250,-0.125\r\n*CENTERX2,________ __ ________ __ ________ __ ________ __ ____\r\nA,2.5,-0.50,0.50,-0.50\r\n\r\n*DASHDOT,__ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . _\r\nA,0.50,-0.250,0,-0.250\r\n*DASHDOT2,_._._._._._._._._._._._._._._._._._._._._._._._._._._._._._.\r\nA,0.25,-0.125,0,-0.125\r\n*DASHDOTX2,____ . ____ . ____ . ____ . ____ . ____ . ____ \r\nA,1.0,-0.5,0,-0.5\r\n\r\n*DASHED,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,0.50,-0.250\r\n*DASHED2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.250,-0.1250\r\n*DASHEDX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,1.0,-0.5\r\n\r\n*DIVIDE,____ . . ____ . . ____ . . ____ . . ____ . . ____ . . ____ . .\r\nA,0.50,-0.25,0,-0.25,0,-0.25\r\n*DIVIDE2,__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..\r\nA,0.25,-0.125,0,-0.125,0,-0.125\r\n*DIVIDEX2,________ . . ________ . . ________ . . ________ . .\r\nA,1.0,-0.5,0,-0.5,0,-0.50\r\n\r\n*DOT,. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\r\nA,0,-0.25\r\n*DOT2,................................................................\r\nA,0,-0.125\r\n*DOTX2,. . . . . . . . . . . . . . . . . . . . . \r\nA,0,-0.5\r\n\r\n*HIDDEN,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ \r\nA,0.250,-0.1250\r\n*HIDDEN2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.06250\r\n*HIDDENX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,0.5,-0.25\r\n\r\n*PHANTOM,______ __ __ ______ __ __ ______ __ __ ______ __ __\r\nA,1.25,-0.25,0.250,-0.25,0.25,-0.25\r\n*PHANTOM2,___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.625,-0.125,0.1250,-0.125,0.125,-0.1250\r\n*PHANTOMX2,____________ ____ ____ ____________ ____ ____ \r\nA,2.5,-0.5,0.5,-0.5,0.50,-0.5\r\n\r\n*PHANTOM4,___ _ _ ___ _ _ (1/2 of Phantom2)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.3125,-0.0625,0.0625,-0.0625,0.0625,-0.0625\r\n\r\n*PHANTOM8,___ _ _ ___ _ _ (1/2 of Phantom4)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.15625,-0.03125,0.03125,-0.03125,0.03125,-0.03125\r\n\r\n*BORDER4,__.__.__.__.__.__._ (1/2 of Border2) _.__.__.__.__.__.__.__.__ \r\nA,0.125,-0.0625,0.125,-0.0625,0,-0.0625\r\n\r\n*CENTER4,___ _ ___ _ ___ _ __ (1/2 of Center2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.0625,0.0625,-0.0625\r\n*CENTER8,___ _ ___ _ ___ _ __ (1/2 of Center4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.03125,0.03125,-0.03125\r\n\r\n*CENTERF,_____ _ _____ (Finer short dashes than Center) _____\r\nA,1.25,-0.125,0.125,-0.125\r\n*CENTERF2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.0625,0.0625,-0.0625\r\n;; Auto-Arch additions for finer linetypes.\r\n*CENTERF4,___ _ ___ _ ___ _ __ (1/2 of CenterF2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.03125,0.03125,-0.03125\r\n*CENTERF8,___ _ ___ _ ___ _ __ (1/2 of CenterF4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.0015625,0.0015625,-0.0015625\r\n\r\n*DASHED4,_ _ _ _ _ _ _ _ (1/2 of Dashed2)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.0625\r\n*DASHED8,_ _ _ _ _ _ _ _ (1/2 of Dashed4)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.03125\r\n\r\n*DEMOx2,____ ____ ____ ____
Line truncated
|
||||
export const KOSDIC_LIN = ";; ------------------------------------�� �� �� ��------------------------------------\r\n;; �� �� ���� ���� ����(KOSDIC.lin)�� �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ���� �� ��Ͽ� ���ǵ� �� ������\r\n;; AutoCAD ����ڿ��� �����ϱ� ���� ���۵Ǿ����ϴ�.\r\n;; \r\n;; ǥ�ع��� : �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ�� V1.1(2006.12)\r\n;; ������� : �Ǽ�CALS/EC ������ �ѱ��Ǽ����������\r\n;;\r\n;; �����Ģ : 1/1 ��ô�� ����ũ�� 2.0mm�� ��� ������ ũ�⸦ ������\r\n;; LIB ��Ģ : AutoCAD�� ANSI����(���� ��������� ����)�� ����Ű ���� 1/10�� ������\r\n;; (��) ���� �� ���� ������ 2mm�̸� 0.2unit�� ����\r\n;; ����Ģ : �ǹ����鿡 ����� ��� �������� ��Ģ�� ���յǷ��� ��������ô�� 10�踦 ������\r\n;; (��) LTSCALE=10 (���� 8~10��), ������ô�� 1/5 �̸� LTSCALE=5x10��=50\r\n;;\r\n;; ISO���� �� 15��\r\n// ���� AutoCAD ISO�� 25.4����� ������ �� ����\r\n*A-ISONS01,ISO ������ 01 Continuous ________________________________\r\nA,1,1\r\n*A-ISONS02,ISO ������ 02 dash __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,.47244,-.118111\r\n*A-ISONS03,ISO ������ 03 dash space __ __ __ __ __ __\r\nA,.47244,-.7086614\r\n*A-ISONS04,ISO ������ 04 long-dash dot ____ . ____ . ____ . ____ . _\r\nA,.94488,-.11811,0,-.11811\r\n*A-ISONS05,ISO ������ 05 long-dash double-dot ____ .. ____ .. ____ . \r\nA,.94488,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS06,ISO ������ 06 long-dash triple-dot ____ ... ____ ... ____\r\nA,.94488,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS07,ISO ������ 07 dot . . . . . . . . . . . . . . . . . . . . \r\nA,0,-.11811\r\n*A-ISONS08,ISO ������ 08 long-dash short-dash ____ __ ____ __ ____ _\r\nA,.94488,-.11811,.94488,-.11811\r\n*A-ISONS09,ISO ������ 09 long-dash double-short-dash ____ __ __ ____\r\nA,.94488,-.11811,.94488,-.11811,.94488,-.11811\r\n*A-ISONS10,ISO ������ 10 dash dot __ . __ . __ . __ . __ . __ . __ . \r\nA,.47244,-.11811,0,-.11811\r\n*A-ISONS11,ISO ������ 11 double-dash dot __ __ . __ __ . __ __ . __ _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811\r\n*A-ISONS12,ISO ������ 12 dash double-dot __ . . __ . . __ . . __ . . \r\nA,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS13,ISO ������ 13 double-dash double-dot __ __ . . __ __ . . _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS14,ISO ������ 14 dash triple-dot __ . . . __ . . . __ . . . _\r\nA,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS15,ISO ������ 15 double-dash triple-dot __ __ . . . __ __ . .\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n\r\n;; ���CAD ����\r\n*A-BORDR01,�ܰ���(�Ϲ�) Border __ __ . __ __ . __ __ . __ __ . __ __ .\r\nA,.5,-.25,.5,-.25,0,-.25\r\n*A-BORDR01H,�ܰ���(�ݹ��) Border (.5x) __.__.__.__.__.__.__.__.__.__.__.\r\nA,.25,-.125,.25,-.125,0,-.125\r\n*A-BORDR01X,�ܰ���(���) Border (2x) ____ ____ . ____ ____ . ___\r\nA,1.0,-.5,1.0,-.5,0,-.5\r\n*A-CENTR01,�߽ɼ�(�Ϲ�) Center ____ _ ____ _ ____ _ ____ _ ____ _ ____\r\nA,1.25,-.25,.25,-.25\r\n*A-CENTR01H,�߽ɼ�(�ݹ��) Center (.5x) ___ _ ___ _ ___ _ ___ _ ___ _ ___\r\nA,.75,-.125,.125,-.125\r\n*A-CENTR01X,�߽ɼ�(���) Center (2x) ________ __ ________ __ _____\r\nA,2.5,-.5,.5,-.5\r\n*A-DASDT01,�Ĵ�����(�Ϲ�) Dash dot __ . __ . __ . __ . __ . __ . __ . __\r\nA,.5,-.25,0,-.25\r\n*A-DASDT01H,�Ĵ�����(�ݹ��) Dash dot (.5x) _._._._._._._._._._._._._._._.\r\nA,.25,-.125,0,-.125\r\n*A-DASDT01X,�Ĵ�����(���) Dash dot (2x) ____ . ____ . ____ . ___\r\nA,1.0,-.5,0,-.5\r\n*A-DASHD01,�ļ�(�Ϲ�) Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _\r\nA,.5,-.25\r\n*A-DASHD01H,�ļ�(�ݹ��) Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nA,.25,-.125\r\n*A-DASHD01X,�ļ�(���) Dashed (2x) ____ ____ ____ ____ ____ ___\r\nA,1.0,-.5\r\n*A-DIVID01,���Ҽ�(�Ϲ�) Divide ____ . . ____ . . ____ . . ____ . . ____\r\nA,.5,-.25,0,-.25,0,-.25\r\n*A-DIVID01H,���Ҽ�(�ݹ��) Divide (.5x) __..__..__..__..__..__..__..__.._\r\nA,.25,-.125,0,-.125,0,-.125\r\n*A-DIVID01X,���
Line truncated
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* LIN File Parser & Complex Linetype Registry
|
||||
* Parses AutoCAD LIN file definitions (including text and shape descriptors).
|
||||
*/
|
||||
|
||||
import { ICAD_LIN, KOSDIC_LIN } from './linData';
|
||||
|
||||
export interface LinElement {
|
||||
type: 'dash' | 'gap' | 'shape' | 'text';
|
||||
val?: number; // dash length (+) or gap length (-)
|
||||
shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG
|
||||
shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx
|
||||
text?: string; // e.g. "L2", "GAS", "HW"
|
||||
style?: string; // e.g. Standard
|
||||
scale: number; // s=... (default 1.0)
|
||||
rotation: number; // r=... (angle in degrees)
|
||||
isAbsoluteAngle: boolean;// a=...
|
||||
isUprightAngle: boolean; // u=...
|
||||
xOffset: number; // x=...
|
||||
yOffset: number; // y=...
|
||||
}
|
||||
|
||||
export interface LinPattern {
|
||||
name: string;
|
||||
description: string;
|
||||
elements: LinElement[];
|
||||
patternLength: number; // total length sum of dashes + abs(gaps)
|
||||
}
|
||||
|
||||
function parseBracketElement(inner: string): LinElement {
|
||||
const parts: string[] = [];
|
||||
let cur = '';
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const c = inner[i];
|
||||
if (c === '"') inQuote = !inQuote;
|
||||
if (c === ',' && !inQuote) {
|
||||
parts.push(cur.trim());
|
||||
cur = '';
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
}
|
||||
if (cur.trim()) parts.push(cur.trim());
|
||||
|
||||
let type: 'shape' | 'text' = 'shape';
|
||||
let shapeName: string | undefined;
|
||||
let shxFile: string | undefined;
|
||||
let text: string | undefined;
|
||||
let style: string | undefined;
|
||||
|
||||
let scale = 1.0;
|
||||
let rotation = 0;
|
||||
let isAbsoluteAngle = false;
|
||||
let isUprightAngle = false;
|
||||
let xOffset = 0;
|
||||
let yOffset = 0;
|
||||
|
||||
if (parts.length > 0) {
|
||||
const first = parts[0];
|
||||
if (first.startsWith('"') && first.endsWith('"')) {
|
||||
type = 'text';
|
||||
text = first.slice(1, -1);
|
||||
if (parts.length > 1 && !parts[1].includes('=')) style = parts[1];
|
||||
} else {
|
||||
type = 'shape';
|
||||
shapeName = first;
|
||||
if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1];
|
||||
}
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
const key = part.substring(0, eqIdx).trim().toLowerCase();
|
||||
const valStr = part.substring(eqIdx + 1).trim();
|
||||
const val = parseFloat(valStr);
|
||||
if (!isNaN(val)) {
|
||||
if (key === 's') scale = val;
|
||||
else if (key === 'r') { rotation = val; }
|
||||
else if (key === 'a') { rotation = val; isAbsoluteAngle = true; }
|
||||
else if (key === 'u') { rotation = val; isUprightAngle = true; }
|
||||
else if (key === 'x') xOffset = val;
|
||||
else if (key === 'y') yOffset = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
shapeName,
|
||||
shxFile,
|
||||
text,
|
||||
style,
|
||||
scale,
|
||||
rotation,
|
||||
isAbsoluteAngle,
|
||||
isUprightAngle,
|
||||
xOffset,
|
||||
yOffset,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenizeLinPattern(patternStr: string): string[] {
|
||||
const s = patternStr.replace(/^A\s*,\s*/i, '').trim();
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inBracket = false;
|
||||
let inQuote = false;
|
||||
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') inQuote = !inQuote;
|
||||
if (ch === '[' && !inQuote) inBracket = true;
|
||||
if (ch === ']' && !inQuote) inBracket = false;
|
||||
|
||||
if (ch === ',' && !inBracket && !inQuote) {
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current.trim()) tokens.push(current.trim());
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parseLinContent(content: string): Map<string, LinPattern> {
|
||||
const result = new Map<string, LinPattern>();
|
||||
const lines = content.split(/\r?\n/);
|
||||
let currentName: string | null = null;
|
||||
let currentDesc = '';
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith(';')) continue;
|
||||
if (line.startsWith('*')) {
|
||||
const commaIdx = line.indexOf(',');
|
||||
if (commaIdx > 0) {
|
||||
currentName = line.substring(1, commaIdx).trim();
|
||||
currentDesc = line.substring(commaIdx + 1).trim();
|
||||
} else {
|
||||
currentName = line.substring(1).trim();
|
||||
currentDesc = '';
|
||||
}
|
||||
} else if (currentName && line.toUpperCase().startsWith('A,')) {
|
||||
const tokens = tokenizeLinPattern(line);
|
||||
const elements: LinElement[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (tok.startsWith('[') && tok.endsWith(']')) {
|
||||
const elem = parseBracketElement(tok.slice(1, -1));
|
||||
elements.push(elem);
|
||||
} else {
|
||||
const val = parseFloat(tok);
|
||||
if (!isNaN(val)) {
|
||||
if (val > 1e-6) {
|
||||
elements.push({
|
||||
type: 'dash',
|
||||
val,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
isAbsoluteAngle: false,
|
||||
isUprightAngle: false,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
totalLength += val;
|
||||
} else if (val < -1e-6) {
|
||||
const absVal = Math.abs(val);
|
||||
elements.push({
|
||||
type: 'gap',
|
||||
val: -absVal,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
isAbsoluteAngle: false,
|
||||
isUprightAngle: false,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
totalLength += absVal;
|
||||
} else {
|
||||
// Dot (val === 0)
|
||||
elements.push({
|
||||
type: 'dash',
|
||||
val: 0.05,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
isAbsoluteAngle: false,
|
||||
isUprightAngle: false,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
totalLength += 0.05;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.length > 0) {
|
||||
result.set(currentName.toUpperCase(), {
|
||||
name: currentName,
|
||||
description: currentDesc,
|
||||
elements,
|
||||
patternLength: Math.max(totalLength, 0.1),
|
||||
});
|
||||
}
|
||||
currentName = null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin
|
||||
const globalRegistry = new Map<string, LinPattern>();
|
||||
|
||||
export function registerLinContent(content: string) {
|
||||
const parsed = parseLinContent(content);
|
||||
for (const [key, pattern] of parsed.entries()) {
|
||||
globalRegistry.set(key, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
// Warm up registry with bundled LIN files
|
||||
registerLinContent(ICAD_LIN);
|
||||
registerLinContent(KOSDIC_LIN);
|
||||
|
||||
const CIVIL_LT_TEXT_MAP: Record<string, string> = {
|
||||
'H-DICHL02': 'L2-2/0',
|
||||
'H-DICHL02R': 'L2-2',
|
||||
'H-DICHL01': 'L2-3/0',
|
||||
'H-DICHL01R': 'L2-3',
|
||||
'H-DICHL06': 'L5-1',
|
||||
'H-DICHL06R': 'L5-1',
|
||||
'H-DICHL05': 'L5',
|
||||
'H-DICHL05R': 'L5',
|
||||
'H-DICHU05': 'U5',
|
||||
'H-DICHU05R': 'U5',
|
||||
'H-DICHB01': 'B1',
|
||||
'H-DICHB01R': 'B1',
|
||||
'H-DICHM03': 'M3',
|
||||
'H-DICHM03R': 'M3',
|
||||
'H-DICHV01': 'V1',
|
||||
'H-DICHV01R': 'V1',
|
||||
'H-DICHO01': 'O1',
|
||||
'H-DICHO01R': 'O1',
|
||||
'H-DICHL03': 'L3',
|
||||
'H-DICHL03R': 'L3',
|
||||
'H-DICHL04': 'L4',
|
||||
'H-DICHL04R': 'L4',
|
||||
'H-DICHL08': 'L8',
|
||||
'H-DICHL08R': 'L8',
|
||||
};
|
||||
|
||||
const NO_SHAPE_LINETYPES = new Set([
|
||||
'H-DICHL05',
|
||||
'H-DICHL05R',
|
||||
'H-DICHU05',
|
||||
'H-DICHU05R',
|
||||
'H-DICHB01',
|
||||
'H-DICHB01R',
|
||||
'H-DICHM03',
|
||||
'H-DICHM03R',
|
||||
'H-DICHV01',
|
||||
'H-DICHV01R',
|
||||
'H-DICHO01',
|
||||
'H-DICHO01R',
|
||||
]);
|
||||
|
||||
export function getLinPattern(name: string): LinPattern | undefined {
|
||||
if (!name) return undefined;
|
||||
const key = name.toUpperCase().trim();
|
||||
const pattern = globalRegistry.get(key);
|
||||
if (!pattern) return undefined;
|
||||
|
||||
let elements = pattern.elements;
|
||||
|
||||
// Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec
|
||||
if (NO_SHAPE_LINETYPES.has(key)) {
|
||||
elements = elements.filter((elem) => elem.type !== 'shape');
|
||||
}
|
||||
|
||||
const overrideText = CIVIL_LT_TEXT_MAP[key];
|
||||
if (overrideText) {
|
||||
elements = elements.map((elem) =>
|
||||
elem.type === 'text' ? { ...elem, text: overrideText } : elem
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...pattern,
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAllRegisteredPatterns(): Map<string, LinPattern> {
|
||||
return globalRegistry;
|
||||
}
|
||||
|
||||
export function mergeDwgLinetypePattern(
|
||||
ltName: string,
|
||||
dwgPattern: number[],
|
||||
description?: string
|
||||
): LinPattern | undefined {
|
||||
if (!ltName) return undefined;
|
||||
const base = getLinPattern(ltName);
|
||||
if (!dwgPattern || !dwgPattern.length) return base;
|
||||
|
||||
if (!base) {
|
||||
const elements: LinElement[] = [];
|
||||
let totalLength = 0;
|
||||
for (const val of dwgPattern) {
|
||||
if (val > 1e-6) {
|
||||
elements.push({
|
||||
type: 'dash',
|
||||
val,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
isAbsoluteAngle: false,
|
||||
isUprightAngle: false,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
totalLength += val;
|
||||
} else if (val < -1e-6) {
|
||||
const absVal = Math.abs(val);
|
||||
elements.push({
|
||||
type: 'gap',
|
||||
val: -absVal,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
isAbsoluteAngle: false,
|
||||
isUprightAngle: false,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
totalLength += absVal;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: ltName,
|
||||
description: description || '',
|
||||
elements,
|
||||
patternLength: Math.max(totalLength, 0.1),
|
||||
};
|
||||
}
|
||||
|
||||
// Merge DWG numeric dash/gap values into base pattern elements
|
||||
const newElements: LinElement[] = [];
|
||||
let dwgValIdx = 0;
|
||||
let totalLength = 0;
|
||||
|
||||
for (const elem of base.elements) {
|
||||
if (elem.type === 'dash') {
|
||||
const dwgVal =
|
||||
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0
|
||||
? dwgPattern[dwgValIdx]
|
||||
: elem.val || 0.05;
|
||||
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0) {
|
||||
dwgValIdx++;
|
||||
}
|
||||
newElements.push({ ...elem, val: dwgVal });
|
||||
totalLength += dwgVal;
|
||||
} else if (elem.type === 'gap') {
|
||||
const dwgVal =
|
||||
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0
|
||||
? dwgPattern[dwgValIdx]
|
||||
: elem.val;
|
||||
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0) {
|
||||
dwgValIdx++;
|
||||
}
|
||||
const absVal = Math.abs(dwgVal || 0.1);
|
||||
newElements.push({ ...elem, val: -absVal });
|
||||
totalLength += absVal;
|
||||
} else {
|
||||
newElements.push({ ...elem });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
description: description || base.description,
|
||||
elements: newElements,
|
||||
patternLength: Math.max(totalLength, 0.1),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Parse DXF text/buffer into the same CadParseResult shape as DWG.
|
||||
*/
|
||||
import DxfParser from 'dxf-parser';
|
||||
import HatchHandler from './dxfHatchHandler.js';
|
||||
import { dxfToParseResult } from './dxfAdapter.js';
|
||||
import { decodeDxf } from './decodeDxf';
|
||||
import type { CadParseResult } from './dwgParser';
|
||||
|
||||
/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */
|
||||
export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult {
|
||||
return parseDxfText(decodeDxf(buf));
|
||||
}
|
||||
|
||||
/** Parse DXF text already decoded as a string. */
|
||||
export function parseDxfText(text: string): CadParseResult {
|
||||
const parser = new DxfParser();
|
||||
// dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'.
|
||||
parser.registerEntityHandler(
|
||||
HatchHandler as unknown as Parameters<DxfParser['registerEntityHandler']>[0],
|
||||
);
|
||||
const dxf = parser.parseSync(text);
|
||||
return dxfToParseResult(dxf);
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
declare module 'dxf-parser' {
|
||||
export default class DxfParser {
|
||||
parseSync(text: string): unknown;
|
||||
registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'opentype.js' {
|
||||
export function parse(buffer: ArrayBuffer): unknown;
|
||||
}
|
||||
|
||||
declare module '*.wasm?url' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
|
||||
declare module '*.wasm' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's
|
||||
* Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines",
|
||||
* JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent
|
||||
* dedicated to the public domain, attribution retained here).
|
||||
*
|
||||
* Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier
|
||||
* outlines are uploaded once per UNIQUE GLYPH into a float data texture, and
|
||||
* the fragment shader computes an antialiased winding number per pixel by
|
||||
* intersecting horizontal + vertical rays with the glyph's curves (curves
|
||||
* pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays
|
||||
* razor sharp at any zoom and memory scales with glyph count, not text count.
|
||||
*
|
||||
* Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines
|
||||
* (quadratic only), Latin + full Hangul.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { parse as parseFont } from 'opentype.js';
|
||||
|
||||
const TEX_W = 1024; // data texture width in texels (RGBA32F)
|
||||
const BANDS = 8; // bands per axis per glyph
|
||||
const PAD = 0.08; // em padding around glyph quads (AA spill room)
|
||||
|
||||
interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number }
|
||||
|
||||
interface GlyphRec {
|
||||
base: number; // texel index of the glyph header (-1 when no ink, e.g. space)
|
||||
advance: number; // em units
|
||||
}
|
||||
|
||||
/** Default TrueType font URL (host must serve Hangul-capable TTF). */
|
||||
let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf';
|
||||
|
||||
/** Override the default font path before creating CadViewer / Viewer2D. */
|
||||
export function setDefaultFontUrl(url: string): void {
|
||||
defaultFontUrl = url;
|
||||
// reset shared engine so the next shared() uses the new URL
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(SlugTextEngine as any)._shared = null;
|
||||
}
|
||||
|
||||
/** One shared font engine (font fetch + parse happens once). */
|
||||
export class SlugTextEngine {
|
||||
private static _shared: Promise<SlugTextEngine> | null = null;
|
||||
|
||||
static shared(url: string = defaultFontUrl): Promise<SlugTextEngine> {
|
||||
if (!this._shared) {
|
||||
this._shared = (async () => {
|
||||
const buf = await (await fetch(url)).arrayBuffer();
|
||||
return new SlugTextEngine(parseFont(buf));
|
||||
})();
|
||||
// allow a retry on transient fetch failure instead of caching the rejection
|
||||
this._shared.catch(() => { (SlugTextEngine as any)._shared = null; });
|
||||
}
|
||||
return this._shared;
|
||||
}
|
||||
|
||||
readonly capHeightEm: number;
|
||||
readonly ascentEm: number;
|
||||
readonly descentEm: number;
|
||||
|
||||
private font: any;
|
||||
private upem: number;
|
||||
private glyphs = new Map<number, GlyphRec>();
|
||||
private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed
|
||||
private used = 0; // texels consumed
|
||||
private _texture: THREE.DataTexture | null = null;
|
||||
private _texelsUploaded = 0;
|
||||
|
||||
private constructor(font: any) {
|
||||
this.font = font;
|
||||
this.upem = font.unitsPerEm;
|
||||
this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem;
|
||||
this.ascentEm = font.ascender / this.upem;
|
||||
this.descentEm = font.descender / this.upem; // negative
|
||||
}
|
||||
|
||||
/** Layout width of one line, in em units. */
|
||||
measureEm(line: string): number {
|
||||
let w = 0;
|
||||
for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance;
|
||||
return w;
|
||||
}
|
||||
|
||||
ensureGlyph(cp: number): GlyphRec {
|
||||
let rec = this.glyphs.get(cp);
|
||||
if (rec) return rec;
|
||||
|
||||
const glyph = this.font.charToGlyph(String.fromCodePoint(cp));
|
||||
const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem;
|
||||
// getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y.
|
||||
const path = glyph.getPath(0, 0, 1);
|
||||
const curves: Curve[] = [];
|
||||
let sx = 0, sy = 0, px = 0, py = 0;
|
||||
for (const c of path.commands) {
|
||||
switch (c.type) {
|
||||
case 'M': px = sx = c.x; py = sy = -c.y; break;
|
||||
case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break;
|
||||
case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break;
|
||||
case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively
|
||||
const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2;
|
||||
curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y });
|
||||
px = c.x; py = -c.y; break;
|
||||
}
|
||||
case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!curves.length) {
|
||||
rec = { base: -1, advance };
|
||||
this.glyphs.set(cp, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Conservative bbox from all points (control points included).
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const q of curves) {
|
||||
x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1);
|
||||
y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1);
|
||||
}
|
||||
const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6);
|
||||
|
||||
// Band membership (conservative, via control-point extents).
|
||||
const hBands: number[][] = Array.from({ length: BANDS }, () => []);
|
||||
const vBands: number[][] = Array.from({ length: BANDS }, () => []);
|
||||
curves.forEach((q, i) => {
|
||||
const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1);
|
||||
const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1);
|
||||
const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h);
|
||||
for (let b = hb0; b <= hb1; b++) hBands[b].push(i);
|
||||
const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w);
|
||||
for (let b = vb0; b <= vb1; b++) vBands[b].push(i);
|
||||
});
|
||||
|
||||
// ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ──
|
||||
const listTexels = (l: number[]) => Math.ceil(l.length / 4);
|
||||
const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0)
|
||||
+ vBands.reduce((s, l) => s + listTexels(l), 0);
|
||||
const total = 2 + BANDS * 2 + idxTexels + curves.length * 2;
|
||||
const base = this.alloc(total);
|
||||
const d = this.data;
|
||||
const put = (t: number, a: number, b: number, c: number, e: number) => {
|
||||
d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e;
|
||||
};
|
||||
|
||||
const curveBase = base + 2 + BANDS * 2 + idxTexels;
|
||||
put(base, x0, y0, x1, y1);
|
||||
put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase
|
||||
|
||||
let listCursor = base + 2 + BANDS * 2;
|
||||
const writeBands = (bands: number[][], texel: number) => {
|
||||
for (let b = 0; b < BANDS; b++) {
|
||||
put(texel + b, listCursor, bands[b].length, 0, 0);
|
||||
const l = bands[b];
|
||||
for (let j = 0; j < l.length; j += 4) {
|
||||
put(listCursor++,
|
||||
curveBase + l[j] * 2,
|
||||
j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0,
|
||||
j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0,
|
||||
j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
writeBands(hBands, base + 2);
|
||||
writeBands(vBands, base + 2 + BANDS);
|
||||
|
||||
curves.forEach((q, i) => {
|
||||
put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy);
|
||||
put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0);
|
||||
});
|
||||
|
||||
rec = { base, advance };
|
||||
this.glyphs.set(cp, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
bboxOf(rec: GlyphRec): [number, number, number, number] {
|
||||
const t = rec.base * 4;
|
||||
return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]];
|
||||
}
|
||||
|
||||
private alloc(texels: number): number {
|
||||
const need = (this.used + texels) * 4;
|
||||
if (need > this.data.length) {
|
||||
let cap = this.data.length;
|
||||
while (cap < need) cap *= 2;
|
||||
const next = new Float32Array(cap);
|
||||
next.set(this.data);
|
||||
this.data = next;
|
||||
}
|
||||
const at = this.used;
|
||||
this.used += texels;
|
||||
return at;
|
||||
}
|
||||
|
||||
/** Data texture with all glyph data uploaded (recreated when it grew). */
|
||||
texture(): THREE.DataTexture {
|
||||
const rows = Math.max(1, Math.ceil(this.used / TEX_W));
|
||||
if (!this._texture || this._texelsUploaded < this.used) {
|
||||
this._texture?.dispose();
|
||||
const buf = new Float32Array(TEX_W * rows * 4);
|
||||
buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4)));
|
||||
const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType);
|
||||
tex.minFilter = THREE.NearestFilter;
|
||||
tex.magFilter = THREE.NearestFilter;
|
||||
tex.generateMipmaps = false;
|
||||
tex.needsUpdate = true;
|
||||
this._texture = tex;
|
||||
this._texelsUploaded = this.used;
|
||||
}
|
||||
return this._texture;
|
||||
}
|
||||
}
|
||||
|
||||
function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve {
|
||||
return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 };
|
||||
}
|
||||
|
||||
function clampBand(f: number): number {
|
||||
return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS)));
|
||||
}
|
||||
|
||||
// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ──
|
||||
|
||||
const VERT = /* glsl */`
|
||||
attribute vec2 emuv;
|
||||
attribute float gbase;
|
||||
attribute vec3 tcolor;
|
||||
varying vec2 vEm;
|
||||
flat varying int vBase;
|
||||
varying vec3 vColor;
|
||||
void main() {
|
||||
vEm = emuv;
|
||||
vBase = int(gbase);
|
||||
vColor = tcolor;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */`
|
||||
precision highp float;
|
||||
uniform sampler2D dataTex;
|
||||
varying vec2 vEm;
|
||||
flat varying int vBase;
|
||||
varying vec3 vColor;
|
||||
layout(location = 0) out vec4 fragOut;
|
||||
|
||||
vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); }
|
||||
|
||||
// Lengyel's banded winding-number coverage for one ray along +x from p.
|
||||
// Curves are fetched via the band's index list; 'swap' mirrors x/y for the
|
||||
// vertical (+y) ray, whose winding sign flips (handedness reversal).
|
||||
float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) {
|
||||
vec4 band = T(bandTexel);
|
||||
int count = int(band.y);
|
||||
float cov = 0.0;
|
||||
for (int j = 0; j < 256; j++) {
|
||||
if (j >= count) break;
|
||||
vec4 idx4 = T(int(band.x) + (j >> 2));
|
||||
int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w);
|
||||
vec4 A = T(cb);
|
||||
vec2 e = T(cb + 1).xy;
|
||||
// Swap the CURVE points before subtracting: p is already in swapped
|
||||
// coordinates for the vertical ray ((A.xy - p).yx would be wrong).
|
||||
vec2 p1 = (swap ? A.yx : A.xy) - p;
|
||||
vec2 p2 = (swap ? A.wz : A.zw) - p;
|
||||
vec2 p3 = (swap ? e.yx : e.xy) - p;
|
||||
|
||||
uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u;
|
||||
if (code != 0u) {
|
||||
vec2 a = p1 - p2 * 2.0 + p3;
|
||||
vec2 b = p1 - p2;
|
||||
float t1, t2;
|
||||
if (abs(a.y) < 1e-6) {
|
||||
float t = p1.y / (2.0 * b.y);
|
||||
t1 = t; t2 = t;
|
||||
} else {
|
||||
float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0));
|
||||
t1 = (b.y - d) / a.y;
|
||||
t2 = (b.y + d) / a.y;
|
||||
}
|
||||
if ((code & 1u) != 0u) {
|
||||
float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x;
|
||||
cov += clamp(x * ppem + 0.5, 0.0, 1.0);
|
||||
}
|
||||
if (code > 1u) {
|
||||
float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x;
|
||||
cov -= clamp(x * ppem + 0.5, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cov;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 bbox = T(vBase);
|
||||
vec4 bases = T(vBase + 1);
|
||||
vec2 fw = fwidth(vEm);
|
||||
|
||||
vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6));
|
||||
int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1});
|
||||
int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1});
|
||||
|
||||
float covH = rayCoverage(vEm, 1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false);
|
||||
float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true);
|
||||
float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0);
|
||||
if (alpha < 0.004) discard;
|
||||
fragOut = vec4(vColor, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Accumulates positioned strings and builds one merged mesh (single draw call). */
|
||||
export class SlugTextBatch {
|
||||
private pos: number[] = [];
|
||||
private emuv: number[] = [];
|
||||
private gbase: number[] = [];
|
||||
private color: number[] = [];
|
||||
private index: number[] = [];
|
||||
|
||||
constructor(private engine: SlugTextEngine) {}
|
||||
|
||||
/**
|
||||
* alignH: 0/3/5=left datum, 1/4=center, 2=right.
|
||||
* alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching
|
||||
* the previous canvas-sprite behaviour). Multi-line via '\n'.
|
||||
*/
|
||||
add(text: string, pos: { x: number; y: number }, height: number, rotation: number,
|
||||
colorInt: number, alignH: number, alignV: number): void {
|
||||
const eng = this.engine;
|
||||
const lines = String(text).split('\n');
|
||||
// Glyph outlines are em-scaled (getPath(0,0,1)), so raw cap ink height is
|
||||
// capHeightEm em, not 1em. Divide by it so the rendered cap height equals
|
||||
// the DWG `height` field, matching the canvas-sprite fallback's ascent fit.
|
||||
const scale = height / eng.capHeightEm;
|
||||
const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
|
||||
const blockH = (lines.length - 1) * lineStep + height;
|
||||
|
||||
// DXF group 72 = 4 ("Middle") centers both axes on the alignment point;
|
||||
// group 73 (vertical) is ignored. alignH already centers horizontally (below);
|
||||
// force vertical to middle here too, else vA=0 text (block-internal plain
|
||||
// TEXT, e.g. titleblock labels) renders half a line too high.
|
||||
if (alignH === 4) alignV = 2;
|
||||
|
||||
let baseline0: number;
|
||||
if (alignV === 3) baseline0 = -height; // top datum
|
||||
else if (alignV === 2) baseline0 = blockH / 2 - height; // middle
|
||||
else baseline0 = (lines.length - 1) * lineStep; // baseline/bottom
|
||||
|
||||
const cos = Math.cos(rotation), sin = Math.sin(rotation);
|
||||
const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255;
|
||||
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li];
|
||||
if (!line) continue;
|
||||
const wEm = eng.measureEm(line);
|
||||
const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0;
|
||||
const oy = baseline0 - li * lineStep;
|
||||
let pen = 0;
|
||||
for (const ch of line) {
|
||||
const rec = eng.ensureGlyph(ch.codePointAt(0)!);
|
||||
if (rec.base >= 0) {
|
||||
const [x0, y0, x1, y1] = this.engine.bboxOf(rec);
|
||||
const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD;
|
||||
const v = this.pos.length / 3;
|
||||
for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) {
|
||||
const lx = ox + (pen + ex) * scale;
|
||||
const ly = oy + ey * scale;
|
||||
this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1);
|
||||
this.emuv.push(ex, ey);
|
||||
this.gbase.push(rec.base);
|
||||
this.color.push(r, g, b);
|
||||
}
|
||||
this.index.push(v, v + 1, v + 2, v, v + 2, v + 3);
|
||||
}
|
||||
pen += rec.advance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the merged mesh; null when nothing was added. */
|
||||
build(): THREE.Mesh | null {
|
||||
if (!this.index.length) return null;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3));
|
||||
geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2));
|
||||
geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1));
|
||||
geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3));
|
||||
geo.setIndex(this.index);
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
glslVersion: THREE.GLSL3,
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
uniforms: { dataTex: { value: this.engine.texture() } },
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.frustumCulled = true;
|
||||
return mesh;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user