Author SHA1 Message Date
minsungandClaude Opus 5 1f80442f6c feat(ui): options panel with an IndexedDB-backed lineweight display scale
Set the default lineweight scale to 11 px/mm: the value the user matched
against AutoCAD on the actual screen. The 20 px/mm derived from their
screenshot was too heavy in practice - the capture and the monitor do
not share a DPI - so the constant is now only a starting point.

Move the lineweight controls off the toolbar into an Options panel:
display toggle, scale slider plus number box, a readout of what the
drawing's own weights come out to, grid toggle, zoom speed, and a reset.
Settings persist in IndexedDB (hmw-viewer/options) rather than
localStorage, so values keep their type and the store has room to grow
per-drawing later; every call degrades quietly if storage is blocked.

Scale changes still apply to the live materials - each fat batch now
remembers its weight in mm - so dragging the slider retunes a 37MB
drawing instantly.

Verified headless: 0.35mm renders 3-4px at scale 11 and 8-9px at 25,
matching the readout, and the value survives a reload via IndexedDB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 09:00:37 +09:00
minsungandClaude Opus 5 a924b1ec34 feat(viewer2d): make the lineweight display scale adjustable from the toolbar
The px-per-mm mapping was a hard-coded guess twice over, and the right
value depends on the monitor - AutoCAD has the same knob as the Adjust
Display Scale slider. Expose it: a slider plus a number box next to the
lineweight toggle, with a readout of what the drawing's own weights come
out to (0.35mm -> 7px, 0.50mm -> 10px at the 20 px/mm default). The
value persists in localStorage.

Retuning applies to the live materials instead of rebuilding the scene.
The hairline cut-off is a millimetre value, so changing the scale moves
every weight by the same factor without changing which bucket a segment
belongs to - each fat batch now remembers its weight in mm, and only
material.linewidth is rewritten. That keeps a drag on the slider instant
even on the 37 MB road drawing.

Measured headless on its 0.35mm gutter line: 2-3px at scale 8, 7px at
20, 14px at 40 - matching the readout in each case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:47:59 +09:00
minsungandClaude Opus 5 cf1298749b fix(viewer2d): show lineweight at AutoCAD's display scale (20 px/mm)
Hand test against an AutoCAD capture: the 0.50mm road edge is ~10px
there but only ~4px here. Lineweight is zoom-independent screen pixels,
so the two captures compare directly - the 8 px/mm mapping was 2.5x too
small. AutoCAD's default display scale is one pixel per 0.05mm, so use
20 px/mm (0.35mm = 7px, 0.50mm = 10px, 2.11mm = 42px) and raise the
clamp to 48px.

Add setLineweightScale()/getLineweightScale() so a host can retune it -
AutoCAD exposes the same thing as the Adjust Display Scale slider.

Re-measured headless: the 0.35mm road-gutter line now renders ~7px, so
both weight classes land on the same scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 20:34:27 +09:00
minsungandClaude Opus 5 ce3859c487 docs: ship the acadrust MPL-2.0 notice, license text and local patch
The deployed site serves a .wasm with acadrust compiled into it, and our
vendored copy carries one modified MPL file
(src/io/dwg/dwg_stream_readers/object_reader/entities.rs, the read_mesh
array-count bounds fix). That is Executable Form distribution of modified
Covered Software, so MPL-2.0 3.2 requires the Source Code Form to be
available and recipients to be told how to get it. Nothing shipped said
so: no license text, no notice, no obtainable source - the upstream
mirror repos are private.

Add THIRD-PARTY-NOTICES.md plus public/licenses/ (MPL-2.0 text, the
read_mesh patch against the pristine 0.4.1 crate, and a served copy of
the notice), and link them from the page so recipients can actually find
them. Original crate download plus the patch reproduces the exact source
compiled into the wasm.

MPL does not require upstreaming, and the lineweight work did not touch
any MPL file - dwg-wasm/src/lib.rs is our MIT wrapper. docs/license-mpl2-
acadrust.md records the analysis and the checklist for future changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:59:10 +09:00
minsungandClaude Opus 5 c67a803e4d feat(viewer2d): render DWG lineweight in screen pixels
Every line was drawn 1px regardless of its CAD lineweight. Two causes:
the dwg-wasm parseResult never carried the field (acadrust reads
EntityCommon::line_weight but it was dropped in serialization), and the
renderer merged all segments into one LineSegments whose
LineBasicMaterial.linewidth WebGL ignores.

Resolve ByLayer/ByBlock/Default per entity and bucket segments by
dash|lineweight. Weighted buckets get their own LineSegments2 +
LineMaterial with worldUnits:false, so width stays constant in pixels
while zooming - AutoCAD LWDISPLAY semantics - at 8 px/mm (0.25mm = 2px).
Anything at or below the 0.25mm default stays in the hairline batch so
drawings that never assigned a weight render exactly as before.

Fat batches are instanced, so layer masking compacts the instance buffer
and lowers instanceCount instead of rewriting an index; slotOf tracks
where each segment moved so the selection highlight still finds its
vertices. Per-entity color spans (meta.spans) let the highlight repaint
across the hairline batch and every bucket it touched. Buckets past
400k segments fall back to hairline to bound GPU/heap cost.

Display follows the drawing's LWDISPLAY and can be forced from the
toolbar; the property panel now shows the entity lineweight.

Verified headless at a fixed camera on the 65k-entity road drawing:
cyan road edge 2px -> 5px, layer hide/restore returns the exact baseline
pixel counts, and a 0.35mm LWPOLYLINE selects and highlights.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:25:11 +09:00
minsungandClaude Opus 5 eec33820a6 feat(dwg): parse MESH (AcDbSubDMesh) and document the fix
The road-surface drawing (대산당진 2공구_노면) rendered nothing for its
노면/부체도로면 geometry. The cause was not the renderer: the parseResult
contained zero MESH entities.

Two faults, both in the wasm parser, fixed upstream in hmwebviewer 35f5b1c:

1. dwg-wasm never serialized MESH. acadrust reads the entity in full; only the
   JSON arm was missing.

2. acadrust's read_mesh clamped its array counts with the blanket 100_000-item
   corrupt-data guard. This drawing's road mesh has 105_497 vertices, so the
   count was truncated, the remaining vertex bits were never consumed, and the
   face/edge/crease lists after them decoded from the wrong bit offset —
   producing a plausible-looking vertex count next to a nonsense 14-face list.
   Counts are now bounded by the bits actually left in the object stream.

       before  verts=100000 faces=14      face sizes [64, 0, 64, 23, 0, 0, 0, 15]
       after   verts=105497 faces=185444  face sizes [3, 3, 3, ...]

This commit carries the vendored side of that work:

- the rebuilt wasm, which now emits MESH as flat arrays (vertices [x,y,z,...],
  faceList in DXF group-93 layout, deduplicated edges). The sample drawing goes
  from 116 MB to 130 MB of parseResult with parse time unchanged at ~3.2 s.
- a MESH case in the property inspector (vertex/face/edge counts, subdivision
  level, bbox and elevation range)
- docs/subdmesh-rendering.md, plus README pointers
- the acadrust licence notice, which can no longer say "consumed unmodified" —
  read_mesh now carries a local patch. MPL-2.0 is file-level copyleft, so the
  patched file stays under MPL and its source ships in hmwebviewer.

Verified: 9 MESH entities parsed from the drawing; entity histograms over six
other sample drawings are byte-identical to the previous wasm, so nothing else
moved.

Two deliberate omissions, both because a lineweight/fat-line refactor is in
flight in this working tree:

- Viewer2D.js is not included. Its _meshSegs helper and three MESH dispatch
  sites share hunks with that refactor and cannot be separated; the same
  renderer code is committed upstream in hmwebviewer 35f5b1c and will land here
  with the refactor. docs/subdmesh-rendering.md records this.
- dist2/ is not rebuilt here. The current build embeds the half-wired
  lineweight button and progress-bar markup; deploy per
  docs/deploy-dist2-static-build.md once that work lands.

The wasm binary also carries the new lineweight parser fields (lwdisplay,
celweight, layer and entity lineWeight), since it was built from a tree that
already had them. They are additive JSON keys with no consumer in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:05:39 +09:00
minsungandClaude Opus 5 6f200ef692 docs: record the dist2 static-build deploy procedure
This repository has no deploy script or CI: package.json only builds to
dist/ (gitignored), yet dist2/ is committed and is what actually gets
served. The procedure was folklore reconstructed from commit history each
time someone deployed.

Write it down, including the two traps found while deploying fb8f7ca:

- vite's emptyOutDir defaults to true when outDir sits inside the project
  root, so a bare `vite build --outDir dist2` deletes the hand-placed test
  assets in dist2/samples (configBak/, dwg_18.3mb.dwg). Always pass
  --emptyOutDir false.
- npx vite can fail here with `Missing script: "vite"`; call
  ./node_modules/.bin/vite directly.

Also notes the open issues: no build:dist2 script, old hashed bundles
accumulating in dist2/assets, and test fixtures living inside the build
output directory.

Link it from the README issue table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:21:10 +09:00
minsungandClaude Opus 5 3480945b5a perf(viewer2d): toggle layers by visibility instead of reloading the drawing
setHiddenLayers() called load(result, {keepView:true}), so every layer
on/off re-parsed and re-tessellated the whole drawing — arc sampling,
hatch triangulation, complex linetypes and the Slug text batch all rebuilt
from scratch. On an 18 MB DWG a single eye-icon click took 5 s+.

Build every layer once, then switch geometry on and off:

- Merged LineSegments buffers get an index buffer plus a per-segment layer
  id (_registerIndexed). A toggle rewrites the index and setDrawRange;
  positions, colors and lineDistances are untouched, so draw order, dash
  phase and the selection-highlight offsets in meta.colStart stay valid.
- Per-entity meshes (hatch fills, SOLID quads, arrowheads, OLE images,
  sprites) are tagged by layer via _addObj and toggled with .visible.
- Slug text builds one merged mesh per CAD layer instead of one per
  draw order, so text hides with a visible flag. The shader source is
  identical across batches, so three still shares one WebGLProgram.
- Hidden geometry stays in the pick buffers, so _onClick now skips it via
  the _metaHidden mask.
- Fit keeps framing only what is drawn: per-layer Box3 + fit samples are
  recorded at build and unioned over the visible layers.
- getLayerInfo() caches the O(entities) name/color/count pass per load;
  only the visible flag is recomputed.

Layer panel: one delegated click listener instead of re-binding a handler
per row, and a toggle patches the affected row's opacity/icon in place
rather than regenerating the whole list's innerHTML.

Trade-off: hidden layers are now built and kept in memory, so loading a
file with layers already off costs what a full load costs.

