Compare commits

...
2 Commits
Author SHA1 Message Date
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
15 changed files with 7120 additions and 38 deletions
+4 -1
View File
@@ -110,6 +110,9 @@ 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) | | `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) |
| ↳ 위 둘의 측정 결과 + 상호작용(메쉬 선분이 굵기 버킷 상한을 넘길 위험) | [`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` 반영 절차는 각 문서를 따른다.
@@ -121,6 +124,6 @@ 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 | MIT |
| NanumGothic | OFL | | NanumGothic | OFL |
+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
+41 -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;
@@ -159,7 +198,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-KG0HO38y.js"></script> <script type="module" crossorigin src="/assets/index-D3FqDGwP.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -175,6 +214,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="lwt" type="button" title="선가중치 표시 (AutoCAD LWDISPLAY)">선가중치</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>
+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` 으로 감싸는 방식이었다.
+266
View File
@@ -0,0 +1,266 @@
# 선가중치(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 × 8 (LW_PX_PER_MM, 최대 24px)
0.25mm ≈ 2px · 0.50mm ≈ 4px · 2.11mm ≈ 17px ← AutoCAD 기본 표시 배율과 동일 눈금
```
### 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-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
- `index.html` · `src/main.ts` — 툴바 **선가중치** 버튼 (상태에 따라 강조, 툴팁에 ON/OFF)
- `src/propertyInspector.ts``선가중치 (Lineweight)` 행 추가.
`-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. 회귀 없음
- `npx tsc --noEmit` 통과
- `npm run build` 통과
- 대조 도면 4종: 굵기 전부 Default → **화면 변화 없음**(설계 3-2 의도대로)
---
## 6. 남은 과제
1. **표시 배율 고정**`LW_PX_PER_MM = 8` 이 상수다. AutoCAD 는 "선가중치 설정 → 표시 배율"
슬라이더로 조절 가능하므로, 필요하면 `setLineweightScale()` 로 노출할 것.
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`
+40
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;
@@ -174,6 +213,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="lwt" type="button" title="선가중치 표시 (AutoCAD LWDISPLAY)">선가중치</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>
+12
View File
@@ -162,6 +162,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();
syncLwtBtn();
return result; return result;
} }
@@ -229,6 +230,17 @@ document.getElementById('theme')!.addEventListener('click', () => {
viewer.setTheme(dark); viewer.setTheme(dark);
fillLayers(); fillLayers();
}); });
// 선가중치(LWT): 처음에는 도면의 LWDISPLAY 값을 따르고, 누르면 강제 on/off.
const lwtBtn = document.getElementById('lwt') as HTMLButtonElement;
const syncLwtBtn = () => {
const on = viewer.getLineweightEnabled();
lwtBtn.classList.toggle('primary', on);
lwtBtn.title = `선가중치 표시 ${on ? 'ON' : 'OFF'} (AutoCAD LWDISPLAY)`;
};
lwtBtn.addEventListener('click', () => {
viewer.setLineweightEnabled(!viewer.getLineweightEnabled());
syncLwtBtn();
});
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';
}); });
+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': {
+3
View File
@@ -16,6 +16,9 @@ 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;
/** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */
setLineweightEnabled(on: boolean | null): void;
getLineweightEnabled(): boolean;
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. */
+341 -33
View File
@@ -4,10 +4,14 @@
* 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites. * 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites.
* Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID, * Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID,
* TEXT, MTEXT, INSERT (ownerHandle block expansion), HATCH (solid+outline+bulge), * TEXT, MTEXT, INSERT (ownerHandle block expansion), HATCH (solid+outline+bulge),
* MESH (AcDbSubDMesh, base-mesh wireframe),
* DIMENSION_LINEAR/ALIGNED/RADIUS/DIAMETER/ANG_3PT/ANG_2LN/ORDINATE * DIMENSION_LINEAR/ALIGNED/RADIUS/DIAMETER/ANG_3PT/ANG_2LN/ORDINATE
*/ */
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { LineSegments2 } from 'three/examples/jsm/lines/LineSegments2.js';
import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeometry.js';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js';
import { aciToHex } from './aciColors.js'; import { aciToHex } from './aciColors.js';
import { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces'; import { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces';
import { SlugTextEngine, SlugTextBatch } from './slugText'; import { SlugTextEngine, SlugTextBatch } from './slugText';
@@ -18,6 +22,23 @@ const ARC_SEGS = 64;
const ELLIPSE_SEGS = 72; const ELLIPSE_SEGS = 72;
const CLICK_THRESHOLD_PX = 14; const CLICK_THRESHOLD_PX = 14;
// ── Lineweight (AutoCAD LWDISPLAY) ─────────────────────────────────────────
// DWG stores lineweight as an i16 in 1/100 mm (-1 ByLayer, -2 ByBlock,
// -3 Default). AutoCAD shows it in *screen* pixels — the width does not grow
// when you zoom in — at roughly 8 px per mm on the default display-scale
// slider (0.25 mm ≈ 2 px, 2.11 mm ≈ 17 px), so that is the mapping used here.
const LW_PX_PER_MM = 8;
// LWDEFAULT: what -3 (and a layer that never set one) resolves to.
const LW_DEFAULT_MM = 0.25;
// At or below the CAD default the line stays in the 1-px hairline batch: a
// drawing that never assigned weights must look exactly as it did before.
const LW_HAIRLINE_MM = 0.25;
const LW_MAX_PX = 24;
// Fat lines cost 12 floats + 8 verts per segment (instanced quads). Past this
// many segments in one weight bucket, fall back to hairline instead of risking
// a GPU/heap stall on huge survey drawings.
const LW_MAX_FAT_SEGS = 400000;
/** Bucket pending texts by their owning CAD layer (insertion order preserved). */ /** Bucket pending texts by their owning CAD layer (insertion order preserved). */
function groupByLayer(list) { function groupByLayer(list) {
const m = new Map(); const m = new Map();
@@ -72,9 +93,18 @@ export class Viewer2D {
this._layerBoxes = new Map(); this._layerBoxes = new Map();
this._layerSamples = new Map(); this._layerSamples = new Map();
this._indexedBufs = []; this._indexedBufs = [];
// · _fatBufs LineSegments2 batches (one per lineweight/dash bucket).
// Instanced, so layer masking compacts the instance buffer
// instead of rewriting an index (see _applyLayerVisibility).
this._fatBufs = [];
this._metaHidden = null; this._metaHidden = null;
this._fullContentBox = null; this._fullContentBox = null;
this._hiddenLayers = new Set(); this._hiddenLayers = new Set();
// Lineweight display: null = follow the drawing's LWDISPLAY header var,
// true/false = forced by the host UI (setLineweightEnabled).
this._lwForced = null;
this._lwDisplay = false;
this._curLwPx = 0;
SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure
this._scene = new THREE.Scene(); this._scene = new THREE.Scene();
@@ -302,6 +332,11 @@ export class Viewer2D {
this._buildLayerMap(result?.tables?.layers); this._buildLayerMap(result?.tables?.layers);
this._buildStyleMap(result?.tables?.styles || result?.tables?.textStyles); this._buildStyleMap(result?.tables?.styles || result?.tables?.textStyles);
this._buildLinetypeMap(result); this._buildLinetypeMap(result);
// LWDISPLAY decides whether weights show at all — same switch as AutoCAD's
// status-bar "Show/Hide Lineweight". The host UI can override it.
// DXF spells the header var $LWDISPLAY; dwg-wasm emits vars.lwdisplay.
this._lwDisplay = this._lwForced
?? !!(result?.vars?.lwdisplay ?? result?.vars?.$LWDISPLAY ?? result?.vars?.LWDISPLAY);
// Sheet orientation: the active model-space viewport's VIEWTWIST rotates the // Sheet orientation: the active model-space viewport's VIEWTWIST rotates the
// view so a drawing stored tilted in WCS (rotated survey sheets — header UCS // view so a drawing stored tilted in WCS (rotated survey sheets — header UCS
// stays identity) displays with its title border upright. Applied as a // stays identity) displays with its title border upright. Applied as a
@@ -330,11 +365,14 @@ export class Viewer2D {
if (id === undefined) { id = layerNames.length; layerNames.push(name); layerIds.set(name, id); } if (id === undefined) { id = layerNames.length; layerNames.push(name); layerIds.set(name, id); }
return id; return id;
}; };
// Dashed-linetype segments go into separate buckets keyed by dash|gap size; // Segments that can't join the one merged hairline batch go into buckets
// each becomes its own LineDashedMaterial LineSegments at assembly. Solid // keyed by dash|gap size and lineweight; each becomes its own mesh at
// (Continuous / ByLayer→Continuous) segments stay in lineVerts/lineColors. // assembly (LineDashedMaterial for thin dashes, LineSegments2 for anything
const dashBuckets = new Map(); // with a real lineweight). Solid hairlines stay in lineVerts/lineColors.
const segBuckets = new Map();
let curDash = null; // {key,dash,gap} for the entity currently being emitted let curDash = null; // {key,dash,gap} for the entity currently being emitted
let entIdx = -1; // _entityMeta index of that entity (-1 before the loop)
let curSpans = []; // buckets it has written to so far
const box = new THREE.Box3(); const box = new THREE.Box3();
const _tmp = new THREE.Vector3(); const _tmp = new THREE.Vector3();
const pendingTexts = []; const pendingTexts = [];
@@ -423,9 +461,18 @@ export class Viewer2D {
const r = ((color >> 16) & 0xFF) / 255; const r = ((color >> 16) & 0xFF) / 255;
const g = ((color >> 8) & 0xFF) / 255; const g = ((color >> 8) & 0xFF) / 255;
const b = (color & 0xFF) / 255; const b = (color & 0xFF) / 255;
if (curDash) { const lwPx = this._curLwPx;
let bk = dashBuckets.get(curDash.key); if (curDash || lwPx > 0) {
if (!bk) { bk = { dash: curDash.dash, gap: curDash.gap, verts: [], colors: [], segLayer: [] }; dashBuckets.set(curDash.key, bk); } const key = `${curDash ? curDash.key : ''}#${lwPx}`;
let bk = segBuckets.get(key);
if (!bk) {
bk = { dash: curDash?.dash ?? 0, gap: curDash?.gap ?? 0, lwPx, verts: [], colors: [], segLayer: [], entIdx: -2 };
segBuckets.set(key, bk);
}
// Selection highlight recolors an entity's own vertices. Its colors no
// longer live in one array, so remember where this entity's run starts
// in every bucket it touches (see meta.spans).
if (bk.entIdx !== entIdx) { bk.entIdx = entIdx; bk.entStart = bk.colors.length; curSpans.push(bk); }
bk.verts.push(ax, ay, z, bx, by, z); bk.verts.push(ax, ay, z, bx, by, z);
bk.colors.push(r, g, b, r, g, b); bk.colors.push(r, g, b, r, g, b);
bk.segLayer.push(this._curLayerId); bk.segLayer.push(this._curLayerId);
@@ -502,7 +549,10 @@ export class Viewer2D {
const complexLt = this._resolveComplexLinetype(e); const complexLt = this._resolveComplexLinetype(e);
if (complexLt) curDash = null; if (complexLt) curDash = null;
else curDash = this._resolveDash(e); else curDash = this._resolveDash(e);
this._curLwPx = this._lwPx(e);
const meta = { entity: e, type, bounds: null, layer: this._curLayer, colStart: lineColors.length }; const meta = { entity: e, type, bounds: null, layer: this._curLayer, colStart: lineColors.length };
entIdx = this._entityMeta.length;
curSpans = [];
this._entityMeta.push(meta); this._entityMeta.push(meta);
this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below
const textStart = pendingTexts.length; const textStart = pendingTexts.length;
@@ -709,6 +759,16 @@ export class Viewer2D {
break; break;
} }
// ── MESH (AcDbSubDMesh) ───────────────────────────────────────────
// Drawn as its face wireframe, matching AutoCAD's 2D wireframe view.
case 'MESH': {
if (d.vertices?.length >= 3) {
this._meshSegs(d, color, pushSeg);
meta.bounds = { type:'point', cx:d.vertices[0], cy:d.vertices[1] };
}
break;
}
// ── Text ────────────────────────────────────────────────────────── // ── Text ──────────────────────────────────────────────────────────
case 'TEXT': case 'TEXT':
case 'MTEXT': { case 'MTEXT': {
@@ -1045,6 +1105,10 @@ export class Viewer2D {
} }
} catch { /* skip malformed entity */ } } catch { /* skip malformed entity */ }
meta.colEnd = lineColors.length; meta.colEnd = lineColors.length;
if (curSpans.length) {
meta.spans = curSpans.map((bk) => ({ bk, start: bk.entStart, end: bk.colors.length }));
curSpans = [];
}
// Deferred texts are flushed after the loop (async) — stamp the owning // Deferred texts are flushed after the loop (async) — stamp the owning
// layer now so _drawTexts can batch per layer. // layer now so _drawTexts can batch per layer.
for (let ti = textStart; ti < pendingTexts.length; ti++) { for (let ti = textStart; ti < pendingTexts.length; ti++) {
@@ -1088,7 +1152,26 @@ export class Viewer2D {
} }
this._layerNames = layerNames; this._layerNames = layerNames;
this._lineColorAttr = null; this._origColors = null; this._lineColorAttr = null; this._origColors = null; this._hairRt = null;
// A weighted bucket only earns its own fat-line mesh while it stays inside
// the segment cap; past that it is folded back into the hairline batch
// (solid) or drawn as a thin dash bucket, which is what the viewer did
// before lineweight existed.
for (const bk of segBuckets.values()) {
bk.fat = bk.lwPx > 0 && bk.verts.length / 6 <= LW_MAX_FAT_SEGS;
if (bk.lwPx > 0 && !bk.fat) {
console.warn(`선가중치 ${bk.lwPx}px 세그먼트 ${bk.verts.length / 6}개 → 헤어라인으로 대체`);
}
if (!bk.fat && bk.dash <= 0) {
bk.mergeOffset = lineColors.length;
for (let i = 0; i < bk.verts.length; i++) lineVerts.push(bk.verts[i]);
for (let i = 0; i < bk.colors.length; i++) lineColors.push(bk.colors[i]);
for (let i = 0; i < bk.segLayer.length; i++) lineSegLayer.push(bk.segLayer[i]);
bk.merged = true;
}
}
if (lineVerts.length) { if (lineVerts.length) {
const geom = new THREE.BufferGeometry(); const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3)); geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3));
@@ -1099,23 +1182,45 @@ export class Viewer2D {
this._group.add(lmesh); this._group.add(lmesh);
this._lineColorAttr = colorAttr; this._lineColorAttr = colorAttr;
this._origColors = Float32Array.from(colorAttr.array); this._origColors = Float32Array.from(colorAttr.array);
this._hairRt = { attr: colorAttr, orig: this._origColors };
} }
// Dashed linetypes (HIDDEN / CENTER / …) — one LineSegments per dash|gap // One mesh per remaining bucket.
// bucket with a LineDashedMaterial. computeLineDistances() is REQUIRED for // · lineweight > 0 → LineSegments2: screen-space quads, so the width stays
// the dash pattern to appear; on LineSegments each 2-vertex pair dashes // constant in pixels while zooming (AutoCAD LWDISPLAY semantics).
// independently from its own start (correct for CAD segments). // · otherwise → LineSegments + LineDashedMaterial for the dash
for (const bk of dashBuckets.values()) { // linetypes (HIDDEN / CENTER / …). computeLineDistances() is REQUIRED
if (!bk.verts.length) continue; // for the pattern to appear; on LineSegments each 2-vertex pair dashes
const dgeom = new THREE.BufferGeometry(); // independently from its own start (correct for CAD segments).
dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3)); for (const bk of segBuckets.values()) {
dgeom.setAttribute('color', new THREE.Float32BufferAttribute(bk.colors, 3)); if (!bk.verts.length || bk.merged) continue;
const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({ if (bk.fat) {
vertexColors: true, dashSize: bk.dash, gapSize: bk.gap, const fgeom = new LineSegmentsGeometry();
})); fgeom.setPositions(bk.verts);
dline.computeLineDistances(); // must run BEFORE setIndex (three skips indexed geometry) fgeom.setColors(bk.colors);
this._registerIndexed(dline, bk.segLayer); const fmat = new LineMaterial({
this._group.add(dline); vertexColors: true, linewidth: bk.lwPx, worldUnits: false,
dashed: bk.dash > 0, dashSize: bk.dash, gapSize: bk.gap,
});
this._sizeLineMaterial(fmat);
const fline = new LineSegments2(fgeom, fmat);
if (bk.dash > 0) fline.computeLineDistances();
this._registerFat(fline, bk.segLayer);
bk.rt = this._fatBufs[this._fatBufs.length - 1];
this._group.add(fline);
} else {
const dgeom = new THREE.BufferGeometry();
dgeom.setAttribute('position', new THREE.Float32BufferAttribute(bk.verts, 3));
const dcol = new THREE.Float32BufferAttribute(bk.colors, 3);
dgeom.setAttribute('color', dcol);
const dline = new THREE.LineSegments(dgeom, new THREE.LineDashedMaterial({
vertexColors: true, dashSize: bk.dash, gapSize: bk.gap,
}));
dline.computeLineDistances(); // must run BEFORE setIndex (three skips indexed geometry)
this._registerIndexed(dline, bk.segLayer);
bk.rt = { attr: dcol, orig: Float32Array.from(dcol.array) };
this._group.add(dline);
}
} }
// Coordinate readout frame (Model UCS vs paper identity). // Coordinate readout frame (Model UCS vs paper identity).
@@ -1228,6 +1333,50 @@ export class Viewer2D {
this._indexedBufs.push({ mesh, segLayer: Int32Array.from(segLayerArr), index, attr }); this._indexedBufs.push({ mesh, segLayer: Int32Array.from(segLayerArr), index, attr });
} }
/**
* Track a LineSegments2 batch for layer masking. Its geometry is instanced
* (one instance per segment, positions/colors interleaved 6 floats each), so
* there is no index to rewrite: hiding a layer compacts the visible segments
* into the front of the instance buffers and drops `instanceCount`.
* The untouched originals are kept so any later mask starts from clean data.
*/
_registerFat(mesh, segLayerArr) {
const geo = mesh.geometry;
const posBuf = geo.attributes.instanceStart?.data;
const colBuf = geo.attributes.instanceColorStart?.data;
if (!posBuf) return;
posBuf.setUsage(THREE.DynamicDrawUsage);
colBuf?.setUsage(THREE.DynamicDrawUsage);
this._fatBufs.push({
mesh,
segLayer: Int32Array.from(segLayerArr),
posBuf, colBuf,
posSrc: Float32Array.from(posBuf.array),
colSrc: colBuf ? Float32Array.from(colBuf.array) : null,
});
}
/** Fat-line materials need the canvas size in px to convert linewidth. */
_sizeLineMaterial(mat) {
const w = this._container?.clientWidth || this._renderer?.domElement?.clientWidth || 1;
const h = this._container?.clientHeight || this._renderer?.domElement?.clientHeight || 1;
mat.resolution.set(w, h);
}
/**
* Lineweight display switch (AutoCAD LWDISPLAY).
* @param {boolean|null} on true/false forces it, null follows the drawing.
*/
setLineweightEnabled(on) {
this._lwForced = (on == null) ? null : !!on;
if (this._lastResult && !this._isLoading) {
this.load(this._lastResult, { keepView: true, keepTheme: true, spaceHandle: this._activeSpaceHex });
}
}
/** Whether lineweights are currently being drawn. */
getLineweightEnabled() { return this._lwDisplay; }
/** Track an entity-owned object so layer toggles can flip its visibility. */ /** Track an entity-owned object so layer toggles can flip its visibility. */
_addObj(obj, layer) { _addObj(obj, layer) {
const key = layer ?? this._curLayer; const key = layer ?? this._curLayer;
@@ -1272,6 +1421,28 @@ export class Viewer2D {
buf.mesh.geometry.setDrawRange(0, w); buf.mesh.geometry.setDrawRange(0, w);
} }
// 2b) Fat-line batches → compact the instance buffers, then cap instanceCount.
// slotOf records where each source segment ended up so the selection
// highlight can still find its vertices (-1 = this segment is hidden).
for (const buf of this._fatBufs) {
const seg = buf.segLayer;
if (!buf.slotOf) buf.slotOf = new Int32Array(seg.length);
const slotOf = buf.slotOf;
let w = 0;
for (let s = 0; s < seg.length; s++) {
const id = seg[s];
if (id >= 0 && hiddenId[id]) { slotOf[s] = -1; continue; }
buf.posBuf.array.set(buf.posSrc.subarray(s * 6, s * 6 + 6), w * 6);
if (buf.colBuf) buf.colBuf.array.set(buf.colSrc.subarray(s * 6, s * 6 + 6), w * 6);
slotOf[s] = w;
w++;
}
buf.posBuf.needsUpdate = true;
if (buf.colBuf) buf.colBuf.needsUpdate = true;
buf.mesh.geometry.instanceCount = w;
buf.mesh.visible = w > 0;
}
// 3) Picking mask — _onClick skips segments/fills owned by hidden layers. // 3) Picking mask — _onClick skips segments/fills owned by hidden layers.
const metas = this._entityMeta; const metas = this._entityMeta;
const mh = new Uint8Array(metas.length); const mh = new Uint8Array(metas.length);
@@ -1282,6 +1453,10 @@ export class Viewer2D {
if (this._selMeta && hidden.has(this._selMeta.layer)) { if (this._selMeta && hidden.has(this._selMeta.layer)) {
this._highlight(null); this._highlight(null);
this._onSelectCb?.(null); this._onSelectCb?.(null);
} else if (this._selMeta && this._fatBufs.length) {
// Compaction above rewrote the fat instance colors from the pristine
// source — repaint the selection on its new slots.
this._paintMeta(this._selMeta, this._selColor);
} }
// 4) Fit box for the visible subset. // 4) Fit box for the visible subset.
@@ -1586,6 +1761,10 @@ export class Viewer2D {
} }
break; break;
} }
case 'MESH':
// pushSeg already maps model → paper and clips, so no xf here.
if (d.vertices?.length >= 3) this._meshSegs(d, color, pushSeg);
break;
case 'HATCH': { case 'HATCH': {
const paths = d.paths || e.paths || []; const paths = d.paths || e.paths || [];
for (const path of paths) { for (const path of paths) {
@@ -2111,6 +2290,8 @@ export class Viewer2D {
this._layerByHandle.clear(); this._layerByHandle.clear();
this._layerByName.clear(); this._layerByName.clear();
this._layerNameByHandle = new Map(); this._layerNameByHandle = new Map();
this._layerLwByHandle = new Map();
this._layerLwByName = new Map();
this._layer0Handle = null; this._layer0Handle = null;
if (!layers) return; if (!layers) return;
for (const l of layers) { for (const l of layers) {
@@ -2122,9 +2303,40 @@ export class Viewer2D {
if (name) this._layerByName.set(name, hex); if (name) this._layerByName.set(name, hex);
if (handle != null && name) this._layerNameByHandle.set(String(handle), name); if (handle != null && name) this._layerNameByHandle.set(String(handle), name);
if (name === '0' && handle != null) this._layer0Handle = String(handle); if (name === '0' && handle != null) this._layer0Handle = String(handle);
// Raw i16 lineweight (1/100 mm, or -1/-2/-3). DXF spells it 370.
const lw = l.lineWeight ?? l.lineweight ?? l.lineWeightRaw;
if (typeof lw === 'number') {
if (handle != null) this._layerLwByHandle.set(String(handle), lw);
if (name) this._layerLwByName.set(name, lw); // DXF layers carry no handle
}
} }
} }
/**
* Effective lineweight of an entity, in screen pixels (0 = hairline).
*
* ByLayer (-1) resolves against the LAYER table, ByBlock (-2) against the
* weight inherited from the owning INSERT, Default (-3) against LWDEFAULT.
* Anything at or below the CAD default stays hairline so drawings that never
* assigned a weight render exactly as before.
*/
_lwPx(entity, inheritedPx = 0) {
if (!this._lwDisplay) return 0;
let raw = entity?.entityHeader?.lineWeight ?? entity?.lineWeight ?? entity?.lineweight;
if (typeof raw !== 'number') return 0;
if (raw === -2) return inheritedPx; // ByBlock
if (raw === -1) { // ByLayer
const lh = entity.layerHandle?.value ?? entity.layerHandle;
const byHandle = lh != null ? this._layerLwByHandle.get(String(lh)) : undefined;
const ln = entity.layer ?? entity.layerName;
raw = byHandle ?? (ln != null ? this._layerLwByName.get(ln) : undefined) ?? -3;
if (raw === -1 || raw === -2) raw = -3; // layer can't be ByLayer/ByBlock
}
const mm = raw === -3 ? LW_DEFAULT_MM : raw / 100;
if (!(mm > LW_HAIRLINE_MM)) return 0;
return Math.min(LW_MAX_PX, Math.round(mm * LW_PX_PER_MM * 10) / 10);
}
// Active-viewport VIEWTWIST (radians). The sheet is un-twisted by rotating the // Active-viewport VIEWTWIST (radians). The sheet is un-twisted by rotating the
// camera up by -viewTwist (verified against samples/11.dwg: title border upright). // camera up by -viewTwist (verified against samples/11.dwg: title border upright).
_readViewTwist(result) { _readViewTwist(result) {
@@ -2370,6 +2582,58 @@ export class Viewer2D {
} }
} }
/**
* MESH (AcDbSubDMesh) → wireframe segments.
*
* dwg-wasm emits the mesh as flat number arrays (see its `EntityType::Mesh`
* arm) because a civil road-surface mesh runs to ~10^5 vertices:
* vertices [x, y, z, …]
* edges deduplicated vertex-index pairs [a, b, …]
* faceList DXF group-93 layout — [n, i0 … i(n-1)] repeated, n-gons included
*
* The edge list is preferred: it is already deduplicated, so an edge shared by
* two triangles is drawn once instead of twice (~2× fewer segments on a TIN).
* faceList is the fallback for a mesh whose edge list is empty, and it is what
* a future filled pass would triangulate.
*
* Only the base (control) mesh is drawn. `subdivisionLevel > 0` means AutoCAD
* displays a Catmull-Clark refinement of these vertices; refining is not
* implemented, so such a mesh renders slightly more angular than in AutoCAD.
*
* `xf(x, y) -> [x, y]` optionally maps the coordinates (block-local INSERT,
* or model space seen through a paper-space viewport).
*/
_meshSegs(d, color, pushSeg, xf = null) {
const v = d.vertices;
if (!v?.length) return;
const n = (v.length / 3) | 0;
const seg = (a, b) => {
if (a === b || !(a >= 0 && a < n) || !(b >= 0 && b < n)) return;
let ax = v[a*3], ay = v[a*3+1];
let bx = v[b*3], by = v[b*3+1];
// pushSeg carries one z per segment; a sloped edge uses its midpoint
// elevation, which is what a top view needs for depth ordering.
const z = ((v[a*3+2] || 0) + (v[b*3+2] || 0)) / 2;
if (xf) { [ax, ay] = xf(ax, ay); [bx, by] = xf(bx, by); }
pushSeg(ax, ay, bx, by, z, color);
};
const edges = d.edges;
if (edges?.length >= 2) {
for (let i = 0; i + 1 < edges.length; i += 2) seg(edges[i], edges[i+1]);
return;
}
const fl = d.faceList;
if (!fl?.length) return;
for (let i = 0; i < fl.length; ) {
const cnt = fl[i++];
// A corrupt count would run the cursor off the end — stop instead.
if (!(cnt >= 3) || i + cnt > fl.length) break;
for (let k = 0; k < cnt; k++) seg(fl[i + k], fl[i + ((k + 1) % cnt)]);
i += cnt;
}
}
// Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant). // Resolve start/end width for segment i→i+1 (DXF 40/41, fallback 43 constant).
_segWidths(ent, i, scale = 1) { _segWidths(ent, i, scale = 1) {
const d = ent?.data || ent || {}; const d = ent?.data || ent || {};
@@ -3295,17 +3559,23 @@ export class Viewer2D {
const scaleAcc = parentScale * Math.max(Math.abs(sx), Math.abs(sy)); const scaleAcc = parentScale * Math.max(Math.abs(sx), Math.abs(sy));
const rotAcc = parentRot + rot; const rotAcc = parentRot + rot;
// Weight the INSERT itself carries — what a ByBlock child resolves to.
const insertLwPx = this._curLwPx;
for (const be of entities) { for (const be of entities) {
const bd = be.data || be; const bd = be.data || be;
const bt = (be.type || be.typeName || '').toUpperCase(); const bt = (be.type || be.typeName || '').toUpperCase();
if (bt === 'ATTDEF') continue; // skip attribute definitions if (bt === 'ATTDEF') continue; // skip attribute definitions
const ecol = this._resolveBlockColor(be, color); const ecol = this._resolveBlockColor(be, color);
this._curLwPx = this._lwPx(be, insertLwPx);
try { try {
if (bt === 'LINE' && bd.start && bd.end) { if (bt === 'LINE' && bd.start && bd.end) {
const [ax, ay] = xf(bd.start.x, bd.start.y); const [ax, ay] = xf(bd.start.x, bd.start.y);
const [bx, by] = xf(bd.end.x, bd.end.y); const [bx, by] = xf(bd.end.x, bd.end.y);
pushSeg(ax, ay, bx, by, 0, ecol); pushSeg(ax, ay, bx, by, 0, ecol);
} else if (bt === 'MESH' && bd.vertices?.length >= 3) {
this._meshSegs(bd, ecol, pushSeg, xf);
} else if ((bt === 'CIRCLE' || bt === 'ARC') && bd.center && bd.radius != null) { } else if ((bt === 'CIRCLE' || bt === 'ARC') && bd.center && bd.radius != null) {
const [cx, cy] = xf(bd.center.x, bd.center.y); const [cx, cy] = xf(bd.center.x, bd.center.y);
const r = bd.radius * scaleAcc; const r = bd.radius * scaleAcc;
@@ -3484,6 +3754,7 @@ export class Viewer2D {
} }
} catch { /* skip */ } } catch { /* skip */ }
} }
this._curLwPx = insertLwPx; // hand the INSERT's own weight back to the caller
} }
// Fallback dimension rendering from entity properties when no block geometry available // Fallback dimension rendering from entity properties when no block geometry available
@@ -3710,19 +3981,53 @@ export class Viewer2D {
/** Recolor the selected entity's line geometry to the accent color (deselect previous). */ /** Recolor the selected entity's line geometry to the accent color (deselect previous). */
_highlight(meta) { _highlight(meta) {
const attr = this._lineColorAttr, orig = this._origColors; if (this._selMeta) this._paintMeta(this._selMeta, null);
if (this._selMeta && attr && orig) {
const a = this._selMeta;
for (let i = a.colStart; i < a.colEnd && i < orig.length; i++) attr.array[i] = orig[i];
}
this._selMeta = meta || null; this._selMeta = meta || null;
if (meta && attr && meta.colEnd > meta.colStart) { if (meta) this._paintMeta(meta, this._selColor);
const c = this._selColor; }
for (let i = meta.colStart; i < meta.colEnd; i += 3) {
attr.array[i] = c.r; attr.array[i + 1] = c.g; attr.array[i + 2] = c.b; /**
* Paint every vertex an entity owns. Its segments are spread over the
* hairline batch plus one run per dash/lineweight bucket it touched
* (meta.spans), so all of them get recolored — or restored when rgb is null.
*/
_paintMeta(meta, rgb) {
const hair = this._hairRt;
if (hair && meta.colEnd > meta.colStart) this._paintSpan(hair, meta.colStart, meta.colEnd, rgb);
if (!meta.spans) return;
for (const s of meta.spans) {
const bk = s.bk;
if (bk.merged) {
if (hair) this._paintSpan(hair, s.start + bk.mergeOffset, s.end + bk.mergeOffset, rgb);
} else if (bk.rt) {
this._paintSpan(bk.rt, s.start, s.end, rgb);
} }
} }
if (attr) attr.needsUpdate = true; }
/** Write one color run: `rgb` to select, null to restore the original color. */
_paintSpan(rt, start, end, rgb) {
if (rt.attr) { // plain vertex-color batch (hairline / thin dashes)
const arr = rt.attr.array, orig = rt.orig;
for (let i = start; i < end && i < arr.length; i += 3) {
if (rgb) { arr[i] = rgb.r; arr[i + 1] = rgb.g; arr[i + 2] = rgb.b; }
else { arr[i] = orig[i]; arr[i + 1] = orig[i + 1]; arr[i + 2] = orig[i + 2]; }
}
rt.attr.needsUpdate = true;
return;
}
// Fat batch: hiding a layer compacts the instance buffer, so segment s of
// the source colors may now live at a different slot (slotOf, -1 = hidden).
const arr = rt.colBuf?.array, orig = rt.colSrc, slotOf = rt.slotOf;
if (!arr || !orig) return;
for (let i = start; i < end && i < orig.length; i += 3) {
const slot = slotOf ? slotOf[(i / 6) | 0] : (i / 6) | 0;
if (slot < 0) continue;
const t = slot * 6 + (i % 6);
if (rgb) { arr[t] = rgb.r; arr[t + 1] = rgb.g; arr[t + 2] = rgb.b; }
else { arr[t] = orig[i]; arr[t + 1] = orig[i + 1]; arr[t + 2] = orig[i + 2]; }
}
rt.colBuf.needsUpdate = true;
} }
/** Selection highlight color (keeps in sync with the UI accent). */ /** Selection highlight color (keeps in sync with the UI accent). */
@@ -3910,6 +4215,7 @@ export class Viewer2D {
this._layerBoxes.clear(); this._layerBoxes.clear();
this._layerSamples.clear(); this._layerSamples.clear();
this._indexedBufs = []; this._indexedBufs = [];
this._fatBufs = [];
this._layerNames = null; this._layerNames = null;
this._metaHidden = null; this._metaHidden = null;
this._curLayer = null; this._curLayer = null;
@@ -3928,6 +4234,8 @@ export class Viewer2D {
this._camera.left = -halfW; this._camera.right = halfW; this._camera.left = -halfW; this._camera.right = halfW;
this._camera.top = halfH; this._camera.bottom = -halfH; this._camera.top = halfH; this._camera.bottom = -halfH;
this._camera.updateProjectionMatrix(); this._camera.updateProjectionMatrix();
// Fat lines size themselves against the canvas resolution — restate it.
for (const buf of this._fatBufs) this._sizeLineMaterial(buf.mesh.material);
} }
_animate() { _animate() {
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