Rebuilds dist2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:08:59 +09:00
30 changed files with 32913 additions and 210 deletions
+12 -2
View File
@@ -109,6 +109,11 @@ Vite `?url` import 로 로드됩니다.
|------|------| |------|------|
| 구형 한글 DWG TEXT 깨짐 (CP949→Latin-1 mojibake) | [`docs/korean-dwg-text-encoding.md`](./docs/korean-dwg-text-encoding.md) | | 구형 한글 DWG TEXT 깨짐 (CP949→Latin-1 mojibake) | [`docs/korean-dwg-text-encoding.md`](./docs/korean-dwg-text-encoding.md) |
| Model + Paper 동시 렌더로 글자 겹침 / Layout 탭 | [`docs/model-paper-space.md`](./docs/model-paper-space.md) | | Model + Paper 동시 렌더로 글자 겹침 / Layout 탭 | [`docs/model-paper-space.md`](./docs/model-paper-space.md) |
| `dist2` 정적 빌드 배포 절차 (공식 배포 스크립트 없음) | [`docs/deploy-dist2-static-build.md`](./docs/deploy-dist2-static-build.md) |
| SubDMesh(MESH) 미표시 — parseResult 누락 + 큰 메쉬 잘림 | [`docs/subdmesh-rendering.md`](./docs/subdmesh-rendering.md) |
| 선가중치(Lineweight) 미적용 — parseResult 누락 + WebGL 1px 한계 | [`docs/lineweight-rendering.md`](./docs/lineweight-rendering.md) |
| acadrust MPL-2.0 고지 누락 (수정본 wasm 공개 배포) | [`docs/license-mpl2-acadrust.md`](./docs/license-mpl2-acadrust.md) |
| ↳ 위 둘의 측정 결과 + 상호작용(메쉬 선분이 굵기 버킷 상한을 넘길 위험) | [`docs/improvement-results-2026-08-04.md`](./docs/improvement-results-2026-08-04.md) |
샘플 패치: `fixDwgKoreanText`, `cadSpaces` + Viewer2D 공간 필터, Model/Layout 탭. 샘플 패치: `fixDwgKoreanText`, `cadSpaces` + Viewer2D 공간 필터, Model/Layout 탭.
원본 `hmwebviewer` 반영 절차는 각 문서를 따른다. 원본 `hmwebviewer` 반영 절차는 각 문서를 따른다.
@@ -120,6 +125,11 @@ Vite `?url` import 로 로드됩니다.
| 조각 | 라이선스 | | 조각 | 라이선스 |
|------|----------| |------|----------|
| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) | | Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) |
| acadrust (WASM 내부) | MPL-2.0 | | acadrust (WASM 내부, `read_mesh` 로컬 패치 포함) | **MPL-2.0 — 수정본** |
| dxf-parser · three | MIT | | dxf-parser · three · opentype.js | MIT |
| NanumGothic | OFL | | NanumGothic | OFL |
acadrust 는 파일 단위 카피레프트라 **패치한 파일의 소스를 배포 대상자에게 제공할 의무**가
있다. 고지·MPL 원문·패치는 [`THIRD-PARTY-NOTICES.md`](./THIRD-PARTY-NOTICES.md) 와
`public/licenses/`(배포 시 `/licenses/`)에 동봉한다.
의무 범위와 조치 근거: [`docs/license-mpl2-acadrust.md`](./docs/license-mpl2-acadrust.md)
+70
View File
@@ -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 |
+1
View File
@@ -0,0 +1 @@
function h(t){let e,n;try{const f=x(t,i.__wbindgen_malloc),c=w,o=i.parse_dwg_json(f,c);var r=o[0],s=o[1];if(o[3])throw r=0,s=0,T(o[2]);return e=r,n=s,l(r,s)}finally{i.__wbindgen_free(e,n,1)}}function A(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(l(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),s=E(r,i.__wbindgen_malloc,i.__wbindgen_realloc),f=w;m().setInt32(e+4,f,!0),m().setInt32(e+0,s,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(l(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return l(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=i.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==i.memory.buffer)&&(a=new DataView(i.memory.buffer)),a}function l(t,e){return R(t>>>0,e)}let u=null;function b(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(i.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return b().set(t,n/1),w=t.length,n}function E(t,e,n){if(n===void 0){const o=d.encode(t),_=e(o.length,1)>>>0;return b().subarray(_,_+o.length).set(o),w=o.length,_}let r=t.length,s=e(r,1)>>>0;const f=b();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;f[s+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),s=n(s,r,r=c+t.length*3,1)>>>0;const o=b().subarray(s+c,s+r),_=d.encodeInto(t,o);c+=_.written,s=n(s,r,c,1)>>>0}return w=c,s}function T(t){const e=i.__wbindgen_externrefs.get(t);return i.__externref_table_dealloc(t),e}let g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});g.decode();const D=2146435072;let y=0;function R(t,e){return y+=e,y>=D&&(g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),g.decode(),y=e),g.decode(b().subarray(t,t+e))}const d=new TextEncoder;"encodeInto"in d||(d.encodeInto=function(t,e){const n=d.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,i;function W(t,e){return i=t.exports,a=null,u=null,i.__wbindgen_start(),i}async function M(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(s){if(t.ok&&n(t.type)&&t.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",s);else throw s}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function S(t){if(i!==void 0)return i;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-DmCEpFBu.wasm",import.meta.url));const e=A();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await M(await t,e);return W(n)}const O="/assets/acadrust_dwg_bg-DmCEpFBu.wasm";let p=null;function v(){return p||(p=S({module_or_path:O}).then(()=>{})),p}function I(t){return JSON.parse(h(t))}export{v as initAcadrustParser,I as parseDwgAcadrust};
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+112 -1
View File
@@ -14,6 +14,45 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); } html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
#app { position: fixed; inset: 0; } #app { position: fixed; inset: 0; }
/* ── 상단 로딩 프로그레스 바 (src/topProgress.ts 가 구동) ───────────────
.bar : 실제 진행률. width 트랜지션 = 메인 스레드 의존.
.sweep : WASM 파싱 / Viewer2D.load() 같은 동기 블로킹 구간용. transform
키프레임은 컴포지터 스레드에서 돌기 때문에 메인 스레드가 멈춰
있어도 계속 흐른다 — 그 구간에 줄 수 있는 유일한 피드백. */
#progress {
position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 100;
pointer-events: none; overflow: hidden;
opacity: 0; transition: opacity .3s ease .15s;
}
#progress.on { opacity: 1; transition-delay: 0s; }
#progress .bar {
position: absolute; top: 0; bottom: 0; left: 0; width: 0;
background: linear-gradient(90deg, var(--accent), #58a6ff 55%, var(--wasm));
box-shadow: 0 0 10px rgba(63,185,80,.65), 0 0 4px rgba(88,166,255,.5);
transition: width .28s cubic-bezier(.25,.8,.25,1);
}
#progress.err .bar {
background: var(--danger);
box-shadow: 0 0 10px rgba(248,81,73,.7);
}
#progress .sweep {
position: absolute; top: 0; bottom: 0; left: 0; width: 34%;
background: linear-gradient(90deg, transparent, rgba(230,237,243,.9), transparent);
transform: translateX(-105%); will-change: transform; opacity: 0;
}
#progress.busy .sweep {
opacity: 1;
animation: pr-sweep 1.15s cubic-bezier(.4,0,.2,1) infinite;
}
@keyframes pr-sweep {
from { transform: translateX(-105%); }
to { transform: translateX(400%); }
}
@media (prefers-reduced-motion: reduce) {
#progress .sweep { animation: none; }
#progress .bar { transition: none; }
}
#toolbar { #toolbar {
position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10; position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none;
@@ -27,6 +66,25 @@
button.primary { background: #238636; border-color: #2ea043; } button.primary { background: #238636; border-color: #2ea043; }
button.wasm { border-color: var(--wasm); color: var(--wasm); } button.wasm { border-color: var(--wasm); color: var(--wasm); }
label.btn input { display: none; } label.btn input { display: none; }
/* 옵션 패널 — 값은 IndexedDB(hmw-viewer/options)에 저장된다 */
#optBody { padding: 12px 14px; overflow: auto; font-size: 12px; color: var(--ink); }
.opt-sec { margin-bottom: 16px; }
.opt-sec > h4 {
margin: 0 0 8px; font-size: 11px; font-weight: 700; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
}
.opt-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.opt-row > label { flex: 1; color: var(--ink); }
.opt-row input[type=range] { flex: 1; min-width: 90px; accent-color: var(--accent); }
.opt-row input[type=number] {
width: 58px; background: #0d1117; color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px;
font: 12px var(--mono); text-align: right;
}
.opt-row input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); }
.opt-unit { font: 11px var(--mono); color: var(--muted); }
.opt-note { font: 11px var(--mono); color: var(--accent); line-height: 1.6; word-break: keep-all; }
.opt-help { font-size: 11px; color: var(--muted); line-height: 1.5; margin: 4px 0 0; }
#status { #status {
margin-left: auto; font: 12px var(--mono); color: var(--muted); margin-left: auto; font: 12px var(--mono); color: var(--muted);
background: rgba(22,27,34,.85); border: 1px solid var(--line); background: rgba(22,27,34,.85); border: 1px solid var(--line);
@@ -130,6 +188,7 @@
#properties { right: 12px; } #properties { right: 12px; }
#layers { right: 344px; display: none; } #layers { right: 344px; display: none; }
#options { right: 676px; width: 340px; height: auto; max-height: 70vh; display: none; }
#propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } #propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
#layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } #layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@@ -159,7 +218,7 @@
} }
.layer-sub-btn:hover { color: var(--ink); border-color: var(--accent); background: rgba(63,185,80,.1); } .layer-sub-btn:hover { color: var(--ink); border-color: var(--accent); background: rgba(63,185,80,.1); }
</style> </style>
<script type="module" crossorigin src="/assets/index-CmUgC68r.js"></script> <script type="module" crossorigin src="/assets/index-VgpJrp_J.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -175,6 +234,7 @@
<div id="spaces" title="Model Space / Paper Space (Layout)"></div> <div id="spaces" title="Model Space / Paper Space (Layout)"></div>
<button id="fit" type="button">Fit (F)</button> <button id="fit" type="button">Fit (F)</button>
<button id="theme" type="button">Theme</button> <button id="theme" type="button">Theme</button>
<button id="optionsBtn" type="button" title="표시 옵션 (선가중치 · 그리드 · 줌)">옵션</button>
<button id="layersBtn" type="button">Layers</button> <button id="layersBtn" type="button">Layers</button>
<button id="propsBtn" type="button">Props (속성)</button> <button id="propsBtn" type="button">Props (속성)</button>
<div id="status">ready</div> <div id="status">ready</div>
@@ -210,6 +270,51 @@
</div> </div>
</div> </div>
<!-- 옵션 (Options) 패널 — 설정은 IndexedDB 에 저장 -->
<div id="options" class="cad-panel">
<div class="hdr">
<h3><span>⚙️</span> 옵션 (Options)</h3>
<div class="hdr-actions">
<button id="optReset" type="button" title="기본값으로">↺ 기본값</button>
<button id="collapseOptBtn" class="hdr-btn collapse-btn" type="button" title="접기"></button>
<button id="closeOptBtn" class="hdr-btn" type="button" title="닫기"></button>
</div>
</div>
<div id="optBody">
<div class="opt-sec">
<h4>선가중치 (Lineweight)</h4>
<div class="opt-row">
<label for="optLwOn">표시 (LWDISPLAY)</label>
<input id="optLwOn" type="checkbox" />
</div>
<div class="opt-row">
<label for="optLwScale">표시 배율</label>
<input id="optLwScale" type="range" min="2" max="60" step="0.5" value="11" />
<input id="optLwScaleNum" type="number" min="1" max="80" step="0.5" value="11" />
<span class="opt-unit">px/mm</span>
</div>
<div id="optLwOut" class="opt-note"></div>
<p class="opt-help">
AutoCAD "선가중치 설정 → 표시 배율"과 같다. 모델 공간에서 선가중치는 줌과
무관한 화면 픽셀로 그려지므로, 화면 DPI에 맞춰 이 값을 조정한다.
</p>
</div>
<div class="opt-sec">
<h4>화면 (Display)</h4>
<div class="opt-row">
<label for="optGrid">그리드 표시</label>
<input id="optGrid" type="checkbox" />
</div>
<div class="opt-row">
<label for="optZoomSpeed">줌 속도</label>
<input id="optZoomSpeed" type="range" min="0.2" max="3" step="0.1" value="1" />
<input id="optZoomSpeedNum" type="number" min="0.1" max="5" step="0.1" value="1" />
</div>
</div>
</div>
</div>
<!-- 속성 (Properties) 패널 --> <!-- 속성 (Properties) 패널 -->
<div id="properties" class="cad-panel"> <div id="properties" class="cad-panel">
<div class="hdr"> <div class="hdr">
@@ -236,6 +341,12 @@
코드: <code>src/main.ts</code> · 코드: <code>src/main.ts</code> ·
지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a> 지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
</p> </p>
<!-- MPL-2.0 §3.2(b): recipients of the wasm must be told where the
acadrust source (with our local patch) can be obtained. -->
<p style="margin:6px 0 0">
오픈소스 고지: <a href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank">THIRD-PARTY-NOTICES</a>
(acadrust · MPL-2.0 · <a href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank">local patch</a>)
</p>
</aside> </aside>
</body> </body>
+70
View File
@@ -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 }
}
+375
View File
@@ -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.
+146
View File
@@ -0,0 +1,146 @@
# `dist2` 정적 빌드 배포 절차
| 항목 | 내용 |
|------|------|
| 작성 목적 | Slug GPU 텍스트 cap-height 수정 배포 작업을 기록하고, 공식 배포 스크립트가 없는 이 저장소에서 재사용 가능한 절차로 남김 |
| 배포 대상 | `dist2/` (저장소에 커밋되는 정적 빌드 산출물, 실제 서빙 디렉터리) |
| 계기 커밋 | `fb8f7ca` fix(viewer2d): correct Slug GPU text scale to match DWG cap-height |
| 배포일 | 2026-08-04 |
| 상태 | 완료 (origin/master push 완료) |
---
## 1. `dist2`란 무엇인가
이 저장소는 **공식 배포 스크립트가 없다.** `package.json`에는 `dev` / `build` (→ `dist/`, `.gitignore` 대상) / `preview` / `typecheck` 뿐이고, `dist2`를 만드는 스크립트나 CI는 존재하지 않는다.
그런데도 `dist2/`는 저장소에 **커밋되어 있고** (`git ls-files dist2` 로 확인 가능), 과거 커밋들(`3d3e052` 등)에서 반복적으로 갱신되어 온 정황으로 볼 때 **`vite build`를 수동으로 `--outDir dist2` 옵션과 함께 실행해 만든 산출물을 그대로 커밋 → push 하는 방식**이 이 프로젝트의 사실상 배포 방식이다.
```
src/ ──(vite build)──▶ dist/ (gitignore, 로컬 전용)
src/ ──(vite build --outDir dist2)──▶ dist2/ (git 커밋, 실제 배포물)
```
`dist2/samples/`, `dist2/fonts/`, `dist2/docs/``public/`의 사본 + 빌드시 자동 복사분이다.
### 함정: `dist2`에 소스가 아닌 파일이 섞여 있다
배포 작업 시작 시 `git status`에 다음 **untracked** 파일이 있었다:
```
dist2/samples/configBak/ (cuiBak.cui, regBak.json — AutoCAD 설정 백업, 테스트용)
dist2/samples/dwg_18.3mb.dwg (대용량 테스트 도면)
```
이 파일들은 `public/samples/`에 없다 → 빌드가 만든 게 아니라 **사람이 `dist2`에 직접 넣어둔 테스트 자료**다. Vite의 기본 `emptyOutDir`는 `outDir`가 프로젝트 루트 안에 있으면 `true`(빌드 전 디렉터리 전체 삭제)이므로, 아무 옵션 없이 `vite build --outDir dist2`를 돌리면 **이 파일들이 삭제된다.**
→ 배포 시 반드시 `--emptyOutDir false`를 붙인다 (아래 3절).
---
## 2. 사전 확인
```bash
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
git status --short # untracked/미커밋 상태 파악 (dist2 안 이물질 포함)
npx tsc --noEmit -p . # 타입체크 먼저 (build 스크립트가 어차피 포함하지만 분리 확인)
```
`npx vite ...`가 이 환경에서 `npm error Missing script: "vite"`로 실패하는 경우가 있었다 (원인 미상 — npx가 로컬 bin 대신 npm script를 찾으려 한 것으로 보임). 재현되면 로컬 바이너리를 직접 호출:
```bash
./node_modules/.bin/vite build --outDir dist2 --emptyOutDir false
```
---
## 3. 빌드
```bash
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
./node_modules/.bin/vite build --outDir dist2 --emptyOutDir false
```
- `--outDir dist2`: 기본 `dist/`(gitignore) 대신 커밋 대상 디렉터리로 출력
- `--emptyOutDir false`: **필수.** `dist2/samples/configBak/`, `dwg_18.3mb.dwg` 등 빌드가 모르는 파일을 보존
- 결과: 새 해시가 붙은 `assets/index-*.js`, `assets/acadrust_dwg_bg-*.wasm` 등이 **추가로** 생기고, 예전 해시 파일은 자동 삭제되지 않음 (이 저장소의 기존 관행과 동일 — `dist2/assets`에 여러 세대의 `index-*.js`가 누적돼 있음). 이번 배포에서도 정리하지 않았다.
- `dist2/index.html`이 새 해시를 가리키도록 갱신됨
이번 실행 결과:
```
dist2/index.html 10.88 kB
dist2/assets/acadrust_dwg_bg-Dt2ifJSA.wasm 879.93 kB
dist2/assets/acadrustParser-CJeZuNZe.js 4.18 kB
dist2/assets/index-CmUgC68r.js 1000.36 kB
```
---
## 4. 변경분 검토 (커밋 전 필수)
```bash
git status --short dist2 src
git diff dist2/index.html # 새 해시 참조로만 바뀌었는지 확인
```
빌드 직전 `dist2/index.html`이 커밋 `199317d`(Entity 선택/Property/Layer 패널) 이전 상태를 가리키고 있었다 — 즉 **이번 배포 전까지 `dist2`가 여러 커밋만큼 stale**했다. `git diff` 결과 UI 패널 CSS/마크업 100줄+ 이 새로 반영된 것으로 확인, 이번 빌드가 그 gap을 포함해 최신화했다.
---
## 5. 스테이징 — 빌드 산출물만, 이물질 제외
```bash
git add src/viewer2d/slugText.ts \
dist2/index.html \
dist2/assets/acadrustParser-CJeZuNZe.js \
dist2/assets/acadrust_dwg_bg-Dt2ifJSA.wasm \
dist2/assets/index-CmUgC68r.js
git status --short # dist2/samples/configBak/, dwg_18.3mb.dwg 는 untracked 로 남아있어야 함
```
`git add dist2``git add -A`를 쓰지 않는다 — `configBak/`, 대용량 테스트 `.dwg`가 함께 커밋되는 것을 막기 위함.
---
## 6. 커밋 & 푸시
```bash
git commit -m "fix(viewer2d): correct Slug GPU text scale to match DWG cap-height
..."
git push origin master
```
- push는 공유 원격(gitea `origin`)에 영향을 주는 되돌리기 어려운 작업 → **사용자 확인 후 실행**했다.
- 결과: `fb8f7ca` 커밋으로 push 완료.
---
## 7. 체크리스트 (다음 배포 시 재사용)
- [ ] `git status --short``dist2` 안 untracked 이물질 있는지 먼저 확인
- [ ] `tsc --noEmit` 통과
- [ ] `./node_modules/.bin/vite build --outDir dist2 --emptyOutDir false` (npx가 실패하면 이 경로로)
- [ ] `git diff dist2/index.html` 로 해시/마크업 변경분 확인
- [ ] `git status --short dist2` 로 새로 생긴 `assets/*` 해시 파일 목록 확인
- [ ] 필요한 파일만 개별 `git add` (샘플/백업 이물질 제외)
- [ ] 커밋 메시지에 무엇/왜 남김
- [ ] push 전 사용자 확인 (원격 공유 저장소)
---
## 8. 후속 과제
1. **공식 배포 스크립트 부재**`package.json``"build:dist2": "vite build --outDir dist2 --emptyOutDir false"` 같은 스크립트를 추가하면 `--outDir`/`--emptyOutDir` 플래그를 매번 손으로 맞출 필요가 없어진다.
2. **누적된 구세대 해시 파일**`dist2/assets/index-*.js`가 세대별로 계속 쌓여만 있다 (정리 안 됨). 정리하려면 배포 직후 `dist2/index.html`이 참조하지 않는 해시 파일을 찾아 지우는 스텝이 필요하나, 이번 배포에서는 기존 관행을 바꾸지 않기 위해 손대지 않았다.
3. **`dist2` 안 테스트 이물질**(`configBak/`, `dwg_18.3mb.dwg`) — 배포 산출물 디렉터리에 테스트 자료가 섞여 있는 구조 자체가 위험(다음 사람이 무심코 `emptyOutDir` 기본값으로 밀어버릴 수 있음). `public/samples/` 같은 소스 디렉터리로 옮기거나 별도 경로 분리를 검토할 것.
---
## 9. 참고
- 빌드 산출물: [`../dist2/`](../dist2/)
- Vite 설정: [`../vite.config.ts`](../vite.config.ts)
- 이번 배포로 반영된 수정: [`../src/viewer2d/slugText.ts`](../src/viewer2d/slugText.ts)
- 관련 배포(원인 무관, 이후 별도 push로 `dist2` 추가 갱신됨): `3480945` perf(viewer2d): toggle layers by visibility instead of reloading the drawing
+227
View File
@@ -0,0 +1,227 @@
# 개선 결과 보고 — SubDMesh + 선가중치 (2026-08-04)
| 항목 | 내용 |
|------|------|
| 작성 목적 | 같은 날 들어간 두 개선(SubDMesh 렌더링, 선가중치 렌더링)의 **측정 결과**와, 개별 문서에는 없는 **두 변경의 상호작용**을 남긴다 |
| 개별 문서 | [`subdmesh-rendering.md`](./subdmesh-rendering.md) · [`lineweight-rendering.md`](./lineweight-rendering.md) |
| 기준 도면 | `dist2/samples/대산당진 2공구_노면_20260804_143854.dwg` (37.1 MB, AC1032 / AutoCAD 2018) |
| 측정 방식 | `Viewer2D.js` 에서 `_meshSegs` · `_lwPx` 를 원본 그대로 들어내 실제 parseResult 에 돌린 값. 브라우저 픽셀 측정이 아니라 **세그먼트 회계** |
---
## 1. 요약
| | 개선 전 | 개선 후 |
|--|--------|--------|
| MESH 엔티티 파싱 | 0개 | 9개 |
| 도로면 메쉬 | 정점 100,000 / 면 14 (**깨진 값**) | 정점 105,497 / 면 185,444 / 모서리 290,931 |
| MESH 화면 출력 | 없음 | 315,633 선분 (면 wireframe) |
| parseResult 크기 | 116 MB | 130 MB (+12%) |
| 파싱 시간 | ~3.2 s | ~3.2 s (변화 없음) |
| 선가중치 | 전부 1px 헤어라인 | 3개 굵기 버킷이 fat line 으로 분리 (아래 3절) |
---
## 2. 기준 도면 프로필
파싱 결과 65,495 엔티티.
```
LWPOLYLINE 47,849 MTEXT 13,974 ATTRIB 1,504 TEXT 1,389
INSERT 752 ATTDEF 15 MESH 9 LINE 2 CIRCLE 1
```
MESH 9개:
| handle | layer | 정점 | 면 | 모서리 | 세분레벨 |
|--------|-------|------|-----|--------|---------|
| 4188 | 도로면 | 105,497 | 185,444 | 290,931 | 0 |
| 4189 | 부체도로면 | 11,712 | 12,568 | 24,226 | 0 |
| 296564 | 일반도로부체도로연결부 | 48 | 44 | 90 | 0 |
| 296766 · 296927 · 297111 · 297259 · 297433 · 297665 | 부체도로(부체도로)연결부 | 28~53 | 25~47 | 51~96 | 0 |
전부 `subdivisionLevel = 0` 삼각 TIN → Catmull-Clark 세분 미구현이 이 도면에서는
화면 차이를 만들지 않는다.
---
## 3. 개선 1 — SubDMesh
원인 분석과 수정 내용은 [`subdmesh-rendering.md`](./subdmesh-rendering.md) 에 있다.
여기에는 **결과 수치**만 남긴다.
### 3-1. 스트림 어긋남 해소
`read_mesh` 의 배열 상한을 `MAX_ARRAY_COUNT`(100,000) 에서 "남은 스트림 비트 ÷
항목 최소 인코딩 길이" 로 바꾼 결과:
| | 정점 | 면 | 모서리 | 앞쪽 면 크기 |
|--|------|-----|--------|-------------|
| 전 | 100,000 | 14 | 3 | `[64, 0, 64, 23, 0, 0, 0, 15]` |
| 후 | 105,497 | 185,444 | 290,931 | `[3, 3, 3, 3, 3, 3, 3, 3]` |
오일러 표수 V E + F = 105,497 290,931 + 185,444 = **10**. 경계를 가진
다중 컴포넌트 곡면으로 타당한 값이다 (닫힌 단일 구면이면 2).
### 3-2. 선분 생성
| 경로 | 선분 수 | 비고 |
|------|---------|------|
| `edges` (중복 제거된 모서리) | **315,633** | 실제 사용 |
| `faceList` 폴백 (면 루프 순회) | 741,072 | 2.35배. 두 경로의 그림 범위는 동일 |
- 비유한(NaN/Infinity) 좌표 **0건**
- 길이 0 선분 6건 (원본 데이터의 축퇴 모서리, 무해)
- 선분 bbox 가 메쉬 정점 bbox 와 일치 — x[151,892 … 161,087] y[477,930 … 482,146]
- z 는 선분 양 끝 표고의 중간값, 범위 1.5 ~ 68. 이 도면은 LWPOLYLINE(0~68)·
TEXT(0~54)도 같은 대역에 있어서 **메쉬만 위로 뜨지 않는다**
### 3-3. 손상 입력 방어
| 입력 | 결과 |
|------|------|
| 빈 객체 / 정점 배열 길이 2 (3의 배수 아님) | 0 선분, 예외 없음 |
| 범위 밖 모서리 인덱스 (`[0, 99, -3, 1]`) | 0 선분 (경계 검사에서 탈락) |
| 면 개수가 남은 배열보다 큼 (`[9, 0, 1]`) | 0 선분, 루프 탈출 |
| 면 개수 0 반복 (`[0, 0, 0]`) | 0 선분, **무한루프 없음** |
| 정상 삼각형 (`[3, 0, 1, 2]`) | 3 선분 |
---
## 4. 개선 2 — 선가중치
측정·판정은 [`lineweight-rendering.md`](./lineweight-rendering.md) 3절(헤드리스
Chrome `readPixels` 비교)에 있다. 이 문서는 그 결과를 다시 주장하지 않고,
아래 5절에서 **세그먼트 부하** 관점만 더한다.
이 도면의 엔티티 선가중치 원시값 분포 (i16, 1/100 mm):
```
-3 (Default) ×15,176 -1 (ByLayer) ×2,613
13 (0.13mm) × 8,794 35 (0.35mm) ×37,944 40 ×119 50 ×849
vars.lwdisplay = true vars.celweight = 29
```
---
## 5. 새로 확인된 상호작용 — 메쉬 315,633 선분이 어느 버킷에 들어가는가
두 개선이 같은 `pushSeg` 를 공유한다. 선가중치 작업이 `pushSeg`
`dash|lineweight` 버킷으로 쪼갰으므로, **메쉬 선분도 그 버킷 규칙을 탄다.**
개별 문서 어느 쪽에도 이 얘기가 없어서 실측했다.
### 5-1. 지금은 안전하다
MESH 9개 전부:
```
own lineWeight = -3 (Default), layer lineWeight = -3
```
`_lwPx``-3``LW_DEFAULT_MM`(0.25mm) → `!(0.25 > LW_HAIRLINE_MM)`**0 반환**.
즉 315,633 선분이 전부 기존 병합 `LineSegments` 헤어라인 배치에 남고,
`LineSegments2` 인스턴싱에는 **한 개도 들어가지 않는다.**
"0.25mm 이하는 헤어라인 유지" 규칙이 — 원래는 굵기 미지정 도면의 모양 보존이
목적이었는데 — 결과적으로 대형 TIN 메쉬가 fat-line 경로로 쏟아지는 것을 막고 있다.
### 5-2. 현재 버킷 부하
LINE · LWPOLYLINE · MESH 기준 (ARC/CIRCLE/HATCH 는 미포함이라 실제 값은 이보다 조금 큼):
| 굵기 px | 선분 수 | 경로 | `LW_MAX_FAT_SEGS`(400,000) 대비 |
|--------|---------|------|-------------------------------|
| 0 (헤어라인) | 1,760,253 | 병합 `LineSegments` | — |
| 2.8 (0.35mm) | 182,539 | fat line | 45.6% |
| 3.2 (0.40mm) | 4,254 | fat line | 1.1% |
| 4.0 (0.50mm) | 92,130 | fat line | 23.0% |
헤어라인 1,760,253 중 메쉬가 **315,633 (17.9%)** 을 차지한다.
### 5-3. 위험 — 메쉬 레이어에 굵기가 지정되면 버킷이 터진다
버킷 키는 `dash|lwPx` 이고 **도면 전역**이다. 도로면/부체도로면 레이어에
0.35mm 를 지정하면 메쉬 선분이 이미 182,539 선분이 있는 2.8px 버킷에 합류한다.
```
182,539 + 315,633 = 498,172 선분 = LW_MAX_FAT_SEGS 의 124.5%
```
상한을 넘으면 `bk.fat = false` 가 되어 **그 버킷 전체가 헤어라인으로 되돌아간다.**
메쉬만 헤어라인이 되는 게 아니라, 지금 정상적으로 굵게 나오는 도로 가장자리
182,539 선분까지 같이 얇아진다. `console.warn` 한 줄만 남고 화면상으로는
"선가중치가 갑자기 안 먹는" 것처럼 보인다.
**대응 후보** (지금 당장 필요하진 않다 — 이 도면은 5-1 때문에 해당 없음):
1. MESH 를 선가중치 대상에서 제외 — 메쉬 wireframe 을 굵게 그릴 실익이 없다.
`_lwPx` 호출부에서 `type === 'MESH'` 면 0 을 쓰는 게 가장 싸다.
2. 상한 초과 시 버킷 전체를 버리지 말고 **큰 기여자만** 헤어라인으로 강등.
3. `LW_MAX_FAT_SEGS` 를 GPU 능력 기준으로 잡기 — 현재 40만은 고정 상수다.
---
## 6. 회귀 검증
새 wasm 과 직전 wasm(`git show HEAD:` 로 추출)으로 나머지 샘플 6개를 파싱해
엔티티 히스토그램을 비교했다.
| 도면 | 버전 | 엔티티 | 히스토그램 |
|------|------|--------|-----------|
| BasicSample.dwg | R2018 | 7,309 | 동일 |
| bb.dwg | R2018 | 438 | 동일 |
| civil.dwg | R2000 | 17,810 | 동일 |
| C0060202-001-평면및종단면도(1) | R2000 | 17,810 | 동일 |
| C0060203-001-표준단면도(2차로) | R2007 | 2,068 | 동일 |
| dwg_18.3mb.dwg | R2018 | 82,393 | 동일 |
6개 전부 **완전 일치**. 노면 도면만 65,486 → 65,495 (+9 MESH). `read_mesh` 상한
변경이 MESH 이외 엔티티에 영향을 주지 않음을 확인한 것이다.
추가로 `tsc --noEmit`, `vite build`, 편집한 JS 3개 `node --check` 통과.
---
## 7. 반영 상태
| 대상 | 상태 |
|------|------|
| hmwebviewer `feat/subdmesh-render` `35f5b1c` | 파서 + 렌더러 + 속성 패널. **푸시 안 됨** |
| 이 저장소 `feat/subdmesh-render` `eec3382` | wasm + 속성 패널 + 문서. **푸시 안 됨** |
| 이 저장소 `src/viewer2d/Viewer2D.js` | **미커밋.** `_meshSegs` + MESH 디스패치 3군데가 선가중치/fat-line 리팩터와 hunk 가 겹쳐 분리 불가. 같은 코드가 hmwebviewer `35f5b1c` 에는 들어가 있다 |
| `dist2/` | **미갱신.** 현재 빌드에 반쯤 배선된 선가중치 UI 가 섞여 있다 |
| hmwebviewer wasm 산출물 | 미커밋. 선가중치 소스가 커밋될 때 `npm run build:dwg-wasm` 로 함께 |
커밋된 wasm 바이너리에는 선가중치 파서 필드(`lwdisplay`, `celweight`,
layer/entity `lineWeight`)도 들어 있다. 빌드 시점의 소스에 이미 있었기 때문이며,
소비 측 없는 추가 JSON 키라 무해하다.
---
## 8. 남은 과제
1. **5-3 버킷 상한** — 메쉬 레이어에 굵기가 지정된 도면이 들어오면 재현된다.
샘플을 만들어 재현시켜 두는 편이 좋다.
2. **Catmull-Clark 세분**`subdivisionLevel > 0` 메쉬는 AutoCAD 보다 각져 보인다.
기준 도면에는 해당 없어 미구현.
3. **POLYFACE MESH / POLYGON MESH** — 구형 POLYLINE flag 16/64 계열은 여전히
미지원. SubDMesh 와 별개 엔티티다.
4. **메쉬 채움(shaded) 패스**`faceList` 를 이미 내보내고 있으므로 삼각화만
하면 된다. 2D 와이어프레임 뷰어라 우선순위는 낮다.
5. **`dist2` 배포** — 선가중치 작업이 끝난 뒤
[`deploy-dist2-static-build.md`](./deploy-dist2-static-build.md) 절차로.
---
## 9. 측정 재현
```bash
# 네이티브 메쉬 진단 (면 크기가 3/4 로 균일하지 않으면 스트림 어긋남 의심)
cd D:\MYCLAUDE_PROJECT\hmwebviewer\rust\dwg-wasm
cargo run --release --bin meshprobe -- <file.dwg>
```
5절의 버킷 표는 `Viewer2D.js` 에서 `_meshSegs` · `_lwPx` 를 들어내 parseResult 에
직접 돌려 만들었다 (`LINE` / `LWPOLYLINE` / `MESH` 만 계수). `Viewer2D.js` 는 three
와 확장자 없는 TS 모듈을 import 하므로 node 에서 그대로 import 되지 않는다 —
함수 본문만 뽑아 `new Function` 으로 감싸는 방식이었다.
+137
View File
@@ -0,0 +1,137 @@
# acadrust MPL-2.0 — 의무 사항과 조치 내역
| 항목 | 내용 |
|------|------|
| 작성 목적 | 파서(acadrust, MPL-2.0)를 담은 wasm 을 공개 배포하면서 지켜야 할 의무를 확정하고 조치 내역 기록 |
| 대상 배포물 | `https://dwg-dxf-viewer-sample.pages.dev/` (Cloudflare Pages, 공개) |
| 검토일 | 2026-08-04 |
| 상태 | **조치 완료** — 고지 파일·MPL 원문·패치 동봉, 사이트에서 링크 |
---
## 0. 결론 (세 줄)
1. **이번 lineweight 작업은 MPL 파일을 건드리지 않았다.** 수정한 `dwg-wasm/src/lib.rs`
우리 MIT 코드다. → MPL 의무가 *새로* 생기지 않았다.
2. 그러나 **이전부터** acadrust 를 한 파일 패치해서 wasm 에 컴파일해 공개 배포 중이었고,
그 상태가 **MPL-2.0 §3.2 위반**이었다. 라이선스 원문도 고지도 소스 입수 경로도 없었다.
3. **upstream(hakanaktt/acadrust)에 PR 을 올릴 의무는 없다.** MPL 은 기여 강제가 아니다.
**배포물에 고지 + 소스 입수 경로만 넣으면 된다.** ← 이번에 그렇게 조치했다.
---
## 1. 무엇이 MPL 이고 무엇이 우리 것인가
| 코드 | 위치 | 라이선스 | 이번 세션에서 수정? |
|------|------|----------|---------------------|
| **acadrust 0.4.1** (DWG 파서 본체) | `hmwebviewer/rust/acadrust/` | **MPL-2.0** | **아니오** |
| dwg-wasm (wasm-bindgen 래퍼 + JSON 직렬화) | `hmwebviewer/rust/dwg-wasm/src/lib.rs` | MIT (우리 코드) | 예 (lineWeight 필드 추가) |
| Viewer2D 외 프론트엔드 | `src/` | MIT | 예 |
MPL-2.0 은 **파일 단위(file-level) 카피레프트**다. GPL 처럼 프로젝트 전체로 번지지 않는다.
MPL 파일을 고쳤으면 **그 파일만** MPL 로 유지하고 소스를 공개하면 되고,
MPL 파일을 호출·링크하는 우리 코드(§3.3 의 "Larger Work")는 우리가 원하는 라이선스로 배포할 수 있다.
**`lib.rs` 에 필드 세 개 추가한 것은 MPL 전염 사유가 아니다.**
## 2. 그럼에도 의무가 있는 이유 — 이미 한 파일을 고쳤다
vendored acadrust 를 crates.io 원본과 diff 하면 **정확히 한 파일**이 다르다.
```bash
diff -rq ~/.cargo/registry/src/index.crates.io-*/acadrust-0.4.1/src \
hmwebviewer/rust/acadrust/src
# → src/io/dwg/dwg_stream_readers/object_reader/entities.rs 만 differ
```
`read_mesh` 의 배열 길이를 남은 비트 수로 제한하는 패치다(대형 SubD 메쉬 손상 수정,
[`subdmesh-rendering.md`](./subdmesh-rendering.md)). **이 파일은 Modified Covered
Software** 이고, 그 소스는 MPL 하에 제공되어야 한다.
그리고 `.wasm` 은 acadrust 를 컴파일해 담은 **Executable Form** 이다. 공개 사이트에서
브라우저로 내려보내는 순간 MPL 상 "Distribution" 에 해당한다.
### 적용되는 조항
| 조항 | 요구 |
|------|------|
| §3.1 | Source Code Form 을 배포하면 MPL 하에 배포하고 라이선스 사본을 동봉 |
| **§3.2** | **Executable Form 배포 시**: (a) 해당 Covered Software 의 Source Code Form 을 MPL 하에 제공하고, (b) **수령자에게 그 입수 방법을 알릴 것** (배포 비용 이상 청구 금지) |
| §3.3 | Larger Work(우리 앱)는 다른 라이선스로 배포 가능 — 위 (a)(b)를 지키는 한 |
| §3.4 | 라이선스·저작권 고지를 제거하지 말 것 |
**MPL 에는 upstream 기여 의무 조항이 없다.** §3.2 는 "수령자에게 제공"이지
"원저자에게 반영"이 아니다.
## 3. 위반 상태였던 지점 (조치 전)
- 배포 사이트에 MPL-2.0 **원문 없음**
- acadrust 를 쓴다는 **고지 없음** (README 표에만 한 줄, 배포물에는 미포함)
- 패치된 `entities.rs` 소스를 **얻을 방법 없음**
- `hmwebviewer` GitHub 저장소는 비공개(API 404)
- 샘플 저장소는 사내 gitea
- 즉 공개 배포 대상자가 소스에 접근할 경로가 전무
## 4. 조치 사항 (완료)
### 4-1. 배포물에 동봉
| 파일 | 배포 URL | 내용 |
|------|----------|------|
| `public/licenses/acadrust-MPL-2.0.txt` | `/licenses/acadrust-MPL-2.0.txt` | MPL-2.0 원문 (§3.1·§3.4) |
| `public/licenses/acadrust-0.4.1-read_mesh.patch` | `/licenses/acadrust-0.4.1-read_mesh.patch` | 우리 수정분 diff (§3.2(a)) |
| `public/licenses/THIRD-PARTY-NOTICES.md` | `/licenses/THIRD-PARTY-NOTICES.md` | 컴포넌트·버전·업스트림·수정 여부·복원 절차 (§3.2(b)) |
저장소 루트에도 같은 내용의 [`../THIRD-PARTY-NOTICES.md`](../THIRD-PARTY-NOTICES.md) 를 둔다.
### 4-2. 사이트에서 링크
`index.html` 우측 "Module recipe" 패널 하단에 고지 링크 추가.
§3.2(b) 의 "reasonable manner" 요건은 **수령자가 실제로 찾을 수 있는 위치**를 요구한다.
### 4-3. 소스 복원 절차 명시
원본 크레이트 + 패치 조합이면 컴파일된 소스를 바이트 단위로 재현할 수 있다.
```bash
curl -O https://crates.io/api/v1/crates/acadrust/0.4.1/download
tar xf download && cd acadrust-0.4.1
patch -p1 < acadrust-0.4.1-read_mesh.patch
```
업스트림 커밋 `f249c2f816acf36ee51cd5533716bdd443c2517e` 도 고지에 적어 뒀다
(크레이트의 `.cargo_vcs_info.json` 값).
## 5. 질문에 대한 답
> **"해당 레포(upstream)에 올려야 하나, 배포시 문서만 추가하면 되나?"**
**문서(고지) + 소스 입수 경로만 배포물에 넣으면 된다. upstream PR 은 의무가 아니다.**
| 선택지 | MPL 상 필요? | 판단 |
|--------|--------------|------|
| 배포물에 MPL 원문 + 고지 + 패치 동봉 | **필수** | ✅ 이번에 조치 |
| upstream `hakanaktt/acadrust` 에 PR | 아니오 | 선택 — read_mesh 수정은 일반 버그픽스라 올리면 유지보수 부담이 준다 (권장, 의무 아님) |
| `hmwebviewer` 저장소 공개 전환 | 아니오 | 불필요 — 패치 동봉으로 §3.2 충족 |
| 패치된 acadrust 전체 소스를 이 저장소에 vendoring | 아니오 | 더 엄격한 대안. 189 파일이 샘플 저장소에 들어와 무거워짐. 원본+패치 방식은 배포판(distro) 관행과 동일하고 §3.2 를 충족 |
> **"lineweight 작업으로 라이선스 의무가 생겼나?"**
**아니오.** 이번에 고친 `dwg-wasm/src/lib.rs` 는 MIT 인 우리 파일이고, MPL 파일은
건드리지 않았다(위 §2 diff 로 확인). 의무는 **이전 read_mesh 패치 + wasm 공개 배포**에서
이미 발생해 있었고, 이번에 뒤늦게 정리한 것이다.
## 6. 앞으로 지킬 것 (체크리스트)
- [ ] `rust/acadrust/` 아래 파일을 고치면 → 패치 파일을 다시 뽑아 `public/licenses/` 갱신
- [ ] acadrust 버전을 올리면 → 고지의 버전·커밋·패치를 함께 갱신
- [ ] 새 서드파티를 번들하면 → `THIRD-PARTY-NOTICES.md` 에 행 추가
- [ ] MPL 파일에서 라이선스 헤더 주석을 지우지 말 것 (§3.4)
- [ ] 배포 전 `/licenses/` 3개 파일이 실제로 서빙되는지 확인
## 7. 참고
- MPL-2.0 원문: [`../public/licenses/acadrust-MPL-2.0.txt`](../public/licenses/acadrust-MPL-2.0.txt)
- FAQ: https://www.mozilla.org/MPL/2.0/FAQ/ (Q: file-level copyleft, Q: 배포 의무 범위)
- 수정 경위: [`subdmesh-rendering.md`](./subdmesh-rendering.md)
- 이번 파서 변경(MIT 영역): [`lineweight-rendering.md`](./lineweight-rendering.md) §4-1
+337
View File
@@ -0,0 +1,337 @@
# 선가중치(Lineweight) 미적용 — 원인 분석 및 해결 가이드
| 항목 | 내용 |
|------|------|
| 작성 목적 | 모든 선이 1px 헤어라인으로만 그려지던 문제의 원인 규명과 수정·검증 내역 기록 |
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
| 원본 저장소 | `hmwebviewer` (`rust/dwg-wasm/`, `src/viewer2d/` 동기화 대상) |
| 재현 도면 | `대산당진 2공구_노면_20260804_143854.dwg` (65,495 entities · LWDISPLAY=on · 0.13/0.35/0.40/0.50mm 혼재) |
| 대조 도면 | `BasicSample.dwg`, `civil.dwg`, `bb.dwg`, `C0060202-001-평면및종단면도(1).dwg` (모두 굵기 미지정) |
| 정답지 | AutoCAD 스크린샷 (`dist2/samples/1785826356.png`) |
| 수정일 | 2026-08-04 |
| 상태 | **해결** — 파서·렌더러 수정 완료, `dist2` 재빌드 완료, 커밋/푸시는 미실행 |
> **폭(width)과 혼동 주의.** 도곽 외곽선이 예전부터 굵게 그려진 것은 lineweight 가 아니라
> LWPOLYLINE 의 **폭**(`constantWidth` / `startWidths` / `endWidths`)이다. 도면단위 실폭이라
> 줌하면 같이 커지고, `_widePolyMesh` 가 삼각형 메쉬로 처리한다. lineweight 는 화면 픽셀
> 단위(줌해도 폭 고정)이며 이번 문서의 대상이다. 둘은 별개 기능이다.
---
## 1. 현상
AutoCAD 에서는 도로 가장자리·교량라인이 뚜렷하게 굵고 비탈면 선은 가는데,
뷰어에서는 **모든 선이 같은 1px**로 그려진다. 색상·선종류(점선)는 정상.
정답지와 대조하면 굵기 비율이 완전히 소실된 상태였다.
---
## 2. 오류 원인
원인은 **두 겹**이었다. 한쪽만 고쳐서는 화면이 바뀌지 않는다.
### 2-1. 파서가 lineweight 를 내보내지 않았다 (데이터 부재)
acadrust 는 lineweight 를 **이미 읽고 있었다.**
```
acadrust/src/entities/mod.rs:260 pub line_weight: LineWeight, // EntityCommon
acadrust/src/tables/layer.rs:58 pub line_weight: LineWeight, // Layer
acadrust/src/types/line_weight.rs DWG 5비트 테이블 인덱스 ↔ 값 변환까지 구현
```
그런데 `dwg-wasm` 의 JSON 직렬화(`common_json` / `tables_value` / `vars_value`)에서
해당 필드가 빠져 있어 parseResult 에 값이 존재하지 않았다.
확인 방법 — wasm 바이너리의 serde 필드명 문자열을 직접 조회:
```bash
grep -a -c "linetypeScale" acadrust_dwg_bg.wasm # 1 (있음)
grep -a -c "entityHeader" acadrust_dwg_bg.wasm # 1 (있음)
grep -a -o -i "[a-z_]*weight[a-z_]*" acadrust_dwg_bg.wasm | sort -u # (없음)
```
parseResult 덤프로도 재확인:
```
layer[0] = { colorIndex, frozen, handle, lineType, locked, name, on, plotting } ← lineWeight 없음
entityHeader = { colorIndex, invisible, linetypeScale } ← lineWeight 없음
```
### 2-2. 렌더러가 굵기를 그릴 수단이 없었다 (표현 부재)
Viewer2D 는 성능을 위해 도면의 모든 선분을 **하나의 병합 버퍼**로 그린다.
```js
const lmesh = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true }));
```
WebGL 코어 프로파일에서 `LineBasicMaterial.linewidth`**무시되고 항상 1px** 이다
(three.js 문서 명시). 즉 2-1 이 해결되어 값이 들어오더라도 이 배치 구조로는
굵기를 표현할 수 없다.
### 2-3. 왜 지금까지 드러나지 않았나
굵기를 지정한 도면이 저장소 샘플에 사실상 없었다. 엔티티 lineweight 히스토그램:
| 도면 | LWDISPLAY | 엔티티 lineweight |
|------|-----------|-------------------|
| `BasicSample.dwg` | true | `-2` ×62, `-1` ×7247 → 전부 ByLayer/ByBlock → 레이어가 전부 Default |
| `civil.dwg` / `bb.dwg` / `C0060202` | **false** | `-1` ×17241, `0` ×569 |
| **`대산당진 2공구_노면`** | true | `-3` ×15176, `-1` ×2613, **`13` ×8794, `35` ×37944, `40` ×119, `50` ×849** |
굵기 데이터가 실제로 있는 도면은 노면 도면 하나뿐이었고, 그 도면은 37MB 라
로딩 성능 이슈가 해결된 뒤에야 일상적으로 열리기 시작했다.
---
## 3. 개선 사항 (설계 결정)
수정 방향을 정할 때 확정한 규칙들.
### 3-1. 픽셀 환산 — AutoCAD LWDISPLAY 규격을 따른다
lineweight 는 **화면 픽셀 폭**이다. 줌해도 굵기가 변하지 않는다(모델 공간 기준).
따라서 월드 단위가 아니라 스크린 스페이스로 그려야 한다.
```
px = mm × 11 (LW_PX_PER_MM 기본값, 최대 48px)
0.35mm = 3.9px · 0.50mm = 5.5px · 2.11mm = 23px
```
**이 배율은 상수로 확정할 수 있는 값이 아니다.** AutoCAD 도 모델 공간에서는 선가중치를
줌과 무관한 픽셀로만 그리고, 그 환산은 "선가중치 설정 → 표시 배율" 슬라이더가 정한다.
즉 화면 DPI·모니터에 따라 맞는 값이 달라진다.
- 코드 기본값: `LW_PX_PER_MM = 11` (사용자가 실제 화면에서 AutoCAD 와 나란히 놓고 맞춘 값)
- 런타임 조정: **옵션 패널 → 선가중치 → 표시 배율**, 또는 `setLineweightScale(pxPerMm)`
- 조정은 **재파싱 없이 즉시 반영**된다 (3-6 절)
> 시행착오 기록: 처음 8px/mm(96dpi 어림) → 손테스트 "얇다" 반려 → 정답지 캡처에서
> 역산해 20px/mm → 실제 화면 대조 후 최종 11px/mm. 상수 추정이 두 번 빗나갔고,
> 그래서 값을 코드에서 빼 옵션으로 노출했다.
### 3-2. 0.25mm 이하는 헤어라인 유지 — **회귀 방지 장치**
`-3`(Default)·`-1`(ByLayer → 레이어도 Default)이 도면 대부분을 차지한다.
이것까지 2px 로 그리면 **굵기를 지정한 적 없는 기존 도면이 통째로 두꺼워진다.**
`LW_HAIRLINE_MM = 0.25` 이하는 기존 병합 배치에 그대로 남긴다.
그 결과 대조 도면 4종은 **픽셀 단위로 이전과 동일**하게 렌더링된다.
### 3-3. 굵은 선만 별도 배치 — 성능 보존
전체를 fat line 으로 바꾸면 세그먼트당 인스턴스 8정점이라 대형 도면에서 감당이 안 된다.
`dash|lineweight` 조합으로 버킷을 나눠 **굵기가 있는 버킷만** 별도 메쉬로 뽑는다.
| 버킷 | 메쉬 | 비고 |
|------|------|------|
| 실선 + 헤어라인 | 기존 병합 `LineSegments` | 변경 없음 |
| 점선 + 헤어라인 | `LineSegments` + `LineDashedMaterial` | 변경 없음 |
| 굵기 있음 | `LineSegments2` + `LineMaterial` | 신규 (스크린스페이스 쿼드) |
한 버킷이 `LW_MAX_FAT_SEGS`(40만) 세그먼트를 넘으면 헤어라인으로 폴백하고
`console.warn` 을 남긴다. 대형 측량 도면에서 GPU/힙이 터지는 것을 막는 상한.
### 3-4. 기존 기능을 깨지 않는다
새 메쉬 타입이 들어오면서 다음 두 기능이 조용히 망가질 수 있었다. 둘 다 대응했다.
- **레이어 토글** — 인스턴스 지오메트리는 인덱스 재작성이 불가
- **선택 하이라이트** — 색상 배열이 여러 버퍼로 쪼개짐
### 3-6. 배율 조정은 리빌드 없이
헤어라인 컷오프가 **mm 값**(0.25mm)이라 배율이 바뀌어도 어떤 세그먼트가 어느 버킷에
들어가는지는 변하지 않는다. 모든 굵기가 같은 배수로 움직일 뿐이다.
→ 각 fat 배치가 자기 굵기를 **mm 로 기억**(`_fatBufs[].lwMm`)하고,
`setLineweightScale()``material.linewidth` 만 다시 쓴다. 37MB 도면에서도 슬라이더를
끄는 동안 지연이 없다.
반면 **표시 on/off** 는 굵기가 0이 되어 버킷 구성이 바뀌므로 재빌드가 필요하다.
### 3-5. 표시 on/off — AutoCAD LWDISPLAY 와 동일하게
굵기 표시는 CAD 에서 원래 켜고 끌 수 있는 옵션이다. 도면의 헤더 값을 기본으로 따르되
사용자가 강제할 수 있어야 한다.
---
## 4. 조치 사항
### 4-1. 파서 — `hmwebviewer/rust/dwg-wasm/src/lib.rs`
원시 i16 값(1/100 mm, `-1` ByLayer · `-2` ByBlock · `-3` Default)을 세 군데에 추가.
| 함수 | 추가 필드 |
|------|-----------|
| `common_json``entityHeader` | `lineWeight` |
| `tables_value``layers[]` | `lineWeight` |
| `vars_value` | `lwdisplay`, `celweight` |
재빌드 + 산출물 복사:
```bash
cd D:\MYCLAUDE_PROJECT\hmwebviewer
npm run build:dwg-wasm
cp src/viewer2d/acadrust-dwg/acadrust_dwg* \
../dwg-dxf-viewer-sample/src/viewer2d/acadrust-dwg/
```
검증(노면 도면):
```
vars.lwdisplay = true vars.celweight = 29
entity lineWeight = { -3:15176, -1:2613, 13:8794, 35:37944, 40:119, 50:849 }
```
### 4-2. 렌더러 — `src/viewer2d/Viewer2D.js`
| 추가/변경 | 내용 |
|-----------|------|
| `_lwPx(entity, inheritedPx)` | ByLayer→LAYER 테이블, ByBlock→INSERT 굵기, Default→0.25mm 해석 후 픽셀 환산. 0.25mm 이하는 `0`(헤어라인) 반환 |
| `_buildLayerMap` | 레이어 lineweight 를 핸들·이름 두 키로 보관 (DXF 레이어는 핸들이 없음) |
| `pushSeg` | 세그먼트를 `dash|lineweight` 버킷으로 분기 + 엔티티별 색상 구간(`meta.spans`) 기록 |
| 조립부 | 굵은 버킷 → `LineSegments2` + `LineMaterial(worldUnits:false)`. 캡 초과 시 헤어라인 폴백 |
| `_registerFat` / `_applyLayerVisibility` | 보이는 인스턴스만 버퍼 앞으로 압축 후 `geometry.instanceCount` 축소. 원본 배열 보존, 이동 위치는 `slotOf` 에 기록 |
| `_paintMeta` / `_paintSpan` | 헤어라인 구간 + 모든 버킷 구간을 함께 칠함. 레이어 토글로 재배치되면 `slotOf` 경유로 다시 칠함 |
| `_insertEntities` | 자식마다 `_lwPx(child, insertLwPx)` — ByBlock 이 INSERT 굵기를 상속. 루프 종료 시 호출자 값 복구 |
| `_onResize` | `LineMaterial.resolution` 재설정 (픽셀 폭 기준이 캔버스 크기라서 필수) |
| `setLineweightEnabled(on)` / `getLineweightEnabled()` | 표시 스위치. `null` = 도면의 LWDISPLAY 를 따름 |
### 4-3. UI
툴바 **옵션** 버튼 → `옵션 (Options)` 패널 (`index.html` · `src/main.ts`).
| 항목 | 동작 |
|------|------|
| 표시 (LWDISPLAY) | on/off. 체크 해제 시 전부 헤어라인 (재빌드) |
| 표시 배율 | 슬라이더 + 숫자 입력 (px/mm). 즉시 반영 |
| 환산 표시 | 그 도면이 실제로 쓰는 굵기만 `0.35mm → 3.9px` 식으로 표기 |
| ↺ 기본값 | 배율·표시·그리드·줌속도 초기화 |
설정은 **IndexedDB** (`hmw-viewer` DB → `options` 스토어)에 저장한다
([`../src/optionsStore.ts`](../src/optionsStore.ts)). 키: `lineweightScale`,
`lineweightOn`, `grid`, `zoomSpeed`. 스토리지가 막혀 있어도 조용히 기본값으로 동작한다.
속성 패널에는 `선가중치 (Lineweight)` 행이 있다 (`src/propertyInspector.ts`).
`-1``BYLAYER`, `-2``BYBLOCK`, `-3``DEFAULT (0.25mm)`, 그 외 `0.35mm` 형식.
### 4-4. 배포
```bash
./node_modules/.bin/vite build --outDir dist2 --emptyOutDir false
```
`dist2/assets/index-D3FqDGwP.js`, `acadrust_dwg_bg-DmCEpFBu.wasm`.
`--emptyOutDir false` 는 필수 ([`deploy-dist2-static-build.md`](./deploy-dist2-static-build.md) 참조).
**커밋/푸시는 하지 않았다** — 공유 원격이라 사용자 확인 필요.
---
## 5. 검증
헤드리스 Chrome(SwiftShader) 1400×1000, 노면 도면, 동일 카메라(30단계 확대)에서
`gl.readPixels` 로 직접 계측.
### 5-1. 굵기 반영
| | 밝은 픽셀 수 | 중앙 스캔라인 최대 cyan 폭 |
|--|--|--|
| 선가중치 **ON** | 137,528 | **5 px** |
| 선가중치 **OFF** | 122,245 | 2 px |
0.50mm × 8 = 4px + 안티에일리어싱 → 5px. 설계값과 일치.
### 5-2. 레이어 토글 무손실
`도로 가장자리` 레이어 끄기 → 켜기:
```
기준 {"lit":137528,"cyan":12162,"accent":6741}
숨김 {"lit":136735,"cyan":12553,"accent":5776}
복원 {"lit":137528,"cyan":12162,"accent":6741} ← 기준과 완전 일치
```
### 5-3. 선택 하이라이트
굵은 선(0.35mm LWPOLYLINE, 레이어 `비탈면방향`) 클릭:
```
LWPOLYLINE · #151257
레이어 비탈면방향 · 색상 BYLAYER · 선가중치 (Lineweight) 0.35mm
```
cyan 픽셀 수가 감소(11502→11486) → fat 배치의 정점이 강조색으로 재도색됨을 확인.
### 5-4. 도면이 실제로 들고 있는 수치
굵기는 **레이어가 아니라 엔티티**에 들어 있다. 23개 레이어 lineweight 는 **전부 `-3`
(Default)** 이라 ByLayer 로는 아무 정보도 나오지 않는다.
| 레이어 | ACI | lineweight | 개수 |
|---|---|---|---|
| 도로 가장자리 | 1 (빨강) | **50 = 0.50mm** | 18 |
| 도로마킹 | 255 / 7 / 2 / 4 | 50 = 0.50mm | 799 |
| 부체도로 가장자리 | 20 | 50 = 0.50mm | 32 |
| 비탈면방향 | 140 / 150 | 35 = 0.35mm | 34,758 |
| 노면측구 | 4 (시안) | 35 = 0.35mm | 303 |
| 시작·끝라인 / Drawing확폭 | 4 / 6 / 2 / 3 | 40 = 0.40mm | 105 |
| SubContour · MainContour | 3 / 6 | 13 = 0.13mm | 8,794 |
| **도로면 (MESH 삼각망)** | 7 | **-3 Default** | 1 (185,444 faces) |
| Station (중심선·라벨) | 1 (빨강) | -1 → 레이어 -3 → Default | 1,082 |
주의: 화면의 **빨간 선이 두 종류**다. 굵은 쪽이 `도로 가장자리`(0.50mm), 도로 한가운데를
지나는 얇은 쪽이 `Station` 중심선(Default → 헤어라인). 정답지에서 굵게 나오는 것은 전자다.
### 5-5. 정답지 대조 (배율 확정 근거)
lineweight 는 줌과 무관한 화면 픽셀이므로, 두 캡처의 **절대 픽셀 두께**를 그대로 비교한다.
| 배율 | `도로 가장자리` 0.50mm | `노면측구` 0.35mm (헤드리스 실측) |
|--|--|--|
| 8 px/mm (최초, 반려) | 4 px | 23 px |
| 20 px/mm (캡처 역산) | 10 px | 7 px |
| **11 px/mm (현재 기본값)** | **5.5 px** | **34 px** |
| 25 px/mm (참고) | 12.5 px | 89 px |
캡처 이미지에서 역산한 20 은 실제 화면에서 과했다 — 캡처 DPI 와 사용자 모니터가 달라서다.
최종값은 사용자가 뷰어와 AutoCAD 를 나란히 놓고 맞춘 **11**. 이래서 상수로 못 박지 않고
옵션으로 뺐다.
### 5-6. 회귀 없음
- `npx tsc --noEmit` 통과
- `npm run build` 통과
- 대조 도면 4종: 굵기 전부 Default → **화면 변화 없음**(설계 3-2 의도대로)
---
## 6. 남은 과제
1. **배율 기본값은 환경 의존** — 11 은 특정 모니터에서 맞춘 값이다. 다른 화면에서는
옵션 패널로 다시 맞춰야 한다. `devicePixelRatio` 로 자동 보정하는 것은 검토만 하고
넣지 않았다(캡처·원격 데스크톱 등에서 오히려 어긋난다).
2. **ByBlock 근사** — 블록 자식의 `-2` 는 INSERT 굵기를 상속하지만, 중첩 블록에서
중간 INSERT 가 `-2` 인 경우까지는 추적하지 않는다(점선 처리와 동일한 기존 정책).
3. **DXF 레이어 굵기 없음**`dxf-parser` 의 LAYER 핸들러가 코드 370 을 무시한다.
DXF 는 엔티티 자체 굵기(`entity.lineweight`)만 반영되고 ByLayer 는 Default 로 떨어진다.
4. **`celweight` 미사용** — 헤더 CELWEIGHT 를 값이 아니라 DWG 5비트 테이블 인덱스로
내보내고 있다(노면 도면에서 `29` = ByLayer 인덱스). 현재 렌더링에 쓰지 않으므로
방치했으나, 쓰게 되면 `LineWeight::from_dwg_index` 변환을 파서 쪽에 넣어야 한다.
5. **40만 세그먼트 캡** — 초과 시 조용히 얇아지는 대신 `console.warn` 만 남는다.
상태 표시줄 등 사용자에게 보이는 알림이 필요할 수 있다.
---
## 7. 참고
- 렌더러: [`../src/viewer2d/Viewer2D.js`](../src/viewer2d/Viewer2D.js) —
`_lwPx`, `_registerFat`, `_applyLayerVisibility`, `_paintMeta`, `setLineweightEnabled`
- 속성 패널: [`../src/propertyInspector.ts`](../src/propertyInspector.ts) — `formatLineweight`
- 파서: `hmwebviewer/rust/dwg-wasm/src/lib.rs``common_json`, `tables_value`, `vars_value`
- 폭(width)과의 차이: `_segWidths` / `_widePolyMesh` (도면단위 실폭)
- 같은 유형의 선례(파서 필드 누락 → 렌더 불가): [`subdmesh-rendering.md`](./subdmesh-rendering.md)
- 배포 절차: [`deploy-dist2-static-build.md`](./deploy-dist2-static-build.md)
+162
View File
@@ -0,0 +1,162 @@
# SubDMesh(MESH) 렌더링
| 항목 | 내용 |
|------|------|
| 증상 | `대산당진 2공구_노면_*.dwg` 의 노면/부체도로면이 뷰어에 전혀 나오지 않음 |
| 원인 | ① dwg-wasm 이 MESH 엔티티를 parseResult 에 아예 내보내지 않음 ② acadrust 의 배열 개수 상한(100,000)이 큰 메쉬를 잘라내 뒤따르는 face/edge 목록까지 깨뜨림 |
| 대상 클래스 | `AcDbSubDMesh` (DXF 엔티티명 `MESH`, AutoCAD 2010+) |
| 수정일 | 2026-08-04 |
| 반영 상태 | 파서(wasm)·속성 패널·문서는 이 커밋에 포함. **`Viewer2D.js` 렌더러 hunk 는 아직 미커밋** — 같은 파일에서 진행 중인 lineweight/fat-line 리팩터와 hunk 가 겹쳐 분리가 안 된다. 렌더러 원본은 본가 hmwebviewer `35f5b1c` 에 들어가 있고, 이 저장소에는 작업 트리에만 있다. 그 리팩터가 커밋될 때 같이 들어간다. |
---
## 1. 무엇이 안 나왔나
문제 도면(AC1032 / AutoCAD 2018)에는 MESH 엔티티가 9개 있다.
| handle | layer | 정점 | 면 | 모서리 |
|--------|-------|------|----|--------|
| 4188 | 도로면 | 105,497 | 185,444 | 290,931 |
| 4189 | 부체도로면 | 11,712 | 12,568 | 24,226 |
| 296564 … 297665 | 연결부 (7개) | 28 ~ 53 | 25 ~ 47 | 51 ~ 96 |
파싱 결과 엔티티 히스토그램에는 `MESH`**0개** 였다. 즉 뷰어의 렌더 문제가 아니라
**파서 출력에 데이터 자체가 없었다.**
---
## 2. 원인 두 겹
### 2-1. dwg-wasm 이 MESH 를 버리고 있었다
`rust/dwg-wasm/src/lib.rs``entity_to_json()` 마지막 arm:
```rust
// Not rendered by Viewer2D (leaders, rays, meshes, …) — skip.
_ => {}
```
acadrust 자체는 MESH 를 **완전히 읽고 있었다** (`OBJ_MESH``entities::read_mesh()`
`EntityType::Mesh`). JSON 직렬화 단계에서만 빠져 있었다.
### 2-2. acadrust 의 배열 상한이 큰 메쉬를 깨뜨렸다
`object_reader/mod.rs` 에는 손상 데이터 방어용 상한이 있다.
```rust
const MAX_ARRAY_COUNT: i32 = 100_000;
fn safe_count(raw: i32) -> i32 { raw.max(0).min(MAX_ARRAY_COUNT) }
```
`read_mesh()` 가 이 상한을 정점 개수에 그대로 쓰고 있었다. 도로면 메쉬의 실제 정점은
105,497개 → 100,000 으로 잘림 → **5,497개 분량의 비트를 안 읽고 다음 필드로 넘어감**
그 뒤의 face/edge/crease 목록이 통째로 어긋난다. 진단 출력이 이렇게 나왔다.
```
MESH #1 verts=100000 faces=14 edges=3
first face sizes: [64, 0, 64, 23, 0, 0, 0, 15]
```
정점 수가 그럴듯해 보여서 에러로 드러나지 않는 종류의 고장이다. 정상 TIN 이면 face 는 전부
3정점이어야 한다.
**수정**: `read_mesh()` 전용으로 “남은 스트림 비트 수” 기준 상한을 쓴다
(`stream_bounded_count`). 항목 1개의 최소 인코딩 길이(3BD = 6비트, BitLong = 2비트)로
남은 비트를 나눈 값이 상한이므로, 무제한 할당 방어는 그대로 유지되면서 정상 도면은
잘리지 않는다.
```
MESH #1 verts=105497 faces=185444 edges=290931
first face sizes: [3, 3, 3, 3, 3, 3, 3, 3]
```
> acadrust 는 MPL-2.0. 이 저장소 기준으로 **처음 생긴 로컬 패치**다. MPL 은 파일 단위
> copyleft 이므로 해당 파일이 MPL 로 남고 소스가 저장소에 포함되면 요건을 만족한다.
> `rust/dwg-wasm/Cargo.toml` · `src/lib.rs` 의 "consumed unmodified" 문구를 갱신했다.
---
## 3. parseResult 스키마
MESH 는 **평면 숫자 배열**로 나간다. `{x,y,z}` 객체와 중첩 face 배열을 쓰면 같은 숫자에
대해 JSON 이 약 3배로 부푼다 (이 도면 기준 전체 JSON 116MB → 130MB 로 끝난 이유).
```jsonc
{
"type": "MESH",
"handle": { "value": 4188 }, "layer": "도로면", "entityHeader": { },
"vertices": [x, y, z, x, y, z, ], // 평면
"vertexCount": 105497,
"faceList": [n, i0, , i(n-1), n, ],// DXF group 93 배열과 같은 형태 (n각형 허용)
"faceCount": 185444,
"edges": [a, b, a, b, ], // acadrust 가 중복 제거해 준 모서리 목록
"subdivisionLevel": 0,
"blendCrease": true,
"meshVersion": 2
}
```
범위를 벗어난 인덱스는 `u32::MAX` 로 나가므로, 소비 측 경계 검사에서 그 모서리 하나만
버려진다 (엉뚱한 정점으로 감기지 않는다).
---
## 4. 렌더링
`Viewer2D._meshSegs()` 가 면 wireframe 을 선분으로 뱉는다. AutoCAD 의 2D 와이어프레임
표시와 같은 그림이다.
- **`edges` 우선.** 이미 중복이 제거돼 있어 삼각형이 공유하는 모서리를 한 번만 그린다.
이 도면에서 315,633 선분 (faceList 로 그리면 741,072 → 약 1.8배).
- `edges` 가 비어 있으면 `faceList` 로 폴백. 두 경로의 그림 범위(extent)는 동일함을 확인.
- z 는 선분 양 끝 정점 표고의 중간값. 이 도면은 LWPOLYLINE·TEXT 도 이미 z 0~68 대역에
있으므로 메쉬만 위로 뜨지 않는다.
- 세 군데 디스패치 모두에 연결: 모델 공간 switch, 페이퍼 뷰포트 통과 switch,
`_insertEntities`(블록 안의 MESH, `xf` 로 좌표 변환).
### 한계
`subdivisionLevel > 0` 인 메쉬는 **기본(control) 메쉬만** 그린다. AutoCAD 는 그 정점들을
Catmull-Clark 로 세분한 결과를 보여주므로, 그런 메쉬는 AutoCAD 보다 각져 보인다.
문제 도면은 9개 전부 `subdivisionLevel = 0` 이라 차이가 없다.
`POLYFACE MESH` / `POLYGON MESH`(구형 POLYLINE flag 16/64)는 별개 엔티티이며 이 작업
범위 밖이다.
---
## 5. 검증 도구
```bash
# 네이티브에서 메쉬가 제대로 읽히는지 (hmwebviewer 쪽)
cd D:\MYCLAUDE_PROJECT\hmwebviewer\rust\dwg-wasm
cargo run --release --bin meshprobe -- <file.dwg>
```
face 크기가 전부 3(또는 4)이 아니라 `[64, 0, 64, 23, …]` 처럼 들쭉날쭉하면 위 2-2 의
스트림 어긋남을 다시 의심한다.
---
## 6. wasm 재빌드
MESH 지원은 wasm 안에 들어 있으므로 재빌드가 필요하다. 빌드 스크립트는 hmwebviewer 에만
있고, 산출물을 이 저장소로 복사한다.
```bash
cd D:\MYCLAUDE_PROJECT\hmwebviewer
npm run build:dwg-wasm # → hmwebviewer/src/viewer2d/acadrust-dwg/
cp src/viewer2d/acadrust-dwg/acadrust_dwg* \
../dwg-dxf-viewer-sample/src/viewer2d/acadrust-dwg/
```
이후 정적 배포는 [`deploy-dist2-static-build.md`](./deploy-dist2-static-build.md) 절차를 따른다.
---
## 7. 참고
- 렌더러: [`../src/viewer2d/Viewer2D.js`](../src/viewer2d/Viewer2D.js) — `_meshSegs`, `case 'MESH'`
- 속성 패널: [`../src/propertyInspector.ts`](../src/propertyInspector.ts) — `case 'MESH'`
- 직렬화: `hmwebviewer/rust/dwg-wasm/src/lib.rs``EntityType::Mesh`
- 비트 파서: `hmwebviewer/rust/acadrust/src/io/dwg/dwg_stream_readers/object_reader/entities.rs``read_mesh`, `stream_bounded_count`
+111
View File
@@ -14,6 +14,45 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); } html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
#app { position: fixed; inset: 0; } #app { position: fixed; inset: 0; }
/* ── 상단 로딩 프로그레스 바 (src/topProgress.ts 가 구동) ───────────────
.bar : 실제 진행률. width 트랜지션 = 메인 스레드 의존.
.sweep : WASM 파싱 / Viewer2D.load() 같은 동기 블로킹 구간용. transform
키프레임은 컴포지터 스레드에서 돌기 때문에 메인 스레드가 멈춰
있어도 계속 흐른다 — 그 구간에 줄 수 있는 유일한 피드백. */
#progress {
position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 100;
pointer-events: none; overflow: hidden;
opacity: 0; transition: opacity .3s ease .15s;
}
#progress.on { opacity: 1; transition-delay: 0s; }
#progress .bar {
position: absolute; top: 0; bottom: 0; left: 0; width: 0;
background: linear-gradient(90deg, var(--accent), #58a6ff 55%, var(--wasm));
box-shadow: 0 0 10px rgba(63,185,80,.65), 0 0 4px rgba(88,166,255,.5);
transition: width .28s cubic-bezier(.25,.8,.25,1);
}
#progress.err .bar {
background: var(--danger);
box-shadow: 0 0 10px rgba(248,81,73,.7);
}
#progress .sweep {
position: absolute; top: 0; bottom: 0; left: 0; width: 34%;
background: linear-gradient(90deg, transparent, rgba(230,237,243,.9), transparent);
transform: translateX(-105%); will-change: transform; opacity: 0;
}
#progress.busy .sweep {
opacity: 1;
animation: pr-sweep 1.15s cubic-bezier(.4,0,.2,1) infinite;
}
@keyframes pr-sweep {
from { transform: translateX(-105%); }
to { transform: translateX(400%); }
}
@media (prefers-reduced-motion: reduce) {
#progress .sweep { animation: none; }
#progress .bar { transition: none; }
}
#toolbar { #toolbar {
position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10; position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none;
@@ -27,6 +66,25 @@
button.primary { background: #238636; border-color: #2ea043; } button.primary { background: #238636; border-color: #2ea043; }
button.wasm { border-color: var(--wasm); color: var(--wasm); } button.wasm { border-color: var(--wasm); color: var(--wasm); }
label.btn input { display: none; } label.btn input { display: none; }
/* 옵션 패널 — 값은 IndexedDB(hmw-viewer/options)에 저장된다 */
#optBody { padding: 12px 14px; overflow: auto; font-size: 12px; color: var(--ink); }
.opt-sec { margin-bottom: 16px; }
.opt-sec > h4 {
margin: 0 0 8px; font-size: 11px; font-weight: 700; letter-spacing: .05em;
text-transform: uppercase; color: var(--muted);
}
.opt-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.opt-row > label { flex: 1; color: var(--ink); }
.opt-row input[type=range] { flex: 1; min-width: 90px; accent-color: var(--accent); }
.opt-row input[type=number] {
width: 58px; background: #0d1117; color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px;
font: 12px var(--mono); text-align: right;
}
.opt-row input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); }
.opt-unit { font: 11px var(--mono); color: var(--muted); }
.opt-note { font: 11px var(--mono); color: var(--accent); line-height: 1.6; word-break: keep-all; }
.opt-help { font-size: 11px; color: var(--muted); line-height: 1.5; margin: 4px 0 0; }
#status { #status {
margin-left: auto; font: 12px var(--mono); color: var(--muted); margin-left: auto; font: 12px var(--mono); color: var(--muted);
background: rgba(22,27,34,.85); border: 1px solid var(--line); background: rgba(22,27,34,.85); border: 1px solid var(--line);
@@ -130,6 +188,7 @@
#properties { right: 12px; } #properties { right: 12px; }
#layers { right: 344px; display: none; } #layers { right: 344px; display: none; }
#options { right: 676px; width: 340px; height: auto; max-height: 70vh; display: none; }
#propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } #propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
#layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; } #layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
@@ -174,6 +233,7 @@
<div id="spaces" title="Model Space / Paper Space (Layout)"></div> <div id="spaces" title="Model Space / Paper Space (Layout)"></div>
<button id="fit" type="button">Fit (F)</button> <button id="fit" type="button">Fit (F)</button>
<button id="theme" type="button">Theme</button> <button id="theme" type="button">Theme</button>
<button id="optionsBtn" type="button" title="표시 옵션 (선가중치 · 그리드 · 줌)">옵션</button>
<button id="layersBtn" type="button">Layers</button> <button id="layersBtn" type="button">Layers</button>
<button id="propsBtn" type="button">Props (속성)</button> <button id="propsBtn" type="button">Props (속성)</button>
<div id="status">ready</div> <div id="status">ready</div>
@@ -209,6 +269,51 @@
</div> </div>
</div> </div>
<!-- 옵션 (Options) 패널 — 설정은 IndexedDB 에 저장 -->
<div id="options" class="cad-panel">
<div class="hdr">
<h3><span>⚙️</span> 옵션 (Options)</h3>
<div class="hdr-actions">
<button id="optReset" type="button" title="기본값으로">↺ 기본값</button>
<button id="collapseOptBtn" class="hdr-btn collapse-btn" type="button" title="접기"></button>
<button id="closeOptBtn" class="hdr-btn" type="button" title="닫기"></button>
</div>
</div>
<div id="optBody">
<div class="opt-sec">
<h4>선가중치 (Lineweight)</h4>
<div class="opt-row">
<label for="optLwOn">표시 (LWDISPLAY)</label>
<input id="optLwOn" type="checkbox" />
</div>
<div class="opt-row">
<label for="optLwScale">표시 배율</label>
<input id="optLwScale" type="range" min="2" max="60" step="0.5" value="11" />
<input id="optLwScaleNum" type="number" min="1" max="80" step="0.5" value="11" />
<span class="opt-unit">px/mm</span>
</div>
<div id="optLwOut" class="opt-note"></div>
<p class="opt-help">
AutoCAD "선가중치 설정 → 표시 배율"과 같다. 모델 공간에서 선가중치는 줌과
무관한 화면 픽셀로 그려지므로, 화면 DPI에 맞춰 이 값을 조정한다.
</p>
</div>
<div class="opt-sec">
<h4>화면 (Display)</h4>
<div class="opt-row">
<label for="optGrid">그리드 표시</label>
<input id="optGrid" type="checkbox" />
</div>
<div class="opt-row">
<label for="optZoomSpeed">줌 속도</label>
<input id="optZoomSpeed" type="range" min="0.2" max="3" step="0.1" value="1" />
<input id="optZoomSpeedNum" type="number" min="0.1" max="5" step="0.1" value="1" />
</div>
</div>
</div>
</div>
<!-- 속성 (Properties) 패널 --> <!-- 속성 (Properties) 패널 -->
<div id="properties" class="cad-panel"> <div id="properties" class="cad-panel">
<div class="hdr"> <div class="hdr">
@@ -235,6 +340,12 @@
코드: <code>src/main.ts</code> · 코드: <code>src/main.ts</code> ·
지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a> 지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
</p> </p>
<!-- MPL-2.0 §3.2(b): recipients of the wasm must be told where the
acadrust source (with our local patch) can be obtained. -->
<p style="margin:6px 0 0">
오픈소스 고지: <a href="/licenses/THIRD-PARTY-NOTICES.md" target="_blank">THIRD-PARTY-NOTICES</a>
(acadrust · MPL-2.0 · <a href="/licenses/acadrust-0.4.1-read_mesh.patch" target="_blank">local patch</a>)
</p>
</aside> </aside>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
+70
View File
@@ -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 }
}
+375
View File
@@ -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.
+45 -26
View File
@@ -49,6 +49,15 @@ export class LayerPanelManager {
if (options?.hideAllBtnEl) { if (options?.hideAllBtnEl) {
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers()); 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 */ /** Refresh and re-render layer list from Viewer2D state */
@@ -66,7 +75,7 @@ export class LayerPanelManager {
public showAllLayers(): void { public showAllLayers(): void {
this.hiddenLayers.clear(); this.hiddenLayers.clear();
this.viewer.setHiddenLayers(this.hiddenLayers); this.viewer.setHiddenLayers(this.hiddenLayers);
this.render(); this.syncRows();
} }
public hideAllLayers(): void { public hideAllLayers(): void {
@@ -75,7 +84,7 @@ export class LayerPanelManager {
this.hiddenLayers.add(l.name); this.hiddenLayers.add(l.name);
} }
this.viewer.setHiddenLayers(this.hiddenLayers); this.viewer.setHiddenLayers(this.hiddenLayers);
this.render(); this.syncRows();
} }
public toggleLayer(name: string): void { public toggleLayer(name: string): void {
@@ -85,7 +94,34 @@ export class LayerPanelManager {
this.hiddenLayers.add(name); this.hiddenLayers.add(name);
} }
this.viewer.setHiddenLayers(this.hiddenLayers); this.viewer.setHiddenLayers(this.hiddenLayers);
this.render(); 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 { public render(): void {
@@ -141,30 +177,13 @@ export class LayerPanelManager {
</div>`; </div>`;
}) })
.join(''); .join('');
// Clicks are handled by the delegated listener bound in the constructor.
}
}
// Bind eye button click events /** Escape a layer name for use inside a double-quoted CSS attribute selector. */
this.listEl.querySelectorAll('.eye-btn').forEach((btn) => { function cssEscape(str: string): string {
btn.addEventListener('click', (e) => { return str.replace(/["\\]/g, '\\$&');
e.stopPropagation();
const layerName = (btn as HTMLElement).dataset.toggle;
if (layerName) {
this.toggleLayer(layerName);
}
});
});
// Also toggle when clicking row
this.listEl.querySelectorAll('.layer-row').forEach((row) => {
row.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.closest('.eye-btn')) return;
const layerName = (row as HTMLElement).dataset.name;
if (layerName) {
this.toggleLayer(layerName);
}
});
});
}
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {
+87
View File
@@ -29,6 +29,7 @@ import { setDefaultFontUrl } from './viewer2d/slugText';
import { renderPropertyInspector } from './propertyInspector'; import { renderPropertyInspector } from './propertyInspector';
import { LayerPanelManager } from './layerPanel'; import { LayerPanelManager } from './layerPanel';
import { makePanelDraggableAndCollapsible } from './panelUtils'; import { makePanelDraggableAndCollapsible } from './panelUtils';
import { getAllOptions, setOption } from './optionsStore';
// parseDxfBuffer 내부: // parseDxfBuffer 내부:
// dxf-parser → HatchHandler → dxfAdapter // dxf-parser → HatchHandler → dxfAdapter
@@ -162,6 +163,7 @@ async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseRe
const def = pickDefaultSpace(spaces); const def = pickDefaultSpace(spaces);
viewer.load(result, { spaceHandle: def?.handleHex ?? null }); viewer.load(result, { spaceHandle: def?.handleHex ?? null });
viewer.resize(); viewer.resize();
syncOptions();
return result; return result;
} }
@@ -229,6 +231,91 @@ document.getElementById('theme')!.addEventListener('click', () => {
viewer.setTheme(dark); viewer.setTheme(dark);
fillLayers(); fillLayers();
}); });
// ── 옵션 패널 ────────────────────────────────────────────────────
// 값은 IndexedDB(hmw-viewer/options)에 남는다. 선가중치 배율은 재파싱 없이
// 즉시 반영되므로 드래그하면서 화면을 보고 맞출 수 있다.
const optionsPanel = document.getElementById('options')!;
const optLwOn = document.getElementById('optLwOn') as HTMLInputElement;
const optLwScale = document.getElementById('optLwScale') as HTMLInputElement;
const optLwScaleNum = document.getElementById('optLwScaleNum') as HTMLInputElement;
const optLwOut = document.getElementById('optLwOut')!;
const optGrid = document.getElementById('optGrid') as HTMLInputElement;
const optZoomSpeed = document.getElementById('optZoomSpeed') as HTMLInputElement;
const optZoomSpeedNum = document.getElementById('optZoomSpeedNum') as HTMLInputElement;
makePanelDraggableAndCollapsible(optionsPanel, {
collapseBtnEl: document.getElementById('collapseOptBtn')!,
});
document.getElementById('optionsBtn')!.addEventListener('click', () => {
optionsPanel.style.display = optionsPanel.style.display === 'flex' ? 'none' : 'flex';
});
document.getElementById('closeOptBtn')!.addEventListener('click', () => {
optionsPanel.style.display = 'none';
});
/** 컨트롤 값을 뷰어의 현재 상태로 되돌린다(도면을 새로 열었을 때도 호출). */
function syncOptions() {
const s = viewer.getLineweightScale();
optLwOn.checked = viewer.getLineweightEnabled();
optLwScale.value = String(s);
optLwScaleNum.value = String(s);
const classes = viewer.getLineweightClasses();
optLwOut.textContent = classes.length
? classes.map((mm) => `${mm.toFixed(2)}mm → ${Math.round(mm * s * 10) / 10}px`).join(' ')
: `이 도면에는 굵은 선이 없다 (0.50mm 였다면 ${Math.round(0.5 * s * 10) / 10}px)`;
}
function applyLwScale(v: number, persist = true) {
if (!Number.isFinite(v) || v <= 0) return;
viewer.setLineweightScale(v);
if (persist) void setOption('lineweightScale', v);
syncOptions();
}
optLwScale.addEventListener('input', () => applyLwScale(parseFloat(optLwScale.value)));
optLwScaleNum.addEventListener('input', () => applyLwScale(parseFloat(optLwScaleNum.value)));
optLwOn.addEventListener('change', () => {
viewer.setLineweightEnabled(optLwOn.checked);
void setOption('lineweightOn', optLwOn.checked);
syncOptions();
});
optGrid.addEventListener('change', () => {
viewer.setGrid(optGrid.checked);
void setOption('grid', optGrid.checked);
});
function applyZoomSpeed(v: number, persist = true) {
if (!Number.isFinite(v) || v <= 0) return;
viewer.setZoomSpeed(v);
optZoomSpeed.value = String(v);
optZoomSpeedNum.value = String(v);
if (persist) void setOption('zoomSpeed', v);
}
optZoomSpeed.addEventListener('input', () => applyZoomSpeed(parseFloat(optZoomSpeed.value)));
optZoomSpeedNum.addEventListener('input', () => applyZoomSpeed(parseFloat(optZoomSpeedNum.value)));
document.getElementById('optReset')!.addEventListener('click', () => {
viewer.setLineweightScale(null); // 뷰어 기본 배율
viewer.setLineweightEnabled(null); // 도면의 LWDISPLAY 를 다시 따름
viewer.setGrid(false);
applyZoomSpeed(1);
optGrid.checked = false;
void setOption('lineweightScale', null);
void setOption('lineweightOn', null);
void setOption('grid', false);
syncOptions();
});
// 저장된 옵션 복원 (IndexedDB 는 비동기라 첫 도면 로드와 경쟁하지 않도록 즉시 적용)
void getAllOptions().then((o) => {
if (typeof o.lineweightScale === 'number') applyLwScale(o.lineweightScale, false);
if (typeof o.lineweightOn === 'boolean') viewer.setLineweightEnabled(o.lineweightOn);
if (o.grid === true) { viewer.setGrid(true); optGrid.checked = true; }
if (typeof o.zoomSpeed === 'number') applyZoomSpeed(o.zoomSpeed, false);
syncOptions();
});
syncOptions();
document.getElementById('layersBtn')!.addEventListener('click', () => { document.getElementById('layersBtn')!.addEventListener('click', () => {
layersPanel.style.display = layersPanel.style.display === 'flex' ? 'none' : 'flex'; layersPanel.style.display = layersPanel.style.display === 'flex' ? 'none' : 'flex';
}); });
+75
View File
@@ -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;
}
+45
View File
@@ -40,6 +40,16 @@ function formatColor(colorVal: any): string {
return String(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 { export function formatEntityProperties(entity: any): FormattedProperties {
if (!entity) { if (!entity) {
return { id: '', type: '', handle: '', layer: '', general: [], geometry: [] }; return { id: '', type: '', handle: '', layer: '', general: [], geometry: [] };
@@ -51,6 +61,9 @@ export function formatEntityProperties(entity: any): FormattedProperties {
const layer = entity.layer ?? entity.layerName ?? '0'; const layer = entity.layer ?? entity.layerName ?? '0';
const color = formatColor(entity.color ?? d.color); const color = formatColor(entity.color ?? d.color);
const linetype = entity.lineType ?? entity.linetype ?? d.lineType ?? 'BYLAYER'; 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][] = [ const general: [string, string][] = [
['유형 (Type)', type], ['유형 (Type)', type],
@@ -58,6 +71,7 @@ export function formatEntityProperties(entity: any): FormattedProperties {
['레이어 (Layer)', String(layer)], ['레이어 (Layer)', String(layer)],
['색상 (Color)', color], ['색상 (Color)', color],
['선종류 (Linetype)', String(linetype)], ['선종류 (Linetype)', String(linetype)],
['선가중치 (Lineweight)', lineweight],
]; ];
const geometry: [string, string][] = []; const geometry: [string, string][] = [];
@@ -155,6 +169,37 @@ export function formatEntityProperties(entity: any): FormattedProperties {
break; 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 'TEXT':
case 'MTEXT': case 'MTEXT':
case 'ATTRIB': { case 'ATTRIB': {
+10
View File
@@ -16,6 +16,16 @@ export class Viewer2D {
setAccent(hex: string): void; setAccent(hex: string): void;
setTheme(dark: boolean): void; setTheme(dark: boolean): void;
setGrid(visible: boolean): void; setGrid(visible: boolean): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): 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 }[]; getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void; setHiddenLayers(nameSet: Set<string>): void;
/** Model / Paper (Layout) spaces for the loaded drawing. */ /** Model / Paper (Layout) spaces for the loaded drawing. */
+625 -65
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+3 -3
View File
@@ -1,8 +1,8 @@
/** /**
* DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0, * DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0,
* consumed unmodified; see rust/dwg-wasm). Emits the parseResult shape * vendored with a local read_mesh patch; see hmwebviewer rust/dwg-wasm). Emits
* Viewer2D.load() consumes. Lazy-imported so the wasm is only fetched when a * the parseResult shape Viewer2D.load() consumes. Lazy-imported so the wasm is
* DWG is actually opened. * only fetched when a DWG is actually opened.
* *
* History: until 2026-07-10 this module selected between acadrust and * History: until 2026-07-10 this module selected between acadrust and
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed * @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed