diff --git a/public/docs/modules-dwg-dxf.html b/public/docs/modules-dwg-dxf.html new file mode 100644 index 0000000..8b1951e --- /dev/null +++ b/public/docs/modules-dwg-dxf.html @@ -0,0 +1,361 @@ + + + + + +DWG/DXF · 모듈 조합 레시피 & 샘플 + + + +
+
+
compose · don’t invent
+

DWG · DXF 모듈 조합 레시피

+

+ 전체 제품이 아니라, 지도에 있는 재사용 모듈만 골라 조립하면 2D CAD 뷰어가 됩니다. + 아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다. +

+ + ▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev) + +

+ cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173 +

+
+ + +

01 조합에 쓰는 모듈 (지도 카드)

+
+ WASM 파서 + JS 모듈 · 렌더러 +
+
+
+
acadrust-dwg wasm
+
DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.
+
src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)
+
+
+
dwgParser / acadrustParser wrap
+
WASM 1회 init + parseDwgBuffer(bytes).
+
dwgParser.ts · acadrustParser.ts
+
+
+
dxf-parser npm
+
DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.
+
npm: dxf-parser
+
+
+
dxfHatchHandler patch
+
registerEntityHandler로 HATCH 보강.
+
dxfHatchHandler.js
+
+
+
dxfAdapter adapt
+
dxf-parser 출력 → Viewer2D 공통 스키마.
+
dxfAdapter.js
+
+
+
Viewer2D render
+
Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…
+
Viewer2D.js
+
+
+
three peer
+
WebGLRenderer + OrbitControls (pan/zoom).
+
npm: three ≥0.160
+
+
+
slugText + font text
+
opentype.js → GPU 텍스트. TTF를 호스트가 서빙.
+
slugText.ts · /fonts/*.ttf
+
+
+
ext2d util
+
isDwg / isDxf / is2D — 의존 zero.
+
ext2d.ts
+
+
+ + +

02 조립 파이프라인

+
+
+
+
Host
+
File · URL · Drop
+
.dwg / .dxf
+
+
→
+
+
ext2d
+
isDwg / isDxf
+
분기
+
+
→
+
+
Parser A or B
+
acadrust · dxf-parser
+
(+ hatch · adapter)
+
+
→
+
+
Contract
+
CadParseResult
+
공통 JSON
+
+
→
+
+
Viewer2D
+
.load(result)
+
three 씬
+
+
+
+ 핵심 계약: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다. + 중간은 항상 CadParseResult 하나. +
+
+ + +

03 다른 프로젝트에 옮기는 순서

+
+
+
1
+
+

모듈 소스 복사

+

+ 이 샘플의 src/viewer2d/ 전체를 복사. + npm: three, dxf-parser, opentype.js. + (원본 소스는 형제 폴더 hmwebviewer/src/viewer2d.) +

+
+
+
+
2
+
+

정적 자산

+

+ public/fonts/NanumGothic-Regular.ttf (한글 TEXT). + WASM은 소스가 ?url로 import — Vite면 assetsInclude: ['**/*.wasm']. +

+
+
+
+
3
+
+

아래 스니펫처럼 조합

+

+ 래퍼 클래스 없이 모듈을 직접 import 해도 됩니다. + 실행 가능한 전체 예: 이 프로젝트 src/main.ts. +

+
+
+
+
4
+
+

호스트 UI만 붙이기

+

+ 드롭존 · 툴바 · 레이어 패널은 모듈이 아님. + 샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음. +

+
+
+
+ + +

04 최소 조합 코드 (샘플과 동일 패턴)

+
// ① 모듈 지도 카드 → import
+import { Viewer2D } from './viewer2d/Viewer2D.js';
+import { parseDwgBuffer } from './viewer2d/dwgParser';       // acadrust-dwg
+import { parseDxfBuffer } from './viewer2d/parseDxf';         // dxf-parser+hatch+adapter
+import { isDwg, isDxf } from './viewer2d/ext2d';
+import { setDefaultFontUrl } from './viewer2d/slugText';
+
+setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
+
+// ② 렌더러
+const viewer = new Viewer2D(document.getElementById('app')!);
+
+// ③ 파서 조합
+async function parseCad(buf: ArrayBuffer, name: string) {
+  if (isDxf(name)) return parseDxfBuffer(buf);                 // dxf-parser 경로
+  if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf)); // WASM 경로
+  throw new Error('need .dwg or .dxf name');
+}
+
+// ④ 계약: parseResult → load
+async function open(file: File) {
+  const result = await parseCad(await file.arrayBuffer(), file.name);
+  viewer.load(result);
+  viewer.resize();
+  viewer.fit();
+}
+ +

05 DWG 경로 vs DXF 경로

+
+
+
.dwg 경로 wasm
+
+ parseDwgBuffer
+ → acadrustParser.init
+ → parse_dwg(bytes)
+ → CadParseResult +
+
Sample DWG 버튼으로 검증
+
+
+
.dxf 경로 js
+
+ parseDxfBuffer
+ → decodeDxf (UTF-8 / CP949…)
+ → DxfParser + HatchHandler
+ → dxfToParseResult +
+
Sample DXF 버튼으로 검증
+
+
+ +

06 샘플 위치

+
dwg-dxf-viewer-sample/   ← hmwebviewer 형제 폴더
+  src/main.ts              ← 모듈을 직접 import 해서 조립 (핵심)
+  src/viewer2d/            ← acadrust-dwg · Viewer2D · dxf-*
+  index.html               ← 툴바 + Module recipe 패널
+  public/fonts/            ← NanumGothic (slugText)
+  public/samples/          ← BasicSample.dwg · simple.dxf
+  docs/modules-dwg-dxf.html
+
+실행
+  cd ../dwg-dxf-viewer-sample   # (hmwebviewer 기준 형제)
+  npm install
+  npm run dev              → http://localhost:5173
+ + +
+ + diff --git a/public/fonts/NanumGothic-Regular.ttf b/public/fonts/NanumGothic-Regular.ttf new file mode 100644 index 0000000..2aa2b20 Binary files /dev/null and b/public/fonts/NanumGothic-Regular.ttf differ diff --git a/public/licenses/THIRD-PARTY-NOTICES.md b/public/licenses/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..a3ecaef --- /dev/null +++ b/public/licenses/THIRD-PARTY-NOTICES.md @@ -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 | diff --git a/public/licenses/acadrust-0.4.1-read_mesh.patch b/public/licenses/acadrust-0.4.1-read_mesh.patch new file mode 100644 index 0000000..59193bd --- /dev/null +++ b/public/licenses/acadrust-0.4.1-read_mesh.patch @@ -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 } + } + diff --git a/public/licenses/acadrust-MPL-2.0.txt b/public/licenses/acadrust-MPL-2.0.txt new file mode 100644 index 0000000..7321068 --- /dev/null +++ b/public/licenses/acadrust-MPL-2.0.txt @@ -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. + diff --git a/public/samples/BasicSample.dwg b/public/samples/BasicSample.dwg new file mode 100644 index 0000000..cd34d2c Binary files /dev/null and b/public/samples/BasicSample.dwg differ diff --git a/public/samples/C0060202-001-평면및종단면도(1)(대산방향STA.18+492.08-19+411.50).dwg b/public/samples/C0060202-001-평면및종단면도(1)(대산방향STA.18+492.08-19+411.50).dwg new file mode 100644 index 0000000..3ee01bb Binary files /dev/null and b/public/samples/C0060202-001-평면및종단면도(1)(대산방향STA.18+492.08-19+411.50).dwg differ diff --git a/public/samples/C0060203-001-표준단면도(2차로,자연환기300m이상).dwg b/public/samples/C0060203-001-표준단면도(2차로,자연환기300m이상).dwg new file mode 100644 index 0000000..a8ec60d Binary files /dev/null and b/public/samples/C0060203-001-표준단면도(2차로,자연환기300m이상).dwg differ diff --git a/public/samples/bb.dwg b/public/samples/bb.dwg new file mode 100644 index 0000000..438112c Binary files /dev/null and b/public/samples/bb.dwg differ diff --git a/public/samples/civil.dwg b/public/samples/civil.dwg new file mode 100644 index 0000000..3ee01bb Binary files /dev/null and b/public/samples/civil.dwg differ diff --git a/public/samples/simple.dxf b/public/samples/simple.dxf new file mode 100644 index 0000000..10fa791 --- /dev/null +++ b/public/samples/simple.dxf @@ -0,0 +1,146 @@ +0 +SECTION +2 +HEADER +9 +$ACADVER +1 +AC1015 +9 +$INSUNITS +70 +4 +0 +ENDSEC +0 +SECTION +2 +TABLES +0 +TABLE +2 +LAYER +0 +LAYER +2 +0 +70 +0 +62 +7 +0 +LAYER +2 +OUTLINE +70 +0 +62 +1 +0 +ENDTAB +0 +ENDSEC +0 +SECTION +2 +BLOCKS +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LINE +8 +OUTLINE +10 +0.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +0.0 +31 +0.0 +0 +LINE +8 +OUTLINE +10 +100.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +60.0 +31 +0.0 +0 +LINE +8 +OUTLINE +10 +100.0 +20 +60.0 +30 +0.0 +11 +0.0 +21 +60.0 +31 +0.0 +0 +LINE +8 +OUTLINE +10 +0.0 +20 +60.0 +30 +0.0 +11 +0.0 +21 +0.0 +31 +0.0 +0 +CIRCLE +8 +0 +10 +50.0 +20 +30.0 +30 +0.0 +40 +15.0 +0 +TEXT +8 +0 +10 +10.0 +20 +70.0 +30 +0.0 +40 +5.0 +1 +Hello DWG/DXF package +0 +ENDSEC +0 +EOF diff --git a/src/engine/layerPanel.ts b/src/engine/layerPanel.ts new file mode 100644 index 0000000..68b1897 --- /dev/null +++ b/src/engine/layerPanel.ts @@ -0,0 +1,195 @@ +/** + * Layer Panel manager for dwg-dxf-viewer-sample. + * Renders layer items, color swatches, entity counts, search filters, and visibility toggles. + */ + +import type { Viewer2D } from './viewer2d/Viewer2D'; + +export interface LayerItem { + name: string; + colorHex: string; + count: number; + visible: boolean; +} + +export class LayerPanelManager { + private viewer: Viewer2D; + private listEl: HTMLElement; + private countEl: HTMLElement | null; + private searchInputEl: HTMLInputElement | null; + private hiddenLayers: Set = new Set(); + private searchQuery: string = ''; + + constructor( + viewer: Viewer2D, + listEl: HTMLElement, + options?: { + countEl?: HTMLElement | null; + searchInputEl?: HTMLInputElement | null; + showAllBtnEl?: HTMLElement | null; + hideAllBtnEl?: HTMLElement | null; + } + ) { + this.viewer = viewer; + this.listEl = listEl; + this.countEl = options?.countEl || null; + this.searchInputEl = options?.searchInputEl || null; + + if (this.searchInputEl) { + this.searchInputEl.addEventListener('input', (e) => { + this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase(); + this.render(); + }); + } + + if (options?.showAllBtnEl) { + options.showAllBtnEl.addEventListener('click', () => this.showAllLayers()); + } + + if (options?.hideAllBtnEl) { + options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers()); + } + + // Delegated click handling: bound once, so render() never has to re-attach + // a listener per row (that was O(layers) DOM work on every single toggle). + this.listEl.addEventListener('click', (e) => { + const row = (e.target as HTMLElement)?.closest?.('.layer-row') as HTMLElement | null; + if (!row) return; + const layerName = row.dataset.name; + if (layerName) this.toggleLayer(layerName); + }); + } + + /** Refresh and re-render layer list from Viewer2D state */ + public update(): void { + this.hiddenLayers.clear(); + const layers = this.viewer.getLayerInfo() || []; + for (const l of layers) { + if (!l.visible) { + this.hiddenLayers.add(l.name); + } + } + this.render(); + } + + public showAllLayers(): void { + this.hiddenLayers.clear(); + this.viewer.setHiddenLayers(this.hiddenLayers); + this.syncRows(); + } + + public hideAllLayers(): void { + const layers = this.viewer.getLayerInfo() || []; + for (const l of layers) { + this.hiddenLayers.add(l.name); + } + this.viewer.setHiddenLayers(this.hiddenLayers); + this.syncRows(); + } + + public toggleLayer(name: string): void { + if (this.hiddenLayers.has(name)) { + this.hiddenLayers.delete(name); + } else { + this.hiddenLayers.add(name); + } + this.viewer.setHiddenLayers(this.hiddenLayers); + this.syncRow(name); + } + + /** Patch one row in place — a full render() would rebuild the whole list. */ + private syncRow(name: string): void { + const row = this.listEl?.querySelector( + `.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('.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('.eye-btn'); + if (btn) { + btn.textContent = visible ? '👁️' : '🕶️'; + btn.title = visible ? '숨기기' : '표시하기'; + } + } + + public render(): void { + if (!this.listEl) return; + + const layers: LayerItem[] = this.viewer.getLayerInfo() || []; + + if (this.countEl) { + this.countEl.textContent = `${layers.length}개`; + } + + if (!layers.length) { + this.listEl.innerHTML = ` +
+ 도면에 레이어 정보가 없습니다 +
`; + return; + } + + const filtered = layers.filter((l) => + l.name.toLowerCase().includes(this.searchQuery) + ); + + if (!filtered.length) { + this.listEl.innerHTML = ` +
+ 검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}") +
`; + return; + } + + this.listEl.innerHTML = filtered + .map((l) => { + const isVisible = !this.hiddenLayers.has(l.name); + const opacity = isVisible ? '1' : '0.4'; + const eyeIcon = isVisible ? '👁️' : '🕶️'; + const eyeTitle = isVisible ? '숨기기' : '표시하기'; + + return ` +
+ + + ${escapeHtml(l.name)} + + + ${l.count} + + +
`; + }) + .join(''); + // Clicks are handled by the delegated listener bound in the constructor. + } +} + +/** Escape a layer name for use inside a double-quoted CSS attribute selector. */ +function cssEscape(str: string): string { + return str.replace(/["\\]/g, '\\$&'); +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/engine/optionsStore.ts b/src/engine/optionsStore.ts new file mode 100644 index 0000000..b0b83c7 --- /dev/null +++ b/src/engine/optionsStore.ts @@ -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 | null = null; + +function openDb(): Promise { + 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(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + return openDb().then( + (db) => + new Promise((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(key: string, fallback: T): Promise { + const v = await tx('readonly', (s) => s.get(key)); + return v === null || v === undefined ? fallback : v; +} + +/** 옵션 하나 저장. 저장이 막혀 있어도 예외를 던지지 않는다. */ +export async function setOption(key: string, value: unknown): Promise { + await tx('readwrite', (s) => s.put(value, key)); +} + +/** 전체 옵션을 한 번에 읽는다 (기동 시 1회 호출용). */ +export async function getAllOptions(): Promise> { + const keys = (await tx('readonly', (s) => s.getAllKeys())) ?? []; + const vals = (await tx('readonly', (s) => s.getAll())) ?? []; + const out: Record = {}; + keys.forEach((k, i) => { out[String(k)] = vals[i]; }); + return out; +} diff --git a/src/engine/panelUtils.ts b/src/engine/panelUtils.ts new file mode 100644 index 0000000..20315f7 --- /dev/null +++ b/src/engine/panelUtils.ts @@ -0,0 +1,149 @@ +/** + * Utility helper to make floating UI panels draggable and collapsible. + */ + +let globalMaxZIndex = 30; + +export interface PanelControlOptions { + handleEl?: HTMLElement | null; + collapseBtnEl?: HTMLElement | null; + bodyEl?: HTMLElement | null; + onCollapseToggle?: (isCollapsed: boolean) => void; +} + +export function makePanelDraggableAndCollapsible( + panelEl: HTMLElement, + options?: PanelControlOptions +): { toggleCollapse: () => void; isCollapsed: () => boolean } { + if (!panelEl) return { toggleCollapse: () => {}, isCollapsed: () => false }; + + const handleEl = (options?.handleEl || panelEl.querySelector('.hdr') || panelEl) as HTMLElement; + const collapseBtnEl = (options?.collapseBtnEl || panelEl.querySelector('.collapse-btn')) as HTMLElement | null; + const bodyEl = (options?.bodyEl || panelEl.querySelector('#propBody') || panelEl.querySelector('#layerBody') || panelEl.children[1]) as HTMLElement | null; + + let isCollapsed = false; + let isDragging = false; + let startX = 0; + let startY = 0; + let initialLeft = 0; + let initialTop = 0; + + // Set initial drag cursor + if (handleEl) { + handleEl.style.cursor = 'grab'; + handleEl.style.userSelect = 'none'; + } + + // Focus panel to top z-index when clicked anywhere + panelEl.addEventListener('pointerdown', () => { + globalMaxZIndex++; + panelEl.style.zIndex = String(globalMaxZIndex); + }); + + // Dragging handler + const onPointerDown = (e: PointerEvent) => { + // Ignore clicks on buttons, inputs, or interactive controls inside the header + const target = e.target as HTMLElement; + if (target.closest('button, input, select, a, label, .hdr-actions')) { + return; + } + + isDragging = true; + globalMaxZIndex++; + panelEl.style.zIndex = String(globalMaxZIndex); + if (handleEl) handleEl.style.cursor = 'grabbing'; + + const rect = panelEl.getBoundingClientRect(); + startX = e.clientX; + startY = e.clientY; + initialLeft = rect.left; + initialTop = rect.top; + + // Convert CSS right/bottom to explicit left/top positioning on first drag + panelEl.style.left = `${initialLeft}px`; + panelEl.style.top = `${initialTop}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + e.preventDefault(); + }; + + const onPointerMove = (e: PointerEvent) => { + if (!isDragging) return; + + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + let newLeft = initialLeft + dx; + let newTop = initialTop + dy; + + // Clamp within window boundaries so panel doesn't disappear off-screen + const maxLeft = Math.max(10, window.innerWidth - panelEl.offsetWidth - 10); + const maxTop = Math.max(10, window.innerHeight - panelEl.offsetHeight - 10); + + newLeft = Math.max(10, Math.min(newLeft, maxLeft)); + newTop = Math.max(10, Math.min(newTop, maxTop)); + + panelEl.style.left = `${newLeft}px`; + panelEl.style.top = `${newTop}px`; + }; + + const onPointerUp = () => { + if (!isDragging) return; + isDragging = false; + if (handleEl) handleEl.style.cursor = 'grab'; + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + }; + + if (handleEl) { + handleEl.addEventListener('pointerdown', onPointerDown as EventListener); + + // Double-click on header to toggle collapse/expand + handleEl.addEventListener('dblclick', (e) => { + const target = e.target as HTMLElement; + if (target.closest('button, input, select, a, label')) return; + toggleCollapseState(); + }); + } + + let lastHeight = ''; + + // Collapse / Expand toggle function + const toggleCollapseState = () => { + isCollapsed = !isCollapsed; + panelEl.classList.toggle('collapsed', isCollapsed); + + if (isCollapsed) { + lastHeight = panelEl.style.height || `${panelEl.offsetHeight}px`; + panelEl.style.height = '42px'; + if (bodyEl) bodyEl.style.display = 'none'; + } else { + panelEl.style.height = lastHeight || '480px'; + if (bodyEl) bodyEl.style.display = 'flex'; + } + + if (collapseBtnEl) { + collapseBtnEl.textContent = isCollapsed ? '▸' : '▾'; + collapseBtnEl.setAttribute('title', isCollapsed ? '펼치기' : '접기'); + } + + if (options?.onCollapseToggle) { + options.onCollapseToggle(isCollapsed); + } + }; + + if (collapseBtnEl) { + collapseBtnEl.addEventListener('click', (e) => { + e.stopPropagation(); + toggleCollapseState(); + }); + } + + return { + toggleCollapse: toggleCollapseState, + isCollapsed: () => isCollapsed, + }; +} diff --git a/src/engine/propertyInspector.ts b/src/engine/propertyInspector.ts new file mode 100644 index 0000000..71b7f90 --- /dev/null +++ b/src/engine/propertyInspector.ts @@ -0,0 +1,358 @@ +/** + * Entity Property Inspector module for dwg-dxf-viewer-sample. + * Formats CAD entity properties and renders detailed property UI panels. + */ + +export interface FormattedProperties { + id: string; + type: string; + handle: string; + layer: string; + general: [string, string][]; + geometry: [string, string][]; +} + +const f2 = (v: any): string => (typeof v === 'number' ? v.toFixed(2) : (v ?? '-')); +const f3 = (v: any): string => (typeof v === 'number' ? v.toFixed(3) : (v ?? '-')); +const deg = (r: any): string => { + if (typeof r !== 'number') return '-'; + let d = (r * 180) / Math.PI; + while (d < 0) d += 360; + while (d >= 360) d -= 360; + return d.toFixed(1) + '°'; +}; + +const pt = (p: any): string => { + if (!p) return '-'; + const x = typeof p.x === 'number' ? f2(p.x) : '0.00'; + const y = typeof p.y === 'number' ? f2(p.y) : '0.00'; + const z = typeof p.z === 'number' && Math.abs(p.z) > 1e-6 ? `, ${f2(p.z)}` : ''; + return `${x}, ${y}${z}`; +}; + +function formatColor(colorVal: any): string { + if (colorVal == null) return 'BYLAYER'; + if (typeof colorVal === 'number') { + if (colorVal === 256) return 'BYLAYER (256)'; + if (colorVal === 0) return 'BYBLOCK (0)'; + return `ACI ${colorVal}`; + } + return String(colorVal); +} + +/** DWG lineweight: i16 in 1/100 mm, with -1/-2/-3 reserved for the inherit modes. */ +function formatLineweight(raw: unknown): string { + if (typeof raw !== 'number') return 'BYLAYER'; + if (raw === -1) return 'BYLAYER'; + if (raw === -2) return 'BYBLOCK'; + if (raw === -3) return 'DEFAULT (0.25mm)'; + if (raw < 0) return String(raw); + return `${(raw / 100).toFixed(2)}mm`; +} + +export function formatEntityProperties(entity: any): FormattedProperties { + if (!entity) { + return { id: '', type: '', handle: '', layer: '', general: [], geometry: [] }; + } + + const d = entity.data || entity; + const type = (entity.type || entity.typeName || 'ENTITY').toUpperCase(); + const handle = entity.handle?.value ?? entity.handle ?? '-'; + const layer = entity.layer ?? entity.layerName ?? '0'; + const color = formatColor(entity.color ?? d.color); + const linetype = entity.lineType ?? entity.linetype ?? d.lineType ?? 'BYLAYER'; + const lineweight = formatLineweight( + entity.entityHeader?.lineWeight ?? entity.lineWeight ?? entity.lineweight ?? d.lineweight, + ); + + const general: [string, string][] = [ + ['유형 (Type)', type], + ['핸들 (Handle)', String(handle)], + ['레이어 (Layer)', String(layer)], + ['색상 (Color)', color], + ['선종류 (Linetype)', String(linetype)], + ['선가중치 (Lineweight)', lineweight], + ]; + + const geometry: [string, string][] = []; + + switch (type) { + case 'LINE': { + if (d.start && d.end) { + const dx = d.end.x - d.start.x; + const dy = d.end.y - d.start.y; + const dz = (d.end.z || 0) - (d.start.z || 0); + const len = Math.hypot(dx, dy, dz); + const angle = Math.atan2(dy, dx); + + geometry.push(['시작점 (Start)', pt(d.start)]); + geometry.push(['끝점 (End)', pt(d.end)]); + geometry.push(['증분 (ΔX, ΔY)', `${f2(dx)}, ${f2(dy)}`]); + geometry.push(['길이 (Length)', f3(len)]); + geometry.push(['각도 (Angle)', deg(angle)]); + } + break; + } + + case 'CIRCLE': { + if (d.center && d.radius != null) { + const r = d.radius; + const diameter = r * 2; + const circumference = 2 * Math.PI * r; + const area = Math.PI * r * r; + + geometry.push(['중심점 (Center)', pt(d.center)]); + geometry.push(['반지름 (Radius)', f3(r)]); + geometry.push(['지름 (Diameter)', f3(diameter)]); + geometry.push(['둘레 (Circumference)', f3(circumference)]); + geometry.push(['넓이 (Area)', f3(area)]); + } + break; + } + + case 'ARC': { + if (d.center && d.radius != null) { + const r = d.radius; + const sa = d.startAngle ?? 0; + const ea = d.endAngle ?? Math.PI * 2; + let sweep = ea - sa; + while (sweep < 0) sweep += Math.PI * 2; + const arcLen = sweep * r; + + geometry.push(['중심점 (Center)', pt(d.center)]); + geometry.push(['반지름 (Radius)', f3(r)]); + geometry.push(['시작각 (Start Angle)', deg(sa)]); + geometry.push(['끝각 (End Angle)', deg(ea)]); + geometry.push(['중심각 (Sweep Angle)', deg(sweep)]); + geometry.push(['호 길이 (Arc Length)', f3(arcLen)]); + } + break; + } + + case 'LWPOLYLINE': + case 'POLYLINE': { + const rawPts = d.points ?? d.vertices ?? []; + const isClosed = !!(d.closed || (d.flags & 1)); + let totalLen = 0; + + for (let i = 0; i < rawPts.length - (isClosed ? 0 : 1); i++) { + const p1 = rawPts[i]; + const p2 = rawPts[(i + 1) % rawPts.length]; + if (p1 && p2) { + totalLen += Math.hypot(p2.x - p1.x, p2.y - p1.y); + } + } + + geometry.push(['정점 개수 (Vertices)', String(rawPts.length)]); + geometry.push(['닫힘 여부 (Closed)', isClosed ? '예 (Closed)' : '아니오 (Open)']); + geometry.push(['총 길이 (Total Length)', f3(totalLen)]); + + if (isClosed && rawPts.length >= 3) { + let shoelace = 0; + for (let i = 0; i < rawPts.length; i++) { + const p1 = rawPts[i]; + const p2 = rawPts[(i + 1) % rawPts.length]; + shoelace += p1.x * p2.y - p2.x * p1.y; + } + geometry.push(['면적 (Area)', f3(Math.abs(shoelace) / 2)]); + } + + if (rawPts.length > 0) { + const sampleCount = Math.min(rawPts.length, 4); + for (let i = 0; i < sampleCount; i++) { + geometry.push([`정점 P${i + 1}`, pt(rawPts[i])]); + } + if (rawPts.length > sampleCount) { + geometry.push(['...', `외 ${rawPts.length - sampleCount}개 정점`]); + } + } + break; + } + + // MESH (AcDbSubDMesh). dwg-wasm emits flat arrays: vertices [x,y,z,…], + // faceList [n, i0 … i(n-1)] repeated, edges [a,b,…]. + case 'MESH': { + const verts: number[] = d.vertices ?? []; + const vCount = d.vertexCount ?? Math.floor(verts.length / 3); + const fCount = d.faceCount ?? 0; + const eCount = Math.floor((d.edges?.length ?? 0) / 2); + const sub = d.subdivisionLevel ?? 0; + + geometry.push(['정점 개수 (Vertices)', String(vCount)]); + geometry.push(['면 개수 (Faces)', String(fCount)]); + geometry.push(['모서리 개수 (Edges)', String(eCount)]); + geometry.push(['세분 레벨 (Subdivision)', String(sub)]); + geometry.push(['크리스 혼합 (Blend Crease)', d.blendCrease ? '예 (Yes)' : '아니오 (No)']); + + if (vCount > 0) { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (let i = 0; i + 2 < verts.length; i += 3) { + const x = verts[i], y = verts[i + 1], z = verts[i + 2]; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; + } + geometry.push(['최소점 (Min)', pt({ x: minX, y: minY, z: minZ })]); + geometry.push(['최대점 (Max)', pt({ x: maxX, y: maxY, z: maxZ })]); + geometry.push(['표고 범위 (Z Range)', `${f3(minZ)} ~ ${f3(maxZ)}`]); + } + break; + } + + case 'TEXT': + case 'MTEXT': + case 'ATTRIB': { + const content = d.text ?? entity.text ?? d.textValue ?? '-'; + const height = d.textHeight ?? d.height ?? entity.textHeight; + const rot = d.rotationAngle ?? d.rotation ?? 0; + const pos = d.insertionPoint ?? d.insertionPt ?? d.alignmentPt ?? d.pos; + + geometry.push(['텍스트 내용 (Content)', String(content)]); + if (height != null) geometry.push(['높이 (Height)', f3(height)]); + geometry.push(['회전각 (Rotation)', deg(rot)]); + if (pos) geometry.push(['삽입점 (Position)', pt(pos)]); + break; + } + + case 'INSERT': { + const name = d.name ?? d.blockName ?? entity.name ?? '-'; + const pos = d.insertionPoint ?? d.insertionPt ?? entity.insertionPt; + const sx = d.xScale ?? d.scale?.x ?? 1; + const sy = d.yScale ?? d.scale?.y ?? 1; + const sz = d.zScale ?? d.scale?.z ?? 1; + const rot = d.rotation ?? 0; + + geometry.push(['블록 이름 (Block)', String(name)]); + if (pos) geometry.push(['삽입점 (Position)', pt(pos)]); + geometry.push(['축척 (Scale X,Y,Z)', `${f2(sx)}, ${f2(sy)}, ${f2(sz)}`]); + geometry.push(['회전각 (Rotation)', deg(rot)]); + break; + } + + case 'HATCH': { + const isSolid = d.solidFill ?? entity.solidFill ?? false; + const pattern = d.patternName ?? d.name ?? (isSolid ? 'SOLID' : 'HATCH'); + const paths = d.paths ?? entity.paths ?? []; + + geometry.push(['패턴 (Pattern)', String(pattern)]); + geometry.push(['채우기 유형 (Type)', isSolid ? '단색 채우기 (Solid)' : '패턴 (Pattern)']); + geometry.push(['경계 루프 수 (Loops)', String(paths.length)]); + break; + } + + case 'DIMENSION': + case 'DIMENSION_LINEAR': + case 'DIMENSION_ALIGNED': + case 'DIMENSION_RADIUS': + case 'DIMENSION_DIAMETER': + case 'DIMENSION_ANG_3PT': + case 'DIMENSION_ANG_2LN': + case 'DIMENSION_ORDINATE': { + const meas = d.actualMeasurement ?? entity.actualMeasurement; + const textVal = d.userText || (meas != null ? f3(meas) : '-'); + const midPt = d.textMidPt ?? entity.textMidPt; + + geometry.push(['치수 유형 (Dim Type)', type]); + if (meas != null) geometry.push(['측정값 (Measurement)', f3(meas)]); + geometry.push(['표시 텍스트 (Text)', String(textVal)]); + if (midPt) geometry.push(['텍스트 위치 (Text Mid)', pt(midPt)]); + break; + } + + case 'ELLIPSE': { + if (d.center) { + geometry.push(['중심점 (Center)', pt(d.center)]); + if (d.majorAxis) geometry.push(['주축 벡터 (Major Axis)', pt(d.majorAxis)]); + if (d.axisRatio != null || d.ratio != null) { + geometry.push(['단축 비율 (Ratio)', f3(d.axisRatio ?? d.ratio)]); + } + } + break; + } + + default: { + let count = 0; + for (const [k, v] of Object.entries(d)) { + if (typeof v === 'object' || v == null || typeof v === 'function') continue; + geometry.push([k, String(v)]); + count++; + if (count >= 12) break; + } + } + } + + const id = `${type}${handle !== '-' ? ' · #' + handle : ''}`; + return { id, type, handle: String(handle), layer: String(layer), general, geometry }; +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function renderPropertyInspector( + bodyEl: HTMLElement, + entity: any, + options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null } +): void { + if (!bodyEl) return; + + if (!entity) { + if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none'; + bodyEl.innerHTML = ` +
+
🔍
+ 도면에서 객체(엔티티)를 클릭하면
상세 속성이 표시됩니다 +
`; + return; + } + + if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex'; + + const prop = formatEntityProperties(entity); + + const renderRows = (rows: [string, string][]) => + rows + .map( + ([k, v]) => ` +
+ ${escapeHtml(k)} + ${escapeHtml(v)} +
` + ) + .join(''); + + bodyEl.innerHTML = ` +
+ +
+ + ${escapeHtml(prop.id)} +
+ + +
+
+ 일반 정보 (General) +
+ ${renderRows(prop.general)} +
+ + + ${ + prop.geometry.length > 0 + ? ` +
+
+ 기하 속성 (Geometry) +
+ ${renderRows(prop.geometry)} +
` + : '' + } +
`; +} diff --git a/src/engine/viewer2d/Viewer2D.d.ts b/src/engine/viewer2d/Viewer2D.d.ts new file mode 100644 index 0000000..f18295e --- /dev/null +++ b/src/engine/viewer2d/Viewer2D.d.ts @@ -0,0 +1,50 @@ +// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves +// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed. +import type { CadSpace } from './cadSpaces'; + +export class Viewer2D { + constructor(container: HTMLElement); + /** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */ + load(result: unknown, opts?: { keepView?: boolean; keepTheme?: boolean; spaceHandle?: string | number | null }): void; + /** Zoom-extents to the loaded drawing. */ + fit(): void; + /** Resync renderer/camera to the container size (call after un-hiding). */ + resize(): void; + onSelect(cb: (entity: unknown) => void): void; + getSelectedEntity(): unknown; + zoomToSelection(): void; + setAccent(hex: string): void; + setTheme(dark: boolean): void; + setGrid(visible: boolean): void; + setZoomSpeed(speed: number): void; + getZoomSpeed(): number; + /** 2.5D (tilted 3D) view: orbit the drawing so entity elevation (Z) is visible. */ + set25D(on: boolean, opts?: { keepView?: boolean }): void; + is25D(): boolean; + /** Height exaggeration for 2.5D (1 = true scale); ignored in top view. */ + setZScale(k: number): void; + getZScale(): number; + /** Lineweight display (AutoCAD LWDISPLAY); null follows the drawing's own flag. */ + setLineweightEnabled(on: boolean | null): void; + getLineweightEnabled(): boolean; + /** Lineweight display scale in px per mm (AutoCAD's display-scale slider). Default 20. */ + setLineweightScale(pxPerMm: number | null): void; + getLineweightScale(): number; + /** Distinct lineweights (mm) present in the loaded drawing, ascending. */ + getLineweightClasses(): number[]; + getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[]; + setHiddenLayers(nameSet: Set): void; + /** Model / Paper (Layout) spaces for the loaded drawing. */ + getSpaces(): CadSpace[]; + getActiveSpace(): string | null; + /** Switch Model ↔ Layout; re-renders (fit unless keepView). */ + setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void; + getZoomPercent(): number; + onViewChange(cb: () => void): void; + /** Screen client coords → drawing world (Z=0 plane). */ + screenToWorld(clientX: number, clientY: number): { x: number; y: number; z: number } | null; + /** Live CAD coords under the cursor; null when pointer leaves the canvas. */ + onPointerWorld(cb: ((p: { x: number; y: number; z: number } | null) => void) | null): void; + startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void; + stopMeasure(): void; +} diff --git a/src/engine/viewer2d/Viewer2D.js b/src/engine/viewer2d/Viewer2D.js new file mode 100644 index 0000000..0154861 --- /dev/null +++ b/src/engine/viewer2d/Viewer2D.js @@ -0,0 +1,4532 @@ +/** + * Viewer2D — three.js renderer for DWG/DXF parseResult (orthographic 2D drawing). + * Ported from webviewer/src/viewer/Viewer3D.js (class renamed 3D→2D) for the + * 2D+3D merge. Renders CAD entities as LineSegments / Mesh / CanvasTexture sprites. + * Supported: LINE, CIRCLE, ARC, LWPOLYLINE, POLYLINE, POINT, ELLIPSE, SOLID, + * 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 + */ +import * as THREE from 'three'; +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 { buildAllowedOwnerHexes, listCadSpaces } from './cadSpaces'; +import { SlugTextEngine, SlugTextBatch } from './slugText'; +import { getLinPattern, mergeDwgLinetypePattern } from './linParser'; + +const DEFAULT_COLOR = 0xc9d1d9; +const ARC_SEGS = 64; +const ELLIPSE_SEGS = 72; +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. The px-per-mm factor is AutoCAD's "Adjust Display Scale" +// slider and depends on the monitor, so it is only a default: 11 px/mm is the +// value that matched an AutoCAD reference side by side (0.35 mm ≈ 4 px, +// 0.50 mm ≈ 5.5 px). Retune per host/monitor with setLineweightScale(). +const LW_PX_PER_MM = 11; +// 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 = 48; +// 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). */ +function groupByLayer(list) { + const m = new Map(); + for (const t of list) { + const k = t.layer ?? '0'; + let arr = m.get(k); + if (!arr) m.set(k, arr = []); + arr.push(t); + } + return m; +} + +function stripMText(s) { + if (!s) return ''; + return s + .replace(/\\A\d+;/g, '') // \A1; vertical alignment + .replace(/\\p[^;]*;/g, '') // \p...; paragraph properties (lowercase, has ;) + .replace(/\\P/g, '\n') // \P paragraph break → newline (capital, no ;) + .replace(/\\[a-zA-Z][^;]*;/g, '') // remaining inline codes \f \H \C \W \Q \T... + .replace(/\{[^{}]*\}/g, m => stripMText(m.slice(1, -1))) + .replace(/%%d/gi, '°').replace(/%%p/gi, '±').replace(/%%c/gi, 'Ø') + .replace(/[{}]/g, '') + .replace(/[ \t]{2,}/g, ' ') // collapse runs of spaces + .replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n) + .replace(/\n{3,}/g, '\n\n') + .replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines +} + +export class Viewer2D { + constructor(container) { + this._container = container; + this._entityMeta = []; + this._layerByHandle = new Map(); + this._layerByName = new Map(); + this._onSelectCb = null; + this._measureActive = false; + this._measureCb = null; + this._measurePtA = null; + this._measureMarkers = []; + this._textGen = 0; // invalidation token for the async Slug text flush + // ── Layer visibility index (see _applyLayerVisibility) ─────────────────── + // Layer on/off used to re-run load() = full re-tessellation (5 s+ on an + // 18 MB DWG). Instead we build every layer once and toggle visibility: + // · _layerObjs layer name → Object3D[] (hatch/solid/text/sprite meshes) + // · _indexedBufs merged LineSegments whose index buffer is rebuilt from a + // per-segment layer id — keeps the original draw order. + // · _layerBoxes / _layerSamples per-layer AABB + fit samples so Fit still + // frames only what is visible. + this._curLayer = null; + this._curLayerId = -1; + this._layerObjs = new Map(); + this._layerBoxes = new Map(); + this._layerSamples = new Map(); + 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._fullContentBox = null; + 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; + this._lwScale = null; // null = LW_PX_PER_MM (setLineweightScale) + // ── 2.5D view ──────────────────────────────────────────────────────────── + // Entities already carry their CAD elevation (Z) in the vertex buffers — + // the classic view simply stares straight down at them. 2.5D re-aims the + // same orthographic camera at an isometric angle and unlocks OrbitControls' + // rotate, so contours / TIN / 3D polylines show their relief. Nothing is + // re-tessellated: toggling is a camera + controls change only. + this._is25D = false; + this._zScale = 1; // height exaggeration (flat relief over a wide site) + this._need25Reset = false; // next _fit should snap back to the default angle + this._contentZ = null; // true Z range of the load (the fit box is flattened) + // Active viewport transform for filled geometry, set only while + // _emitModelThroughViewport runs. Lines reach the viewport through pushSeg, + // but SOLID/HATCH fills and dimension arrowheads hand a finished mesh built + // from model coordinates straight to _addObj — this is how that mesh finds + // out it belongs on a sheet. null outside the viewport pass. + this._fillXf = null; + SlugTextEngine.shared().catch(() => {}); // warm the font load; sprite fallback covers failure + + this._scene = new THREE.Scene(); + this._scene.background = new THREE.Color(0x0d1117); + this._isDark = true; + this._isLoading = false; + + const w = container.clientWidth || 800; + const h = container.clientHeight || 600; + this._camera = new THREE.OrthographicCamera(-w/2, w/2, h/2, -h/2, -10000, 10000); + this._camera.position.set(0, 0, 100); + + this._renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); + this._renderer.setPixelRatio(window.devicePixelRatio); + this._renderer.setSize(w, h); + container.appendChild(this._renderer.domElement); + + this._controls = new OrbitControls(this._camera, this._renderer.domElement); + this._controls.enableRotate = false; + this._controls.screenSpacePanning = true; + this._controls.zoomToCursor = true; + this._controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; + this._controls.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN }; + + const savedSpeed = localStorage.getItem('hmw:zoom-speed'); + if (savedSpeed) { + const speed = parseFloat(savedSpeed); + if (!isNaN(speed)) { + this._controls.zoomSpeed = speed; + } + } + + window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); }); + + this._group = new THREE.Group(); + this._scene.add(this._group); + + window.addEventListener('resize', () => this._onResize()); + // Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas + this._renderer.domElement.addEventListener('pointerdown', (e) => { + if (e.button === 1) e.preventDefault(); + }); + this._renderer.domElement.addEventListener('mousedown', (e) => { + this._downXY = [e.clientX, e.clientY]; + if (e.button === 1) e.preventDefault(); + }); + this._renderer.domElement.addEventListener('click', (e) => this._onClick(e)); + this._renderer.domElement.addEventListener('pointermove', (e) => this._onPointerMove(e)); + this._renderer.domElement.addEventListener('pointerleave', () => { + if (this._onPointerWorldCb) this._onPointerWorldCb(null); + }); + this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 }; + this._gridVisible = false; + this._gridMesh = null; + /** @type {string|null} active Model/Paper space handle hex; null = no space filter */ + this._activeSpaceHex = null; + /** @type {null|((p:{x:number,y:number,z:number}|null)=>void)} */ + this._onPointerWorldCb = null; + this._ptrWorldTmp = new THREE.Vector3(); + // Display frame for coordinates (Model UCS vs WCS). Paper uses identity. + this._coordOrigin = { x: 0, y: 0, z: 0 }; + this._coordXAxis = { x: 1, y: 0, z: 0 }; + this._coordYAxis = { x: 0, y: 1, z: 0 }; + this._controls.addEventListener('change', () => { if (this._gridVisible) this._rebuildGrid(); }); + this._animate(); + } + + onSelect(cb) { this._onSelectCb = cb; } + resize() { this._onResize(); } + setZoomSpeed(speed) { if (this._controls) this._controls.zoomSpeed = speed; } + getZoomSpeed() { return this._controls ? this._controls.zoomSpeed : 1.0; } + + // ── 2.5D view ────────────────────────────────────────────────────────────── + + /** + * Toggle the 2.5D (tilted 3D) view. + * + * ON — camera moves to an SE-isometric angle (+X/−Y/+Z, the AutoCAD default), + * Z becomes the orbit pole and LEFT-drag orbits (MIDDLE/RIGHT still pan). + * OFF — straight-down top view with rotate locked; identical to before. + * + * Geometry is untouched, so layer masks, lineweights and picking survive. + * @param {boolean} on + * @param {{ keepView?: boolean }} [opts] keepView skips the zoom-extents re-fit + */ + set25D(on, opts = {}) { + const want = !!on; + if (want === this._is25D) return; + this._is25D = want; + this._need25Reset = want; + const c = this._controls; + if (want) { + c.enableRotate = true; + c.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; + c.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }; + // CAD elevation is +Z, so the orbit pole must be Z or dragging feels sideways. + this._camera.up.set(0, 0, 1); + } else { + c.enableRotate = false; + c.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.PAN }; + c.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_PAN }; + } + this._applyZScale(); + if (opts.keepView) { c.update(); return; } + this.fit(); + } + + is25D() { return this._is25D; } + + /** + * Height exaggeration for 2.5D. A survey sheet is often kilometres wide with + * only metres of relief, which reads as dead flat at any orbit angle — + * multiplying Z makes the terrain legible. Only applied in 2.5D (in top view + * it would be invisible anyway) and only as a scale on the scene group, so + * pick coordinates and the X/Y readout stay true to the drawing. + * @param {number} k >0; 1 = true scale + */ + setZScale(k) { + const v = Number(k); + this._zScale = Number.isFinite(v) && v > 0 ? v : 1; + this._applyZScale(); + this._refreshClip25(); + } + + getZScale() { return this._zScale; } + + _applyZScale() { + this._group.scale.set(1, 1, this._is25D ? (this._zScale || 1) : 1); + this._group.updateMatrixWorld(true); + } + + /** True Z extents of the drawing (the fit box is flattened to z=0). */ + _zRange() { + const z = this._contentZ; + if (!z || !Number.isFinite(z.min) || !Number.isFinite(z.max)) return { min: 0, max: 0 }; + return z; + } + + /** + * Re-cut the orthographic clip slab around the (possibly Z-exaggerated) + * content without moving the camera — otherwise a big exaggeration pushes + * geometry through `near`/`far` and it vanishes mid-drag. + */ + _refreshClip25() { + if (!this._is25D) return; + const box = this._box25(this._contentBox || this._fullContentBox); + if (!box) return; + const diag = Math.max(box.getSize(new THREE.Vector3()).length(), 1e-6); + const span = this._camera.position.distanceTo(this._controls.target) + diag + 1; + this._camera.near = -span; + this._camera.far = span; + this._camera.updateProjectionMatrix(); + } + + /** Fit box with the real (exaggerated) Z range restored. */ + _box25(src) { + if (!src || src.isEmpty()) return null; + const b = src.clone(); + if (b.max.z - b.min.z < 1e-9) { + const z = this._zRange(); + b.min.z = z.min; + b.max.z = z.max; + } + const k = this._zScale || 1; + b.min.z *= k; + b.max.z *= k; + if (b.max.z < b.min.z) { const t = b.min.z; b.min.z = b.max.z; b.max.z = t; } + return b; + } + + /** + * Subscribe to mouse world-coordinate updates (CAD WCS / drawing units). + * Callback receives `{ x, y, z }` or `null` when the pointer leaves the canvas. + */ + onPointerWorld(cb) { this._onPointerWorldCb = cb || null; } + + /** + * Convert browser client coords → **CAD display** coordinates (Z=0 plane). + * Model Space: model UCS (matches AutoCAD status bar when UCS = drawing UCS). + * Paper/Layout: paper drawing units (identity). + * Pick/measure still use raw WCS via `_screenToWcs`. + */ + screenToWorld(clientX, clientY) { + const wcs = this._screenToWcs(clientX, clientY); + if (!wcs) return null; + return this._wcsToCad(wcs.x, wcs.y); + } + + /** + * Raw WCS (geometry storage frame) under the cursor. + * Ortho 2D: map NDC through camera right/up axes (handles view-twist up vector). + */ + _screenToWcs(clientX, clientY) { + const el = this._renderer?.domElement; + const cam = this._camera; + if (!el || !cam) return null; + const rect = el.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return null; + + cam.updateMatrixWorld(true); + const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1; + const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1; + + // 2.5D: the view no longer lies in the drawing plane, so shoot the (parallel) + // ortho ray through the pixel and intersect the Z=0 plane. Exact for the + // drawing plane itself; entities lifted by their elevation read as the point + // of the plane under them, which is what a plan-coordinate readout means. + if (this._is25D) { + // Cached vectors: this runs on every pointermove. + const o = (this._ray25O ||= new THREE.Vector3()).set(ndcX, ndcY, -1).unproject(cam); + const d = cam.getWorldDirection(this._ray25D ||= new THREE.Vector3()); + if (Math.abs(d.z) < 1e-9) return null; + const t = -o.z / d.z; + return { x: o.x + d.x * t, y: o.y + d.y * t, z: 0 }; + } + + // Orthographic frustum half-sizes in world units (respect zoom). + const zoom = cam.zoom || 1; + const halfW = ((cam.right - cam.left) * 0.5) / zoom; + const halfH = ((cam.top - cam.bottom) * 0.5) / zoom; + + // Camera basis in world (columns of matrixWorld). + const te = cam.matrixWorld.elements; + const rx = te[0], ry = te[1]; // camera local +X (right) + const ux = te[4], uy = te[5]; // camera local +Y (up) + // Anchor on the look target (z=0 plane), not camera.position.z + const tx = this._controls?.target?.x ?? cam.position.x; + const ty = this._controls?.target?.y ?? cam.position.y; + + return { + x: tx + ndcX * halfW * rx + ndcY * halfH * ux, + y: ty + ndcX * halfW * ry + ndcY * halfH * uy, + z: 0, + }; + } + + /** WCS → CAD display (model UCS when axes are available). */ + _wcsToCad(wx, wy) { + const o = this._coordOrigin || { x: 0, y: 0 }; + const xx = this._coordXAxis || { x: 1, y: 0 }; + const yy = this._coordYAxis || { x: 0, y: 1 }; + const dx = wx - (o.x || 0); + const dy = wy - (o.y || 0); + // Orthogonal axes: project onto X/Y directions (identity → subtract origin). + return { + x: dx * (xx.x || 0) + dy * (xx.y || 0), + y: dx * (yy.x || 0) + dy * (yy.y || 0), + z: 0, + }; + } + + /** Configure coordinate readout frame for the active space. */ + _setCoordFrame(result) { + const spaces = listCadSpaces(result); + const active = spaces.find((s) => s.handleHex === this._activeSpaceHex); + const vars = result?.vars || {}; + // Model: AutoCAD status bar uses model UCS origin/axes when set. + // Paper: sheet coordinates — identity. + if (active?.kind === 'model' && vars.ucsOrigin) { + this._coordOrigin = { + x: vars.ucsOrigin.x || 0, + y: vars.ucsOrigin.y || 0, + z: vars.ucsOrigin.z || 0, + }; + this._coordXAxis = vars.ucsXAxis || { x: 1, y: 0, z: 0 }; + this._coordYAxis = vars.ucsYAxis || { x: 0, y: 1, z: 0 }; + } else { + this._coordOrigin = { x: 0, y: 0, z: 0 }; + this._coordXAxis = { x: 1, y: 0, z: 0 }; + this._coordYAxis = { x: 0, y: 1, z: 0 }; + } + } + + _onPointerMove(e) { + if (!this._onPointerWorldCb) return; + const p = this.screenToWorld(e.clientX, e.clientY); + this._onPointerWorldCb(p); + } + + /** + * Zoom extents to visible entity AABB (space/layer filtered). + * Prefer the load-time content box (same filter as draw); recompute from + * scene if missing. Percentile-trim drops far junk verts. + */ + fit() { + // Prefer AABB recorded while drawing (matches visible entities exactly). + if (this._contentBox && !this._contentBox.isEmpty()) { + this._fit(this._contentBox); + return; + } + const box = new THREE.Box3(); + const samples = []; + this._group.traverse((obj) => { + if (!obj.geometry || obj.visible === false) return; + box.expandByObject(obj); + const pos = obj.geometry.attributes?.position; + if (!pos?.array) return; + const arr = pos.array; + const step = Math.max(3, Math.floor(arr.length / 3 / 4000) * 3); + for (let i = 0; i + 1 < arr.length; i += step) { + if (Number.isFinite(arr[i]) && Number.isFinite(arr[i + 1])) samples.push(arr[i], arr[i + 1]); + } + }); + if (box.isEmpty()) return; + const use = this._trimmedContentBox(samples, box) || box; + // Don't cache while layers are off — the merged line buffer still holds the + // hidden segments, so this box is wider than what's on screen. + if (!this._hiddenLayers.size) this._contentBox = use.clone(); + this._fit(use); + } + + load(result, opts = {}) { + this._isLoading = true; + try { + this._lastResult = result; + this._selMeta = null; + this._clear(); + this._entityMeta = []; + + // Model vs Paper (Layout): never draw both spaces at once. + // - opts.spaceHandle provided (including null) → set active space + // - omitted → keep previous _activeSpaceHex if still valid for this file + if (Object.prototype.hasOwnProperty.call(opts, 'spaceHandle')) { + const sh = opts.spaceHandle; + if (sh == null || sh === '') this._activeSpaceHex = null; + else this._activeSpaceHex = typeof sh === 'number' ? sh.toString(16) : String(sh).toLowerCase(); + } + + if (!opts.keepTheme) { + const spacesList = listCadSpaces(result); + const activeSp = spacesList.find((s) => s.handleHex === this._activeSpaceHex); + const isPaper = activeSp?.kind === 'paper'; + this.setTheme(!isPaper); + } + + this._buildLayerMap(result?.tables?.layers); + this._buildStyleMap(result?.tables?.styles || result?.tables?.textStyles); + 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 + // view so a drawing stored tilted in WCS (rotated survey sheets — header UCS + // stays identity) displays with its title border upright. Applied as a + // camera-up rotation in _fit (geometry untouched → picking stays exact). + this._viewTwist = this._readViewTwist(result); + // Geometric pick index — the ACTUAL rendered geometry, not a bounds proxy. + // _pickSegs: flat [ax,ay,bx,by,…] of every line segment (lines, arcs, circles, + // polylines, block wires, dimension leaders/arrows) with a parallel owner-meta + // index. _pickFills: filled regions (hatch loops, SOLID quads, text quads) + // tested by even-odd point-in-polygon. _onClick raycasts these directly. + this._pickSegs = []; + this._pickSegMeta = []; + this._pickFills = []; + this._pickCurIdx = -1; + + const entities = result?.entities || []; + const lineVerts = []; + const lineColors = []; + // Layer id per emitted segment (2 verts). Drives the index rebuild that + // hides/shows layers without touching positions or colors. + const lineSegLayer = []; + const layerIds = new Map(); + const layerNames = []; + const layerIdOf = (name) => { + let id = layerIds.get(name); + if (id === undefined) { id = layerNames.length; layerNames.push(name); layerIds.set(name, id); } + return id; + }; + // Segments that can't join the one merged hairline batch go into buckets + // keyed by dash|gap size and lineweight; each becomes its own mesh at + // assembly (LineDashedMaterial for thin dashes, LineSegments2 for anything + // 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 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 _tmp = new THREE.Vector3(); + const pendingTexts = []; + + // Build set of user block definition handles. + // Block-def entities have ownerHandle = their block's handle. They're rendered + // via INSERT (_insertEntities) and must be SKIPPED in the main loop to avoid + // expanding the bounding box with block-local coordinates (near 0,0), which + // would make the real drawing appear as a tiny speck on zoom-extents. + const blockDefHandles = new Set(); + // Also track model/paper space handles to verify correctness + const spaceHandles = new Set(); + // Block header handle (hex) → basePoint, for nested INSERT transform composition. + const blockBaseByHex = new Map(); + const blockNameByHex = new Map(); + for (const b of (result?.tables?.blocks ?? [])) { + const name = (b.name ?? '').toLowerCase().replace(/\*/g, '').trim(); + const h = typeof b.handle === 'object' ? b.handle?.value?.toString(16) : b.handle?.toString(16); + if (!h) continue; + if (b.basePoint) blockBaseByHex.set(h, b.basePoint); + blockNameByHex.set(h, (b.name ?? '').toLowerCase()); + // System space blocks: any of these names → entity owner, render directly + if (name === 'model_space' || name === 'paper_space' || + name === 'ms' || name === 'ps' || + name.startsWith('model') || name.startsWith('paper') || + name === '') { + spaceHandles.add(h); + } else { + blockDefHandles.add(h); + } + } + // Safety: if spaceHandles is empty we couldn't identify model space → + // disable the filter entirely to avoid hiding everything. + const useBlockFilter = spaceHandles.size > 0; + this._blockCount = blockDefHandles.size; + + // If active space is set but no longer present (new file), clear it. + if (this._activeSpaceHex && spaceHandles.size && !spaceHandles.has(this._activeSpaceHex)) { + this._activeSpaceHex = null; + } + // Owner closure for ATTRIB under INSERT in the active space, etc. + const allowedOwners = this._activeSpaceHex + ? buildAllowedOwnerHexes(entities, this._activeSpaceHex) + : null; + + // Pre-group all entities by ownerHandle hex string for O(1) lookup. + // Without this, every INSERT/DIMENSION does entities.filter() = O(n) per call = O(n²) total. + const entsByOwner = new Map(); + for (const ent of entities) { + const key = ent.ownerHandle?.value?.toString(16); + if (key != null) { + if (!entsByOwner.has(key)) entsByOwner.set(key, []); + entsByOwner.get(key).push(ent); + } + } + + // AABB of whatever is actually drawn (current Model/Layout + layers). + // Samples feed percentile trim so Fit matches CAD extents (ignore 1–2 junk verts). + const fitSamples = []; + let fitSampleN = 0; + // Per-layer AABB + fit samples: Fit must frame only the VISIBLE layers, and + // toggling no longer re-runs load(), so the split has to happen at build. + let curLayerBox = null; + let curLayerSamples = null; + const setCurLayer = (name) => { + const key = name || '0'; + if (key === this._curLayer) return; + this._curLayer = key; + this._curLayerId = layerIdOf(key); + curLayerBox = this._layerBoxes.get(key); + if (!curLayerBox) this._layerBoxes.set(key, curLayerBox = new THREE.Box3()); + curLayerSamples = this._layerSamples.get(key); + if (!curLayerSamples) this._layerSamples.set(key, curLayerSamples = []); + }; + const expand = (x, y, z = 0) => { + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + box.expandByPoint(_tmp.set(x, y, z)); + if (curLayerBox) curLayerBox.expandByPoint(_tmp); + fitSampleN++; + if ((fitSampleN & 7) === 0 && fitSamples.length < 100000) { + fitSamples.push(x, y); + if (curLayerSamples) curLayerSamples.push(x, y); + } + }; + const pushSeg = (ax, ay, bx, by, z, color) => { + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + const lwPx = this._curLwPx; + if (curDash || lwPx > 0) { + const key = `${curDash ? curDash.key : ''}#${lwPx}`; + let bk = segBuckets.get(key); + if (!bk) { + // lwMm is kept so setLineweightScale() can retune the material width + // without rebuilding the scene (the hairline cut-off is in mm, so + // bucket membership does not depend on the scale). + bk = { dash: curDash?.dash ?? 0, gap: curDash?.gap ?? 0, lwPx, lwMm: this._curLwMm || 0, + 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.colors.push(r, g, b, r, g, b); + bk.segLayer.push(this._curLayerId); + } else { + lineVerts.push(ax, ay, z, bx, by, z); + lineColors.push(r, g, b, r, g, b); + lineSegLayer.push(this._curLayerId); + } + if (this._pickCurIdx >= 0) { this._pickSegs.push(ax, ay, bx, by); this._pickSegMeta.push(this._pickCurIdx); } + expand(ax, ay, z); expand(bx, by, z); + }; + + // ── Layout draw order ──────────────────────────────────────────────── + // AutoCAD: model shown through VIEWPORT sits *under* paper-space entities + // (title block, notes, borders). We used to append viewport geometry after + // the paper pass → VP content painted on top. Fix: emit VP model first + // (z slightly behind) so paper entities and paper text overdraw it. + // + // DWG draw-order: acadrust has SORTENTSTABLE (ACAD_SORTENTS) + SORTENTS + // header flag, but dwg-wasm parseResult does not export them yet — so we + // apply the standard Layout stacking: viewport content below paper. + const VIEWPORT_Z = -1; // camera looks from +Z; lower Z is behind + const spacesList = listCadSpaces(result); + const activeSp = spacesList.find((s) => s.handleHex === this._activeSpaceHex); + if (activeSp?.kind === 'paper') { + const modelSp = spacesList.find((s) => s.kind === 'model'); + if (modelSp) { + const modelAllowed = buildAllowedOwnerHexes(entities, modelSp.handleHex); + const viewports = entities.filter((e) => { + if ((e.type || e.typeName || '').toUpperCase() !== 'VIEWPORT') return false; + const oh = e.ownerHandle?.value?.toString(16); + return oh === this._activeSpaceHex && this._isModelViewport(e); + }); + const vpPush = (ax, ay, bx, by, _z, color) => { + pushSeg(ax, ay, bx, by, VIEWPORT_Z, color); + }; + const vpExpand = (x, y, _z = 0) => expand(x, y, VIEWPORT_Z); + for (const vp of viewports) { + this._emitModelThroughViewport(vp, { + entities, + modelAllowed, + blockDefHandles, + entsByOwner, + blockBaseByHex, + blockNameByHex, + pushSeg: vpPush, + expand: vpExpand, + pendingTexts, + setCurLayer, + box, + _tmp, + fillZ: VIEWPORT_Z, // fills ride the same underlay plane as the wires + underlay: true, // tag pending texts drawn under paper labels + }); + } + } + } + + for (const e of entities) { + // Skip block-definition entities — they live at block-local coords and are + // rendered at correct world positions via INSERT / _insertEntities. + // Only apply filter when we've successfully identified model/paper space handles. + const ownerHex = e.ownerHandle?.value?.toString(16); + if (useBlockFilter && ownerHex && blockDefHandles.has(ownerHex)) continue; + // Model/Paper filter: only entities belonging to the active space (and + // their INSERT children such as ATTRIB). Other space geometry is hidden. + if (allowedOwners && ownerHex && !allowedOwners.has(ownerHex)) continue; + // Hidden layers are NOT skipped here: geometry for every layer is built + // once and switched on/off by _applyLayerVisibility, so a toggle costs an + // index rebuild instead of a full reload. + setCurLayer(this._layerNameOf(e)); + const d = e.data || e; + const type = (e.type || e.typeName || '').toUpperCase(); + const color = this._entityColor(e); + const complexLt = this._resolveComplexLinetype(e); + if (complexLt) curDash = null; + else curDash = this._resolveDash(e); + this._curLwPx = this._lwPx(e); + const meta = { entity: e, type, bounds: null, layer: this._curLayer, colStart: lineColors.length }; + entIdx = this._entityMeta.length; + curSpans = []; + this._entityMeta.push(meta); + this._pickCurIdx = this._entityMeta.length - 1; // owner for pick geometry emitted below + const textStart = pendingTexts.length; + + try { + switch (type) { + + // ── Basic geometry ──────────────────────────────────────────────── + case 'LINE': + if (d.start && d.end) { + if (complexLt) { + this._drawComplexPath([d.start, d.end], false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + } else { + pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color); + } + meta.bounds = { type:'line', x1:d.start.x, y1:d.start.y, x2:d.end.x, y2:d.end.y }; + } + break; + + case 'CIRCLE': + if (d.center && d.radius != null) { + if (complexLt) { + const pts = this._sampleArcPoints(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0); + this._drawComplexPath(pts, true, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + } else { + this._arcSegs(d.center, d.radius, 0, Math.PI*2, d.center.z||0, color, pushSeg); + } + meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius }; + } + break; + + case 'ARC': + if (d.center && d.radius != null) { + if (complexLt) { + const pts = this._sampleArcPoints(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0); + this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + } else { + this._arcSegs(d.center, d.radius, d.startAngle??0, d.endAngle??Math.PI*2, d.center.z||0, color, pushSeg); + } + meta.bounds = { type:'circle', cx:d.center.x, cy:d.center.y, r:d.radius }; + } + break; + + case 'LWPOLYLINE': + if (d.points?.length) { + const hasBulge = d.bulges?.some(b => Math.abs(b) >= 1e-6); + const closed = !!(d.closed || (d.flags & 1)); + const nSeg = closed ? d.points.length : d.points.length - 1; + if (this._polyHasWidth(e, nSeg, 1)) { + this._widePolyMesh(d.points, d.bulges, closed, e, color, 1, expand); + } else if (complexLt) { + const pts = this._samplePolylinePoints(d.points, d.bulges, closed, d.elevation || 0); + this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + pts.forEach(p => expand(p.x, p.y, p.z)); + } else if (hasBulge) { + this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand); + } else { + this._polylineSegs(d.points, closed, d.elevation||0, color, pushSeg); + d.points.forEach(p => expand(p.x, p.y, d.elevation||0)); + } + // Note: uncovered-poly fill is only applied in _insertEntities (block + // glyphs). Model-space polylines can be huge parcels — never fill those. + meta.bounds = { type:'point', cx:d.points[0].x, cy:d.points[0].y }; + } + break; + + case 'POLYLINE': + if (complexLt && (d.vertices || d.points)) { + const closed = !!(d.closed || (d.flags & 1)); + const pts = d.vertices || d.points; + this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + } else { + this._polylineSegs(d.vertices||d.points, d.closed||(d.flags&1), 0, color, pushSeg); + } + break; + + case 'POLYLINE_2D': { + // Old-style 2D polyline: vertices are separate VERTEX_2D entities + // owned by this polyline (point/bulge at top level, not under .data). + const kids = (entsByOwner.get(e.handle?.value?.toString(16)) ?? []) + .filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point); + if (kids.length >= 2) { + const pts = kids.map(v => v.point); + const bulges = kids.map(v => v.bulge || 0); + const closed = (d.flags & 1) === 1; + const elev = d.elevation || 0; + if (complexLt) { + const sampled = this._samplePolylinePoints(pts, bulges, closed, elev); + this._drawComplexPath(sampled, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + sampled.forEach(p => expand(p.x, p.y, p.z)); + } else if (bulges.some(b => Math.abs(b) >= 1e-6)) { + this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand); + } else { + this._polylineSegs(pts, closed, elev, color, pushSeg); + pts.forEach(p => expand(p.x, p.y, elev)); + } + meta.bounds = { type:'point', cx: pts[0].x, cy: pts[0].y }; + } + break; + } + + case 'POINT': { + const p = d.position || d; + if (typeof p.x === 'number') { + const s = 1; + pushSeg(p.x-s, p.y, p.x+s, p.y, p.z||0, color); + pushSeg(p.x, p.y-s, p.x, p.y+s, p.z||0, color); + meta.bounds = { type:'point', cx:p.x, cy:p.y }; + } + break; + } + + case 'ELLIPSE': + if (d.center) { + if (complexLt) { + const pts = this._sampleEllipsePoints(d); + this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + } else { + this._ellipseSegs(d, color, pushSeg); + } + meta.bounds = { type:'point', cx:d.center.x, cy:d.center.y }; + } + break; + + case 'SPLINE': { + const cps = d.controlPoints ?? d.fitPoints; + if (cps?.length >= 2) { + const pts = cps.length >= 3 + ? (() => { + const segs = cps.length * 10; + const result = []; + for (let i = 0; i <= segs; i++) { + const t = i / segs; + result.push(this._catmullRom(cps, t)); + } + return result; + })() + : cps; + if (complexLt) { + this._drawComplexPath(pts, !!d.closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + } else { + this._polylineSegs(pts, false, 0, color, pushSeg); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + } + meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y }; + } + break; + } + + case 'ATTRIB': + case 'ATTDEF': { + if ((d.flags ?? 0) & 1) break; // invisible attribute + const raw = e.text ?? d.text ?? d.textValue ?? d.defaultValue ?? ''; + const text = stripMText(raw); + const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5; + const rotation = e.rotationAngle ?? d.rotationAngle ?? 0; + const alignH = e.horizAlignment ?? d.horizAlignment ?? 0; + const alignV = e.vertAlignment ?? d.vertAlignment ?? 0; + // When aligned (non-default H/V), the alignmentPt is the true anchor; + // insertionPt is the "first point" and differs for centered/right text. + const useAlignPt = alignH !== 0 || alignV !== 0; + const pos = useAlignPt + ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }) + : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }); + if (text) { + // Force vertical center for ATTRIB — attribute values (e.g. node tags) appear centered + pendingTexts.push({ text, pos, height, rotation, color, alignH, alignV: 2 }); + meta.bounds = { type:'point', cx:pos.x, cy:pos.y }; + this._addTextPick(text, pos, height, alignH, 2); + expand(pos.x, pos.y, 0); + } + break; + } + + case 'XLINE': + case 'RAY': { + // Infinite/semi-infinite lines — render as very long line segment + const bp = d.basePoint ?? d.point ?? d.startPoint; + const dir = d.direction ?? d.unitDir ?? d.vector; + if (bp && dir) { + const len = 1e6; + const ex = bp.x + dir.x * len, ey = bp.y + dir.y * len; + if (type === 'XLINE') { + pushSeg(bp.x - dir.x * len, bp.y - dir.y * len, ex, ey, 0, color); + } else { + pushSeg(bp.x, bp.y, ex, ey, 0, color); + } + meta.bounds = { type:'point', cx:bp.x, cy:bp.y }; + expand(bp.x, bp.y); + } + break; + } + + case 'SOLID': { + const corners = [d.corner1||d.pt1||d.point1, d.corner2||d.pt2||d.point2, + d.corner3||d.pt3||d.point3, d.corner4||d.pt4||d.point4].filter(Boolean); + if (corners.length >= 3) { + this._solidMesh(corners, color); + meta.bounds = { type:'point', cx:corners[0].x, cy:corners[0].y }; + } + 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 ────────────────────────────────────────────────────────── + case 'TEXT': + case 'MTEXT': { + const raw = e.text ?? d.text ?? ''; + const text = stripMText(raw); + const height = e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5; + let rotation = 0, alignH = 0, alignV = 0, pos; + if (type === 'TEXT') { + rotation = e.rotationAngle ?? d.rotationAngle ?? 0; + alignH = e.horizAlignment ?? d.horizAlignment ?? 0; + alignV = e.vertAlignment ?? d.vertAlignment ?? 0; + // DXF group 72 = 4 ("Middle") centers the text both horizontally AND + // vertically on the alignment point — group 73 (vertical) is ignored. + // Without this, vA=0 (baseline) placed such text half a line too high + // (titleblock labels 노선이정/설계사/… floated up). + if (alignH === 4) alignV = 2; + // DWG: when alignment is non-default, alignmentPt is the actual anchor + // (insertionPt is the "first point" which may differ from anchor) + const useAlignPt = alignH !== 0 || alignV !== 0; + pos = useAlignPt + ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? { x:0, y:0 }) + : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }); + } else { + const xd = e.xAxisDir ?? d.xAxisDir; + if (xd) rotation = Math.atan2(xd.y, xd.x); + // MTEXT: attachmentPoint 1-3=top, 4-6=mid, 7-9=bottom + // (parsers emit the field as `attachment`; keep both names) + const ap = e.attachment ?? d.attachment ?? e.attachmentPoint ?? d.attachmentPoint ?? 5; + alignH = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1); + alignV = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2); + pos = e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x:0, y:0 }; + } + if (text) { + const entry = { text, pos, height, rotation, color, alignH, alignV }; + if (type === 'MTEXT') { + entry.linespacingFactor = e.linespacingFactor ?? d.linespacingFactor ?? 1; + entry.rectWidth = e.rectWidth ?? d.rectWidth ?? 0; + entry.rectHeight = e.rectHeight ?? d.rectHeight ?? 0; + // Background mask (DXF 90/45/63): bit0=use fill color, bit1=window color, bit4=frame + entry.bgFillFlags = e.bgFillFlags ?? d.bgFillFlags ?? 0; + entry.bgScale = e.bgScale ?? d.bgScale ?? 1.5; + entry.bgColorIndex = e.bgColorIndex ?? d.bgColorIndex; + entry.bgColorRgb = e.bgColorRgb ?? d.bgColorRgb; + } + pendingTexts.push(entry); + meta.bounds = { type:'point', cx:pos.x, cy:pos.y }; + this._addTextPick(text, pos, height, alignH, alignV); + expand(pos.x, pos.y, 0); + } + break; + } + + // ── INSERT (block reference) ────────────────────────────────────── + case 'INSERT': { + const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint; + const sx = d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1; + const sy = d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1; + const rot = d.rotation ?? e.rotation ?? 0; + const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; + + if (bhVal != null) { + // Block definition entities sit in the flat entities array, + // identified by ownerHandle.value === block header handle + const bHandleHex = bhVal.toString(16); + const bEnts = entsByOwner.get(bHandleHex) ?? []; + if (bEnts.length > 0) { + const bh = result?.tables?.blocks?.find(b => { + const bh2 = typeof b.handle === 'object' ? b.handle?.value : b.handle; + return bh2 === bhVal; + }); + this._insertEntities( + bEnts, + { insertionPoint: ip ?? {x:0,y:0,z:0}, xScale:sx, yScale:sy, rotation:rot, basePoint: bh?.basePoint }, + color, pushSeg, pendingTexts, + { entsByOwner, blockBase: blockBaseByHex } + ); + } + } + if (ip) { meta.bounds = { type:'point', cx:ip.x, cy:ip.y }; expand(ip.x, ip.y, 0); } + break; + } + + // ── HATCH ───────────────────────────────────────────────────────── + case 'HATCH': { + const paths = d.paths ?? e.paths; + const solidFill = d.solidFill ?? e.solidFill ?? false; + if (!paths?.length) break; + + let firstPt = null; + for (const path of paths) { + if (!path.points?.length || path.points.length < 2) continue; + if (!firstPt) firstPt = path.points[0]; + const hasBulge = path.bulges?.some(b => Math.abs(b) >= 1e-6); + if (hasBulge) { + this._bulgePolySegs(path.points, path.bulges, true, color, pushSeg, expand); + } else { + this._polylineSegs(path.points, true, 0, color, pushSeg); + path.points.forEach(p => expand(p.x, p.y, 0)); + } + } + if (solidFill) this._hatchFill(paths, color); + if (firstPt) meta.bounds = { type:'point', cx:firstPt.x, cy:firstPt.y }; + break; + } + + // ── DIMENSION (all subtypes) ─────────────────────────────────────── + case 'DIMENSION': + case 'DIMENSION_LINEAR': + case 'DIMENSION_ALIGNED': + case 'DIMENSION_RADIUS': + case 'DIMENSION_DIAMETER': + case 'DIMENSION_ANG_3PT': + case 'DIMENSION_ANG_2LN': + case 'DIMENSION_ORDINATE': { + const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; + let dimEnts = []; + if (bhVal != null) { + const hex = bhVal.toString(16); + dimEnts = entsByOwner.get(hex) ?? []; + } + + if (dimEnts.length > 0) { + // Render pre-built dimension block geometry + for (const child of dimEnts) { + const ct = (child.type || child.typeName || '').toUpperCase(); + const cd = child.data || child; + const cc = this._entityColor(child) || color; + if (ct === 'LINE' && cd.start && cd.end) { + pushSeg(cd.start.x, cd.start.y, cd.end.x, cd.end.y, 0, cc); + expand(cd.start.x, cd.start.y); expand(cd.end.x, cd.end.y); + } else if (ct === 'SOLID') { + const crs = [cd.corner1||cd.pt1, cd.corner2||cd.pt2, + cd.corner3||cd.pt3, cd.corner4||cd.pt4].filter(Boolean); + if (crs.length >= 3) { this._solidMesh(crs, cc); crs.forEach(c => expand(c.x, c.y)); } + } else if (ct === 'ARC' && cd.center && cd.radius != null) { + this._arcSegs(cd.center, cd.radius, cd.startAngle??0, cd.endAngle??Math.PI*2, 0, cc, pushSeg); + } else if ((ct === 'TEXT' || ct === 'MTEXT')) { + const raw = child.text ?? cd.text ?? cd.textValue ?? ''; + const text = stripMText(raw); + const pos = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0}; + const ht = cd.textHeight ?? cd.height ?? child.textHeight ?? 2.5; + if (text) { pendingTexts.push({ text, pos, height:ht, rotation: cd.rotationAngle??0, color:cc, alignH:1, alignV:2 }); expand(pos.x, pos.y); } + } else if (ct === 'INSERT') { + // Arrowhead block INSERT within the dimension block: render the real + // arrow geometry; fall back to a named default for geometry-less + // system blocks (_ClosedFilled / _Dot / _Oblique / _Open / …). + const cip = child.insertionPt ?? cd.insertionPt ?? cd.insertionPoint ?? {x:0,y:0}; + const crot = cd.rotation ?? child.rotation ?? 0; + const csx = cd.scaleX ?? cd.scale?.x ?? child.scale?.x ?? 1; + const csy = cd.scaleY ?? cd.scale?.y ?? child.scale?.y ?? csx; + const abh = child.blockHeaderHandle?.value ?? cd.blockHeaderHandle?.value; + const aHex = abh?.toString(16); + const aname = blockNameByHex.get(aHex) ?? ''; + const arrowEnts = aHex ? (entsByOwner.get(aHex) ?? []) : []; + if (aname.includes('dot')) { + // Dot arrowhead → filled disc (radius from the block's geometry × scale) + this._dotMesh(cip, this._arrowDotRadius(arrowEnts) * Math.abs(csx) || Math.abs(csx) * 0.5, cc); + } else if (arrowEnts.length) { + this._insertEntities( + arrowEnts, + { insertionPoint: cip, xScale: csx, yScale: csy, rotation: crot, basePoint: blockBaseByHex.get(aHex) }, + cc, pushSeg, pendingTexts, + { entsByOwner, blockBase: blockBaseByHex } + ); + } else { + this._defaultArrow(aname, cip, crot, Math.abs(csx) || 1, cc, pushSeg); + } + expand(cip.x, cip.y); + } + } + const fl = dimEnts.find(c => (c.type||c.typeName||'').toUpperCase() === 'LINE'); + const fd = fl?.data || fl; + if (fd?.start) meta.bounds = { type:'point', cx:fd.start.x, cy:fd.start.y }; + } else { + // Fallback: reconstruct from entity properties + this._renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand); + const pt10 = e.pt10 ?? d.pt10; + if (pt10) { meta.bounds = { type:'point', cx:pt10.x, cy:pt10.y }; expand(pt10.x, pt10.y); } + } + break; + } + + case 'LEADER': { + const pts = d.points ?? e.points; + if (pts?.length >= 2) { + this._polylineSegs(pts, false, 0, color, pushSeg); + pts.forEach(p => expand(p.x, p.y, p.z || 0)); + if (d.arrowheadOn ?? e.arrowheadOn ?? true) { + const p0 = pts[0], p1 = pts[1]; + const rot = Math.atan2(p1.y - p0.y, p1.x - p0.x); + const arrowSz = d.arrowSize ?? e.arrowSize ?? 2.5; + this._arrowMesh(p0, rot, arrowSz, color); + } + meta.bounds = { type:'point', cx:pts[0].x, cy:pts[0].y }; + } + break; + } + + // ── OLE2FRAME (Embedded OLE Object) ────────────────────────────── + case 'OLE2FRAME': { + const ul = e.upperLeft ?? d.upperLeft ?? { x: 0, y: 0, z: 0 }; + const lr = e.lowerRight ?? d.lowerRight ?? { x: 0, y: 0, z: 0 }; + const appName = e.sourceApp ?? d.sourceApp ?? e.source_application ?? ''; + const imageDataUrl = e.imageDataUrl ?? d.imageDataUrl; + + // Use actual entity bounds from the WASM parser (upper_left / lower_right) + let minX = Math.min(ul.x, lr.x); + let maxX = Math.max(ul.x, lr.x); + let minY = Math.min(ul.y, lr.y); + let maxY = Math.max(ul.y, lr.y); + let width = maxX - minX; + let height = maxY - minY; + const z = ul.z || 0; + + // Always draw border rectangle + pushSeg(minX, minY, maxX, minY, z, color); + pushSeg(maxX, minY, maxX, maxY, z, color); + pushSeg(maxX, maxY, minX, maxY, z, color); + pushSeg(minX, maxY, minX, minY, z, color); + + // If embedded image texture (BMP/DIB) is present, render it as a 3D Plane Mesh! + if (imageDataUrl) { + try { + // Pure-JS BMP decoder — works in ALL browsers (Brave/Firefox don't support data:image/bmp in ) + const decodeBmpDataUrl = (dataUrl) => { + const b64 = dataUrl.split(',')[1]; + const bin = atob(b64); + const buf = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); + + const dv = new DataView(buf.buffer); + // BITMAPFILEHEADER (14 bytes) + const sig = String.fromCharCode(buf[0], buf[1]); + if (sig !== 'BM') throw new Error('Not a BMP file'); + const pixelOffset = dv.getUint32(10, true); + // BITMAPINFOHEADER (40 bytes, starts at offset 14) + const bmpWidth = dv.getInt32(18, true); + const bmpHeight = dv.getInt32(22, true); // positive = bottom-up + const bitCount = dv.getUint16(28, true); + const absH = Math.abs(bmpHeight); + const flipY = bmpHeight > 0; // bottom-up storage + + const cvs = document.createElement('canvas'); + cvs.width = bmpWidth; + cvs.height = absH; + const ctx2 = cvs.getContext('2d'); + const imgData = ctx2.createImageData(bmpWidth, absH); + const px = imgData.data; + + if (bitCount === 32) { + const bpr = bmpWidth * 4; + for (let y = 0; y < absH; y++) { + const srcRow = flipY ? (absH - 1 - y) : y; + const srcOff = pixelOffset + srcRow * bpr; + const dstOff = y * bmpWidth * 4; + for (let x = 0; x < bmpWidth; x++) { + const s = srcOff + x * 4; + const d = dstOff + x * 4; + px[d] = buf[s + 2]; // R (BMP stores BGR) + px[d + 1] = buf[s + 1]; // G + px[d + 2] = buf[s]; // B + px[d + 3] = 255; // A — GDI 32bpp has zeroed alpha padding + } + } + } else if (bitCount === 24) { + const stride = Math.ceil(bmpWidth * 3 / 4) * 4; // row padded to 4 bytes + for (let y = 0; y < absH; y++) { + const srcRow = flipY ? (absH - 1 - y) : y; + const srcOff = pixelOffset + srcRow * stride; + const dstOff = y * bmpWidth * 4; + for (let x = 0; x < bmpWidth; x++) { + const s = srcOff + x * 3; + const d = dstOff + x * 4; + px[d] = buf[s + 2]; + px[d + 1] = buf[s + 1]; + px[d + 2] = buf[s]; + px[d + 3] = 255; + } + } + } + ctx2.putImageData(imgData, 0, 0); + return cvs; + }; + + const bmpCanvas = decodeBmpDataUrl(imageDataUrl); + const texture = new THREE.CanvasTexture(bmpCanvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + const planeGeom = new THREE.PlaneGeometry(width, Math.abs(height)); + const planeMat = new THREE.MeshBasicMaterial({ + map: texture, + side: THREE.DoubleSide, + transparent: false, + }); + const mesh = new THREE.Mesh(planeGeom, planeMat); + mesh.position.set(minX + width / 2, minY + Math.abs(height) / 2, z + 10); + this._addObj(mesh); + this._renderer.render(this._scene, this._camera); + } catch (err) { + console.error('Failed rendering CanvasTexture for OLE image:', err); + } + } else { + // Draw diagonal cross + overlay label fallback + pushSeg(minX, minY, maxX, maxY, z, color); + pushSeg(minX, maxY, maxX, minY, z, color); + + const hSpan = Math.abs(height); + const lblHeight = Math.max(Math.min(hSpan * 0.05, 300), 40); + const labelText = appName ? `[OLE: ${appName}]` : '[OLE2Frame]'; + const labelPos = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z }; + pendingTexts.push({ + text: labelText, + pos: labelPos, + height: lblHeight, + rotation: 0, + color, + alignH: 1, + alignV: 2, + }); + } + + // Expand scene bounds & set meta bounds for picking/fitting + expand(minX, minY, z); + expand(maxX, maxY, z); + meta.bounds = { type: 'point', cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 }; + break; + } + + // Paper-space VIEWPORT: drawn later as a model-space projection window. + case 'VIEWPORT': + break; + + default: break; + } + } catch { /* skip malformed entity */ } + 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 + // layer now so _drawTexts can batch per layer. + for (let ti = textStart; ti < pendingTexts.length; ti++) { + if (pendingTexts[ti].layer === undefined) pendingTexts[ti].layer = this._curLayer; + } + } + + // ── Render deferred text (underlay / paper stack) ───────────────────── + // Viewport-sourced labels were pushed first (underlay:true); paper labels + // follow. Draw underlay first so paper text meshes land later = on top. + if (pendingTexts.length) { + // Historical floor: diag×0.0002 made paper-sheet labels readable at + // zoom-extents. On survey Model Space (diag ~1e6 m) that floor is + // ~200–300 drawing units while native TEXT height is ~0.3–3.5 → every + // label becomes a giant billboard over the contours (MOT/FLOW etc.). + // Cap the floor at the median native height so we never upscale past CAD. + const sz = box.isEmpty() ? new THREE.Vector3(1, 1, 0) : box.getSize(new THREE.Vector3()); + const diagSize = Math.hypot(Math.max(sz.x, 1), Math.max(sz.y, 1)); + const heights = []; + for (const t of pendingTexts) { + const h = t.height; + if (h > 0 && Number.isFinite(h)) heights.push(h); + } + heights.sort((a, b) => a - b); + const medianH = heights.length ? heights[heights.length >> 1] : 2.5; + const minTextH = Math.min(diagSize * 0.0002, medianH); + // Cap sprite count to avoid GPU/heap OOM on large drawings. With the + // per-(text,color) texture cache in _textSprite, memory scales with + // UNIQUE strings, so the cap is a sprite-object guard, not a raster one + // (3000 previously culled half the texts on table sheets, e.g. BLOCK4). + const MAX_TEXTS = 20000; + const underlay = pendingTexts.filter((t) => t.underlay); + const overlay = pendingTexts.filter((t) => !t.underlay); + const ordered = underlay.concat(overlay); + const renderTexts = ordered.length > MAX_TEXTS + ? (console.warn(`텍스트 ${ordered.length}개 → ${MAX_TEXTS}개로 제한`), + // Prefer keeping paper (overlay) labels when capping + overlay.concat(underlay).sort((a, b) => b.height - a.height).slice(0, MAX_TEXTS)) + : ordered; + void this._drawTexts(renderTexts, minTextH); + } + + this._layerNames = layerNames; + 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) { + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(lineVerts, 3)); + const colorAttr = new THREE.Float32BufferAttribute(lineColors, 3); + geom.setAttribute('color', colorAttr); + const lmesh = new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ vertexColors: true })); + this._registerIndexed(lmesh, lineSegLayer); + this._group.add(lmesh); + this._lineColorAttr = colorAttr; + this._origColors = Float32Array.from(colorAttr.array); + this._hairRt = { attr: colorAttr, orig: this._origColors }; + } + + // One mesh per remaining bucket. + // · lineweight > 0 → LineSegments2: screen-space quads, so the width stays + // constant in pixels while zooming (AutoCAD LWDISPLAY semantics). + // · otherwise → LineSegments + LineDashedMaterial for the dash + // linetypes (HIDDEN / CENTER / …). computeLineDistances() is REQUIRED + // for the pattern to appear; on LineSegments each 2-vertex pair dashes + // independently from its own start (correct for CAD segments). + for (const bk of segBuckets.values()) { + if (!bk.verts.length || bk.merged) continue; + if (bk.fat) { + const fgeom = new LineSegmentsGeometry(); + fgeom.setPositions(bk.verts); + fgeom.setColors(bk.colors); + const fmat = new LineMaterial({ + 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.lwMm); + 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). + this._setCoordFrame(result); + + // Visible-content AABB for Fit — trim extreme outliers (matches CAD zoom extents better). + const fitBox = box.isEmpty() ? null : (this._trimmedContentBox(fitSamples, box) || box); + this._fullContentBox = fitBox ? fitBox.clone() : null; + this._contentBox = fitBox ? fitBox.clone() : null; + // The trimmed fit box is flattened to z=0 (percentile trim is X/Y only), so + // keep the drawing's real elevation range for the 2.5D fit. + this._contentZ = box.isEmpty() ? null : { min: box.min.z, max: box.max.z }; + // Geometry for every layer is in the scene now — switch off the hidden ones + // (also refreshes _contentBox to the visible subset). + this._applyLayerVisibility(); + const useBox = this._contentBox; + if (!opts.keepView && useBox && !useBox.isEmpty()) this._fit(useBox); + } finally { + this._isLoading = false; + } + } + + // ── UI integration ───────────────────────────────────────────────────────── + + /** Swap canvas background for theme. dark=true → near-black (Model), false → white (Layout). */ + setTheme(dark) { + const isDarkBool = !!dark; + const changed = (this._isDark !== isDarkBool); + this._isDark = isDarkBool; + this._scene.background = new THREE.Color(this._isDark ? 0x0a0b0d : 0xffffff); + if (this._gridVisible) this._rebuildGrid(); + + if (changed && this._lastResult && !this._isLoading) { + this.load(this._lastResult, { keepView: true, keepTheme: true, spaceHandle: this._activeSpaceHex }); + } + } + + setGrid(visible) { + this._gridVisible = visible; + if (!visible) { + if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } + } else { + this._rebuildGrid(); + } + } + + _rebuildGrid() { + if (this._gridMesh) { this._scene.remove(this._gridMesh); this._gridMesh.geometry.dispose(); this._gridMesh.material.dispose(); this._gridMesh = null; } + const zoom = this._camera.zoom || 1; + // Centre on the orbit target, not the camera: in 2.5D the camera sits off to + // the side of what it is looking at. + const cx = this._controls?.target?.x ?? this._camera.position.x; + const cy = this._controls?.target?.y ?? this._camera.position.y; + const hw = (this._camera.right - this._camera.left) / zoom / 2; + const hh = (this._camera.top - this._camera.bottom) / zoom / 2; + const ext = Math.max(hw, hh) * 3; + const spacing = this._niceSpacing(Math.max(hw, hh) * 2 / 20); + const [minX, maxX, minY, maxY] = [cx - ext, cx + ext, cy - ext, cy + ext]; + const isDark = this._scene.background.r < 0.5; + const color = isDark ? 0x2b2b2b : 0xbbbbbb; + const verts = []; + for (let x = Math.ceil(minX / spacing) * spacing; x <= maxX; x += spacing) verts.push(x, minY, -0.5, x, maxY, -0.5); + for (let y = Math.ceil(minY / spacing) * spacing; y <= maxY; y += spacing) verts.push(minX, y, -0.5, maxX, y, -0.5); + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + this._gridMesh = new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color })); + this._scene.add(this._gridMesh); + } + + _niceSpacing(raw) { + if (raw <= 0) return 1; + const exp = Math.pow(10, Math.floor(Math.log10(raw))); + const norm = raw / exp; + return (norm < 2 ? 1 : norm < 5 ? 2 : 5) * exp; + } + + /** Zoom % relative to fit (ortho camera zoom). */ + getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); } + + /** Subscribe to pan/zoom changes (for live zoom readout). */ + onViewChange(cb) { this._controls?.addEventListener('change', cb); } + + /** + * Hide entities on the given layer names (Set). + * O(scene objects + segments) — no re-parse, no re-tessellation, no reload. + * Falls back to a full load() only if the scene predates the layer index. + */ + setHiddenLayers(nameSet) { + // Copy: the Layers panel keeps mutating the Set it handed us. + this._hiddenLayers = new Set(nameSet || []); + if (this._applyLayerVisibility()) return; + if (this._lastResult) this.load(this._lastResult, { keepView: true }); + } + + /** Layer name of an entity ('0' when the file gives none). */ + _layerNameOf(e) { + const lh = e.layerHandle?.value ?? e.layerHandle; + return (lh != null && this._layerNameByHandle?.get(String(lh))) ?? e.layer ?? e.layerName ?? '0'; + } + + /** + * Give a merged LineSegments an index buffer so layers can be masked by + * rewriting indices (positions/colors/lineDistances stay put, which keeps + * draw order and the selection-highlight offsets in meta.colStart valid). + */ + _registerIndexed(mesh, segLayerArr) { + const vcount = mesh.geometry.attributes.position.count; + if (!vcount) return; + const IdxArr = vcount > 65535 ? Uint32Array : Uint16Array; + const index = new IdxArr(vcount); + for (let i = 0; i < vcount; i++) index[i] = i; + const attr = new THREE.BufferAttribute(index, 1); + attr.setUsage(THREE.DynamicDrawUsage); + mesh.geometry.setIndex(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, lwMm = 0) { + 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, lwMm, + 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; } + + /** + * Display scale in screen pixels per mm of lineweight — AutoCAD's + * "Adjust Display Scale" slider. Default 20 (1 px per 0.05 mm). + * + * Applied to the live materials, not by rebuilding: 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. Retuning is + * therefore instant even on a 37 MB drawing. + * + * @param {number|null} pxPerMm null restores the default. + */ + setLineweightScale(pxPerMm) { + this._lwScale = (pxPerMm > 0) ? pxPerMm : null; + const scale = this.getLineweightScale(); + for (const buf of this._fatBufs) { + if (!(buf.lwMm > 0)) continue; + buf.mesh.material.linewidth = Math.min(LW_MAX_PX, Math.round(buf.lwMm * scale * 10) / 10); + } + } + + /** Current lineweight display scale (px per mm). */ + getLineweightScale() { return this._lwScale || LW_PX_PER_MM; } + + /** Lineweights present in the current drawing, in mm (ascending). */ + getLineweightClasses() { + return [...new Set(this._fatBufs.map((b) => b.lwMm).filter((m) => m > 0))].sort((a, b) => a - b); + } + + /** Track an entity-owned object so layer toggles can flip its visibility. */ + _addObj(obj, layer) { + // Inside a paper-space VIEWPORT this mesh is still in model coordinates. + // Drop it when it maps outside the viewport frame, exactly as _clipSegToRect + // drops line work. + if (this._fillXf && !this._bakeFillXf(obj)) { + obj.geometry?.dispose(); + if (obj.material) { obj.material.map?.dispose(); obj.material.dispose(); } + return; + } + const key = layer ?? this._curLayer; + if (key != null) { + obj.userData.cadLayer = key; + let arr = this._layerObjs.get(key); + if (!arr) this._layerObjs.set(key, arr = []); + arr.push(obj); + if (this._hiddenLayers.has(key)) obj.visible = false; + } + this._group.add(obj); + } + + /** + * Apply _hiddenLayers to the already-built scene. + * @returns {boolean} false when there is nothing built yet (caller reloads). + */ + _applyLayerVisibility() { + if (!this._layerNames && !this._layerObjs.size) return false; + const hidden = this._hiddenLayers; + + // 1) Whole objects (hatch fills, SOLID quads, arrows, images, text meshes). + for (const [name, objs] of this._layerObjs) { + const vis = !hidden.has(name); + for (const o of objs) o.visible = vis; + } + + // 2) Merged line buffers → rewrite the index to skip hidden segments. + const names = this._layerNames || []; + const hiddenId = new Uint8Array(names.length); + for (let i = 0; i < names.length; i++) if (hidden.has(names[i])) hiddenId[i] = 1; + for (const buf of this._indexedBufs) { + const seg = buf.segLayer, idx = buf.index; + let w = 0; + for (let s = 0; s < seg.length; s++) { + const id = seg[s]; + if (id >= 0 && hiddenId[id]) continue; + const v = s * 2; + idx[w++] = v; idx[w++] = v + 1; + } + buf.attr.needsUpdate = true; + 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. + const metas = this._entityMeta; + const mh = new Uint8Array(metas.length); + if (hidden.size) { + for (let i = 0; i < metas.length; i++) if (hidden.has(metas[i].layer)) mh[i] = 1; + } + this._metaHidden = mh; + if (this._selMeta && hidden.has(this._selMeta.layer)) { + this._highlight(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. + this._contentBox = this._visibleContentBox(); + return true; + } + + /** AABB (outlier-trimmed) of the layers currently switched on. */ + _visibleContentBox() { + const hidden = this._hiddenLayers; + if (!hidden.size) return this._fullContentBox ? this._fullContentBox.clone() : null; + const box = new THREE.Box3(); + const samples = []; + for (const [name, b] of this._layerBoxes) { + if (hidden.has(name) || b.isEmpty()) continue; + box.union(b); + const s = this._layerSamples.get(name); + if (s) for (let i = 0; i < s.length; i++) samples.push(s[i]); + } + if (box.isEmpty()) return null; + return (this._trimmedContentBox(samples, box) || box).clone(); + } + + /** + * Model / Paper (Layout) spaces present in the last loaded result. + * @returns {import('./cadSpaces').CadSpace[]} + */ + getSpaces() { + return listCadSpaces(this._lastResult); + } + + /** Currently active space handle hex, or null if unfiltered. */ + getActiveSpace() { + return this._activeSpaceHex; + } + + /** + * Switch Model ↔ Layout (paper) space and re-render. + * @param {string|number|null} spaceHandle hex string or numeric handle; null clears filter + * @param {{ keepView?: boolean }} [opts] + */ + setSpace(spaceHandle, opts = {}) { + if (spaceHandle == null || spaceHandle === '') { + this._activeSpaceHex = null; + } else if (typeof spaceHandle === 'number') { + this._activeSpaceHex = spaceHandle.toString(16); + } else { + this._activeSpaceHex = String(spaceHandle).toLowerCase(); + } + if (this._lastResult) { + this.load(this._lastResult, { + keepView: !!opts.keepView, + spaceHandle: this._activeSpaceHex, + }); + // load() refreshes coord frame for Model UCS vs paper identity + } + } + + /** + * True if this VIEWPORT is a window into model space (not the overall paper VP). + * R2000 often stores id=0 for all VPs, so we detect the overall paper viewport by + * 1:1 scale + viewTarget≈0 + viewCenter≈paper center. + */ + _isModelViewport(vp) { + if (!vp) return false; + if (vp.status && vp.status.isOn === false) return false; + const id = vp.id ?? 0; + // Classic DXF: id 1 = overall paper-space viewport (not a model window). + if (id === 1) return false; + const H = Number(vp.height) || 0; + const vH = Number(vp.viewHeight) || 0; + if (H <= 0 || vH <= 0) return false; + const scale = H / vH; + const c = vp.center || {}; + const vc = vp.viewCenter || {}; + const vt = vp.viewTarget || {}; + const targetNear0 = Math.hypot(vt.x || 0, vt.y || 0) < 1e-3; + const viewCtrNearPaper = Math.hypot((vc.x || 0) - (c.x || 0), (vc.y || 0) - (c.y || 0)) < Math.max(H, 1) * 0.05; + // Overall paper VP: identity mapping of the sheet onto itself. + if (Math.abs(scale - 1) < 0.03 && targetNear0 && viewCtrNearPaper) return false; + return true; + } + + /** + * Model WCS (x,y) → paper-space point for a VIEWPORT (orthographic / top). + * DCS = R(+twist) * (model - viewTarget) [matches acadrust/ODA view-center convention] + * paper = center + scale * (DCS - viewCenter) + * scale = paperHeight / viewHeight + */ + _viewportModelToPaper(vp) { + const c = vp.center || { x: 0, y: 0 }; + const vc = vp.viewCenter || { x: 0, y: 0 }; + const vt = vp.viewTarget || { x: 0, y: 0 }; + const H = Number(vp.height) || 1; + const vH = (Number(vp.viewHeight) > 1e-12) ? Number(vp.viewHeight) : H; + const scale = H / vH; + const twist = Number(vp.twistAngle) || 0; + const cos = Math.cos(twist); + const sin = Math.sin(twist); + const fn = (mx, my) => { + const dx = mx - (vt.x || 0); + const dy = my - (vt.y || 0); + // R(+twist) · [dx, dy] + const rx = cos * dx - sin * dy; + const ry = sin * dx + cos * dy; + return { + x: (c.x || 0) + (rx - (vc.x || 0)) * scale, + y: (c.y || 0) + (ry - (vc.y || 0)) * scale, + }; + }; + fn.scale = scale; + fn.twist = twist; + return fn; + } + + _viewportPaperClip(vp) { + const c = vp.center || { x: 0, y: 0 }; + const hw = (Number(vp.width) || 0) / 2; + const hh = (Number(vp.height) || 0) / 2; + // Slight expand to avoid hairline gaps at edges + const pad = Math.max(hw, hh) * 1e-6; + return { + minX: (c.x || 0) - hw - pad, + maxX: (c.x || 0) + hw + pad, + minY: (c.y || 0) - hh - pad, + maxY: (c.y || 0) + hh + pad, + }; + } + + /** Cohen–Sutherland clip; calls out(x1,y1,x2,y2) for the visible segment (if any). */ + _clipSegToRect(x1, y1, x2, y2, rect, out) { + const INSIDE = 0, LEFT = 1, RIGHT = 2, BOTTOM = 4, TOP = 8; + const code = (x, y) => { + let c = INSIDE; + if (x < rect.minX) c |= LEFT; + else if (x > rect.maxX) c |= RIGHT; + if (y < rect.minY) c |= BOTTOM; + else if (y > rect.maxY) c |= TOP; + return c; + }; + let c1 = code(x1, y1), c2 = code(x2, y2); + for (;;) { + if (!(c1 | c2)) { out(x1, y1, x2, y2); return; } + if (c1 & c2) return; + const c = c1 || c2; + let x = 0, y = 0; + if (c & TOP) { + x = x1 + (x2 - x1) * (rect.maxY - y1) / (y2 - y1 || 1e-30); + y = rect.maxY; + } else if (c & BOTTOM) { + x = x1 + (x2 - x1) * (rect.minY - y1) / (y2 - y1 || 1e-30); + y = rect.minY; + } else if (c & RIGHT) { + y = y1 + (y2 - y1) * (rect.maxX - x1) / (x2 - x1 || 1e-30); + x = rect.maxX; + } else { + y = y1 + (y2 - y1) * (rect.minX - x1) / (x2 - x1 || 1e-30); + x = rect.minX; + } + if (c === c1) { x1 = x; y1 = y; c1 = code(x1, y1); } + else { x2 = x; y2 = y; c2 = code(x2, y2); } + } + } + + /** + * Bake the active viewport transform into a filled mesh and report whether it + * survives the viewport frame. + * + * _solidMesh / _hatchFill / _arrowMesh / _dotMesh build their own THREE.Mesh + * and never touch pushSeg, so the model→paper mapping has to happen here or + * not at all. Left unmapped they sit at raw survey coordinates: off the sheet, + * and dragging the content box out to the drawing's WCS origin — a sheet 18 + * units wide framed inside a 480,000-unit box renders as a blank screen. + * + * The mesh's own transform is folded in first (arrowheads carry their position + * and rotation on the object, not in the buffer), then reset, so the paper + * coordinates end up in the geometry where the fit box can see them. + * @returns {boolean} false when the mesh lies wholly outside the viewport. + */ + _bakeFillXf(obj) { + const { xf, clip, z } = this._fillXf; + const pos = obj.geometry?.attributes?.position; + if (!pos?.array) return true; + obj.updateMatrix(); + const m = obj.matrix; + const v = (this._fillTmp ||= new THREE.Vector3()); + const arr = pos.array; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let i = 0; i + 2 < arr.length; i += 3) { + v.set(arr[i], arr[i + 1], arr[i + 2]).applyMatrix4(m); + const p = xf(v.x, v.y); + arr[i] = p.x; + arr[i + 1] = p.y; + // Viewport content sits behind paper entities (see VIEWPORT_Z), but keep + // the helpers' own bias — arrowheads are lifted 0.5 above the dimension + // line they cap — scaled down so it can't cross into the paper layer. + arr[i + 2] = z + (v.z || 0) * 1e-3; + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + pos.needsUpdate = true; + obj.position.set(0, 0, 0); + obj.rotation.set(0, 0, 0); + obj.scale.set(1, 1, 1); + obj.updateMatrix(); + obj.geometry.computeBoundingSphere(); + return !(maxX < clip.minX || minX > clip.maxX || maxY < clip.minY || minY > clip.maxY); + } + + /** + * Emit model-space geometry into a paper VIEWPORT (transformed + clipped). + * Covers the entity types that dominate civil plan/profile sheets. + */ + _emitModelThroughViewport(vp, ctx) { + const { + entities, modelAllowed, blockDefHandles, entsByOwner, blockBaseByHex, + pushSeg: basePush, expand: baseExpand, pendingTexts, setCurLayer, + underlay = false, + } = ctx; + const xf = this._viewportModelToPaper(vp); + const clip = this._viewportPaperClip(vp); + const scale = xf.scale || 1; + const frozen = new Set((vp.frozenLayers || []).map((h) => String(h))); + + const pushSeg = (ax, ay, bx, by, z, color) => { + const a = xf(ax, ay); + const b = xf(bx, by); + this._clipSegToRect(a.x, a.y, b.x, b.y, clip, (x1, y1, x2, y2) => { + basePush(x1, y1, x2, y2, z || 0, color); + }); + }; + const expand = (x, y, z = 0) => { + const p = xf(x, y); + if (p.x >= clip.minX && p.x <= clip.maxX && p.y >= clip.minY && p.y <= clip.maxY) { + baseExpand(p.x, p.y, z); + } + }; + const inClip = (x, y) => x >= clip.minX && x <= clip.maxX && y >= clip.minY && y <= clip.maxY; + + // Hand the mapping to _addObj for the fills that bypass pushSeg. Cleared at + // the end of the pass (and in _clear) so paper-space entities, which are + // already in sheet coordinates, are never re-mapped. + this._fillXf = { xf, clip, z: ctx.fillZ ?? 0 }; + + // Quick reject: model bbox far from view focus (optional optimisation skip for correctness) + + for (const e of entities) { + const ownerHex = e.ownerHandle?.value?.toString(16); + if (!ownerHex || !modelAllowed.has(ownerHex)) continue; + if (blockDefHandles.has(ownerHex)) continue; + // Layer visibility is applied post-build (see _applyLayerVisibility). + setCurLayer?.(this._layerNameOf(e)); + if (frozen.size) { + const lh = e.layerHandle?.value; + if (lh != null && frozen.has(String(lh))) continue; + } + + const d = e.data || e; + const type = (e.type || e.typeName || '').toUpperCase(); + const color = this._entityColor(e); + const complexLt = this._resolveComplexLinetype(e); + const vpTextStart = pendingTexts.length; + + try { + switch (type) { + case 'LINE': + if (d.start && d.end) { + if (complexLt) { + this._drawComplexPath([d.start, d.end], false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + } else { + pushSeg(d.start.x, d.start.y, d.end.x, d.end.y, d.start.z || 0, color); + } + } + break; + case 'CIRCLE': + if (d.center && d.radius != null) { + if (complexLt) { + const pts = this._sampleArcPoints(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0); + this._drawComplexPath(pts, true, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + } else { + // pushSeg maps model endpoints → paper; keep radius in model units + this._arcSegs(d.center, d.radius, 0, Math.PI * 2, d.center.z || 0, color, pushSeg); + } + } + break; + case 'ARC': + if (d.center && d.radius != null) { + if (complexLt) { + const pts = this._sampleArcPoints(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0); + this._drawComplexPath(pts, false, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + } else { + this._arcSegs(d.center, d.radius, d.startAngle ?? 0, d.endAngle ?? Math.PI * 2, d.center.z || 0, color, pushSeg); + } + } + break; + case 'LWPOLYLINE': + if (d.points?.length) { + const hasBulge = d.bulges?.some((b) => Math.abs(b) >= 1e-6); + const closed = !!(d.closed || (d.flags & 1)); + if (complexLt) { + const pts = this._samplePolylinePoints(d.points, d.bulges, closed, d.elevation || 0); + this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + pts.forEach((p) => expand(p.x, p.y, p.z)); + } else if (hasBulge) this._bulgePolySegs(d.points, d.bulges, closed, color, pushSeg, expand); + else { + this._polylineSegs(d.points, closed, d.elevation || 0, color, pushSeg); + d.points.forEach((p) => expand(p.x, p.y, d.elevation || 0)); + } + } + break; + case 'POLYLINE': + if (complexLt && (d.vertices || d.points)) { + const closed = !!(d.closed || (d.flags & 1)); + const pts = d.vertices || d.points; + this._drawComplexPath(pts, closed, complexLt.pattern, complexLt.ltscale, color, pushSeg, pendingTexts); + pts.forEach((p) => expand(p.x, p.y, p.z || 0)); + } else { + this._polylineSegs(d.vertices || d.points, d.closed || (d.flags & 1), 0, color, pushSeg); + } + break; + case 'ELLIPSE': + if (d.center) { + // Reuse main-path ellipse helper if present; else approximate via parametric segs + if (typeof this._ellipseSegs === 'function') { + this._ellipseSegs(d, color, pushSeg); + } else { + const cx = d.center.x, cy = d.center.y; + const maj = d.smAxis || d.majorAxis || { x: d.radius || 1, y: 0 }; + const ratio = d.axisRatio ?? d.minorAxisRatio ?? 0.5; + const a0 = d.startAngle ?? d.startParameter ?? 0; + const a1 = d.endAngle ?? d.endParameter ?? Math.PI * 2; + const n = 64; + let px, py; + for (let i = 0; i <= n; i++) { + const t = a0 + (a1 - a0) * (i / n); + const cos = Math.cos(t), sin = Math.sin(t); + const x = cx + maj.x * cos - maj.y * ratio * sin; + const y = cy + maj.y * cos + maj.x * ratio * sin; + if (i > 0) pushSeg(px, py, x, y, 0, color); + px = x; py = y; + } + } + } + break; + case 'SOLID': { + const corners = [d.corner1 || d.pt1, d.corner2 || d.pt2, d.corner3 || d.pt3, d.corner4 || d.pt4].filter(Boolean); + if (corners.length >= 2) { + for (let i = 0; i < corners.length; i++) { + const a = corners[i], b = corners[(i + 1) % corners.length]; + if (a && b) pushSeg(a.x, a.y, b.x, b.y, 0, color); + } + } + 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': { + const paths = d.paths || e.paths || []; + for (const path of paths) { + const pts = path.points || path.vertices; + if (!pts?.length) continue; + const closed = path.closed !== false; + const bulges = path.bulges; + if (bulges?.some((b) => Math.abs(b) >= 1e-6)) { + this._bulgePolySegs(pts, bulges, closed, color, pushSeg, expand); + } else { + this._polylineSegs(pts, closed, 0, color, pushSeg); + pts.forEach((p) => expand(p.x, p.y)); + } + } + break; + } + case 'SPLINE': { + const pts = d.fitPoints?.length ? d.fitPoints : d.controlPoints; + if (pts?.length >= 2) this._polylineSegs(pts, !!d.closed, 0, color, pushSeg); + break; + } + case 'TEXT': + case 'MTEXT': { + const raw = e.text ?? d.text ?? ''; + const text = stripMText(raw); + if (!text) break; + const height = (e.textHeight ?? e.height ?? d.textHeight ?? d.height ?? 2.5) * scale; + // Geometry uses R(+twist); text angle in paper is modelAngle + twist. + let rotation = (e.rotationAngle ?? d.rotationAngle ?? 0) + (xf.twist || 0); + let alignH = 0, alignV = 0, pos; + if (type === 'TEXT') { + alignH = e.horizAlignment ?? d.horizAlignment ?? 0; + alignV = e.vertAlignment ?? d.vertAlignment ?? 0; + if (alignH === 4) alignV = 2; + const useAlignPt = alignH !== 0 || alignV !== 0; + pos = useAlignPt + ? (e.alignmentPt ?? d.alignmentPt ?? e.insertionPt ?? d.insertionPt ?? { x: 0, y: 0 }) + : (e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x: 0, y: 0 }); + } else { + const xd = e.xAxisDir ?? d.xAxisDir; + if (xd) rotation = Math.atan2(xd.y, xd.x) + (xf.twist || 0); + const ap = e.attachment ?? d.attachment ?? e.attachmentPoint ?? d.attachmentPoint ?? 5; + alignH = ([1, 4, 7].includes(ap) ? 0 : [3, 6, 9].includes(ap) ? 2 : 1); + alignV = ([1, 2, 3].includes(ap) ? 3 : [7, 8, 9].includes(ap) ? 1 : 2); + pos = e.insertionPt ?? d.insertionPt ?? d.insertionPoint ?? { x: 0, y: 0 }; + } + const pp = xf(pos.x, pos.y); + if (!inClip(pp.x, pp.y)) break; + pendingTexts.push({ + text, pos: pp, height, rotation, color, alignH, alignV, + underlay: !!underlay, + }); + expand(pos.x, pos.y); + break; + } + case 'INSERT': { + const ip = e.insertionPt ?? d.insertionPt ?? d.insertionPoint; + if (!ip) break; + const sx = (d.scaleX ?? d.scale?.x ?? e.scale?.x ?? 1); + const sy = (d.scaleY ?? d.scale?.y ?? e.scale?.y ?? 1); + const bhVal = e.blockHeaderHandle?.value ?? d.blockHeaderHandle?.value; + const bHex = bhVal != null ? bhVal.toString(16) : null; + const bEnts = bHex ? (entsByOwner.get(bHex) ?? []) : []; + // Transform block children by composing INSERT xf with viewport xf via pushSeg + if (bEnts.length) { + const textStart = pendingTexts.length; + this._insertEntities( + bEnts, + { + insertionPoint: ip, + xScale: sx, + yScale: sy, + rotation: d.rotation ?? e.rotation ?? 0, + basePoint: blockBaseByHex.get(bHex), + }, + color, + pushSeg, + pendingTexts, + { entsByOwner, blockBase: blockBaseByHex }, + ); + // INSERT may push model-space text; re-map into paper + underlay flag + for (let ti = textStart; ti < pendingTexts.length; ti++) { + const t = pendingTexts[ti]; + if (!t?.pos) continue; + const q = xf(t.pos.x, t.pos.y); + t.pos = q; + t.height = (t.height || 2.5) * scale; + t.rotation = (t.rotation || 0) + (xf.twist || 0); + t.underlay = !!underlay; + } + } + expand(ip.x, ip.y); + break; + } + default: + break; + } + if (complexLt && pendingTexts.length > vpTextStart) { + for (let ti = vpTextStart; ti < pendingTexts.length; ti++) { + const t = pendingTexts[ti]; + if (!t?.pos) continue; + const q = xf(t.pos.x, t.pos.y); + t.pos = q; + t.height = (t.height || 2.5) * scale; + t.rotation = (t.rotation || 0) + (xf.twist || 0); + t.underlay = !!underlay; + } + } + } catch { /* skip malformed */ } + for (let ti = vpTextStart; ti < pendingTexts.length; ti++) { + if (pendingTexts[ti].layer === undefined) pendingTexts[ti].layer = this._curLayer; + } + } + + this._fillXf = null; + + // Transform any pending texts that are still in model space (from INSERT helpers). + // Entries created above already have paper pos. Detect model-space leftovers by + // checking if pos lies far outside the paper clip *and* inside a model-ish range — + // safer: tag viewport texts. For insert-pushed texts, re-map those outside paper + // sheet bounds of the whole drawing is hard. Instead, convert texts whose height + // wasn't scaled (model heights are often large): skip — INSERT text in viewports + // is rare on these sheets. + } + + /** Entity count, block definition count — for recent-files metadata. */ + getStats() { + return { entities: this._entityMeta.length, blocks: this._blockCount ?? 0 }; + } + + /** Capture current view as a WebP Blob. Requires preserveDrawingBuffer:true (already set). */ + snapshotWebPBlob() { + return new Promise((resolve) => { + this._renderer.render(this._scene, this._camera); + this._renderer.domElement.toBlob((b) => resolve(b), 'image/webp', 0.75); + }); + } + + /** + * Layer list for the Layers panel: [{ name, colorHex, count, visible }]. + * The name/color/count part is O(entities) — cached per load so the panel can + * call this on every toggle without re-walking a 500k-entity drawing. + */ + getLayerInfo() { + const result = this._lastResult; + if (!result) return []; + let base = this._layerInfoCache; + if (!base) { + const counts = new Map(); + const allowed = this._activeSpaceHex + ? buildAllowedOwnerHexes(result.entities || [], this._activeSpaceHex) + : null; + for (const e of (result.entities || [])) { + const ownerHex = e.ownerHandle?.value?.toString(16); + if (allowed && ownerHex && !allowed.has(ownerHex)) continue; + const name = this._layerNameOf(e); + counts.set(name, (counts.get(name) || 0) + 1); + } + base = (result.tables?.layers || []).map(l => { + const name = l.name ?? l.layerName ?? '0'; + const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7); + return { + name, + colorHex: '#' + ((aciToHex(aci, this._isDark) ?? DEFAULT_COLOR).toString(16).padStart(6, '0')), + count: counts.get(name) || 0, + }; + }).filter(l => l.count > 0 || !l.name.startsWith('*')); + this._layerInfoCache = base; + } + const hidden = this._hiddenLayers || new Set(); + return base.map(l => ({ ...l, visible: !hidden.has(l.name) })); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + _resolveComplexLinetype(e) { + let ltName = e.lineType ?? e.linetype; + if (!ltName || ltName === 'ByLayer' || ltName === 'BYLAYER' || + ltName === 'ByBlock' || ltName === 'BYBLOCK') { + const lh = e.layerHandle?.value ?? e.layerHandle; + ltName = (lh != null && this._layerLtByHandle?.get(String(lh))) + || this._layerLtByName?.get(e.layer ?? e.layerName) || null; + } + if (!ltName || ltName === 'Continuous' || ltName === 'ByLayer' || ltName === 'ByBlock') return null; + + const upperKey = ltName.toUpperCase().trim(); + const pattern = this._dwgLinPatterns?.get(upperKey) || getLinPattern(ltName); + if (!pattern) return null; + + const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1); + const ltscale = (this._globalLtscale || 1) * entScale; + + return { pattern, ltscale }; + } + + _sampleArcPoints(center, radius, startAngle, endAngle, elevation = 0) { + let sweep = endAngle - startAngle; + if (sweep <= 0) sweep += Math.PI * 2; + const steps = Math.max(16, Math.ceil((sweep / (Math.PI * 2)) * ARC_SEGS)); + const pts = []; + const z = center.z || elevation || 0; + for (let i = 0; i <= steps; i++) { + const a = startAngle + (sweep * i) / steps; + pts.push({ + x: center.x + radius * Math.cos(a), + y: center.y + radius * Math.sin(a), + z, + }); + } + return pts; + } + + _samplePolylinePoints(points, bulges, closed, elevation = 0) { + if (!points || points.length < 2) return points || []; + const res = []; + const n = closed ? points.length : points.length - 1; + for (let i = 0; i < n; i++) { + const u = points[i]; + const v = points[(i + 1) % points.length]; + const b = bulges?.[i] || 0; + const z = u.z || elevation || 0; + if (Math.abs(b) < 1e-6) { + if (res.length === 0) res.push({ x: u.x, y: u.y, z }); + res.push({ x: v.x, y: v.y, z }); + } else { + const dx = v.x - u.x; + const dy = v.y - u.y; + const dist = Math.hypot(dx, dy); + if (dist > 1e-9) { + const theta = 4 * Math.atan(b); + const halfTheta = Math.abs(theta) / 2; + const R = dist / 2 / Math.sin(halfTheta); + const dCenter = R * Math.cos(halfTheta); + const mx = (u.x + v.x) / 2; + const my = (u.y + v.y) / 2; + const nx = -dy / dist; + const ny = dx / dist; + const dir = b > 0 ? 1 : -1; + const cx = mx + dir * nx * dCenter; + const cy = my + dir * ny * dCenter; + const a1 = Math.atan2(u.y - cy, u.x - cx); + const steps = Math.max(4, Math.ceil(Math.abs(theta) / (Math.PI / 16))); + for (let k = (res.length === 0 ? 0 : 1); k <= steps; k++) { + const a = a1 + (theta * k) / steps; + res.push({ + x: cx + R * Math.cos(a), + y: cy + R * Math.sin(a), + z, + }); + } + } + } + } + return res; + } + + _sampleEllipsePoints(d) { + const major = d.majorAxis ?? d.smAxis ?? { x: d.radius || 1, y: 0 }; + const center = d.center || { x: 0, y: 0 }; + const ratio = d.ratio ?? d.axisRatio ?? d.minorAxisRatio ?? 0.5; + const startParam = d.startParam ?? d.startAngle ?? 0; + const endParam = d.endParam ?? d.endAngle ?? Math.PI * 2; + const aLen = Math.hypot(major.x ?? 1, major.y ?? 0); + const bLen = aLen * ratio; + const rot = Math.atan2(major.y ?? 0, major.x ?? 1); + let sweep = endParam - startParam; + if (sweep <= 0) sweep += Math.PI * 2; + const steps = ELLIPSE_SEGS; + const pts = []; + const z = center.z || 0; + for (let i = 0; i <= steps; i++) { + const t = startParam + (sweep * i) / steps; + const ex = aLen * Math.cos(t); + const ey = bLen * Math.sin(t); + const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot); + const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot); + pts.push({ x, y, z }); + } + return pts; + } + + _drawComplexPath(points, closed, linPattern, ltscale, color, pushSeg, pendingTexts) { + if (!points || points.length < 2) return; + const pts = points.slice(); + if (closed && (Math.hypot(pts[pts.length - 1].x - pts[0].x, pts[pts.length - 1].y - pts[0].y) > 1e-6)) { + pts.push(pts[0]); + } + + const segLens = []; + let totalLen = 0; + for (let i = 0; i < pts.length - 1; i++) { + const dx = pts[i + 1].x - pts[i].x; + const dy = pts[i + 1].y - pts[i].y; + const len = Math.hypot(dx, dy); + segLens.push(len); + totalLen += len; + } + if (totalLen < 1e-6) return; + + const getPathState = (dist) => { + let s = Math.max(0, Math.min(dist, totalLen)); + for (let i = 0; i < segLens.length; i++) { + const len = segLens[i]; + if (s <= len || i === segLens.length - 1) { + const t = len > 1e-9 ? s / len : 0; + const p0 = pts[i]; + const p1 = pts[i + 1]; + const x = p0.x + (p1.x - p0.x) * t; + const y = p0.y + (p1.y - p0.y) * t; + const z = (p0.z || 0) + ((p1.z || 0) - (p0.z || 0)) * t; + const angle = Math.atan2(p1.y - p0.y, p1.x - p0.x); + return { x, y, z, angle }; + } + s -= len; + } + const last = pts[pts.length - 1]; + const prev = pts[pts.length - 2]; + return { + x: last.x, + y: last.y, + z: last.z || 0, + angle: Math.atan2(last.y - prev.y, last.x - prev.x), + }; + }; + + const elements = linPattern.elements || []; + const patternLen = linPattern.patternLength * ltscale; + if (patternLen < 1e-6 || !elements.length) return; + + // Calculate distance from pattern start to shape (arrowhead) element if present + let shapeOffset = -1; + let accum = 0; + for (const e of elements) { + if (e.type === 'shape') { + shapeOffset = accum; + break; + } + if (e.type === 'dash') accum += Math.max((e.val || 0.05) * ltscale, 0.01 * ltscale); + else if (e.type === 'gap') accum += Math.abs(e.val || 0.1) * ltscale; + } + + let s = 0; + let elemIdx = 0; + let lastShapePos = -1; + + while (s < totalLen) { + const elem = elements[elemIdx % elements.length]; + elemIdx++; + + if (elem.type === 'dash') { + const dashLen = Math.max((elem.val || 0.05) * ltscale, 0.01 * ltscale); + const startDist = s; + const endDist = Math.min(s + dashLen, totalLen); + + if (endDist > startDist) { + const pStart = getPathState(startDist); + const pEnd = getPathState(endDist); + pushSeg(pStart.x, pStart.y, pEnd.x, pEnd.y, pStart.z, color); + } + s += dashLen; + } else if (elem.type === 'gap') { + const gapLen = Math.abs(elem.val || 0.1) * ltscale; + s += gapLen; + } else if (elem.type === 'shape') { + lastShapePos = s; + const pState = getPathState(s); + this._renderShapeSymbol(elem, pState, ltscale, color, pushSeg); + } else if (elem.type === 'text') { + const pState = getPathState(s); + if (elem.text && pendingTexts) { + // 1. Data-driven Text Height: (fixedH * elem.scale) or (elem.scale * 0.5) to match arrowhead scale ratio + const sName = (elem.style || 'STANDARD').toUpperCase(); + const fixedH = this._textStyleHeightMap?.get(sName) || 0; + const sFactor = (elem.scale > 0 ? elem.scale * 0.5 : 0.1); + const baseH = fixedH > 0 ? (fixedH * sFactor) : sFactor; + const h = baseH * ltscale; + + let angle = pState.angle + ((elem.rotation || 0) * Math.PI) / 180; + if (elem.isAbsoluteAngle) { + angle = ((elem.rotation || 0) * Math.PI) / 180; + } + + // CAD Standard Upright Rule: text must always read from bottom or right (-90deg to +90deg) + let normA = Math.atan2(Math.sin(angle), Math.cos(angle)); + if (normA > Math.PI / 2) normA -= Math.PI; + else if (normA < -Math.PI / 2) normA += Math.PI; + angle = normA; + + const cosT = Math.cos(angle); + const sinT = Math.sin(angle); + + const xo = (elem.xOffset || 0) * ltscale; + // Vertical mid alignment: set yo = 0 so text middle lies directly on line center axis + const yo = 0; + + // 2. Exact Visual Midpoint: determine primary text gap (previous vs next gap) + const prevElem = elements[(elemIdx - 2 + elements.length) % elements.length]; + const nextElem = elements[elemIdx % elements.length]; + + const prevGapLen = (prevElem && prevElem.type === 'gap') ? Math.abs(prevElem.val || 0) * ltscale : 0; + const nextGapLen = (nextElem && nextElem.type === 'gap') ? Math.abs(nextElem.val || 0) * ltscale : 0; + + let midState = pState; + if (prevGapLen > nextGapLen && prevGapLen > 1e-6) { + // Text belongs inside previous gap (e.g. H-DICHL06R reverse mode S2 = -0.99) + midState = getPathState(s - prevGapLen / 2); + } else if (nextGapLen > 1e-6) { + // Text belongs inside next gap (e.g. H-DICHL06 forward mode S3 = -0.74) + midState = getPathState(s + nextGapLen / 2); + } + + const tx = midState.x + (xo * cosT - yo * sinT); + const ty = midState.y + (xo * sinT + yo * cosT); + + pendingTexts.push({ + text: elem.text, + pos: { x: tx, y: ty, z: midState.z }, + height: Math.max(h, 0.01), + rotation: angle, + color, + alignH: 1, // center horizontally in gap + alignV: 2, // center vertically on line axis (vertical mid) + }); + } + } + } + } + + _renderShapeSymbol(elem, pState, ltscale, color, pushSeg) { + const name = (elem.shapeName || '').toUpperCase(); + const sc = (elem.scale || 0.1) * ltscale; + const angle = elem.isAbsoluteAngle + ? ((elem.rotation || 0) * Math.PI) / 180 + : pState.angle + ((elem.rotation || 0) * Math.PI) / 180; + + const xo = (elem.xOffset || 0) * ltscale; + const yo = (elem.yOffset || 0) * ltscale; + + const cosA = Math.cos(pState.angle); + const sinA = Math.sin(pState.angle); + const cx = pState.x + (xo * cosA - yo * sinA); + const cy = pState.y + (xo * sinA + yo * cosA); + const cz = pState.z || 0; + + const cosR = Math.cos(angle); + const sinR = Math.sin(angle); + + const transform = (lx, ly) => ({ + x: cx + (lx * cosR - ly * sinR) * sc, + y: cy + (lx * sinR + ly * cosR) * sc, + }); + + if (name.includes('KSC35') || name.includes('KSC36') || name.includes('KSC19') || name.includes('ARROW')) { + // Solid hatched arrowhead (matching AutoCAD KSC35 standard symbol) + const steps = 12; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const lx = -0.5 + t; + const hy = 0.25 * (1 - t); + const top = transform(lx, hy); + const bot = transform(lx, -hy); + pushSeg(top.x, top.y, bot.x, bot.y, cz, color); + } + const p1 = transform(0.5, 0); + const p2 = transform(-0.5, 0.25); + const p3 = transform(-0.5, -0.25); + pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color); + pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color); + pushSeg(p3.x, p3.y, p1.x, p1.y, cz, color); + } else if (name.includes('CIRC') || name.includes('KSC11') || name.includes('KSC01')) { + const segs = 12; + let prev = transform(0.3, 0); + for (let i = 1; i <= segs; i++) { + const a = (i / segs) * Math.PI * 2; + const curr = transform(0.3 * Math.cos(a), 0.3 * Math.sin(a)); + pushSeg(prev.x, prev.y, curr.x, curr.y, cz, color); + prev = curr; + } + } else if (name.includes('BOX') || name.includes('KSC02') || name.includes('KSC05')) { + const p1 = transform(-0.3, -0.3); + const p2 = transform(0.3, -0.3); + const p3 = transform(0.3, 0.3); + const p4 = transform(-0.3, 0.3); + pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color); + pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color); + pushSeg(p3.x, p3.y, p4.x, p4.y, cz, color); + pushSeg(p4.x, p4.y, p1.x, p1.y, cz, color); + } else if (name.includes('TRACK1')) { + const p1 = transform(0, -0.4); + const p2 = transform(0, 0.4); + pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color); + } else if (name.includes('BAT') || name.includes('DIAMOND')) { + const p1 = transform(0, 0.4); + const p2 = transform(0.4, 0); + const p3 = transform(0, -0.4); + const p4 = transform(-0.4, 0); + pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color); + pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color); + pushSeg(p3.x, p3.y, p4.x, p4.y, cz, color); + pushSeg(p4.x, p4.y, p1.x, p1.y, cz, color); + } else { + const p1 = transform(0.3, 0); + const p2 = transform(-0.3, 0.2); + const p3 = transform(-0.3, -0.2); + pushSeg(p1.x, p1.y, p2.x, p2.y, cz, color); + pushSeg(p2.x, p2.y, p3.x, p3.y, cz, color); + pushSeg(p3.x, p3.y, p1.x, p1.y, cz, color); + } + } + + _buildStyleMap(styles) { + if (!this._textStyleHeightMap) this._textStyleHeightMap = new Map(); + this._textStyleHeightMap.clear(); + if (!styles || !Array.isArray(styles)) return; + for (const st of styles) { + const sName = (st.name || st.styleName || '').toUpperCase(); + const h = st.height ?? st.fixedTextHeight ?? 0; + if (sName && h > 0) { + this._textStyleHeightMap.set(sName, h); + } + } + } + + _buildLayerMap(layers) { + this._layerByHandle.clear(); + this._layerByName.clear(); + this._layerNameByHandle = new Map(); + this._layerLwByHandle = new Map(); + this._layerLwByName = new Map(); + this._layer0Handle = null; + if (!layers) return; + for (const l of layers) { + const handle = l.handle?.value ?? l.handle; + const name = l.name ?? l.layerName; + const aci = Math.abs(l.colorIndex ?? l.color ?? l.colorNumber ?? 7); + const hex = aciToHex(aci, this._isDark) ?? DEFAULT_COLOR; + if (handle != null) this._layerByHandle.set(String(handle), hex); + if (name) this._layerByName.set(name, hex); + if (handle != null && name) this._layerNameByHandle.set(String(handle), name); + 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) { + this._curLwMm = 0; + if (!this._lwDisplay) return 0; + let raw = entity?.entityHeader?.lineWeight ?? entity?.lineWeight ?? entity?.lineweight; + if (typeof raw !== 'number') return 0; + if (raw === -2) { // ByBlock + const scale = this.getLineweightScale(); + this._curLwMm = inheritedPx > 0 ? inheritedPx / scale : 0; + return inheritedPx; + } + 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; + this._curLwMm = mm; + const scale = this.getLineweightScale(); + return Math.min(LW_MAX_PX, Math.round(mm * scale * 10) / 10); + } + + // Active-viewport VIEWTWIST (radians). The sheet is un-twisted by rotating the + // camera up by -viewTwist (verified against samples/11.dwg: title border upright). + _readViewTwist(result) { + const vports = result?.tables?.vports; + if (!Array.isArray(vports) || !vports.length) return 0; + const vp = vports.find(v => /active/i.test(v.name || '')) || vports[0]; + const tw = vp?.viewTwist; + return (typeof tw === 'number' && isFinite(tw)) ? tw : 0; + } + + // Build linetype lookup: pattern-by-name (signed element lengths, drawing + // units) + layer→linetype-name maps + the global LTSCALE. Consumed by + // _resolveDash to turn an entity's linetype into a dash/gap size. + _buildLinetypeMap(result) { + this._ltPatterns = new Map(); + this._dwgLinPatterns = new Map(); + this._layerLtByName = new Map(); + this._layerLtByHandle = new Map(); + this._globalLtscale = (result?.vars?.ltscale > 0 ? result.vars.ltscale : 1); + for (const lt of (result?.tables?.lineTypes ?? [])) { + if (lt?.name) { + const patArr = Array.isArray(lt.pattern) ? lt.pattern : []; + this._ltPatterns.set(lt.name, patArr); + if (patArr.length > 0) { + const merged = mergeDwgLinetypePattern(lt.name, patArr, lt.description); + if (merged) { + this._dwgLinPatterns.set(lt.name.toUpperCase().trim(), merged); + } + } + } + } + for (const l of (result?.tables?.layers ?? [])) { + const name = l.name ?? l.layerName; + const handle = l.handle?.value ?? l.handle; + const ltn = l.lineType ?? l.linetype; + if (name && ltn) this._layerLtByName.set(name, ltn); + if (handle != null && ltn) this._layerLtByHandle.set(String(handle), ltn); + } + } + + // Resolve an entity's dash pattern → { key, dash, gap } world-unit sizes, or + // null for a solid line. BYLAYER / BYBLOCK / empty fall back to the layer's + // linetype; dash/gap = Σ|element| × LTSCALE × entity linetypeScale (CELTSCALE). + _resolveDash(e) { + if (!this._ltPatterns || this._ltPatterns.size === 0) return null; + let ltName = e.lineType ?? e.linetype; + if (!ltName || ltName === 'ByLayer' || ltName === 'BYLAYER' || + ltName === 'ByBlock' || ltName === 'BYBLOCK') { + const lh = e.layerHandle?.value ?? e.layerHandle; + ltName = (lh != null && this._layerLtByHandle.get(String(lh))) + || this._layerLtByName.get(e.layer ?? e.layerName) || null; + } + if (!ltName || ltName === 'Continuous' || ltName === 'ByLayer' || ltName === 'ByBlock') return null; + const pattern = this._ltPatterns.get(ltName); + if (!pattern || pattern.length === 0) return null; + const entScale = (e.entityHeader?.linetypeScale > 0 ? e.entityHeader.linetypeScale : 1); + const scale = this._globalLtscale * entScale; + let dash = 0, gap = 0; + for (const d of pattern) { + const len = Math.abs(d) * scale; + if (d > 1e-6) dash += len; + else if (d < -1e-6) gap += len; + else dash += Math.max(len, 0.01 * scale); // dot → tiny dash + } + if (dash < 1e-4 || gap < 1e-4) return null; + return { key: `${dash.toFixed(3)}|${gap.toFixed(3)}`, dash, gap }; + } + + // Resolve an entity's color inside a block, given the color inherited from the + // INSERT context. BYBLOCK and layer-0 BYLAYER entities inherit the INSERT color + // (AutoCAD's layer-0 rule); everything else uses its own color/layer. + _resolveBlockColor(entity, inherited) { + const aci = entity.entityHeader?.colorIndex + ?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256; + if (aci === 0) return inherited; // BYBLOCK + const lh = String(entity.layerHandle?.value ?? entity.layerHandle ?? ''); + if (aci === 256 && this._layer0Handle && lh === this._layer0Handle) return inherited; + return this._entityColor(entity); + } + + _entityColor(entity) { + const aci = entity.entityHeader?.colorIndex + ?? entity.color ?? entity.colorIndex ?? entity.colorNumber ?? 256; + if (aci != null) { + const hex = aciToHex(aci, this._isDark); + if (hex !== null) return hex; + } + const lh = entity.layerHandle?.value ?? entity.layerHandle; + if (lh != null) { + const c = this._layerByHandle.get(String(lh)); + if (c !== undefined) return c; + } + const ln = entity.layer ?? entity.layerName; + if (ln) { + const c = this._layerByName.get(ln); + if (c !== undefined) return c; + } + return DEFAULT_COLOR; + } + + // Filled triangle arrow mesh (replaces V-line arrowhead) + _arrowMesh(pt, rotation, size, color) { + if (!pt || size < 1e-4) return; + const w = size * 0.166; + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute([0,0,0, -size,w,0, -size,-w,0], 3)); + geom.setAttribute('color', new THREE.Float32BufferAttribute([r,g,b, r,g,b, r,g,b], 3)); + const mesh = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide })); + mesh.position.set(pt.x, pt.y, (pt.z || 0) + 0.5); + mesh.rotation.z = rotation; + this._addObj(mesh); + } + + // Default geometry for AutoCAD standard arrowhead blocks that ship no geometry + // in the file (system blocks). `name` is the lowercased block name. + _defaultArrow(name, pt, rotation, size, color, pushSeg) { + if (!pt || size < 1e-4) return; + const n = (name || '').replace(/^[_*]+/, ''); + const cos = Math.cos(rotation), sin = Math.sin(rotation); + // local→world: arrow points toward +X at the insertion point + const P = (lx, ly) => ({ x: pt.x + lx * cos - ly * sin, y: pt.y + lx * sin + ly * cos }); + if (n === 'none' || n === 'small' || n === 'integral') return; + if (n === 'dot' || n === 'dotsmall' || n === 'dotblank' || n === 'dotsmallblank' || n === 'origin' || n === 'origin2') { + this._dotMesh(pt, size * (n === 'dotsmall' ? 0.12 : 0.5), color); + return; + } + if (n === 'oblique' || n === 'archtick') { + const a = P(size * 0.5, size * 0.5), b = P(-size * 0.5, -size * 0.5); + pushSeg(a.x, a.y, b.x, b.y, (pt.z || 0) + 0.5, color); + return; + } + if (n === 'open' || n === 'open30' || n === 'open90') { + const w = n === 'open90' ? size : size * 0.42; + const t = P(0, 0), u = P(-size, w), v = P(-size, -w); + pushSeg(u.x, u.y, t.x, t.y, (pt.z || 0) + 0.5, color); + pushSeg(v.x, v.y, t.x, t.y, (pt.z || 0) + 0.5, color); + return; + } + // default (_ClosedFilled, "", closed, boxfilled, datumfilled, …) → filled triangle + this._arrowMesh(pt, rotation, size, color); + } + + // Filled disc (dot arrowhead). + _dotMesh(center, radius, color) { + if (!center || !(radius > 1e-5)) return; + const r = ((color >> 16) & 0xFF) / 255, g = ((color >> 8) & 0xFF) / 255, b = (color & 0xFF) / 255; + const segs = 20, verts = [], cols = [], z = (center.z || 0) + 0.5; + for (let i = 0; i < segs; i++) { + const a0 = i / segs * Math.PI * 2, a1 = (i + 1) / segs * Math.PI * 2; + verts.push(center.x, center.y, z, + center.x + radius * Math.cos(a0), center.y + radius * Math.sin(a0), z, + center.x + radius * Math.cos(a1), center.y + radius * Math.sin(a1), z); + cols.push(r, g, b, r, g, b, r, g, b); + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); + this._addObj(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); + } + + // Dot radius (block-local) from an arrow block's geometry, measured from its centroid. + _arrowDotRadius(ents) { + let r = 0; + for (const ae of (ents || [])) { + const ad = ae.data || ae; + if ((ae.type || '').toUpperCase() === 'CIRCLE' && ad.radius) r = Math.max(r, ad.radius); + const pts = ad.points ?? ad.vertices; + if (pts?.length) { + let cx = 0, cy = 0; pts.forEach(p => { cx += p.x; cy += p.y; }); cx /= pts.length; cy /= pts.length; + pts.forEach(p => { r = Math.max(r, Math.hypot(p.x - cx, p.y - cy)); }); + } + } + return r; + } + + _arcSegs(center, r, a0, a1, z, color, pushSeg) { + let span = a1 - a0; + if (span <= 0) span += Math.PI * 2; + const steps = Math.max(8, Math.ceil((span / (Math.PI * 2)) * ARC_SEGS)); + let prev = null; + for (let i = 0; i <= steps; i++) { + const a = a0 + (span * i) / steps; + const x = center.x + r * Math.cos(a); + const y = center.y + r * Math.sin(a); + if (prev) pushSeg(prev.x, prev.y, x, y, z, color); + prev = { x, y }; + } + } + + // Centripetal CatmullRom interpolation for SPLINE + _catmullRom(pts, t) { + const n = pts.length; + const f = t * (n - 1); + const i = Math.min(Math.floor(f), n - 2); + const tt = f - i; + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, n - 1)]; + const t2 = tt * tt, t3 = t2 * tt; + return { + x: 0.5 * ((2*p1.x) + (-p0.x+p2.x)*tt + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3), + y: 0.5 * ((2*p1.y) + (-p0.y+p2.y)*tt + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3), + z: 0.5 * ((2*(p1.z||0)) + (-(p0.z||0)+(p2.z||0))*tt + (2*(p0.z||0)-5*(p1.z||0)+4*(p2.z||0)-(p3.z||0))*t2 + (-(p0.z||0)+3*(p1.z||0)-3*(p2.z||0)+(p3.z||0))*t3), + }; + } + + _ellipseSegs(d, color, pushSeg) { + // field names: majorAxis or smAxis; ratio or axisRatio + const ma = d.majorAxis ?? d.smAxis ?? { x: 1, y: 0, z: 0 }; + const center = d.center; + const ratio = d.ratio ?? d.axisRatio ?? 1; + const startParam = d.startParam ?? 0; + const endParam = d.endParam ?? Math.PI * 2; + const mx = ma?.x ?? 1, my = ma?.y ?? 0; + const a = Math.sqrt(mx*mx + my*my); + const b = a * ratio; + const rot = Math.atan2(my, mx); + let span = endParam - startParam; + if (span <= 0) span += Math.PI * 2; + let prev = null; + for (let i = 0; i <= ELLIPSE_SEGS; i++) { + const t = startParam + (span * i) / ELLIPSE_SEGS; + const ex = a * Math.cos(t), ey = b * Math.sin(t); + const x = center.x + ex * Math.cos(rot) - ey * Math.sin(rot); + const y = center.y + ex * Math.sin(rot) + ey * Math.cos(rot); + if (prev) pushSeg(prev.x, prev.y, x, y, center.z || 0, color); + prev = { x, y }; + } + } + + _polylineSegs(points, closed, z, color, pushSeg) { + if (!points || points.length < 2) return; + for (let i = 0; i < points.length - 1; i++) { + pushSeg(points[i].x, points[i].y, points[i+1].x, points[i+1].y, z, color); + } + if (closed) { + const a = points[points.length-1], b = points[0]; + pushSeg(a.x, a.y, b.x, b.y, z, color); + } + } + + /** + * 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). + _segWidths(ent, i, scale = 1) { + const d = ent?.data || ent || {}; + const cw = (d.constantWidth ?? d.constWidth ?? 0) * scale; + const dsw = (d.defaultStartWidth ?? 0) * scale; + const dew = (d.defaultEndWidth ?? 0) * scale; + const sw0 = (d.startWidths?.[i] ?? 0) * scale; + const ew0 = (d.endWidths?.[i] ?? 0) * scale; + const sw = Math.abs(sw0) > 1e-12 ? sw0 : (Math.abs(cw) > 1e-12 ? cw : dsw); + const ew = Math.abs(ew0) > 1e-12 ? ew0 : (Math.abs(cw) > 1e-12 ? cw : dew); + return { sw: Math.abs(sw), ew: Math.abs(ew) }; + } + + _polyHasWidth(ent, nSeg, scale = 1) { + for (let i = 0; i < nSeg; i++) { + const { sw, ew } = this._segWidths(ent, i, scale); + if (sw > 1e-9 || ew > 1e-9) return true; + } + return false; + } + + // Draw one trapezoid segment (variable start/end half-width) as two triangles. + _pushWideSeg(verts, p1, p2, w0, w1) { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const len = Math.hypot(dx, dy); + if (len < 1e-12) return; + const nx = -dy / len, ny = dx / len; + const h0 = w0 * 0.5, h1 = w1 * 0.5; + const a = { x: p1.x + nx * h0, y: p1.y + ny * h0 }; + const b = { x: p1.x - nx * h0, y: p1.y - ny * h0 }; + const c = { x: p2.x - nx * h1, y: p2.y - ny * h1 }; + const d = { x: p2.x + nx * h1, y: p2.y + ny * h1 }; + // two triangles a-b-c, a-c-d + verts.push(a.x, a.y, 0, b.x, b.y, 0, c.x, c.y, 0); + verts.push(a.x, a.y, 0, c.x, c.y, 0, d.x, d.y, 0); + } + + // Variable-width polyline → solid mesh (MeshBasicMaterial). Used when any + // segment has start/end/constant width > 0 (도곽 outer frame etc.). + _widePolyMesh(points, bulges, closed, ent, color, scale = 1, expand = null) { + if (!points || points.length < 2) return; + const n = points.length; + const segs = closed ? n : n - 1; + const verts = []; + for (let i = 0; i < segs; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % n]; + const { sw, ew } = this._segWidths(ent, i, scale); + if (sw < 1e-9 && ew < 1e-9) { + // Zero-width segment: still emit a tiny hair so it isn't dropped entirely. + this._pushWideSeg(verts, p1, p2, 0.01 * scale, 0.01 * scale); + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + continue; + } + const bulge = bulges?.[i] || 0; + if (Math.abs(bulge) < 1e-6) { + this._pushWideSeg(verts, p1, p2, sw, ew); + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } else { + // Tessellate bulge arc; interpolate width along the arc. + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const dist = Math.hypot(dx, dy); + if (dist < 1e-9) continue; + const inc = 4 * Math.atan(bulge); + const half = Math.abs(inc) / 2; + const radius = (dist / 2) / Math.sin(half); + const apothem = radius * Math.cos(half); + const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; + const nx2 = -dy / dist, ny2 = dx / dist; + const sign = bulge > 0 ? 1 : -1; + const cx = mx2 + sign * nx2 * apothem; + const cy = my2 + sign * ny2 * apothem; + const sa = Math.atan2(p1.y - cy, p1.x - cx); + const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); + let prev = { x: p1.x, y: p1.y }; + let prevW = sw; + for (let k = 1; k <= steps; k++) { + const t = k / steps; + const a = sa + (inc * k) / steps; + const cur = { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }; + const curW = sw + (ew - sw) * t; + this._pushWideSeg(verts, prev, cur, prevW, curW); + prev = cur; + prevW = curW; + } + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } + } + if (verts.length < 9) return; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geo.computeVertexNormals(); + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(color), + side: THREE.DoubleSide, + depthWrite: false, + }); + this._addObj(new THREE.Mesh(geo, mat)); + // Pick as edge segments only — do NOT register the poly as a fill loop. + // A closed outer frame (도곽 cw=2) used as _pickFills would mark the whole + // interior as "covered" and (with a weak size check) hide every inner line. + if (this._pickCurIdx >= 0 && this._pickSegs) { + for (let i = 0; i < segs; i++) { + const a = points[i], b = points[(i + 1) % n]; + this._pickSegs.push(a.x, a.y, b.x, b.y); + this._pickSegMeta.push(this._pickCurIdx); + } + } + } + + // Polyline with bulge arcs (LWPOLYLINE, HATCH boundaries) + _bulgePolySegs(points, bulges, closed, color, pushSeg, expand) { + const n = points.length; + const segs = closed ? n : n - 1; + for (let i = 0; i < segs; i++) { + const p1 = points[i]; + const p2 = points[(i + 1) % n]; + const bulge = bulges?.[i] || 0; + if (Math.abs(bulge) < 1e-6) { + pushSeg(p1.x, p1.y, p2.x, p2.y, 0, color); + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } else { + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const dist = Math.sqrt(dx*dx + dy*dy); + if (dist < 1e-9) continue; + // Sweep by the signed included angle Δ = 4·atan(bulge). Sweeping the angle + // directly (rather than computing both endpoint angles and resolving the + // 2π wrap) avoids drawing the major arc when |bulge| is tiny. + const inc = 4 * Math.atan(bulge); // signed; >0 = CCW + const half = Math.abs(inc) / 2; + const radius = (dist / 2) / Math.sin(half); + const apothem = radius * Math.cos(half); + const mx2 = (p1.x + p2.x) / 2, my2 = (p1.y + p2.y) / 2; + const nx2 = -dy / dist, ny2 = dx / dist; // left normal of p1→p2 + const sign = bulge > 0 ? 1 : -1; + const cx = mx2 + sign * nx2 * apothem; + const cy = my2 + sign * ny2 * apothem; + const sa = Math.atan2(p1.y - cy, p1.x - cx); + const steps = Math.max(2, Math.ceil(Math.abs(inc) / (Math.PI / 16))); + let prev = p1; + for (let k = 1; k <= steps; k++) { + const a = sa + (inc * k) / steps; + const x = cx + radius * Math.cos(a), y = cy + radius * Math.sin(a); + pushSeg(prev.x, prev.y, x, y, 0, color); + prev = { x, y }; + } + if (expand) { expand(p1.x, p1.y); expand(p2.x, p2.y); } + } + } + } + + _solidMesh(corners, color) { + const r = ((color >> 16) & 0xFF) / 255; + const g = ((color >> 8) & 0xFF) / 255; + const b = (color & 0xFF) / 255; + const verts = [], cols = []; + const addTri = (...pts) => { + for (const p of pts) { verts.push(p.x, p.y, p.z || 0); cols.push(r, g, b); } + }; + if (corners.length >= 4) { + addTri(corners[0], corners[1], corners[3]); + addTri(corners[0], corners[3], corners[2]); + } else { + addTri(corners[0], corners[1], corners[2]); + } + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + geom.setAttribute('color', new THREE.Float32BufferAttribute(cols, 3)); + this._addObj(new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }))); + // Filled quad/tri → pickable region (DWG SOLID vertex order is 0,1,3,2). + if (this._pickCurIdx >= 0 && corners.length >= 3) { + const loop = corners.length >= 4 ? [corners[0], corners[1], corners[3], corners[2]] : corners.slice(0, 3); + this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [loop] }); + } + } + + // Text has no line geometry — register its box as a pickable fill so a click + // anywhere on the text selects it. Box follows the sprite's anchor rules + // (alignH 0/3/5=left,2=right,1/4=center; alignV 3=top,0/1=bottom,2=middle). + _addTextPick(text, pos, height, alignH = 0, alignV = 0) { + if (this._pickCurIdx < 0 || !pos) return; + const rows = String(text).split('\n'); + const cols = rows.reduce((m, s) => Math.max(m, s.length), 1); + const w = cols * height * 0.62; // ~ per-glyph advance + const h = rows.length * height * 1.2; + let x0 = pos.x; + if (alignH === 2) x0 = pos.x - w; // right anchor + else if (alignH === 1 || alignH === 4) x0 = pos.x - w / 2; // center + let y0 = pos.y; // bottom anchor + if (alignV === 3) y0 = pos.y - h; // top anchor + else if (alignV === 2) y0 = pos.y - h / 2; // middle + this._pickFills.push({ metaIdx: this._pickCurIdx, loops: [[ + { x: x0, y: y0 }, { x: x0 + w, y: y0 }, { x: x0 + w, y: y0 + h }, { x: x0, y: y0 + h }, + ]] }); + } + + // Even-odd point-in-region across ALL loops of a fill (holes exclude correctly). + _fillHit(loops, x, y) { + let inside = false; + for (const poly of loops) { + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y; + if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; + } + } + return inside; + } + + // True if (x,y) lies inside ANY single loop (NOT even-odd across loops). + // Used to detect "this point belongs to hatch geometry" including hole + // interiors — even-odd would report holes as uncovered and let a later + // LWPOLYLINE re-solid them (CXGLOGO ㅇ/ㅎ counters). + _pointInAnyLoop(loops, x, y) { + for (const poly of loops) { + if (!poly?.length) continue; + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > y) !== (b.y > y)) && (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + if (c) return true; + } + return false; + } + + // Signed area of a loop (for topmost = smallest-area tie-break on overlapping fills). + _loopArea(poly) { + let s = 0; + for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } + return Math.abs(s) / 2; + } + + // Solid-fill a polyline that is geometrically closed but not covered by any + // existing hatch fill. Text-as-hatch logos sometimes ship glyph strokes as + // LWPOLYLINE outlines only (e.g. ㅅ of 사 in CXGLOGO) while sibling glyphs + // have solid HATCH — without this they render as empty outlines. + // + // Coverage test is ANY-loop containment (not even-odd). Logo blocks list + // HATCH entities first, then the same glyph outlines as LWPOLYLINE. Even-odd + // treats hole interiors as "uncovered", so the post-hatch polyline pass used + // to re-solid ㅇ/ㅎ counters and wipe the punches. Any-loop still lets true + // orphan outlines (ㅅ with no hatch) fill, while skipping anything that + // already belongs to a hatch loop — fill or hole. + _fillClosedPolyIfUncovered(points, bulges, color) { + if (!points || points.length < 3) return; + const a = points[0], b = points[points.length - 1]; + const gap = Math.hypot(a.x - b.x, a.y - b.y); + // Allow tiny gaps relative to polyline size (floating-point closed loops). + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of points) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + const diag = Math.hypot(maxX - minX, maxY - minY) || 1; + if (gap > diag * 1e-4 && gap > 1e-6) return; + // Area gate: logo glyph strokes are small (area ~1–100 in block units). + // Refuse huge loops even inside blocks (defensive). + let sa = 0; + for (let i = 0; i < points.length; i++) { + const p0 = points[i], p1 = points[(i + 1) % points.length]; + sa += p0.x * p1.y - p1.x * p0.y; + } + const absA = Math.abs(sa) / 2; + if (absA < 1e-6 || absA > 5000) return; + let cx = 0, cy = 0; + for (const p of points) { cx += p.x; cy += p.y; } + cx /= points.length; cy /= points.length; + for (const f of this._pickFills) { + if (this._pointInAnyLoop(f.loops, cx, cy)) return; + } + this._hatchFill([{ points, bulges: bulges || null, closed: true }], color); + } + + // Solid fill HATCH using THREE.ShapeGeometry + // Tessellate one (closed) hatch boundary loop → flat {x,y}[] (resolves bulge arcs, + // including the closing edge — a 2-point/2-bulge loop is a full circle). + _pathPoints(path) { + const pts = path.points; + if (!pts?.length) return null; + const bulges = path.bulges; + const n = pts.length; + if (!bulges?.some(b => Math.abs(b) >= 1e-6)) return pts.map(p => ({ x: p.x, y: p.y })); + const out = []; + for (let i = 0; i < n; i++) { + const p1 = pts[i], p2 = pts[(i + 1) % n], bulge = bulges[i] || 0; + out.push({ x: p1.x, y: p1.y }); + if (Math.abs(bulge) < 1e-6) continue; + const dx = p2.x - p1.x, dy = p2.y - p1.y, dist = Math.hypot(dx, dy); + if (dist < 1e-9) continue; + const inc = 4 * Math.atan(bulge), half = Math.abs(inc) / 2; + const radius = (dist / 2) / Math.sin(half), apo = radius * Math.cos(half); + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + const nx = -dy / dist, ny = dx / dist, sign = bulge > 0 ? 1 : -1; + const cx = mx + sign * nx * apo, cy = my + sign * ny * apo; + const sa = Math.atan2(p1.y - cy, p1.x - cx); + const segs = Math.max(6, Math.ceil(Math.abs(inc) / (Math.PI / 16))); + for (let j = 1; j < segs; j++) { // intermediate arc points; p2 added next iter + const a = sa + inc * j / segs; + out.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }); + } + } + return out; + } + + // Solid fill with true even-odd parity across all boundary loops. + // + // DWG solid HATCH (style Normal / odd parity) is even-odd, not "outer + + // nested holes". Text-as-hatch logos (CXGLOGO 한국도로공사) ship + // self-intersecting multi-stroke loops where ShapeGeometry/earcut + geometric + // nesting mis-classifies ㅇ/ㅎ counters. Verified against samples/11.dwg: + // SVG fill-rule=evenodd over all non-outlier loops matches the CAD logo. + _hatchFill(paths, color) { + const totalPts = paths.reduce((s, p) => s + (p.points?.length ?? 0), 0); + if (totalPts > 8000) return; // safety for pathological boundaries + let polys = []; + for (const p of paths) { const pp = this._pathPoints(p); if (pp && pp.length >= 3) polys.push(pp); } + if (!polys.length) return; + + const area = (poly) => { let s = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i], b = poly[(i + 1) % poly.length]; s += a.x * b.y - b.x * a.y; } return Math.abs(s) / 2; }; + + // Drop spurious giant boundaries (parse-corrupt arcs expanded to huge loops). + // Many-loop hatches (≥5) or empty-loop hatches get median×10 outlier filter. + if ((paths.some(p => !(p.points?.length)) && polys.length >= 3) || polys.length >= 5) { + const sorted = polys.map(area).sort((a, b) => a - b); + const med = sorted[Math.floor(sorted.length / 2)] || 0; + if (med > 0) polys = polys.filter(p => area(p) <= med * 10); + if (!polys.length) return; + } + + // Picking uses even-odd across all loops (holes exclude correctly). + if (this._pickCurIdx >= 0 && polys.length) this._pickFills.push({ metaIdx: this._pickCurIdx, loops: polys }); + + // When does this hatch need true even-odd (vs independent union of islands)? + // - Self-intersecting loops (text-as-hatch glyph strokes) + // - Many loops (≥5): logo words like 한국도로공사 + // - Geometric nesting: a smaller loop sits inside a larger one (island/hole) + // Separate solid islands that only touch/overlap (EX logo 4 paths) must be + // UNION — even-odd would punch diamond holes at the overlaps. + const needsEvenOdd = + polys.length >= 5 || + polys.some((p) => this._polySelfIntersects(p)) || + this._hatchHasNestedLoop(polys); + + if (needsEvenOdd) { + this._hatchFillEvenOddCanvas(polys, color); + return; + } + + // Union of simple islands: one ShapeGeometry per loop (no hole punching). + const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), side: THREE.DoubleSide }); + for (const poly of polys) { + try { + const shape = new THREE.Shape(); + shape.moveTo(poly[0].x, poly[0].y); + for (let k = 1; k < poly.length; k++) shape.lineTo(poly[k].x, poly[k].y); + shape.closePath(); + this._addObj(new THREE.Mesh(new THREE.ShapeGeometry(shape), mat)); + } catch { /* degenerate */ } + } + } + + // True if any loop is (mostly) inside a larger loop — island/hole topology. + _hatchHasNestedLoop(polys) { + if (polys.length < 2) return false; + const area = (poly) => { + let s = 0; + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length]; + s += a.x * b.y - b.x * a.y; + } + return Math.abs(s) / 2; + }; + const bboxOf = (poly) => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of poly) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + return { minX, minY, maxX, maxY }; + }; + const inside = (poly, pt) => { + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + return c; + }; + const ar = polys.map(area); + const bbs = polys.map(bboxOf); + for (let i = 0; i < polys.length; i++) { + let cx = 0, cy = 0; + for (const p of polys[i]) { cx += p.x; cy += p.y; } + cx /= polys[i].length; cy /= polys[i].length; + for (let j = 0; j < polys.length; j++) { + if (i === j || ar[j] <= ar[i]) continue; + const bb = bbs[j], bi = bbs[i]; + const bboxHit = bb.minX <= bi.minX && bb.minY <= bi.minY && bb.maxX >= bi.maxX && bb.maxY >= bi.maxY; + if (bboxHit || inside(polys[j], { x: cx, y: cy })) return true; + } + } + return false; + } + + // Cheap self-intersection probe (adjacent edges ignored). Used only to choose + // ShapeGeometry vs even-odd canvas; false negatives still go through canvas + // when there are multiple loops. + _polySelfIntersects(poly) { + const n = poly.length; + if (n < 4) return false; + const cross = (u, v, w) => (v.x - u.x) * (w.y - u.y) - (v.y - u.y) * (w.x - u.x); + const hits = (a, b, c, d) => { + const d1 = cross(a, b, c), d2 = cross(a, b, d), d3 = cross(c, d, a), d4 = cross(c, d, b); + return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); + }; + // Cap checks for large loops (logo glyphs are small). + const lim = Math.min(n, 80); + for (let i = 0; i < lim; i++) { + const a = poly[i], b = poly[(i + 1) % n]; + for (let j = i + 2; j < lim; j++) { + if (i === 0 && j === n - 1) continue; + if (j === (i + n - 1) % n) continue; + if (hits(a, b, poly[j], poly[(j + 1) % n])) return true; + } + } + return false; + } + + // Diameter-like edge through bbox center: corrupt hatch chords (CXGLOGO ㅎ) + // that turn even-odd into a Mercedes/pie pattern. True when shoelace area is + // also inflated above the convex hull (self-intersecting junk). + _isDiameterInflatedPath(pts) { + if (!pts || pts.length < 8) return false; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of pts) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + const w = maxX - minX, h = maxY - minY; + const minDim = Math.min(w, h); + if (!(minDim > 1e-9)) return false; + const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; + const thr = 0.55 * minDim; + const centerR = 0.22 * minDim; + let diamN = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], b = pts[(i + 1) % pts.length]; + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len < thr) continue; + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + if (Math.hypot(mx - cx, my - cy) <= centerR) diamN++; + } + if (!diamN) return false; + // shoelace abs area vs convex hull — inflated ⇒ self-intersecting + let sa = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], b = pts[(i + 1) % pts.length]; + sa += a.x * b.y - b.x * a.y; + } + const absA = Math.abs(sa) / 2; + const hull = this._convexHull(pts); + if (hull.length < 3) return false; + let ha = 0; + for (let i = 0; i < hull.length; i++) { + const a = hull[i], b = hull[(i + 1) % hull.length]; + ha += a.x * b.y - b.x * a.y; + } + ha = Math.abs(ha) / 2; + return ha > 1e-9 && absA > ha * 1.15; + } + + _convexHull(pts) { + const p = pts.map((q) => ({ x: q.x, y: q.y })).sort((a, b) => a.x - b.x || a.y - b.y); + if (p.length <= 2) return p; + const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); + const lower = []; + for (const pt of p) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); + lower.push(pt); + } + const upper = []; + for (let i = p.length - 1; i >= 0; i--) { + const pt = p[i]; + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); + upper.push(pt); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); + } + + _polyCentroid(poly) { + let x = 0, y = 0; + for (const p of poly) { x += p.x; y += p.y; } + return { x: x / poly.length, y: y / poly.length }; + } + + _pointInPoly(poly, pt) { + let c = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i], b = poly[j]; + if (((a.y > pt.y) !== (b.y > pt.y)) && (pt.x < (b.x - a.x) * (pt.y - a.y) / (b.y - a.y) + a.x)) c = !c; + } + return c; + } + + // Raster even-odd fill into a CanvasTexture plane. Sharp enough for logo-scale + // glyphs (tens of units); large site hatches still use ShapeGeometry when they + // are a single simple loop. Texture is disposed with the mesh on reload. + // + // Diameter-inflated loops (ㅎ of 한 in CXGLOGO): pure even-odd yields a pie/ + // Mercedes pattern from spurious center chords. Those loops are filled with + // nonzero (solid glyph + cross), then nested sibling loops punch the counters + // (ㅇ hole). Remaining loops use normal even-odd. + _hatchFillEvenOddCanvas(polys, color) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const poly of polys) { + for (const p of poly) { + if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; + } + } + const worldW = maxX - minX; + const worldH = maxY - minY; + if (!(worldW > 1e-12) || !(worldH > 1e-12)) return; + + // Pad 1px equivalent so AA edges aren't clipped. + const MAX = 2048; + const pxPerUnit = Math.min(MAX / worldW, MAX / worldH, 64); + const tw = Math.max(2, Math.ceil(worldW * pxPerUnit) + 2); + const th = Math.max(2, Math.ceil(worldH * pxPerUnit) + 2); + const sx = (tw - 2) / worldW; + const sy = (th - 2) / worldH; + + const canvas = document.createElement('canvas'); + canvas.width = tw; + canvas.height = th; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, tw, th); + + const toCanvas = (p) => ({ + x: 1 + (p.x - minX) * sx, + y: 1 + (maxY - p.y) * sy, + }); + const addPoly = (poly) => { + if (poly.length < 3) return; + const p0 = toCanvas(poly[0]); + ctx.moveTo(p0.x, p0.y); + for (let i = 1; i < poly.length; i++) { + const p = toCanvas(poly[i]); + ctx.lineTo(p.x, p.y); + } + ctx.closePath(); + }; + + const solidPolys = []; + const evenPolys = []; + for (const poly of polys) { + if (this._isDiameterInflatedPath(poly)) solidPolys.push(poly); + else evenPolys.push(poly); + } + // Self-intersecting solid outlines fail ordinary PIP for hole nesting — use + // their convex hull as the containment oracle (ㅎ outer vs ㅇ counter). + const solidHulls = solidPolys.map((p) => this._convexHull(p)); + + const isHoleOfSolid = (poly) => { + if (poly.length < 3 || !solidHulls.length) return false; + const c = this._polyCentroid(poly); + for (const hull of solidHulls) { + if (hull.length >= 3 && this._pointInPoly(hull, c)) return true; + } + return false; + }; + + // 1) Nonzero fill for diameter-inflated glyphs (ㅎ solid + cross, no pie). + if (solidPolys.length) { + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + for (const poly of solidPolys) addPoly(poly); + ctx.fill('nonzero'); + + // 2) Punch counters nested inside a solid glyph. + const holes = evenPolys.filter(isHoleOfSolid); + if (holes.length) { + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + for (const poly of holes) addPoly(poly); + ctx.fill('nonzero'); + ctx.globalCompositeOperation = 'source-over'; + } + } + + // 3) Remaining loops (not used as hole punches) → even-odd. + const rest = evenPolys.filter((p) => !isHoleOfSolid(p)); + if (rest.length) { + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + for (const poly of rest) addPoly(poly); + ctx.fill('evenodd'); + } + + const tex = new THREE.CanvasTexture(canvas); + tex.colorSpace = THREE.NoColorSpace; + tex.magFilter = THREE.LinearFilter; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.generateMipmaps = true; + tex.needsUpdate = true; + + const mat = new THREE.MeshBasicMaterial({ + map: tex, + color: new THREE.Color(color), + transparent: true, + alphaTest: 0.4, + side: THREE.DoubleSide, + depthWrite: false, + }); + const geo = new THREE.PlaneGeometry(worldW, worldH); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set((minX + maxX) / 2, (minY + maxY) / 2, 0); + // PlaneGeometry is XY; our 2D world is already XY. No rotation needed. + this._addObj(mesh); + } + + // Multi-line MTEXT: one canvas with stacked lines, aligned as a block. + // alignH 0=left 1/4=center 2=right (per line); alignV 3=top 2=middle 0/1=bottom anchor. + _multilineSprite(lines, pos, height, rotation, color, alignH, alignV, renderOrder = 0) { + const fontSize = 96; + const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = font; + let tw = 1, asc = fontSize * 0.70, desc = fontSize * 0.20; + for (const ln of lines) { + const m = ctx.measureText(ln || ' '); + tw = Math.max(tw, Math.ceil(m.width)); + asc = Math.max(asc, m.actualBoundingBoxAscent || fontSize * 0.70); + desc = Math.max(desc, m.actualBoundingBoxDescent || fontSize * 0.20); + } + const pad = Math.ceil(fontSize * 0.15); + const step = Math.ceil(asc + desc + fontSize * 0.35); // baseline-to-baseline + canvas.width = tw + pad * 2; + canvas.height = step * lines.length + pad * 2; + ctx.font = font; // resizing the canvas clears state + + const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF; + ctx.fillStyle = `rgb(${ri},${gi},${bi})`; + ctx.textBaseline = 'alphabetic'; + let ax = pad; + if (alignH === 1 || alignH === 4) { ctx.textAlign = 'center'; ax = canvas.width / 2; } + else if (alignH === 2) { ctx.textAlign = 'right'; ax = canvas.width - pad; } + else { ctx.textAlign = 'left'; ax = pad; } + for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], ax, pad + asc + i * step); + + const texture = new THREE.CanvasTexture(canvas); + const pxToWorld = height / asc; // one line's cap height == DWG text height + const vw = canvas.width * pxToWorld; + const vh = canvas.height * pxToWorld; + + // Anchor the text block so the requested datum lands on pos (block edges). + let ox = 0, oy = 0; + if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x + else if (alignH === 2) ox = -vw / 2; // right edge at pos.x + if (alignV === 3) oy = -vh / 2; // top edge at pos.y + else if (alignV === 0 || alignV === 1) oy = vh / 2; // bottom edge at pos.y + + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); + if (rotation) mat.rotation = rotation; + const sprite = new THREE.Sprite(mat); + sprite.scale.set(vw, vh, 1); + sprite.position.set(pos.x + ox, pos.y + oy, 1); + sprite.renderOrder = renderOrder; + this._addObj(sprite); + } + + // MTEXT background mask (opaque fill behind text). Flags (DXF 90): + // 0x01 = use background fill color (63) + // 0x02 = use drawing window color + // 0x10 = text frame only (R2018+) + // bgScale (DXF 45, default 1.5) = border offset factor × text height. + _drawMTextBackground(t, engine, minTextH) { + const flags = t.bgFillFlags | 0; + if (!flags) return; + const useFill = !!(flags & 0x01) || !!(flags & 0x02); + const useFrame = !!(flags & 0x10); + if (!useFill && !useFrame) return; + + const height = Math.max(t.height || 2.5, minTextH || 0); + const lines = String(t.text || '').split('\n'); + const ls = t.linespacingFactor > 0 ? t.linespacingFactor : 1; + // Match slugText line step (CAD MTEXT default ~ 5/3 of height × factor) + const lineStep = height * (5 / 3) * ls; + let maxEm = 0; + if (engine?.measureEm) { + for (const line of lines) maxEm = Math.max(maxEm, engine.measureEm(line || ' ')); + } else { + // ~0.9 em per Hangul/Latin fallback when engine unavailable + for (const line of lines) maxEm = Math.max(maxEm, (line || ' ').length * 0.9); + } + let textW = Math.max(maxEm * height, height * 0.5); + let textH = lines.length <= 1 + ? height + : height + (lines.length - 1) * lineStep; + if (t.rectWidth > 0) textW = Math.max(textW, t.rectWidth); + if (t.rectHeight > 0) textH = Math.max(textH, t.rectHeight); + + const scale = t.bgScale > 0 ? t.bgScale : 1.5; + // Scale factor expands the text bbox (1.5 → 50% larger total). + const boxW = textW * scale; + const boxH = textH * scale; + + // Text-center offset from insertion/attachment point (same rules as slug/sprite). + const alignH = t.alignH ?? 1; + const alignV = t.alignV ?? 2; + let lx = 0, ly = 0; + if (alignH === 0 || alignH === 3 || alignH === 5) lx = textW / 2; + else if (alignH === 2) lx = -textW / 2; + if (alignV === 3) ly = -textH / 2; + else if (alignV === 0 || alignV === 1) ly = textH / 2; + + const rot = t.rotation || 0; + const cos = Math.cos(rot), sin = Math.sin(rot); + const cx = t.pos.x + lx * cos - ly * sin; + const cy = t.pos.y + lx * sin + ly * cos; + + let bgColor = 0xFFFF00; // default yellow if color missing + if (flags & 0x02) { + // Drawing window color — follow viewer theme + const dark = this._scene?.background?.r < 0.5; + bgColor = dark ? 0x0a0b0d : 0xf7f6f3; + } else if (t.bgColorRgb && (t.bgColorRgb.r != null)) { + bgColor = ((t.bgColorRgb.r & 255) << 16) | ((t.bgColorRgb.g & 255) << 8) | (t.bgColorRgb.b & 255); + } else if (t.bgColorIndex != null) { + const hex = aciToHex(t.bgColorIndex, this._isDark); + if (hex != null) bgColor = hex; + } + + if (useFill) { + const geo = new THREE.PlaneGeometry(boxW, boxH); + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(bgColor), + side: THREE.DoubleSide, + depthWrite: false, + transparent: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(cx, cy, 0.4); // behind text (z≈1) + mesh.rotation.z = rot; + this._addObj(mesh); + } + if (useFrame && !useFill) { + // Outline only — four edges as hairline segs (rare path) + const hw = boxW / 2, hh = boxH / 2; + const corners = [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh], [-hw, -hh]]; + const world = corners.map(([x, y]) => ({ + x: cx + x * cos - y * sin, + y: cy + x * sin + y * cos, + })); + // Use a thin mesh strip ring via Line if we had a helper; simple solid edges: + for (let i = 0; i < 4; i++) { + const a = world[i], b = world[i + 1]; + this._widePolyMesh( + [a, b], null, false, + { constantWidth: Math.max(height * 0.05, 0.01) }, + bgColor, 1, null, + ); + } + } + } + + // Slug vector text (GPU winding-number glyphs, see slugText.ts): one merged + // mesh per load for ALL texts. Async because the TTF loads once on demand; + // falls back to the legacy canvas sprites if the font can't be fetched. + // Underlay (viewport model labels) and overlay (paper labels) are two meshes + // so paper text always composites above viewport content (Layout draw order). + async _drawTexts(texts, minTextH) { + const gen = this._textGen; + const under = texts.filter((t) => t.underlay); + const over = texts.filter((t) => !t.underlay); + const layers = [ + { list: under, order: 0 }, + { list: over, order: 1 }, + ]; + try { + const engine = await SlugTextEngine.shared(); + if (gen !== this._textGen) return; + for (const { list, order } of layers) { + if (!list.length) continue; + for (const t of list) { + if ((t.bgFillFlags | 0) !== 0) { + this._curLayer = t.layer ?? '0'; // _drawMTextBackground emits via _addObj + this._drawMTextBackground(t, engine, minTextH); + } + } + // One merged mesh per CAD layer (not one per order): a layer toggle then + // just flips mesh.visible instead of rebuilding the whole text batch. + for (const [lname, items] of groupByLayer(list)) { + const batch = new SlugTextBatch(engine); + for (const t of items) { + batch.add(t.text, t.pos, Math.max(t.height, minTextH), t.rotation || 0, t.color, t.alignH ?? 0, t.alignV ?? 0); + } + const mesh = batch.build(); + if (!mesh) continue; + mesh.renderOrder = order; + // Transparent text: draw later order on top regardless of z + if (mesh.material) { + mesh.material.depthWrite = false; + mesh.material.transparent = true; + } + this._addObj(mesh, lname); + } + } + } catch (e) { + console.warn('[Viewer2D] Slug text unavailable, falling back to canvas sprites:', e?.message ?? e); + if (gen !== this._textGen) return; + for (const { list, order } of layers) { + for (const t of list) { + this._curLayer = t.layer ?? '0'; + if ((t.bgFillFlags | 0) !== 0) this._drawMTextBackground(t, null, minTextH); + this._textSprite(t.text, t.pos, Math.max(t.height, minTextH), t.rotation, t.color, t.alignH ?? 0, t.alignV ?? 0, order); + } + } + } + } + + // alignH: 0=left, 1=center, 2=right, 4=middle-center. alignV: 0=baseline,1=bottom,2=middle,3=top. + _textSprite(text, pos, height, rotation, color, alignH = 0, alignV = 0, renderOrder = 0) { + // MTEXT paragraph breaks (\P → \n) produce multi-line text: render stacked. + const lines = String(text).split('\n'); + if (lines.length > 1) { + this._multilineSprite(lines, pos, height, rotation, color, alignH, alignV, renderOrder); + return; + } + // Texture cache: table drawings repeat the same strings thousands of times + // ("D25", sizes, counts). Rasterizing each occurrence separately OOMs on + // dense sheets (e.g. 6k+ texts) — share one CanvasTexture per (text, color). + // Alignment/rotation are per-sprite (scale/offset/material), not per-raster. + const cacheKey = text + '' + color; + let tex = (this._texCache ??= new Map()).get(cacheKey); + if (!tex) { + const fontSize = 96; + const font = `${fontSize}px 'Malgun Gothic', 'Apple SD Gothic Neo', monospace`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + ctx.font = font; + const m = ctx.measureText(text); + const tw = Math.max(1, Math.ceil(m.width)); + // Actual ink extents above/below the baseline (fallback to typical font ratios). + const asc = m.actualBoundingBoxAscent || fontSize * 0.70; // ≈ cap/glyph height + const desc = m.actualBoundingBoxDescent || fontSize * 0.20; + const pad = Math.ceil(fontSize * 0.15); + // Symmetric padding → the ink bounding box stays centered in the canvas. + canvas.width = tw + pad * 2; + canvas.height = Math.ceil(asc + desc) + pad * 2; + ctx.font = font; // reset (resizing the canvas clears state) + const ri = (color >> 16) & 0xFF, gi = (color >> 8) & 0xFF, bi = color & 0xFF; + ctx.fillStyle = `rgb(${ri},${gi},${bi})`; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(text, pad, pad + asc); + tex = { texture: new THREE.CanvasTexture(canvas), w: canvas.width, h: canvas.height, asc, pad }; + this._texCache.set(cacheKey, tex); + } + const { texture, asc, pad } = tex; + + // Size so the visible ascent (≈ DWG cap height) equals the DWG text `height`. + const vh = height * tex.h / asc; + const vw = vh * (tex.w / tex.h); + const pxToWorld = vh / tex.h; + const centerPx = tex.h / 2; + + // X: place sprite so the requested horizontal datum lands on pos.x. + let ox = 0; + if (alignH === 0 || alignH === 3 || alignH === 5) ox = vw / 2; // left edge at pos.x + else if (alignH === 1 || alignH === 4) ox = 0; // center at pos.x + else if (alignH === 2) ox = -vw / 2; // right edge at pos.x + + // Y: map the alignment datum (in canvas px) to pos.y. Canvas y grows downward, + // world y upward → world offset of a canvas row = (centerPx - rowPx) * pxToWorld. + // ah=4 (DWG "middle" justification) forces both-axis centering regardless of av. + let oy = 0; + if (alignH !== 4) { + if (alignV === 0 || alignV === 1) oy = (pad + asc - centerPx) * pxToWorld; // baseline/bottom datum + else if (alignV === 3) oy = (pad - centerPx) * pxToWorld; // top datum + // alignV === 2 (middle): ink already centered → oy = 0 + } + + const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); + if (rotation) mat.rotation = rotation; + const sprite = new THREE.Sprite(mat); + sprite.scale.set(vw, vh, 1); + sprite.position.set(pos.x + ox, pos.y + oy, 1); + sprite.renderOrder = renderOrder; + this._addObj(sprite); + } + + // Render block definition entities transformed by INSERT params + _insertEntities(entities, d, color, pushSeg, pendingTexts, ctx) { + const entsByOwner = ctx?.entsByOwner; + const blockBase = ctx?.blockBase; + const parentXf = ctx?.parentXf ?? null; + const parentScale = ctx?.scale ?? 1; + const parentRot = ctx?.rot ?? 0; + const depth = ctx?.depth ?? 0; + + const ip = d.insertionPoint ?? { x: 0, y: 0 }; + const base = d.basePoint; + const sx = d.xScale ?? 1, sy = d.yScale ?? 1; + const rot = d.rotation ?? 0; + const cos = Math.cos(rot), sin = Math.sin(rot); + + // Local transform: block-local coords → parent-local (or world if top level). + const localXf = (px, py) => { + const lx = base ? px - base.x : px; + const ly = base ? py - base.y : py; + return [ + ip.x + (lx * sx) * cos - (ly * sy) * sin, + ip.y + (lx * sx) * sin + (ly * sy) * cos, + ]; + }; + // Compose with parent transform for nested INSERTs. + const xf = parentXf ? (px, py) => parentXf(...localXf(px, py)) : localXf; + // Accumulated uniform scale (for radii) and rotation (for arc/ellipse angles). + const scaleAcc = parentScale * Math.max(Math.abs(sx), Math.abs(sy)); + const rotAcc = parentRot + rot; + + // Weight the INSERT itself carries — what a ByBlock child resolves to. + const insertLwPx = this._curLwPx; + for (const be of entities) { + const bd = be.data || be; + const bt = (be.type || be.typeName || '').toUpperCase(); + if (bt === 'ATTDEF') continue; // skip attribute definitions + const ecol = this._resolveBlockColor(be, color); + this._curLwPx = this._lwPx(be, insertLwPx); + try { + if (bt === 'LINE' && bd.start && bd.end) { + const [ax, ay] = xf(bd.start.x, bd.start.y); + const [bx, by] = xf(bd.end.x, bd.end.y); + 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) { + const [cx, cy] = xf(bd.center.x, bd.center.y); + const r = bd.radius * scaleAcc; + const a0 = bt === 'ARC' ? (bd.startAngle ?? 0) + rotAcc : 0; + const a1 = bt === 'ARC' ? (bd.endAngle ?? Math.PI*2) + rotAcc : Math.PI*2; + this._arcSegs({ x:cx, y:cy, z:0 }, r, a0, a1, 0, ecol, pushSeg); + + } else if (bt === 'ELLIPSE' && bd.center) { + const [cx, cy] = xf(bd.center.x, bd.center.y); + const ma = bd.majorAxis ?? bd.smAxis ?? { x:1, y:0 }; + const mlen = Math.hypot(ma.x ?? 0, ma.y ?? 0) * scaleAcc; + const mang = Math.atan2(ma.y ?? 0, ma.x ?? 1) + rotAcc; + this._ellipseSegs({ + center: { x:cx, y:cy, z:0 }, + majorAxis: { x: mlen * Math.cos(mang), y: mlen * Math.sin(mang) }, + ratio: bd.ratio ?? bd.axisRatio ?? 1, + startParam: bd.startParam ?? 0, + endParam: bd.endParam ?? Math.PI*2, + }, ecol, pushSeg); + + } else if ((bt === 'LWPOLYLINE' || bt === 'POLYLINE') && (bd.points || bd.vertices)) { + const raw = bd.points ?? bd.vertices; + const pts = raw.map(p => { const [nx, ny] = xf(p.x, p.y); return { x:nx, y:ny }; }); + // Preserve per-vertex bulges through the transform (scalar, not position). + const bulges = bd.bulges ?? null; + const closed = !!(bd.closed || (bd.flags & 1)); + // Logo blocks list solid HATCH first, then the same glyph outlines as + // LWPOLYLINE. Re-stroking those glyph outlines draws chords across ㅇ + // holes — skip ONLY true re-outlines: same scale as the covering loop. + // (Not "poly smaller than loop" — that also killed 도곽 안쪽 가는 선 + // whose centroid sat inside the outer frame / a large hatch.) + let skipGlyphReoutline = false; + if (pts.length >= 2 && this._pickFills.length) { + let cx = 0, cy = 0, pMinX = Infinity, pMinY = Infinity, pMaxX = -Infinity, pMaxY = -Infinity; + for (const p of pts) { + cx += p.x; cy += p.y; + if (p.x < pMinX) pMinX = p.x; if (p.y < pMinY) pMinY = p.y; + if (p.x > pMaxX) pMaxX = p.x; if (p.y > pMaxY) pMaxY = p.y; + } + cx /= pts.length; cy /= pts.length; + const pw = Math.max(pMaxX - pMinX, 1e-9); + const ph = Math.max(pMaxY - pMinY, 1e-9); + for (const f of this._pickFills) { + if (!this._pointInAnyLoop(f.loops, cx, cy)) continue; + for (const loop of f.loops) { + if (!loop?.length) continue; + let lMinX = Infinity, lMinY = Infinity, lMaxX = -Infinity, lMaxY = -Infinity; + for (const q of loop) { + if (q.x < lMinX) lMinX = q.x; if (q.y < lMinY) lMinY = q.y; + if (q.x > lMaxX) lMaxX = q.x; if (q.y > lMaxY) lMaxY = q.y; + } + const lw = Math.max(lMaxX - lMinX, 1e-9); + const lh = Math.max(lMaxY - lMinY, 1e-9); + // Similar size both ways (re-outline of same glyph/hatch). + const similar = + pw <= lw * 1.5 && ph <= lh * 1.5 && + lw <= pw * 1.5 && lh <= ph * 1.5; + if (similar) { skipGlyphReoutline = true; break; } + } + if (skipGlyphReoutline) break; + } + } + if (!skipGlyphReoutline) { + const nSeg = closed ? pts.length : pts.length - 1; + // start/end/constant width (도곽 frame etc.) — mesh strip, scaled by INSERT + if (this._polyHasWidth(be, nSeg, scaleAcc)) { + this._widePolyMesh(pts, bulges, closed, be, ecol, scaleAcc, null); + } else { + if (bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg); + else this._polylineSegs(pts, closed, 0, ecol, pushSeg); + this._fillClosedPolyIfUncovered(pts, bulges, ecol); + } + } + + } else if (bt === 'POLYLINE_2D' && entsByOwner) { + // Old-style 2D polyline inside a block: vertices are separate VERTEX_2D + // entities owned by the polyline (point/bulge at top level). + const kids = (entsByOwner.get(be.handle?.value?.toString(16)) ?? []) + .filter(v => (v.type||'').toUpperCase().startsWith('VERTEX') && v.point); + if (kids.length >= 2) { + const pts = kids.map(v => { const [nx, ny] = xf(v.point.x, v.point.y); return { x:nx, y:ny }; }); + const bulges = kids.map(v => v.bulge || 0); + const closed = (bd.flags & 1) === 1; + if (bulges.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(pts, bulges, closed, ecol, pushSeg); + else this._polylineSegs(pts, closed, 0, ecol, pushSeg); + } + + } else if (bt === 'HATCH') { + const paths = bd.paths ?? be.paths; + if (paths?.length) { + const solidFill = bd.solidFill ?? be.solidFill ?? false; + const tpaths = paths.map(p => ({ + ...p, + points: (p.points ?? []).map(pt => { const [nx, ny] = xf(pt.x, pt.y); return { x:nx, y:ny }; }), + })); + // Solid fill: draw fill only (CAD does not render hatch boundaries). + // Pattern hatch: draw boundary outline as a proxy (no pattern support). + if (solidFill) { + this._hatchFill(tpaths, ecol); + } else { + for (const tp of tpaths) { + if ((tp.points?.length ?? 0) < 2) continue; + if (tp.bulges?.some(b => Math.abs(b) >= 1e-6)) this._bulgePolySegs(tp.points, tp.bulges, true, ecol, pushSeg); + else this._polylineSegs(tp.points, true, 0, ecol, pushSeg); + } + } + } + + } else if (bt === 'SOLID') { + const crs = [bd.corner1||bd.pt1, bd.corner2||bd.pt2, + bd.corner3||bd.pt3, bd.corner4||bd.pt4].filter(Boolean); + if (crs.length >= 3) { + const mapped = crs.map(p => { const [nx,ny] = xf(p.x, p.y); return {x:nx, y:ny, z:p.z||0}; }); + this._solidMesh(mapped, ecol); + } + + } else if (bt === 'INSERT' && entsByOwner && depth < 8) { + // Nested block reference: recurse with composed transform. + const bhVal = be.blockHeaderHandle?.value ?? bd.blockHeaderHandle?.value; + if (bhVal != null) { + const hex = bhVal.toString(16); + const childEnts = entsByOwner.get(hex) ?? []; + if (childEnts.length) { + const nip = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint ?? { x:0, y:0 }; + const nsx = bd.scaleX ?? bd.scale?.x ?? be.scale?.x ?? 1; + const nsy = bd.scaleY ?? bd.scale?.y ?? be.scale?.y ?? 1; + const nrot = bd.rotation ?? be.rotation ?? 0; + this._insertEntities( + childEnts, + { insertionPoint: nip, xScale: nsx, yScale: nsy, rotation: nrot, basePoint: blockBase?.get(hex) }, + ecol, pushSeg, pendingTexts, + { entsByOwner, blockBase, parentXf: xf, scale: scaleAcc, rot: rotAcc, depth: depth + 1 } + ); + } + } + + } else if ((bt === 'TEXT' || bt === 'MTEXT' || bt === 'ATTRIB' || bt === 'ATTDEF') && pendingTexts) { + if ((bt === 'ATTRIB' || bt === 'ATTDEF') && ((bd.flags ?? 0) & 1)) continue; + const raw = be.text ?? bd.text ?? bd.textValue ?? bd.defaultValue ?? ''; + const text = stripMText(raw); + let ah, av, rot = (bd.rotationAngle ?? 0) + rotAcc; + if (bt === 'MTEXT') { + // MTEXT anchors via attachment point 1-9 (1-3 top / 4-6 mid / 7-9 + // bottom, columns L/C/R), not horiz/vertAlignment — the fallback + // 0/0 (left-baseline) pushed table cells to the top-right. + const ap = be.attachment ?? bd.attachment ?? be.attachmentPoint ?? bd.attachmentPoint ?? 5; + ah = ([1,4,7].includes(ap) ? 0 : [3,6,9].includes(ap) ? 2 : 1); + av = ([1,2,3].includes(ap) ? 3 : [7,8,9].includes(ap) ? 1 : 2); + const xd = be.xAxisDir ?? bd.xAxisDir; + if (xd) rot = Math.atan2(xd.y, xd.x) + rotAcc; + } else { + ah = bd.horizAlignment ?? be.horizAlignment ?? 0; + av = bd.vertAlignment ?? be.vertAlignment ?? 0; + } + const useAp = bt !== 'MTEXT' && (ah !== 0 || av !== 0); + const alignPt = be.alignmentPt ?? bd.alignmentPt; + const insertPt = be.insertionPt ?? bd.insertionPt ?? bd.insertionPoint; + const tpos = useAp + ? (alignPt ?? insertPt ?? {x:0,y:0}) + : (insertPt ?? {x:0,y:0}); + const [tx, ty] = xf(tpos.x, tpos.y); + const ht = (bd.textHeight ?? bd.height ?? be.textHeight ?? 2.5) * scaleAcc; + if (text) { + const entry = { text, pos:{x:tx, y:ty}, height:ht, rotation:rot, color: ecol, alignH:ah, alignV:av }; + if (bt === 'MTEXT') { + entry.linespacingFactor = be.linespacingFactor ?? bd.linespacingFactor ?? 1; + entry.rectWidth = (be.rectWidth ?? bd.rectWidth ?? 0) * scaleAcc; + entry.rectHeight = (be.rectHeight ?? bd.rectHeight ?? 0) * scaleAcc; + entry.bgFillFlags = be.bgFillFlags ?? bd.bgFillFlags ?? 0; + entry.bgScale = be.bgScale ?? bd.bgScale ?? 1.5; + entry.bgColorIndex = be.bgColorIndex ?? bd.bgColorIndex; + entry.bgColorRgb = be.bgColorRgb ?? bd.bgColorRgb; + } + pendingTexts.push(entry); + } + } + } 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 + _renderDimFallback(e, d, type, color, pushSeg, pendingTexts, expand) { + const pt10 = e.pt10 ?? d.pt10; + const pt13 = e.pt13 ?? d.pt13; + const pt14 = e.pt14 ?? d.pt14; + const pt15 = e.pt15 ?? d.pt15; + const textMidPt = e.textMidPt ?? d.textMidPt; + const actualMeasurement = e.actualMeasurement ?? d.actualMeasurement ?? 0; + const textRot = e.textRot ?? d.textRot ?? 0; + const dimRot = e.dimRot ?? d.dimRot ?? 0; + const userText = e.userText ?? d.userText ?? ''; + + const fmtLen = (v) => Math.abs(v) < 1 ? v.toFixed(3) : v.toFixed(2); + + const drawLine = (a, b) => { + if (!a || !b) return; + pushSeg(a.x, a.y, b.x, b.y, 0, color); + expand(a.x, a.y); expand(b.x, b.y); + }; + const drawArc = (cx, cy, r, sa, ea) => { + this._arcSegs({ x:cx, y:cy }, r, sa, ea, 0, color, pushSeg); + }; + const arrow = (pt, rot, sz) => this._arrowMesh(pt, rot, sz, color); + const drawText = (pos, text, height, rot) => { + if (text && pos) pendingTexts.push({ text, pos, height, rotation: rot ?? 0, color, alignH: 1, alignV: 2 }); + }; + + switch (type) { + case 'DIMENSION': + case 'DIMENSION_LINEAR': + case 'DIMENSION_ALIGNED': { + if (!pt13 || !pt14 || !pt10) return; + let d1x, d1y, d2x, d2y; + if (type === 'DIMENSION_ALIGNED') { + const ddx = pt14.x-pt13.x, ddy = pt14.y-pt13.y; + const len = Math.hypot(ddx, ddy); + if (len < 1e-9) return; + const ux = ddx/len, uy = ddy/len; + const half = actualMeasurement / 2; + d1x = pt10.x - ux*half; d1y = pt10.y - uy*half; + d2x = pt10.x + ux*half; d2y = pt10.y + uy*half; + } else { + const isV = Math.abs(Math.sin(dimRot)) > 0.5; + const half = actualMeasurement / 2; + d1x = pt10.x - (isV ? 0 : half); d1y = pt10.y - (isV ? half : 0); + d2x = pt10.x + (isV ? 0 : half); d2y = pt10.y + (isV ? half : 0); + } + const arrowSz = Math.max(actualMeasurement * 0.04, 1); + drawLine({x:d1x,y:d1y}, {x:d2x,y:d2y}); + drawLine(pt13, {x:d1x,y:d1y}); + drawLine(pt14, {x:d2x,y:d2y}); + arrow({x:d1x,y:d1y,z:0}, Math.atan2(d1y-d2y, d1x-d2x), arrowSz); + arrow({x:d2x,y:d2y,z:0}, Math.atan2(d2y-d1y, d2x-d1x), arrowSz); + drawText(textMidPt ?? {x:(d1x+d2x)/2, y:(d1y+d2y)/2}, userText || fmtLen(actualMeasurement), Math.max(arrowSz*1.5,1), textRot); + break; + } + case 'DIMENSION_RADIUS': { + if (!pt10 || !pt15) return; + const arrowSzR = Math.max(actualMeasurement * 0.05, 0.5); + drawLine(pt10, pt15); + arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzR); + drawText(textMidPt ?? {x:(pt10.x+pt15.x)/2,y:(pt10.y+pt15.y)/2}, userText || `R${fmtLen(actualMeasurement)}`, Math.max(arrowSzR*2,1)); + break; + } + case 'DIMENSION_DIAMETER': { + if (!pt10 || !pt15) return; + const opp = {x:2*pt10.x-pt15.x, y:2*pt10.y-pt15.y}; + const arrowSzD = Math.max((actualMeasurement/2)*0.05, 0.5); + drawLine(pt15, opp); + arrow({x:pt15.x,y:pt15.y,z:0}, Math.atan2(pt15.y-pt10.y, pt15.x-pt10.x), arrowSzD); + arrow({x:opp.x,y:opp.y,z:0}, Math.atan2(opp.y-pt10.y, opp.x-pt10.x), arrowSzD); + drawText(textMidPt ?? pt10, userText || `Ø${fmtLen(actualMeasurement)}`, Math.max(arrowSzD*2,1)); + break; + } + case 'DIMENSION_ANG_3PT': + case 'DIMENSION_ANG_2LN': { + if (!pt10 || !pt13 || !pt14) return; + const vx = pt10.x, vy = pt10.y; + const rr = Math.hypot(pt13.x-vx, pt13.y-vy); + if (rr < 1e-9) return; + let sa = Math.atan2(pt13.y-vy, pt13.x-vx); + let ea = Math.atan2(pt14.y-vy, pt14.x-vx); + let sweep = ea - sa; + while (sweep < 0) sweep += Math.PI*2; + if (sweep > Math.PI) { [sa, ea] = [ea, sa]; sweep = Math.PI*2 - sweep; } + drawArc(vx, vy, rr, sa, sa+sweep); + const arrowSzA = Math.max(rr * 0.05, 0.5); + arrow({x:vx+Math.cos(sa)*rr, y:vy+Math.sin(sa)*rr, z:0}, sa-Math.PI/2, arrowSzA); + arrow({x:vx+Math.cos(sa+sweep)*rr, y:vy+Math.sin(sa+sweep)*rr, z:0}, (sa+sweep)+Math.PI/2, arrowSzA); + const midA = sa + sweep/2; + drawText(textMidPt ?? {x:vx+Math.cos(midA)*rr*1.2, y:vy+Math.sin(midA)*rr*1.2}, + userText || `${(actualMeasurement*180/Math.PI).toFixed(1)}°`, Math.max(rr*0.1,1)); + break; + } + case 'DIMENSION_ORDINATE': { + if (!pt10 || !pt13) return; + drawLine(pt10, pt13); + drawText(textMidPt ?? pt13, userText || (actualMeasurement?.toFixed(2) ?? ''), 2.5); + break; + } + } + } + + _onClick(e) { + // ignore clicks that were really a pan/zoom drag (>5px movement) + if (this._downXY) { + const dx = e.clientX - this._downXY[0], dy = e.clientY - this._downXY[1]; + if (Math.hypot(dx, dy) > 5) return; + } + const cam = this._camera; + const rect = this._renderer.domElement.getBoundingClientRect(); + // WCS for pick/measure (not UCS display frame) + const wcs = this._screenToWcs(e.clientX, e.clientY); + if (!wcs) return; + const wx = wcs.x, wy = wcs.y; + + if (this._measureActive) { this._doMeasure2D(wx, wy); return; } + + const thresh = CLICK_THRESHOLD_PX * (cam.right - cam.left) / (rect.width * (cam.zoom || 1)); + + // Geometric pick — raycast the actual rendered geometry, not a bounds proxy. + // 1) Nearest real line segment within the pixel threshold (lines, arcs, + // circles, polylines, block wires, dimension leaders/arrows). + let bestIdx = -1, bestDist = thresh; + const S = this._pickSegs, SM = this._pickSegMeta; + // Hidden layers stay in the buffers (only masked out of the draw index), so + // picking has to skip them explicitly. + const MH = this._hiddenLayers.size ? this._metaHidden : null; + for (let i = 0, j = 0; i < S.length; i += 4, j++) { + if (MH && MH[SM[j]]) continue; + const d = this._segDist(wx, wy, S[i], S[i + 1], S[i + 2], S[i + 3]); + if (d < bestDist) { bestDist = d; bestIdx = SM[j]; } + } + // 2) No line under the cursor → a filled region (hatch / SOLID / text box). + // Pick the smallest-area fill so text on top of a hatch wins. + if (bestIdx < 0) { + let bestArea = Infinity; + for (const f of this._pickFills) { + if (MH && MH[f.metaIdx]) continue; + if (this._fillHit(f.loops, wx, wy)) { + const a = this._loopArea(f.loops[0] || []); + if (a < bestArea) { bestArea = a; bestIdx = f.metaIdx; } + } + } + } + const bestMeta = bestIdx >= 0 ? this._entityMeta[bestIdx] : null; + this._highlight(bestMeta); + this._onSelectCb?.(bestMeta ? bestMeta.entity : null); + } + + // Distance from point (px,py) to segment (ax,ay)-(bx,by). + _segDist(px, py, ax, ay, bx, by) { + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + if (!len2) return Math.hypot(px - ax, py - ay); + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); + } + + startMeasure(cb) { + this.stopMeasure(); + this._measureCb = cb; + this._measureActive = true; + } + + stopMeasure() { + this._measureActive = false; + this._measureCb = null; + this._measurePtA = null; + for (const m of this._measureMarkers) { + this._group.remove(m); + m.geometry?.dispose(); + m.material?.dispose(); + } + this._measureMarkers = []; + } + + _doMeasure2D(wx, wy) { + if (!this._measurePtA) { + this._measurePtA = { x: wx, y: wy }; + const mk = this._mkMeasureMarker(wx, wy); + this._measureMarkers.push(mk); + this._group.add(mk); + this._measureCb?.({ phase: 'a', ax: wx, ay: wy, az: 0, bx: wx, by: wy, bz: 0, d: 0 }); + } else { + const a = this._measurePtA; + const d = Math.hypot(wx - a.x, wy - a.y); + const mkB = this._mkMeasureMarker(wx, wy); + const ln = this._mkMeasureLine(a.x, a.y, wx, wy); + this._measureMarkers.push(mkB, ln); + this._group.add(mkB, ln); + this._measureCb?.({ phase: 'done', ax: a.x, ay: a.y, az: 0, bx: wx, by: wy, bz: 0, d }); + this._measurePtA = null; + } + } + + _mkMeasureMarker(x, y) { + const cam = this._camera; + const rect = this._renderer.domElement.getBoundingClientRect(); + const s = ((cam.right - cam.left) / (rect.width * (cam.zoom || 1))) * 8; + const pts = [ + new THREE.Vector3(x - s, y, 0), new THREE.Vector3(x + s, y, 0), + new THREE.Vector3(x, y - s, 0), new THREE.Vector3(x, y + s, 0), + ]; + const geo = new THREE.BufferGeometry().setFromPoints(pts); + geo.setIndex([0, 1, 2, 3]); + const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false }); + const m = new THREE.LineSegments(geo, mat); + m.renderOrder = 100; + return m; + } + + _mkMeasureLine(x1, y1, x2, y2) { + const geo = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(x1, y1, 0), new THREE.Vector3(x2, y2, 0), + ]); + const mat = new THREE.LineBasicMaterial({ color: 0xe63030, depthTest: false }); + const m = new THREE.Line(geo, mat); + m.renderOrder = 100; + return m; + } + + /** Recolor the selected entity's line geometry to the accent color (deselect previous). */ + _highlight(meta) { + if (this._selMeta) this._paintMeta(this._selMeta, null); + this._selMeta = meta || null; + if (meta) this._paintMeta(meta, this._selColor); + } + + /** + * 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); + } + } + } + + /** 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). */ + setAccent(hex) { + this._selColor = { + r: parseInt(hex.slice(1, 3), 16) / 255, + g: parseInt(hex.slice(3, 5), 16) / 255, + b: parseInt(hex.slice(5, 7), 16) / 255, + }; + if (this._selMeta) { const m = this._selMeta; this._selMeta = null; this._highlight(m); } + } + + /** Get currently selected CAD entity (or null). */ + getSelectedEntity() { + return this._selMeta ? this._selMeta.entity : null; + } + + /** Zoom extents to the currently selected CAD entity. */ + zoomToSelection() { + if (!this._selMeta) return; + const idx = this._entityMeta.indexOf(this._selMeta); + if (idx < 0) return; + const box = new THREE.Box3(); + const S = this._pickSegs, SM = this._pickSegMeta; + if (S && SM) { + for (let i = 0, j = 0; i < S.length; i += 4, j++) { + if (SM[j] === idx) { + box.expandByPoint(new THREE.Vector3(S[i], S[i + 1], 0)); + box.expandByPoint(new THREE.Vector3(S[i + 2], S[i + 3], 0)); + } + } + } + if (this._pickFills) { + for (const f of this._pickFills) { + if (f.metaIdx === idx) { + for (const loop of f.loops || []) { + for (const p of loop) { + box.expandByPoint(new THREE.Vector3(p.x, p.y, 0)); + } + } + } + } + } + if (box.isEmpty() && this._selMeta.entity) { + const e = this._selMeta.entity; + const d = e.data || e; + const pt = d.start || d.center || d.pos || d.insertionPoint || d.insertionPt; + if (pt) { + const pad = 10; + box.expandByPoint(new THREE.Vector3(pt.x - pad, pt.y - pad, 0)); + box.expandByPoint(new THREE.Vector3(pt.x + pad, pt.y + pad, 0)); + } + } + if (!box.isEmpty()) { + this._fit(box); + } + } + + _distToBounds(wx, wy, b) { + if (b.type === 'point') return Math.hypot(wx - b.cx, wy - b.cy); + if (b.type === 'circle') return Math.abs(Math.hypot(wx - b.cx, wy - b.cy) - b.r); + if (b.type === 'line') { + const dx = b.x2-b.x1, dy = b.y2-b.y1; + const len2 = dx*dx + dy*dy; + if (!len2) return Math.hypot(wx - b.x1, wy - b.y1); + const t = Math.max(0, Math.min(1, ((wx-b.x1)*dx + (wy-b.y1)*dy) / len2)); + return Math.hypot(wx - (b.x1+t*dx), wy - (b.y1+t*dy)); + } + return Infinity; + } + + /** + * Percentile-trim an AABB from samples so 1–2 far junk vertices (or a + * degenerate polyline) do not dominate Zoom Fit. Falls back to fullBox. + * @param {number[]} samples flat [x,y,x,y,…] + * @param {THREE.Box3} fullBox + */ + _trimmedContentBox(samples, fullBox) { + if (!fullBox || fullBox.isEmpty()) return null; + const n = samples ? (samples.length >> 1) : 0; + if (n < 32) return fullBox.clone(); + + const xs = new Float64Array(n); + const ys = new Float64Array(n); + let k = 0; + for (let i = 0; i + 1 < samples.length; i += 2) { + const x = samples[i], y = samples[i + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + xs[k] = x; ys[k] = y; k++; + } + if (k < 32) return fullBox.clone(); + const xsl = xs.subarray(0, k); + const ysl = ys.subarray(0, k); + xsl.sort(); + ysl.sort(); + // 0.5% … 99.5% — drops a handful of extreme verts on ~100k+ samples + const lo = Math.max(0, Math.floor(k * 0.005)); + const hi = Math.min(k - 1, Math.floor(k * 0.995)); + let minX = xsl[lo], maxX = xsl[hi], minY = ysl[lo], maxY = ysl[hi]; + if (!(maxX > minX) || !(maxY > minY)) return fullBox.clone(); + + // Pad slightly so content is not flush to the view edge + const padX = Math.max((maxX - minX) * 0.02, 1); + const padY = Math.max((maxY - minY) * 0.02, 1); + // Trimming is X/Y only — carry the full box's Z through. Pinning Z to 0 here + // and then clamping to fullBox below collapsed the box to empty whenever the + // drawing sat entirely off the z=0 plane, which is exactly where viewport + // underlay geometry lives (VIEWPORT_Z), and an empty box makes _fit a no-op. + const box = new THREE.Box3( + new THREE.Vector3(minX - padX, minY - padY, fullBox.min.z), + new THREE.Vector3(maxX + padX, maxY + padY, fullBox.max.z), + ); + // Never expand beyond the true full box + box.min.max(fullBox.min); + box.max.min(fullBox.max); + return box; + } + + /** + * Zoom camera to a world AABB of visible content (camera-local frustum + pan). + * `box` must already reflect only currently drawn entities (space/layer filter). + * + * Civil model drawings are often extreme horizontal strips (aspect 50–200:1). + * Classic "contain" fit then letterboxes so the strip is only a few pixels tall + * (looks like a single line at the top/center). For such strips, fit so the + * **short axis fills the screen** (Y fills for wide strips); pan to see the rest. + */ + _fit(box) { + if (!box || box.isEmpty()) return; + if (this._is25D) { this._fit25(box); return; } + const c = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const w = this._container.clientWidth || 800; + const h = this._container.clientHeight || 600; + const aspect = w / Math.max(h, 1); + const pad = 1.12; + const bw = Math.max(size.x, 1e-9); + const bh = Math.max(size.y, 1e-9); + const contentAspect = bw / bh; + + let vw, vh; + // Extreme strip: prefer filling the short side so geometry is readable. + // Threshold ~8× screen aspect (e.g. content 16:1 on a 2:1 window). + if (contentAspect > aspect * 8) { + // Very wide (plan corridor / bb-style double box): fill view height + vh = bh * pad; + vw = vh * aspect; + } else if (contentAspect < aspect / 8) { + // Very tall: fill view width + vw = bw * pad; + vh = vw / aspect; + } else { + // Normal sheet / roughly viewport-shaped content: classic contain + vw = bw * pad; + vh = bh * pad; + if (vw / vh > aspect) vh = vw / aspect; + else vw = vh * aspect; + } + + this._camera.zoom = 1; + this._camera.left = -vw / 2; this._camera.right = vw / 2; + this._camera.top = vh / 2; this._camera.bottom = -vh / 2; + const span = Math.max(vw, vh, 1000); + this._camera.near = -span; + this._camera.far = span; + this._camera.position.set(c.x, c.y, 100); + const a = -(this._viewTwist || 0); + this._camera.up.set(-Math.sin(a), Math.cos(a), 0); + this._controls.target.set(c.x, c.y, 0); + this._camera.lookAt(this._controls.target); + this._camera.updateProjectionMatrix(); + this._camera.updateMatrixWorld(true); + this._controls.update(); + } + + /** + * Zoom-extents for the tilted view. The box is sized by its 3D diagonal so the + * drawing stays framed at *any* orbit angle — re-fitting on every rotation + * would fight the user's drag. + */ + _fit25(box) { + const b = this._box25(box); + if (!b) return; + const c = b.getCenter(new THREE.Vector3()); + const diag = Math.max(b.getSize(new THREE.Vector3()).length(), 1e-6); + const w = this._container.clientWidth || 800; + const h = this._container.clientHeight || 600; + const aspect = w / Math.max(h, 1); + const need = diag * 1.1; + const vw = aspect >= 1 ? need * aspect : need; + const vh = aspect >= 1 ? need : need / aspect; + + // Entering 2.5D snaps to SE isometric (+X/−Y/+Z, AutoCAD's default view); + // a later Fit keeps whatever angle the user orbited to. + const dir = new THREE.Vector3(); + if (this._need25Reset) dir.set(1, -1, 1); + else dir.subVectors(this._camera.position, this._controls.target); + if (dir.lengthSq() < 1e-12) dir.set(1, -1, 1); + dir.normalize(); + this._need25Reset = false; + + const dist = diag * 2 + 1; // ortho: only anchors the clip slab, not the scale + this._camera.zoom = 1; + this._camera.left = -vw / 2; this._camera.right = vw / 2; + this._camera.top = vh / 2; this._camera.bottom = -vh / 2; + const span = dist + diag + 1; + this._camera.near = -span; + this._camera.far = span; + this._camera.up.set(0, 0, 1); + this._controls.target.copy(c); + this._camera.position.copy(c).addScaledVector(dir, dist); + this._camera.lookAt(this._controls.target); + this._camera.updateProjectionMatrix(); + this._camera.updateMatrixWorld(true); + this._controls.update(); + } + + _clear() { + for (let i = this._group.children.length - 1; i >= 0; i--) { + const o = this._group.children[i]; + o.geometry?.dispose(); + if (o.material) { o.material.map?.dispose(); o.material.dispose(); } + this._group.remove(o); + } + // Shared text textures were disposed via the sprites above (re-dispose is a + // no-op); drop the cache so the next load rasterizes fresh. + this._texCache?.clear(); + this._textGen++; // invalidate any in-flight async text flush + this._contentBox = null; + this._fullContentBox = null; + this._contentZ = null; + this._layerObjs.clear(); + this._layerBoxes.clear(); + this._layerSamples.clear(); + this._indexedBufs = []; + this._fatBufs = []; + this._layerNames = null; + this._metaHidden = null; + this._curLayer = null; + this._curLayerId = -1; + this._layerInfoCache = null; + this._fillXf = null; // a load that threw mid-viewport must not leak its transform + } + + _onResize() { + const w = this._container.clientWidth, h = this._container.clientHeight; + if (!w || !h) return; + this._renderer.setSize(w, h); + // Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords. + // Just preserve half-widths and re-adjust aspect — camera position tracks panning via OrbitControls. + const halfW = (this._camera.right - this._camera.left) / 2; + const halfH = halfW / (w / h); + this._camera.left = -halfW; this._camera.right = halfW; + this._camera.top = halfH; this._camera.bottom = -halfH; + 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() { + requestAnimationFrame(() => this._animate()); + this._controls.update(); + this._renderer.render(this._scene, this._camera); + } +} diff --git a/src/engine/viewer2d/acadrust-dwg/acadrust_dwg.d.ts b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg.d.ts new file mode 100644 index 0000000..42effc3 --- /dev/null +++ b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg.d.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ + +export function parse_dwg(bytes: Uint8Array): any; + +/** + * The same parseResult as [`parse_dwg`], returned as a JSON string for the + * caller to hand to `JSON.parse`. Prefer this on anything but small drawings. + * + * Materializing the whole result as a `serde_json::Value` is what makes big + * files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing + * peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the + * live heap, so conversion goes quadratic — measured 352 s in wasm for work + * that takes 2.5 s natively. Converting one entity at a time and appending its + * JSON text keeps the live heap flat and the cost linear. + */ +export function parse_dwg_json(bytes: Uint8Array): string; + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly parse_dwg: (a: number, b: number) => [number, number, number]; + readonly parse_dwg_json: (a: number, b: number) => [number, number, number, number]; + readonly __wbindgen_malloc: (a: number, b: number) => number; + readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; + readonly __wbindgen_externrefs: WebAssembly.Table; + readonly __externref_table_dealloc: (a: number) => void; + readonly __wbindgen_free: (a: number, b: number, c: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; + +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/src/engine/viewer2d/acadrust-dwg/acadrust_dwg.js b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg.js new file mode 100644 index 0000000..a2ef7d2 --- /dev/null +++ b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg.js @@ -0,0 +1,320 @@ +/* @ts-self-types="./acadrust_dwg.d.ts" */ + +/** + * @param {Uint8Array} bytes + * @returns {any} + */ +export function parse_dwg(bytes) { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_dwg(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * The same parseResult as [`parse_dwg`], returned as a JSON string for the + * caller to hand to `JSON.parse`. Prefer this on anything but small drawings. + * + * Materializing the whole result as a `serde_json::Value` is what makes big + * files unopenable. The tree costs ~150× the DWG on the heap (a 19 MB drawing + * peaks near 2 GB), and the wasm allocator's per-allocation cost rises with the + * live heap, so conversion goes quadratic — measured 352 s in wasm for work + * that takes 2.5 s natively. Converting one entity at a time and appending its + * JSON text keeps the live heap flat and the cost linear. + * @param {Uint8Array} bytes + * @returns {string} + */ +export function parse_dwg_json(bytes) { + let deferred3_0; + let deferred3_1; + try { + const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_dwg_json(ptr0, len0); + var ptr2 = ret[0]; + var len2 = ret[1]; + if (ret[3]) { + ptr2 = 0; len2 = 0; + throw takeFromExternrefTable0(ret[2]); + } + deferred3_0 = ptr2; + deferred3_1 = len2; + return getStringFromWasm0(ptr2, len2); + } finally { + wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); + } +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg_Error_92b29b0548f8b746: function(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_String_8564e559799eccda: function(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_string_ea5e6cc2e4141dfe: function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }, + __wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg_new_32b398fb48b6d94a: function() { + const ret = new Array(); + return ret; + }, + __wbg_new_7796ffc7ed656783: function() { + const ret = new Map(); + return ret; + }, + __wbg_new_da52cf8fe3429cb2: function() { + const ret = new Object(); + return ret; + }, + __wbg_set_575dd786d51585f8: function(arg0, arg1, arg2) { + const ret = arg0.set(arg1, arg2); + return ret; + }, + __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) { + arg0[arg1] = arg2; + }, + __wbg_set_8a16b38e4805b298: function(arg0, arg1, arg2) { + arg0[arg1 >>> 0] = arg2; + }, + __wbindgen_cast_0000000000000001: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0) { + // Cast intrinsic for `I64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./acadrust_dwg_bg.js": import0, + }; +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('acadrust_dwg_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/src/engine/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm new file mode 100644 index 0000000..36bd496 Binary files /dev/null and b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm differ diff --git a/src/engine/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm.d.ts b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm.d.ts new file mode 100644 index 0000000..309dbd8 --- /dev/null +++ b/src/engine/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm.d.ts @@ -0,0 +1,11 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const parse_dwg: (a: number, b: number) => [number, number, number]; +export const parse_dwg_json: (a: number, b: number) => [number, number, number, number]; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_start: () => void; diff --git a/src/engine/viewer2d/acadrust-dwg/package.json b/src/engine/viewer2d/acadrust-dwg/package.json new file mode 100644 index 0000000..870cb90 --- /dev/null +++ b/src/engine/viewer2d/acadrust-dwg/package.json @@ -0,0 +1,17 @@ +{ + "name": "dwg-wasm", + "type": "module", + "description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape", + "version": "0.1.0", + "license": "MIT", + "files": [ + "acadrust_dwg_bg.wasm", + "acadrust_dwg.js", + "acadrust_dwg.d.ts" + ], + "main": "acadrust_dwg.js", + "types": "acadrust_dwg.d.ts", + "sideEffects": [ + "./snippets/*" + ] +} diff --git a/src/engine/viewer2d/acadrustParser.ts b/src/engine/viewer2d/acadrustParser.ts new file mode 100644 index 0000000..7d534e6 --- /dev/null +++ b/src/engine/viewer2d/acadrustParser.ts @@ -0,0 +1,28 @@ +/** + * acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm + * (wasm-pack --target web, committed under ./acadrust-dwg). + */ +import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js'; +import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url'; +import type { CadParseResult } from './dwgParser'; + +let ready: Promise | null = null; + +/** Initialize the acadrust WASM module once (idempotent). */ +export function initAcadrustParser(): Promise { + if (!ready) ready = init({ module_or_path: wasmUrl }).then(() => undefined); + return ready; +} + +/** + * Parse a DWG buffer. Requires initAcadrustParser() first. + * + * Goes through `parse_dwg_json` + `JSON.parse` rather than the `parse_dwg` + * export that hands back a JsValue. The latter keeps the entire result live on + * the wasm heap as it builds, and the allocator's cost grows with that heap — + * a 19 MB drawing took ~354 s and froze the tab. Streaming the result out as + * text keeps it linear: the same file now lands in ~2.5 s. + */ +export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult { + return JSON.parse(parse_dwg_json(bytes)) as CadParseResult; +} diff --git a/src/engine/viewer2d/aciColors.js b/src/engine/viewer2d/aciColors.js new file mode 100644 index 0000000..cb01809 --- /dev/null +++ b/src/engine/viewer2d/aciColors.js @@ -0,0 +1,64 @@ +/** + * AutoCAD Color Index (ACI) → hex RGB integer. + * Returns null for BYBLOCK (0) and BYLAYER (256) — caller uses layer/default color. + */ + +function hsl2hex(h, s, l) { + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - c / 2; + let r, g, b; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + return Math.round((r + m) * 255) * 65536 + + Math.round((g + m) * 255) * 256 + + Math.round((b + m) * 255); +} + +// ACI 0-9: exact fixed colors +const FIXED = [ + null, // 0: BYBLOCK + 0xFF0000, // 1: red + 0xFFFF00, // 2: yellow + 0x00FF00, // 3: green + 0x00FFFF, // 4: cyan + 0x0000FF, // 5: blue + 0xFF00FF, // 6: magenta + 0xFFFFFF, // 7: white + 0x414141, // 8: dark gray + 0x808080, // 9: gray +]; + +// ACI 10-249: 24 hue groups × 10 saturation/lightness variants +const VARIANTS_SL = [ + [1.00, 0.50], // 0: full + [1.00, 0.28], // 1: dark + [0.65, 0.65], // 2: light pastel + [0.65, 0.38], // 3: pastel dark + [0.35, 0.80], // 4: pale + [0.35, 0.48], // 5: pale mid + [0.18, 0.68], // 6: faint light + [0.18, 0.38], // 7: faint dark + [0.00, 0.55], // 8: light gray + [0.00, 0.30], // 9: dark gray +]; + +export function aciToHex(aci, isDark = true) { + if (aci == null || aci === 0 || aci === 256) return null; + if (aci === 7) return isDark ? 0xFFFFFF : 0x000000; + if (aci <= 9) return FIXED[aci] ?? null; + if (aci >= 250 && aci <= 255) { + const v = Math.round(40 + (aci - 250) * 43); + return v * 65536 + v * 256 + v; + } + if (aci >= 10 && aci <= 249) { + const idx = aci - 10; + const [s, l] = VARIANTS_SL[idx % 10]; + return hsl2hex(Math.floor(idx / 10) * 15, s, l); + } + return 0xC9D1D9; +} diff --git a/src/engine/viewer2d/cadSpaces.ts b/src/engine/viewer2d/cadSpaces.ts new file mode 100644 index 0000000..f289a15 --- /dev/null +++ b/src/engine/viewer2d/cadSpaces.ts @@ -0,0 +1,166 @@ +/** + * Model Space / Paper Space (Layout) discovery for CadParseResult. + * + * DWG entities are owned by a block header handle. System blocks + * *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces + * AutoCAD switches between. Viewer2D must render only one at a time — + * drawing both stacks survey-model geometry on top of layout sheet + * geometry and produces the "all text piled in the center" glitch. + */ + +export type CadSpaceKind = 'model' | 'paper' | 'other'; + +export type CadSpace = { + /** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */ + id: string; + /** Display label, e.g. "Model" / "Layout" / raw block name. */ + label: string; + /** Raw block name from tables.blocks. */ + name: string; + /** Block handle as number when available. */ + handle: number | null; + /** Hex string matching entity ownerHandle.value.toString(16). */ + handleHex: string; + kind: CadSpaceKind; + entityCount: number; +}; + +function handleToHex(handle: unknown): string | null { + if (handle == null) return null; + if (typeof handle === 'object' && handle !== null && 'value' in handle) { + const v = (handle as { value?: unknown }).value; + if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16); + if (typeof v === 'string' && v.length) { + const n = Number(v); + return Number.isFinite(n) ? n.toString(16) : v.toLowerCase(); + } + return null; + } + if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16); + if (typeof handle === 'string' && handle.length) { + const n = Number(handle); + return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase(); + } + return null; +} + +function ownerHex(entity: { ownerHandle?: unknown }): string | null { + return handleToHex(entity.ownerHandle); +} + +function classifySpaceName(raw: string): CadSpaceKind | null { + const name = raw.toLowerCase().replace(/\*/g, '').trim(); + if (!name) return null; + if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model'; + if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper'; + return null; +} + +function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string { + if (kind === 'model') return 'Model'; + if (kind === 'paper') { + // First paper space → "Layout"; further ones keep a disambiguator. + if (paperIndex <= 1) return 'Layout'; + return `Layout (${name.replace(/^\*/, '')})`; + } + return name; +} + +/** + * List model/paper space blocks that own at least zero entities. + * Always includes Model/Paper headers found in tables.blocks. + */ +export function listCadSpaces(result: { + entities?: unknown[]; + tables?: { blocks?: Array<{ name?: string; handle?: unknown }> }; +} | null | undefined): CadSpace[] { + const blocks = result?.tables?.blocks ?? []; + const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>; + + const counts = new Map(); + for (const e of entities) { + const hex = ownerHex(e); + if (!hex) continue; + counts.set(hex, (counts.get(hex) || 0) + 1); + } + + const spaces: CadSpace[] = []; + let paperIndex = 0; + for (const b of blocks) { + const kind = classifySpaceName(b.name ?? ''); + if (!kind) continue; + const hex = handleToHex(b.handle); + if (!hex) continue; + if (kind === 'paper') paperIndex += 1; + const handleNum = + typeof b.handle === 'number' + ? b.handle + : typeof b.handle === 'object' && b.handle && 'value' in b.handle + ? Number((b.handle as { value: unknown }).value) + : null; + spaces.push({ + id: hex, + label: spaceLabel(kind, b.name ?? '', paperIndex), + name: b.name ?? '', + handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null, + handleHex: hex, + kind, + entityCount: counts.get(hex) || 0, + }); + } + + // Stable order: Model first, then papers in table order. + spaces.sort((a, b) => { + if (a.kind !== b.kind) { + if (a.kind === 'model') return -1; + if (b.kind === 'model') return 1; + } + return 0; + }); + return spaces; +} + +/** + * Default tab when a file is opened: + * - Paper/Layout with entities if any (civil sheet drawings often leave Layout active) + * - else Model + * - else first listed space + */ +export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null { + if (!spaces.length) return null; + const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0); + if (paperWithContent) return paperWithContent; + const model = spaces.find((s) => s.kind === 'model'); + if (model) return model; + return spaces[0] ?? null; +} + +/** + * Owners allowed when filtering to one space: the space handle itself plus + * handles of entities that live under it (e.g. INSERT → ATTRIB children). + */ +export function buildAllowedOwnerHexes( + entities: Array<{ handle?: unknown; ownerHandle?: unknown }>, + activeSpaceHex: string, + rounds = 3, +): Set { + const allowed = new Set([activeSpaceHex]); + for (let pass = 0; pass < rounds; pass++) { + let grew = false; + for (const ent of entities) { + const oh = ownerHex(ent); + if (!oh || !allowed.has(oh)) continue; + const h = handleToHex(ent.handle); + if (h && !allowed.has(h)) { + allowed.add(h); + grew = true; + } + } + if (!grew) break; + } + return allowed; +} + +export function isSystemSpaceBlockName(name: string): boolean { + return classifySpaceName(name) != null; +} diff --git a/src/engine/viewer2d/decodeDxf.ts b/src/engine/viewer2d/decodeDxf.ts new file mode 100644 index 0000000..a487136 --- /dev/null +++ b/src/engine/viewer2d/decodeDxf.ts @@ -0,0 +1,35 @@ +/** + * Decode a DXF buffer to text. + * + * Modern CAD exports are UTF-8 but often keep a legacy $DWGCODEPAGE + * (e.g. ANSI_949) that no longer matches the bytes — so the header is + * unreliable. Strategy: try UTF-8 strictly (self-validating); on failure + * fall back to the header's single-byte codepage. The DXF group/value + * structure is ASCII regardless, so this only affects TEXT/MTEXT strings. + */ +export function decodeDxf(buf: ArrayBuffer): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buf); + } catch { + /* not valid UTF-8 → legacy single-byte codepage */ + } + const head = new TextDecoder('latin1').decode( + new Uint8Array(buf, 0, Math.min(buf.byteLength, 4096)), + ); + const cp = head + .match(/\$DWGCODEPAGE[\s\S]*?\n\s*3\s*\n\s*([^\r\n]+)/i)?.[1] + ?.trim() + .toLowerCase(); + let label = 'windows-1252'; + if (cp) { + if (/949/.test(cp)) label = 'euc-kr'; + else if (/932/.test(cp)) label = 'shift_jis'; + else if (/936/.test(cp)) label = 'gbk'; + else if (/950/.test(cp)) label = 'big5'; + } + try { + return new TextDecoder(label).decode(buf); + } catch { + return new TextDecoder('utf-8').decode(buf); + } +} diff --git a/src/engine/viewer2d/dwgParser.ts b/src/engine/viewer2d/dwgParser.ts new file mode 100644 index 0000000..f8307bd --- /dev/null +++ b/src/engine/viewer2d/dwgParser.ts @@ -0,0 +1,31 @@ +/** + * DWG parser — rust/dwg-wasm wrapper around the acadrust crate (MPL-2.0, + * vendored with a local read_mesh patch; see hmwebviewer rust/dwg-wasm). Emits + * the parseResult shape Viewer2D.load() consumes. Lazy-imported so the wasm is + * only fetched when a DWG is actually opened. + * + * History: until 2026-07-10 this module selected between acadrust and + * @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed + * after side-by-side validation (PROGRESS.md Phase 15). + * + * Post-process: fixDwgKoreanText repairs CP949→Latin-1 mojibake common in + * older Korean DWGs (see fixDwgKoreanText.ts). Unicode-correct drawings + * (e.g. BasicSample) are left unchanged. + */ + +import { fixDwgKoreanText } from './fixDwgKoreanText'; + +export type CadParseResult = { + version?: string; + vars?: Record; + entities?: unknown[]; + tables?: Record; + stats?: Record; +}; + +/** Parse a DWG buffer. Returns a parseResult for Viewer2D.load(). */ +export async function parseDwgBuffer(bytes: Uint8Array): Promise { + const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser'); + await initAcadrustParser(); + return fixDwgKoreanText(parseDwgAcadrust(bytes)); +} diff --git a/src/engine/viewer2d/dxfAdapter.d.ts b/src/engine/viewer2d/dxfAdapter.d.ts new file mode 100644 index 0000000..14f4365 --- /dev/null +++ b/src/engine/viewer2d/dxfAdapter.d.ts @@ -0,0 +1,7 @@ +/** Convert dxf-parser output into the parseResult shape Viewer2D.load() consumes. */ +export function dxfToParseResult(dxf: unknown): { + version: string; + vars: Record; + entities: unknown[]; + tables: { blocks: unknown[]; layers: unknown[] }; +}; diff --git a/src/engine/viewer2d/dxfAdapter.js b/src/engine/viewer2d/dxfAdapter.js new file mode 100644 index 0000000..d631b72 --- /dev/null +++ b/src/engine/viewer2d/dxfAdapter.js @@ -0,0 +1,160 @@ +/** + * dxfAdapter — convert dxf-parser (MIT) output into the parseResult shape that + * Viewer2D.load() consumes, so the existing renderer (bulge arcs, dimension + * block geometry, arrowhead blocks, text alignment) is reused unchanged for DXF. + * + * Why an adapter and not dxf-parser's handles directly: dxf-parser exposes the + * raw DXF handle graph where a BLOCK entity's handle differs from the BLOCK_RECORD + * handle its child entities are owned by, and INSERT/DIMENSION reference their + * block by NAME, not handle. Viewer2D matches blocks by handle. So we discard the + * incoherent handles and synthesize one consistent integer handle PER BLOCK NAME: + * - block table entry handle = {value: H} + * - every child entity ownerHandle = {value: H} + * - INSERT.blockHeaderHandle = {value: H of insert.name} + * - DIMENSION.blockHeaderHandle = {value: H of dim.block} + * Model-space entities are owned by the synthetic '*MODEL_SPACE' handle so + * Viewer2D's space-handle filter keeps them visible. + * + * Units: dxf-parser gives ARC/ELLIPSE angles in radians (pass through) but + * INSERT/TEXT/MTEXT rotation in DEGREES → converted to radians here. + * + * Not supported by dxf-parser 1.1.x: HATCH (no handler) → hatches are absent. + */ +const DEG2RAD = Math.PI / 180; + +const vec = (p) => (p ? { x: p.x, y: p.y, z: p.z || 0 } : p); + +/** Map one dxf-parser entity → Viewer2D field names. Returns a shallow copy. */ +function adaptEntity(e, nameToH) { + const o = { ...e }; + delete o.color; // dxf-parser's `color` is a 24-bit RGB int; let colorIndex/layer drive ACI + const t = (e.type || '').toUpperCase(); + + switch (t) { + case 'LINE': + if (e.vertices?.length >= 2) { o.start = vec(e.vertices[0]); o.end = vec(e.vertices[1]); } + break; + + case 'POLYLINE': + case 'LWPOLYLINE': + o.type = 'LWPOLYLINE'; + o.points = (e.vertices || []).map(vec); + o.bulges = (e.vertices || []).map((v) => v.bulge || 0); + o.closed = !!e.shape || ((e.flags || 0) & 1) === 1; + break; + + case 'ARC': + case 'CIRCLE': + o.center = vec(e.center); // radius + (radian) angles already match + break; + + case 'ELLIPSE': + o.center = vec(e.center); + o.majorAxis = vec(e.majorAxisEndPoint); + o.ratio = e.axisRatio; + o.startParam = e.startAngle; + o.endParam = e.endAngle; + break; + + case 'POINT': + o.position = vec(e.position); + break; + + case 'SOLID': + case '3DFACE': { + const p = e.points || []; + if (p[0]) o.corner1 = vec(p[0]); + if (p[1]) o.corner2 = vec(p[1]); + if (p[2]) o.corner3 = vec(p[2]); + if (p[3]) o.corner4 = vec(p[3]); + o.type = 'SOLID'; + break; + } + + case 'TEXT': + case 'ATTRIB': + case 'ATTDEF': + o.insertionPoint = vec(e.startPoint || e.position); + o.alignmentPt = vec(e.endPoint); + o.rotationAngle = (e.rotation || 0) * DEG2RAD; + o.horizAlignment = e.halign || 0; + o.vertAlignment = e.valign || 0; + o.textHeight = e.textHeight ?? e.height; + break; + + case 'MTEXT': + o.insertionPoint = vec(e.position); + o.textHeight = e.height ?? e.textHeight; + if (e.directionVector) o.xAxisDir = vec(e.directionVector); + else if (e.rotation != null) o.xAxisDir = { x: Math.cos(e.rotation * DEG2RAD), y: Math.sin(e.rotation * DEG2RAD) }; + break; + + case 'INSERT': { + o.insertionPoint = vec(e.position); + o.scaleX = e.xScale ?? 1; + o.scaleY = e.yScale ?? 1; + o.rotation = (e.rotation || 0) * DEG2RAD; + const h = nameToH.get(e.name); + if (h != null) o.blockHeaderHandle = { value: h }; + break; + } + + case 'DIMENSION': { + const h = nameToH.get(e.block); + if (h != null) o.blockHeaderHandle = { value: h }; + break; + } + + default: + break; + } + return o; +} + +/** + * Convert a full dxf-parser result → Viewer2D parseResult. + * @param {object} dxf output of `new DxfParser().parseSync(text)` + * @returns {{version:string, vars:object, entities:object[], tables:{blocks:object[], layers:object[]}}} + */ +export function dxfToParseResult(dxf) { + const blocks = dxf.blocks || {}; + const names = Object.keys(blocks); + const nameToH = new Map(); + names.forEach((n, i) => nameToH.set(n, i + 1)); // 1-based synthetic handles + + const msName = names.find((n) => /model_space/i.test(n)) ?? names.find((n) => /model/i.test(n)); + const msH = msName != null ? nameToH.get(msName) : 0; + + const entities = []; + // Model-space entities (top-level array) → owned by the synthetic *MODEL_SPACE handle. + for (const e of (dxf.entities || [])) { + const o = adaptEntity(e, nameToH); + o.ownerHandle = { value: msH }; + entities.push(o); + } + // Block-definition entities → owned by their block's synthetic handle. + for (const n of names) { + const H = nameToH.get(n); + for (const e of (blocks[n].entities || [])) { + const o = adaptEntity(e, nameToH); + o.ownerHandle = { value: H }; + entities.push(o); + } + } + + const blockTable = names.map((n) => ({ + name: n, + handle: { value: nameToH.get(n) }, + basePoint: vec(blocks[n].position) || { x: 0, y: 0, z: 0 }, + })); + + const layerSrc = dxf.tables?.layer?.layers || {}; + const layerTable = Object.values(layerSrc).map((l) => ({ name: l.name, colorIndex: l.colorIndex })); + + return { + version: 'dxf', + vars: dxf.header || {}, + entities, + tables: { blocks: blockTable, layers: layerTable }, + }; +} diff --git a/src/engine/viewer2d/dxfHatchHandler.d.ts b/src/engine/viewer2d/dxfHatchHandler.d.ts new file mode 100644 index 0000000..7a1eb37 --- /dev/null +++ b/src/engine/viewer2d/dxfHatchHandler.d.ts @@ -0,0 +1,5 @@ +/** HATCH entity handler for dxf-parser's registerEntityHandler(). */ +export default class HatchHandler { + ForEntityName: string; + parseEntity(scanner: unknown, curr: { code: number; value: unknown }): unknown; +} diff --git a/src/engine/viewer2d/dxfHatchHandler.js b/src/engine/viewer2d/dxfHatchHandler.js new file mode 100644 index 0000000..6135d11 --- /dev/null +++ b/src/engine/viewer2d/dxfHatchHandler.js @@ -0,0 +1,190 @@ +/** + * HATCH entity handler for dxf-parser (which ships none — dxf-parser 1.1.x drops + * HATCH silently). Registered via the library's public `registerEntityHandler()`, + * so dxf-parser itself stays unmodified (MIT-clean). + * + * Emits the shape Viewer2D's HATCH renderer consumes: + * entity = { type:'HATCH', solidFill:boolean, paths:[{ points:[{x,y}], bulges:[number] }] } + * Viewer2D fills solid hatches (with island/hole nesting) and draws boundary + * outlines; bulge arcs on polyline boundaries are honored. + * + * Boundary kinds handled: + * - polyline boundary (92 flag bit 1) → vertices 10/20 (+ bulge 42) + * - edge boundary: LINE(72=1), ARC(72=2), → tessellated to points + * ELLIPSE(72=3), SPLINE(72=4, control-point polyline) + * + * Pattern fill (non-SOLID) is not expanded into hatch lines — Viewer2D draws the + * boundary outline as a proxy, matching its DWG behavior. + */ +const DEG2RAD = Math.PI / 180; +const ARC_STEPS = 48; + +function tessArc(cx, cy, r, a0, a1, ccw, out) { + let span = a1 - a0; + if (ccw) { while (span <= 0) span += Math.PI * 2; } + else { while (span >= 0) span -= Math.PI * 2; } + const steps = Math.max(2, Math.ceil(Math.abs(span) / (Math.PI * 2) * ARC_STEPS)); + for (let i = 0; i <= steps; i++) { + const a = a0 + (span * i) / steps; + out.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) }); + } +} + +export default class HatchHandler { + constructor() { + this.ForEntityName = 'HATCH'; + } + + parseEntity(scanner, curr) { + const entity = { type: curr.value, paths: [], solidFill: false }; + curr = scanner.next(); + while (!scanner.isEOF()) { + if (curr.code === 0) break; + switch (curr.code) { + case 70: // solid fill flag + entity.solidFill = curr.value !== 0; + break; + case 2: // pattern name + entity.patternName = curr.value; + break; + case 8: // layer + entity.layer = curr.value; + break; + case 62: // color index + entity.colorIndex = curr.value; + break; + case 91: { // number of boundary paths → parse them by count + const n = curr.value; + let g = scanner.next(); + for (let i = 0; i < n && !scanner.isEOF(); i++) { + g = this._readPath(scanner, entity, g); + } + curr = g; // g is the group following the last path's data + continue; // curr already advanced — skip the trailing next() + } + default: + break; + } + curr = scanner.next(); + } + return entity; + } + + /** Parse one boundary path starting at its 92 group; return the next group after it. */ + _readPath(scanner, entity, g) { + // g.code should be 92 (path type flag); if not, skip until we find it. + while (!scanner.isEOF() && g.code !== 92) g = scanner.next(); + const flag = g.value | 0; + const isPolyline = (flag & 2) !== 0; + const path = { points: [], bulges: [] }; + let cur = scanner.next(); + + if (isPolyline) { + let hasBulge = false; + while (cur.code === 72 || cur.code === 73) { + if (cur.code === 72) hasBulge = cur.value !== 0; + cur = scanner.next(); + } + let numVerts = 0; + if (cur.code === 93) { numVerts = cur.value; cur = scanner.next(); } + for (let v = 0; v < numVerts && !scanner.isEOF(); v++) { + let x = 0, y = 0, b = 0; + if (cur.code === 10) { x = cur.value; cur = scanner.next(); } + if (cur.code === 20) { y = cur.value; cur = scanner.next(); } + if (hasBulge && cur.code === 42) { b = cur.value; cur = scanner.next(); } + path.points.push({ x, y }); + path.bulges.push(b); + } + } else { + let numEdges = 0; + if (cur.code === 93) { numEdges = cur.value; cur = scanner.next(); } + for (let e = 0; e < numEdges && !scanner.isEOF(); e++) { + let edgeType = 0; + if (cur.code === 72) { edgeType = cur.value; cur = scanner.next(); } + cur = this._readEdge(scanner, path, edgeType, cur); + } + } + + // Consume the per-path trailing "source boundary objects" block (97 + 330×count). + if (cur.code === 97) { + const srcN = cur.value | 0; + cur = scanner.next(); + for (let s = 0; s < srcN && !scanner.isEOF(); s++) { + if (cur.code === 330) cur = scanner.next(); + else break; + } + } + if (path.points.length >= 2) entity.paths.push(path); + return cur; + } + + /** Tessellate one edge into path.points; return the next group. */ + _readEdge(scanner, path, edgeType, cur) { + if (edgeType === 1) { + // LINE: 10/20 start, 11/21 end + let sx = 0, sy = 0, ex = 0, ey = 0; + if (cur.code === 10) { sx = cur.value; cur = scanner.next(); } + if (cur.code === 20) { sy = cur.value; cur = scanner.next(); } + if (cur.code === 11) { ex = cur.value; cur = scanner.next(); } + if (cur.code === 21) { ey = cur.value; cur = scanner.next(); } + path.points.push({ x: sx, y: sy }); + path.points.push({ x: ex, y: ey }); // last edge's end closes the loop + } else if (edgeType === 2) { + // ARC: 10/20 center, 40 radius, 50 start deg, 51 end deg, 73 ccw + let cx = 0, cy = 0, r = 0, a0 = 0, a1 = 0, ccw = 1; + if (cur.code === 10) { cx = cur.value; cur = scanner.next(); } + if (cur.code === 20) { cy = cur.value; cur = scanner.next(); } + if (cur.code === 40) { r = cur.value; cur = scanner.next(); } + if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); } + tessArc(cx, cy, r, a0, a1, ccw !== 0, path.points); + } else if (edgeType === 3) { + // ELLIPSE: 10/20 center, 11/21 major-axis endpoint, 40 ratio, 50/51 param deg, 73 ccw + let cx = 0, cy = 0, mx = 1, my = 0, ratio = 1, a0 = 0, a1 = Math.PI * 2, ccw = 1; + if (cur.code === 10) { cx = cur.value; cur = scanner.next(); } + if (cur.code === 20) { cy = cur.value; cur = scanner.next(); } + if (cur.code === 11) { mx = cur.value; cur = scanner.next(); } + if (cur.code === 21) { my = cur.value; cur = scanner.next(); } + if (cur.code === 40) { ratio = cur.value; cur = scanner.next(); } + if (cur.code === 50) { a0 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 51) { a1 = cur.value * DEG2RAD; cur = scanner.next(); } + if (cur.code === 73) { ccw = cur.value; cur = scanner.next(); } + const major = Math.hypot(mx, my); + const minor = major * ratio; + const rot = Math.atan2(my, mx); + let span = a1 - a0; + if (ccw !== 0) { while (span <= 0) span += Math.PI * 2; } + else { while (span >= 0) span -= Math.PI * 2; } + const steps = Math.max(4, Math.ceil(Math.abs(span) / (Math.PI * 2) * 72)); + for (let i = 0; i <= steps; i++) { + const t = a0 + (span * i) / steps; + const ex = major * Math.cos(t), ey = minor * Math.sin(t); + path.points.push({ x: cx + ex * Math.cos(rot) - ey * Math.sin(rot), y: cy + ex * Math.sin(rot) + ey * Math.cos(rot) }); + } + } else if (edgeType === 4) { + // SPLINE: approximate by its control points (10/20). Skip knots/weights. + // 94 degree, 73 rational, 74 periodic, 95 numKnots, 96 numCtrl, 40 knots, 10/20 ctrl, 42 weights + let numKnots = 0, numCtrl = 0; + const readInt = () => { const v = cur.value | 0; cur = scanner.next(); return v; }; + while (!scanner.isEOF()) { + if (cur.code === 95) { numKnots = readInt(); } + else if (cur.code === 96) { numCtrl = readInt(); } + else if (cur.code === 94 || cur.code === 73 || cur.code === 74) { cur = scanner.next(); } + else break; + } + for (let k = 0; k < numKnots && cur.code === 40 && !scanner.isEOF(); k++) cur = scanner.next(); + for (let c = 0; c < numCtrl && !scanner.isEOF(); c++) { + let x = 0, y = 0; + if (cur.code === 10) { x = cur.value; cur = scanner.next(); } + if (cur.code === 20) { y = cur.value; cur = scanner.next(); } + if (cur.code === 42) cur = scanner.next(); // weight + path.points.push({ x, y }); + } + } else { + // Unknown edge type: advance one group to avoid stalling. + cur = scanner.next(); + } + return cur; + } +} diff --git a/src/engine/viewer2d/ext2d.ts b/src/engine/viewer2d/ext2d.ts new file mode 100644 index 0000000..3fad55a --- /dev/null +++ b/src/engine/viewer2d/ext2d.ts @@ -0,0 +1,25 @@ +/** + * 2D file-format detection for the 2D+3D merge dispatch. + * + * 2D = CAD drawings (dwg/dxf) → rendered by Viewer2D via the WASM parser. + * Everything else (glb/gltf/obj/fbx/dae/ifc/ply/3ds/3dm) is 3D → ThreeDViewer. + * Kept dependency-free so both the dropzone and the controller can import it + * without pulling in either viewer. + */ +export const DWG_EXT = /\.dwg$/i; +export const DXF_EXT = /\.dxf$/i; + +/** True if the name is a binary DWG (parsed by the WASM parser). */ +export function isDwg(name: string): boolean { + return DWG_EXT.test(name); +} + +/** True if the name is a text DXF (parsed by the dxf-parser JS lib). */ +export function isDxf(name: string): boolean { + return DXF_EXT.test(name); +} + +/** True if the file/URL name is a 2D CAD drawing (dwg or dxf) → Viewer2D. */ +export function is2D(name: string): boolean { + return DWG_EXT.test(name) || DXF_EXT.test(name); +} diff --git a/src/engine/viewer2d/fixDwgKoreanText.ts b/src/engine/viewer2d/fixDwgKoreanText.ts new file mode 100644 index 0000000..78cea1f --- /dev/null +++ b/src/engine/viewer2d/fixDwgKoreanText.ts @@ -0,0 +1,106 @@ +/** + * Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR. + * + * Older Korean DWGs store strings in code page 949. When those bytes are + * decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin + * (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings + * (already containing Hangul syllables) are left unchanged. + * + * Apply after parse_dwg, before Viewer2D.load(). + */ + +const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/; +const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/; + +/** Labels tried in order; first supported decoder wins. */ +const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const; + +let koreanDecoder: TextDecoder | null | undefined; + +function getKoreanDecoder(): TextDecoder | null { + if (koreanDecoder !== undefined) return koreanDecoder; + for (const label of KOREAN_LABELS) { + try { + koreanDecoder = new TextDecoder(label); + return koreanDecoder; + } catch { + /* label not available in this runtime */ + } + } + koreanDecoder = null; + return null; +} + +/** + * If `s` looks like CP949 bytes misread as Latin-1, return the Hangul + * form; otherwise return `s` unchanged. + */ +export function fixCp949Mojibake(s: string): string { + if (!s) return s; + // Already real Hangul → leave alone (e.g. BasicSample.dwg). + if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s; + + let hasHigh = false; + const bytes = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code > 255) return s; // true Unicode beyond Latin-1 + if (code > 127) hasHigh = true; + bytes[i] = code; + } + if (!hasHigh) return s; // pure ASCII + + const decoder = getKoreanDecoder(); + if (!decoder) return s; + + let fixed: string; + try { + fixed = decoder.decode(bytes); + } catch { + return s; + } + + // Accept only when we gain Hangul and the decode is clean. + if ( + (HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) && + !fixed.includes('\uFFFD') + ) { + return fixed; + } + return s; +} + +/** + * Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields + * in place. Returns the same object for chaining. + */ +export function fixDwgKoreanText(root: T): T { + if (root == null || typeof root !== 'object') return root; + const seen = new WeakSet(); + + const walk = (value: unknown): void => { + if (value == null || typeof value !== 'object') return; + if (seen.has(value as object)) return; + seen.add(value as object); + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const item = value[i]; + if (typeof item === 'string') value[i] = fixCp949Mojibake(item); + else walk(item); + } + return; + } + + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key === 'imageDataUrl') continue; + const item = obj[key]; + if (typeof item === 'string') obj[key] = fixCp949Mojibake(item); + else walk(item); + } + }; + + walk(root); + return root; +} diff --git a/src/engine/viewer2d/linData.ts b/src/engine/viewer2d/linData.ts new file mode 100644 index 0000000..ecaed8e --- /dev/null +++ b/src/engine/viewer2d/linData.ts @@ -0,0 +1,3 @@ +// Auto-generated standard LIN file definitions for Icad.lin and KOSDIC.lin +export const ICAD_LIN = "; IntelliCAD Linetypes\r\n;\r\n; icad.lin\r\n; Release 1.0 \r\n; \r\n; .............................................................................\r\n;\r\n; Copyright (c) 1998 by Visio Corporation. All rights reserved.\r\n;\r\n; The Software is subject to the license agreement that accompanies \r\n; or is included with the Software, which specifies the permitted \r\n; and prohibited uses of the Software. Any unauthorized duplication \r\n; or use of Visio Corporation Software, in whole or in part, in print, \r\n; or in any other storage and retrieval system is prohibited.\r\n; \r\n; To the maximum extent permitted by applicable law, Visio Corporation\r\n; and its suppliers disclaim any and all warranties and conditions,\r\n; either express or implied, including, without limitation, implied\r\n; warranties of merchantability, fitness for a particular purpose,\r\n; title, and non-infringement, and those arising out of usage of trade\r\n; or course of dealing, concerning these materials. These materials\r\n; are provided \"as is\" without warranty of any kind.\r\n; .............................................................................\r\n\r\n\r\n\r\n*BORDER,__ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . __ __ . \r\nA,0.50,-0.250,0.50,-0.250,0,-0.250\r\n*BORDER2,__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__.__ \r\nA,0.25,-0.125,0.25,-0.125,0,-0.125\r\n*BORDERX2,____ ____ . ____ ____ . ____ ____ . ____ ____ . \r\nA,1.0,-0.5,1.0,-0.5,0,-0.5\r\n\r\n*CENTER,____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _ ____ _\r\nA,1.250,-0.25,0.25,-0.25\r\n*CENTER2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.1250,0.1250,-0.125\r\n*CENTERX2,________ __ ________ __ ________ __ ________ __ ____\r\nA,2.5,-0.50,0.50,-0.50\r\n\r\n*DASHDOT,__ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . __ . _\r\nA,0.50,-0.250,0,-0.250\r\n*DASHDOT2,_._._._._._._._._._._._._._._._._._._._._._._._._._._._._._.\r\nA,0.25,-0.125,0,-0.125\r\n*DASHDOTX2,____ . ____ . ____ . ____ . ____ . ____ . ____ \r\nA,1.0,-0.5,0,-0.5\r\n\r\n*DASHED,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,0.50,-0.250\r\n*DASHED2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.250,-0.1250\r\n*DASHEDX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,1.0,-0.5\r\n\r\n*DIVIDE,____ . . ____ . . ____ . . ____ . . ____ . . ____ . . ____ . .\r\nA,0.50,-0.25,0,-0.25,0,-0.25\r\n*DIVIDE2,__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..__..\r\nA,0.25,-0.125,0,-0.125,0,-0.125\r\n*DIVIDEX2,________ . . ________ . . ________ . . ________ . .\r\nA,1.0,-0.5,0,-0.5,0,-0.50\r\n\r\n*DOT,. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\r\nA,0,-0.25\r\n*DOT2,................................................................\r\nA,0,-0.125\r\n*DOTX2,. . . . . . . . . . . . . . . . . . . . . \r\nA,0,-0.5\r\n\r\n*HIDDEN,__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ \r\nA,0.250,-0.1250\r\n*HIDDEN2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.06250\r\n*HIDDENX2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,0.5,-0.25\r\n\r\n*PHANTOM,______ __ __ ______ __ __ ______ __ __ ______ __ __\r\nA,1.25,-0.25,0.250,-0.25,0.25,-0.25\r\n*PHANTOM2,___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.625,-0.125,0.1250,-0.125,0.125,-0.1250\r\n*PHANTOMX2,____________ ____ ____ ____________ ____ ____ \r\nA,2.5,-0.5,0.5,-0.5,0.50,-0.5\r\n\r\n*PHANTOM4,___ _ _ ___ _ _ (1/2 of Phantom2)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.3125,-0.0625,0.0625,-0.0625,0.0625,-0.0625\r\n\r\n*PHANTOM8,___ _ _ ___ _ _ (1/2 of Phantom4)__ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,0.15625,-0.03125,0.03125,-0.03125,0.03125,-0.03125\r\n\r\n*BORDER4,__.__.__.__.__.__._ (1/2 of Border2) _.__.__.__.__.__.__.__.__ \r\nA,0.125,-0.0625,0.125,-0.0625,0,-0.0625\r\n\r\n*CENTER4,___ _ ___ _ ___ _ __ (1/2 of Center2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.0625,0.0625,-0.0625\r\n*CENTER8,___ _ ___ _ ___ _ __ (1/2 of Center4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.03125,0.03125,-0.03125\r\n\r\n*CENTERF,_____ _ _____ (Finer short dashes than Center) _____\r\nA,1.25,-0.125,0.125,-0.125\r\n*CENTERF2,___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ _\r\nA,0.75,-0.0625,0.0625,-0.0625\r\n;; Auto-Arch additions for finer linetypes.\r\n*CENTERF4,___ _ ___ _ ___ _ __ (1/2 of CenterF2)__ _ ___ _ ___ _ ___ _ _\r\nA,0.375,-0.03125,0.03125,-0.03125\r\n*CENTERF8,___ _ ___ _ ___ _ __ (1/2 of CenterF4)__ _ ___ _ ___ _ ___ _ _\r\nA,0.1875,-0.0015625,0.0015625,-0.0015625\r\n\r\n*DASHED4,_ _ _ _ _ _ _ _ (1/2 of Dashed2)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.125,-0.0625\r\n*DASHED8,_ _ _ _ _ _ _ _ (1/2 of Dashed4)_ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.03125\r\n\r\n*DEMOx2,____ ____ ____ ____ ____\r\nA,0.250,-0.5\r\n*DEMO,__ __ __ __ __ __ __ __ __ __ __\r\nA,0.125,-0.25\r\n*DEMO2,_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.125\r\n*DEMO4,_ _ _ _ _ _ (1/2 of Demo2) _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.125\r\n*DEMO8,_ _ _ _ _ _ (1/2 of Demo4) _ _ _ _ _ _ _ _ _ _ _\r\nA,0.03125,-0.0625\r\n\r\n*INVISIBLE,. (Dot at endpoints only) .\r\nA,0,-1000\r\n\r\n*DOT4,...................(1/2 of Dot2)................................\r\nA,0,-0.06125\r\n*DOT8,...................(1/2 of Dot4)................................\r\nA,0,-0.03125\r\n*DOT16,..................(1/2 of Dot8)................................\r\nA,0,-0.0015625\r\n\r\n*HIDDEN4,_ _ _ _ _ _ _ _ (1/2 of Hidden2)_ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.0625,-0.03125\r\n*HIDDEN8,_ _ _ _ _ _ _ _ (1/2 of Hidden4)_ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.03125,-0.015\r\n\r\n*JIS_02_0.7, Hot Water Supply __ __ __ __ __ __ __\r\nA,0.8,-0.5\r\n*JIS_02_1.0, HIDDEN 0.8 - - - - - - - - - -\r\nA,0.8,-0.8\r\n*JIS_02_1.2, HIDDEN01.25 - - - - - - - - - -\r\nA,1.26,-1.26\r\n*JIS_02_2.0, HIDDEN02 __ __ __ __ __ __ __ __ __ __ __ \r\nA,1.99,-0.99\r\n*JIS_02_4.0, HIDDEN04 ____ ____ ____ ____ ____ ____ ____ \r\nA,3.999,-0.999\r\n*JIS_08_11, ___ _ ___ _ ___ _ ___ _ ___ _ ___ _ ___\r\nA,10.9999,-0.499,0.499,-0.499\r\n*JIS_08_15, ____ _ ____ _ ____ _ ____ _ ____ _ ____\r\nA,14.999,-0.499,0.499,-0.499\r\n*JIS_08_25, ________ __ ________ __ ________ __\r\nA,24.999,-1,0.8,-1\r\n*JIS_08_37, _____________ __ _____________ __ _____________ __ \r\nA,36.999,-1.0,0.8,-1\r\n*JIS_08_50, , ___________________ __ ___________________ __ \r\nA,49.999,-1.0,0.8,-1\r\n*JIS_09_08, _____ _ _ _____ _ _ _____ _ _ _____ _ _ _____ _ _ \r\nA,7.999,-0.5,0.5,-0.5,0.5,-0.5\r\n*JIS_09_15, _________ _ _ _________ _ _ _________ _ _ _________ _ _ \r\nA,14.999,-0.999,0.499,-0.999,0.499,-0.999\r\n*JIS_09_29, _____________ _ _ _____________ _ _ _____________ _ _ \r\nA,28.999,-1.499,0.999,-1.499,0.999,-1.499\r\n*JIS_09_50, _______________________ __ __ _______________________\r\nA,49.999,-1.499,0.999,-1.499,0.999,-1.499\r\n\r\n\r\n\r\n*CONTOUR,______ ______ ______ ______ ______ ______ ______ ______\r\nA,0.375,-0.09375\r\n*CONTOUR2,____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ ____ \r\nA,0.25,-0.0625\r\n*COLDWR,___ _ ____ _ _____ _ _____ _ _____ _ ____\r\nA,0.25,-0.075,0.075,-0.075\r\n*HOTWR,___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___\r\nA,0.25,-0.075,0.075,-0.075,0.075,-0.075\r\n*HCW,_____ _ _ _ _____ _ _ _ _____ _ _ _ _____\r\nA,1,-0.125,0.125,-0.125,0.125,-0.125,0.125,-0.125\r\n*BOUNDARY,___________ __ __ ___________ __ __ ______ \r\nA,1.75,-0.125,0.125,-0.125,0.125,-0.125\r\n*BOUNDARY2,______ _ _ ______ _ _ ______ _ _ ______ _ _ ______\r\nA,1,-0.0625,0.0625,-0.0625,0.0625,-0.0625\r\n*STREAM,_________ . . ___________ . . ___________\r\nA,1,-0.125,0,-0.125,0,-0.125 \r\n*PNEUM,__ ___ ___ ___ ___ ___ ___ ___ ___ ___ __\r\nA,0.2,-0.1,0.2,-0.1\r\n*WASTE,__ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,0.25,-0.125\r\n*2DASHES,____ _ _ _____ _ _ ____ _ _ ____ _ _ ____\r\nA,0.75,-0.25,0.1,-0.25,0.1,-0.25\r\n*EWIREUP,__ . __ . __ . __ . __ . __ . __ . __ .__\r\nA,0.25,-.075,0,-.075\r\n*EWCELL,___ ___ . ___ ___ . __ __ . __ __ . __ __\r\nA,0.25,-0.075,0.25,-.075,0,-.075\r\n\r\n*EVENDASH,-- -- -- -- -- -- -- -- -- -- --\r\nA,0.125,-0.125\r\n\r\n;; Additional Plumbing linetypes\r\n*DCW,Domestic Cold Water ___ _ ______________ _ ____\r\nA,1.25,-0.06,0.08,-0.06\r\n*DCW2,Domestic Cold Water __ _ ______ _ _____ _ ____\r\nA,0.625,-0.03,0.04,-0.03\r\n*DCWX2,Domestic Cold Water ________ ___ __________\r\nA,2.5,-0.12,0.16,-0.12\r\n*DHW,Domestic Hot Water ___ _ _ ___________ _ _ ____\r\nA,1.25,-0.06,0.08,-0.06,0.08,-0.06\r\n*DHW2,Domestic Hot Water __ _ _ _____ _ _ ____ _ _ __\r\nA,0.625,-0.03,0.04,-0.03,0.04,-0.03\r\n*DHWX2,Domestic Hot Water _____ ___ ___ ___ _____\r\nA,2.5,-0.12,0.16,-0.12,0.16,-0.12\r\n*DHWR,Domestic Hot Wtr Return _____ __ __ __ ________\r\nA,1.25,-0.06,0.08,-0.06,0.08,-0.06,0.08,-0.06\r\n*DHWR2,Domestic Hot Wtr Return ___ _ _ _ ____________\r\nA,0.625,-0.03,0.04,-0.03,0.04,-0.03,0.04,-0.03\r\n*DHWRX2,Domestic Hot Wtr Return ____ ___ ____ ____ ____\r\nA,2.5,-0.12,0.16,-0.12,0.16,-0.12,0.16,-0.12\r\n*VENT,Sanitary Vent Pipe __ __ __ __ __ __ __ __ __ __\r\nA,0.16,-0.08\r\n*VENT2,Sanitary Vent Pipe _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.08,-0.04\r\n*VENTX2,Sanitary Vent Pipe ____ ____ ____ ___\r\nA,0.32,-0.16\r\n*COND,Steam Condensate __ __ __ __ __ __ __ __ __ __ __\r\nA,0.16,-0.08\r\n*COND2,Steam Condensate _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _\r\nA,0.08,-0.04\r\n*CONDX2,Steam Condensate ____ ____ ____ ____\r\nA,0.32,-0.16\r\n\r\n;;; ISO LINTYPES\r\n*DaL013,02b013 Dashed line 0.13mm\r\nA,1.56,-0.39\r\n*DaSpL013,03b013 Dashed space line 0.13mm\r\nA,1.56,-2.34\r\n*LDaDoL013,04b013 Long dashed dotted line 0.13mm \r\nA,3.12,-0.39,0.065,-0.39\r\n*LDaDbDoL,05b013 Long dash double dotted line 0.13mm \r\nA,3.12,-0.39,0.065,-0.39,0.065,-0.39\r\n*LDaTrDoL,06b013 Long dashed triplicate dotted line 0.13mm \r\nA,3.12,-0.39,0.065,-0.39,0.065,-0.39,0.065,-0.39\r\n*DoL013,07b013 Dotted line\r\nA,0.065,-0.39\r\n*LDaShDaL013,08b013 Long dashed short dashed line 0.13mm \r\nA,3.12,-0.39,0.78,-0.39\r\n*09b013,Long dashed double-short-dashed line 0.13mm \r\nA,3.12,-0.39,0.78,-0.39,0.78,-0.39\r\n*10b013,Dashed dotted line 0.13mm \r\nA,1.56,-0.39,0.065,-0.39\r\n*11b013,Double-dashed dotted line 0.13mm \r\nA,1.56,-0.39,1.56,-0.39,0.065,-0.39\r\n*12b013,Dashed double-dotted line 0.13mm \r\nA,1.56,-0.39,0.065,-0.39,0.065,-0.39\r\n*13b013,Double-dashed double-dotted line 0.13mm \r\nA,1.56,-0.39,1.56,-0.39,0.065,-0.39,0.065,-0.39\r\n*14b013,Dashed triplicate-dotted line 0.13mm \r\nA,1.56,-0.39,0.065,-0.39,0.065,-0.39,0.065,-0.39\r\n*15b013,Double-dashed triplicate-dotted line 0.13mm \r\nA,1.56,-0.39,1.56,-0.39,0.065,-0.39,0.065,-0.39,0.065,-0.39\r\n*02b018,Dashed line 0.18mm \r\nA,2.16,-0.54\r\n*03b018,Dashed space line 0.18mm \r\nA,2.16,-3.24\r\n*04b018,Long dashed dotted line 0.18mm \r\nA,4.32,-0.54,0.09,-0.54\r\n*05b018,Long dash double dotted line 0.18mm \r\nA,4.32,-0.54,0.09,-0.54,0.09,-0.54\r\n*06b018,Long dashed triplicate dotted line 0.18mm \r\nA,4.32,-0.54,0.09,-0.54,0.09,-0.54,0.09,-0.54\r\n*07b018,Lotted line\r\nA,0.09,-0.54\r\n*08b018,Long dashed short dashed line 0.18mm \r\nA,4.32,-0.54,1.08,-0.54\r\n*09b018,Long dashed double-short-dashed line 0.18mm \r\nA,4.32,-0.54,1.08,-0.54,1.08,-0.54\r\n*10b018,Dashed dotted line 0.18mm \r\nA,2.16,-0.54,0.09,-0.54\r\n*11b018,Double-dashed dotted line 0.18mm \r\nA,2.16,-0.54,2.16,-0.54,.09,-0.54\r\n*12b018,Dashed double-dotted line 0.18mm \r\nA,2.16,-0.54,0.09,-0.54,0.09,-0.54\r\n*13b018,Double-dashed double-dotted line 0.18mm \r\nA,2.16,-0.54,2.16,-0.54,0.09,-0.54,0.09,-0.54\r\n*14b018,Dashed triplicate-dotted line 0.18mm \r\nA,2.16,-0.54,0.09,-0.54,0.09,-0.54,0.09,-0.54\r\n*15b018,Double-dashed triplicate-dotted line 0.18mm \r\nA,2.16,-0.54,2.16,-0.54,0.09,-0.54,0.09,-0.54,0.09,-0.54\r\n*02b025,Dashed Line 0.25mm \r\nA,3,-0.75\r\n*03b025,Dashed space line 0.25mm \r\nA,3,-4.5\r\n*04b025,Long dashed dotted line 0.25mm \r\nA,6,-0.75,0.125,-0.75\r\n*05b025,Long dash double dotted line 0.25mm \r\nA,6,-0.75,0.125,-0.75,0.125,-0.75\r\n*06b025,Long dashed triplicate dotted line 0.25mm \r\nA,6,-0.75,0.125,-0.75,0.125,-0.75,0.125,-0.75\r\n*07b025,Dotted line 0.25 mm \r\nA,0.125,-0.75\r\n*08b025,Long dashed short dashed line 0.25mm \r\nA,6,-0.75,1.5,-0.75\r\n*09b025,Long dashed double-short-dashed line 0.25mm \r\nA,6,-0.75,1.5,-0.75,1.5,-0.75\r\n*10b025,Dashed dotted line 0.25mm \r\nA,3,-0.75,0.125,-0.75\r\n*11b025,Double-dashed dotted line 0.25mm \r\nA,3,-0.75,3,-0.75,0.125,-0.75\r\n*12b025,Dashed double-dotted line 0.25mm \r\nA,3,-0.75,0.125,-0.75,0.125,-0.75\r\n*13b025,Double-dashed double-dotted line 0.25mm \r\nA,3,-0.75,3,-0.75,0.125,-0.75,0.125,-0.75\r\n*14b025,Dashed triplicate-dotted line 0.25mm \r\nA,3,-0.75,0.125,-0.75,0.125,-0.75,0.125,-0.75\r\n*15b025,Double-dashed triplicate-dotted line 0.25mm \r\nA,3,-0.75,3,-0.75,0.125,-0.75,0.125,-0.75,0.125,-0.75\r\n*02b035,Dashed line 0.35mm \r\nA,4.2,-1.05\r\n*03b035,Dashed space line 0.35mm \r\nA,4.2,-6.3\r\n*04b035,Long dashed dotted line 0.35mm \r\nA,8.4,-1.05,0.175,-1.05\r\n*05b035,Long dash double dotted line 0.35mm \r\nA,8.4,-1.05,0.175,-1.05,0.175,-1.05\r\n*06b035,Long dashed triplicate dotted line 0.35mm \r\nA,8.4,-1.05,.175,-1.05,.175,-1.05,0.175,-1.05\r\n*07b035,Dotted line 0.35mm \r\nA,.175,-1.05\r\n*08b035,Long dashed short dashed line 0.35mm \r\nA,8.4,-1.05,2.1,-1.05\r\n*09b035,Long dashed double-short-dashed line 0.35mm \r\nA,8.4,-1.05,2.1,-1.05,2.1,-1.05\r\n*10b035,Lashed dotted line 0.35mm \r\nA,4.2,-1.05,0.175,-1.05\r\n*11b035,Double-dashed dotted line 0.35mm \r\nA,4.2,-1.05,4.2,-1.05,.175,-1.05\r\n*12b035,Dashed double-dotted line 0.35mm \r\nA,4.2,-1.05,.175,-1.05,.175,-1.05\r\n*13b035,Double-dashed double-dotted line 0.35mm \r\nA,4.2,-1.05,4.2,-1.05,.175,-1.05,.175,-1.05\r\n*14b035,Dashed triplicate-dotted line 0.35mm \r\nA,4.2,-1.05,.175,-1.05,.175,-1.05,.175,-1.05\r\n*15b035,Double-dashed triplicate-dotted line 0.35mm \r\nA,4.2,-1.05,4.2,-1.05,.175,-1.05,.175,-1.05,.175,-1.05\r\n*02b05,Dashed line 0.5mm \r\nA,6,-1.5\r\n*03b05,Dashed space line 0.5mm \r\nA,6,-9\r\n*04b05,Long dashed dotted line 0.5mm \r\nA,12,-1.5,0.25,-1.5\r\n*05b05,Long dash double dotted line 0.5mm \r\nA,12,-1.5,0.25,-1.5,0.25,-1.5\r\n*06b05,Long dashed triplicate dotted line 0.5mm \r\nA,12,-1.5,0.25,-1.5,.25,-1.5,0.25,-1.5\r\n*07b05,Dotted line 0.5mm \r\nA,0.25,-1.5\r\n*08b05,Long dashed short dashed line 0.5mm \r\nA,12,-1.5,3,-1.5\r\n*09b05,Long dashed double-short-dashed line 0.5mm \r\nA,12,-1.5,3,-1.5,3,-1.5\r\n*10b05,Dashed dotted line 0.5mm \r\nA,6,-1.5,0.25,-1.5\r\n*11b05,Double-dashed dotted line 0.5mm \r\nA,6,-1.5,6,-1.5,0.25,-1.5\r\n*12b05,Dashed double-dotted line 0.5mm \r\nA,6,-1.5,.25,-1.5,0.25,-1.5\r\n*13b05,Double-dashed double-dotted line 0.5mm \r\nA,6,-1.5,6,-1.5,0.25,-1.5,0.25,-1.5\r\n*14b05,Dashed triplicate-dotted line 0.5mm \r\nA,6,-1.5,0.25,-1.5,0.25,-1.5,0.25,-1.5\r\n*15b05,Double-dashed triplicate-dotted line 0.5mm \r\nA,6,-1.5,6,-1.5,0.25,-1.5,0.25,-1.5,0.25,-1.5\r\n*02b07,Dashed Line 0.7mm \r\nA,8.4,-2.1\r\n*03b07,Dashed space line 0.7mm \r\nA,8.4,-12.6\r\n*04b07,Long dashed dotted line 0.7mm \r\nA,16.8,-2.1,0.35,-2.1\r\n*05b07,Long dash double dotted line 0.7mm \r\nA,16.8,-2.1,0.35,-2.1,0.35,-2.1\r\n*06b07,Long dashed triplicate dotted line 0.7mm \r\nA,16.8,-2.1,0.35,-2.1,0.35,-2.1,0.35,-2.1\r\n*07b07,Dotted line 0.7mm \r\nA,0.35,-2.1\r\n*08b07,Long dashed short dashed line 0.7mm \r\nA,16.8,-2.1,4.2,-2.1\r\n*09b07,Long dashed double-short-dashed line 0.7mm \r\nA,16.8,-2.1,4.2,-2.1,4.2,-2.1\r\n*10b07,Dashed dotted line 0.7mm \r\nA,8.4,-2.1,0.35,-2.1\r\n*11b07,Double-dashed dotted line 0.7mm \r\nA,8.4,-2.1,8.4,-2.1,0.35,-2.1\r\n*12b07,Dashed double-dotted line 0.7mm \r\nA,8.4,-2.1,0.35,-2.1,0.35,-2.1\r\n*13b07,Double-dashed double-dotted line 0.7mm \r\nA,8.4,-2.1,8.4,-2.1,0.35,-2.1,0.35,-2.1\r\n*14b07,Dashed triplicate-dotted line 0.7mm \r\nA,8.4,-2.1,0.35,-2.1,0.35,-2.1,0.35,-2.1\r\n*15b07,Double-dashed triplicate-dotted line 0.7mm \r\nA,8.4,-2.1,8.4,-2.1,0.35,-2.1,0.35,-2.1,0.35,-2.1\r\n*02b1,Dashed Line 1mm \r\nA,12,-3\r\n*03b1,Dashed space line 1mm \r\nA,12,-18\r\n*04b1,Long dashed dotted line 1mm \r\nA,24,-3,.5,-3\r\n*05b1,Long dash double dotted line 1mm \r\nA,24,-3,.5,-3,.5,-3\r\n*06b1,Long dashed triplicate dotted line 1mm \r\nA,24,-3,.5,-3,.5,-3,.5,-3\r\n*07b1,Dotted line\r\nA,.5,-3\r\n*08b1,Long dashed short dashed line 1mm \r\nA,24,-3,6,-3\r\n*09b1,Long dashed double-short-dashed line 1mm \r\nA,24,-3,6,-3,6,-3\r\n*10b1,Dashed dotted line 1mm \r\nA,12,-3,.5,-3\r\n*11b1,Double-dashed dotted line 1mm \r\nA,12,-3,12,-3,0.5,-3\r\n*12b1,Dashed double-dotted line 1mm \r\nA,12,-3,0.5,-3,0.5,-3\r\n*13b1,Double-dashed double-dotted line 1mm \r\nA,12,-3,12,-3,0.5,-3,0.5,-3\r\n*14b1,Dashed triplicate-dotted line 1mm \r\nA,12,-3,0.5,-3,0.5,-3,0.5,-3\r\n*15b1,Double-dashed triplicate-dotted line 1mm \r\nA,12,-3,12,-3,0.5,-3,.5,-3,0.5,-3\r\n*02b14,Dashed Line 1.4mm \r\nA,16.8,-4.2\r\n*03b14,Dashed space line 1.4mm \r\nA,16.8,25.2\r\n*04b14,Long dashed dotted line 1.4mm \r\nA,33.6,-4.2,.7,-4.2\r\n*05b14,Long dash double dotted line 1.4mm \r\nA,33.6,-4.2,.7,-4.2,.7,-4.2\r\n*06b14,Long dashed triplicate dotted line 1.4mm \r\nA,33.6,-4.2,.7,-4.2,.7,-4.2,.7,-4.2\r\n*07b14,Dotted line 1.4mm \r\nA,.7,-4.2\r\n*08b14,Long dashed short dashed line 1.4mm \r\nA,33.6,-4.2,8.4,-4.2\r\n*09b14,Long dashed double-short-dashed line 1.4mm \r\nA,33.6,-4.2,8.4,-4.2,8.4,-4.2\r\n*10b14,Dashed dotted line 1.4mm \r\nA,16.8,-4.2,.7,-4.2\r\n*11b14,Double-dashed dotted line 1.4mm \r\nA,16.8,-4.2,16.8,-4.2,.7,-4.2\r\n*12b14,Dashed double-dotted line 1.4mm \r\nA,16.8,-4.2,.7,-4.2,.7,-4.2\r\n*13b14,Double-dashed double-dotted line 1.4mm \r\nA,16.8,-4.2,16.8,-4.2,.7,-4.2,.7,-4.2\r\n*14b14,Dashed triplicate-dotted line 1.4mm \r\nA,16.8,-4.2,.7,-4.2,.7,-4.2,.7,-4.2\r\n*15b14,Double-dashed triplicate-dotted line 1.4mm \r\nA,16.8,-4.2,16.8,-4.2,.7,-4.2,.7,-4.2,.7,-4.2\r\n*02b2,Dashed Line 2mm \r\nA,24,-6\r\n*03b2,Dashed space line 2mm \r\nA,24,-36\r\n*04b2,Long dashed dotted line 2mm \r\nA,48,-6,1,-6\r\n*05b2,Long dash double dotted line 2mm \r\nA,48,-6,1,-6,1,-6\r\n*06b2,Long dashed triplicate dotted line 2mm \r\nA,48,-6,1,-6,1,-6,1,-6\r\n*07b2,Dotted line 2mm \r\nA,1,-6\r\n*08b2,Long dashed short dashed line 2mm \r\nA,48,-6,12,-6\r\n*09b2,Long dashed double-short-dashed line 2mm \r\nA,48,-6,12,-6,12,-6\r\n*10b2,Dashed dotted line 2mm \r\nA,24,-6,1,-6\r\n*11b2,Double-dashed dotted line 2mm \r\nA,24,-6,24,-6,1,-6\r\n*12b2,Dashed double-dotted line 2mm \r\nA,24,-6,1,-6,1,-6\r\n*13b2,Double-dashed double-dotted line 2mm \r\nA,24,-6,24,-6,1,-6,1,-6\r\n*14b2,Dashed triplicate-dotted line 2mm \r\nA,24,-6,1,-6,1,-6,1,-6\r\n*15b2,Double-dashed triplicate-dotted line 2mm \r\nA,24,-6,24,-6,1,-6,1,-6,1,-6\r\n;; Complex linetypes using standard style\r\n*FENCELINE1,Fenceline_circle ----0-----0----0-----0----0-----0--\r\nA,.25,-.1,[CIRC1,ltypeshp.shx,x=-.1,s=.1],-.1,.1\r\n*FENCELINE2,Fenceline square ----[]-----[]----[]-----[]----[]---\r\nA,.25,-.1,[BOX,ltypeshp.shx,x=-.1,s=.1],-.1,1\r\n*TRACKS,Tracks -|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-|-\r\nA,.15,[TRACK1,ltypeshp.shx,s=.25],.15\r\n*MAP,Map line ----MAP----MAP----MAP----MAP----MAP---- \r\nA,.5,-.2,[\"MAP\",STANDARD,S=.1,R=0.0,X=-0.1,Y=-.05],-.25\r\n*BATTING,Batting SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS\r\nA,.0001,-.1,[BAT,ltypeshp.shx,x=-.1,s=.1],-.2,[BAT,ltypeshp.shx,r=180,x=.1,s=.1],-.1\r\n*ZIGZAG,Zig zag /\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\\/\r\nA,.0001,-.2,[ZIG,ltypeshp.shx,x=-.2,s=.2],-.4,[ZIG,ltypeshp.shx,r=180,x=.2,s=.2],-.2\r\n*HOT_WATER_SUPPLY,Hot water supply ---- HW ---- HW ---- HW ----\r\nA,12.7,-5.08,[\"HW\",STANDARD,S=2.54,R=0.0,X=-2.54,Y=-1.27],-5.08\r\n*GAS_LINE,Gas line ----GAS----GAS----GAS----GAS----GAS----GAS--\r\nA,.5,-.2,[\"GAS\",STANDARD,S=.1,U=0.0,X=-0.1,Y=-.05],-.25\r\n*SMILEYS,Smiley line ---- :^) ---- 8) ---- :^) ----\r\nA,12.7,-5.08,[\":^)\",STANDARD,S=2.54,R=0.0,X=-2.54,Y=-1.27],-5.08,12.7,-5.08,[\"8)\",STANDARD,S=2.54,R=0.0,X=-2.54,Y=-1.27],-5.08\r\n\r\n;------------Slope--------------\r\n*SLOPE,1B 1S |-|-|-|-|-|-|-|-|-|-|-|-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=0.52,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE-D, 1B 1S Double |-|-|-|-|-|-|-|-|-|-|-|-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=1.04,R=0.0,X=0,Y=1.04],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE2, 1B 2S ||-||-||-||-||-||-||-||-||-||-||-||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=0.52,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE2-D, 1B 2S Double ||-||-||-||-||-||-||-||-||-||-||-||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=1.04,R=0.0,X=0,Y=1.04],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE3, 1B 3S |||-|||-|||-|||-|||-|||-|||-|||-|||-|||-|||-|||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=0.52,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE3-D, 1B 3S Double |||-|||-|||-|||-|||-|||-|||-|||-|||-|||-|||-|||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=1.04,R=0.0,X=0,Y=1.04],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE4, 1B 4S ||||-||||-||||-||||-||||-||||-||||-||||-||||-||||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=0.52,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE4-D, 1B 4S Double ||||-||||-||||-||||-||||-||||-||||-||||-||||-||||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=1.04,R=0.0,X=0,Y=1.04],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE5, 1B 5S |||||-|||||-|||||-|||||-|||||-|||||-|||||-|||||-|||||-|||||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=0.52,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n*SLOPE5-D, 1B 5S Double |||||-|||||-|||||-|||||-|||||-|||||-|||||-|||||-|||||-|||||-\r\nA,0.13,[TRACK1,ltypeshp.shx,S=1.04,R=0.0,X=0,Y=1.04],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.52],0.26,[TRACK1,ltypeshp.shx,S=0.26,R=0.0,X=0,Y=0.26],0.13\r\n\r\n;------------Alphabets--------------\r\n*Letter A, ---- A ---- A ---- A ---- A ---- A ----\r\nA,1.0,-0.5,[\"A\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter B, ---- B ---- B ---- B ---- B ---- B ----\r\nA,1.0,-0.5,[\"B\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter C,---- C ---- C ---- C ---- C ---- C ----\r\nA,1.0,-0.5,[\"C\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter D, ---- D ---- D ---- D ---- D ---- D ----\r\nA,1.0,-0.5,[\"D\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter E, ---- E ---- E ---- E ---- E ---- E ----\r\nA,1.0,-0.5,[\"E\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter F, ---- F ---- F ---- F ---- F ---- F ----\r\nA,1.0,-0.5,[\"F\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter G, ---- G ---- G ---- G ---- G ---- G ----\r\nA,1.0,-0.5,[\"G\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter H, ---- H ---- H ---- H ---- H ---- H ----\r\nA,1.0,-0.5,[\"H\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter I, ---- I ---- I ---- I ---- I ---- I ----\r\nA,1.0,-0.5,[\"I\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter J, ---- J ---- J ---- J ---- J ---- J ----\r\nA,1.0,-0.5,[\"J\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter K, ---- K ---- K ---- K ---- K ---- K ----\r\nA,1.0,-0.5,[\"K\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter L, ---- L ---- L ---- L ---- L ---- L ----\r\nA,1.0,-0.5,[\"L\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter M, ---- M ---- M ---- M ---- M ---- M ----\r\nA,1.0,-0.5,[\"M\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter N, ---- N ---- N ---- N ---- N ---- N ----\r\nA,1.0,-0.5,[\"N\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter O, ---- O ---- O ---- O ---- O ---- O ----\r\nA,1.0,-0.5,[\"O\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter P, ---- P ---- P ---- P ---- P ---- P ----\r\nA,1.0,-0.5,[\"P\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter Q, ---- Q ---- Q ---- Q ---- Q ---- Q ----\r\nA,1.0,-0.5,[\"Q\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter R, ---- R ---- R ---- R ---- R ---- R ----\r\nA,1.0,-0.5,[\"R\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter S, ---- S ---- S ---- S ---- S ---- S ----\r\nA,1.0,-0.5,[\"S\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter T, ---- T ---- T ---- T ---- T ---- T ----\r\nA,1.0,-0.5,[\"T\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter U, ---- U ---- U ---- U ---- U ---- U ----\r\nA,1.0,-0.5,[\"U\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter V, ---- V ---- V ---- V ---- V ---- V ----\r\nA,1.0,-0.5,[\"V\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter W, ---- W ---- W ---- W ---- W ---- W ----\r\nA,1.0,-0.5,[\"W\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter X, ---- X ---- X ---- X ---- X ---- X ----\r\nA,1.0,-0.5,[\"X\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter Y, ---- Y ---- Y ---- Y ---- Y ---- Y ----\r\nA,1.0,-0.5,[\"Y\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Letter Z, ---- Z ---- Z ---- Z ---- Z ---- Z ----\r\nA,1.0,-0.5,[\"Z\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n\r\n;------------Numericals--------------\r\n*Numbers 0, ---- 0 ---- 0 ---- 0 ---- 0 ---- 0 ----\r\nA,1.0,-0.5,[\"0\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 1, ---- 1 ---- 1 ---- 1 ---- 1 ---- 1 ----\r\nA,1.0,-0.5,[\"1\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 2, ---- 2 ---- 2 ---- 2 ---- 2 ---- 2 ----\r\nA,1.0,-0.5,[\"2\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 3, ---- 3 ---- 3 ---- 3 ---- 3 ---- 3 ----\r\nA,1.0,-0.5,[\"3\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 4, ---- 4 ---- 4 ---- 4 ---- 4 ---- 4 ----\r\nA,1.0,-0.5,[\"4\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 5, ---- 5 ---- 5 ---- 5 ---- 5 ---- 5 ----\r\nA,1.0,-0.5,[\"5\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 6, ---- 6 ---- 6 ---- 6 ---- 6 ---- 6 ----\r\nA,1.0,-0.5,[\"6\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 7, ---- 7 ---- 7 ---- 7 ---- 7 ---- 7 ----\r\nA,1.0,-0.5,[\"7\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 8, ---- 8 ---- 8 ---- 8 ---- 8 ---- 8 ----\r\nA,1.0,-0.5,[\"8\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n*Numbers 9, ---- 9 ---- 9 ---- 9 ---- 9 ---- 9 ----\r\nA,1.0,-0.5,[\"9\",standard,S=1,R=0,X=0,Y=-0.5],-1.5\r\n\r\n\r\n"; +export const KOSDIC_LIN = ";; ------------------------------------�� �� �� ��------------------------------------\r\n;; �� �� ���� ���� ����(KOSDIC.lin)�� �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ���� �� ��Ͽ� ���ǵ� �� ������\r\n;; AutoCAD ����ڿ��� �����ϱ� ���� ���۵Ǿ����ϴ�.\r\n;; \r\n;; ǥ�ع��� : �Ǽ�CALS/EC ���ڵ��� �ۼ�ǥ�� V1.1(2006.12)\r\n;; ������� : �Ǽ�CALS/EC ������ �ѱ��Ǽ����������\r\n;;\r\n;; �����Ģ : 1/1 ��ô�� ����ũ�� 2.0mm�� ��� ������ ũ�⸦ ������\r\n;; LIB ��Ģ : AutoCAD�� ANSI����(���� ��������� ����)�� ����Ű ���� 1/10�� ������\r\n;; (��) ���� �� ���� ������ 2mm�̸� 0.2unit�� ����\r\n;; ����Ģ : �ǹ����鿡 ����� ��� �������� ��Ģ�� ���յǷ��� ��������ô�� 10�踦 ������\r\n;; (��) LTSCALE=10 (���� 8~10��), ������ô�� 1/5 �̸� LTSCALE=5x10��=50\r\n;;\r\n;; ISO���� �԰� 15��\r\n// ���� AutoCAD ISO�� 25.4����� ������ �� ����\r\n*A-ISONS01,ISO �����԰� 01 Continuous ________________________________\r\nA,1,1\r\n*A-ISONS02,ISO �����԰� 02 dash __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,.47244,-.118111\r\n*A-ISONS03,ISO �����԰� 03 dash space __ __ __ __ __ __\r\nA,.47244,-.7086614\r\n*A-ISONS04,ISO �����԰� 04 long-dash dot ____ . ____ . ____ . ____ . _\r\nA,.94488,-.11811,0,-.11811\r\n*A-ISONS05,ISO �����԰� 05 long-dash double-dot ____ .. ____ .. ____ . \r\nA,.94488,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS06,ISO �����԰� 06 long-dash triple-dot ____ ... ____ ... ____\r\nA,.94488,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS07,ISO �����԰� 07 dot . . . . . . . . . . . . . . . . . . . . \r\nA,0,-.11811\r\n*A-ISONS08,ISO �����԰� 08 long-dash short-dash ____ __ ____ __ ____ _\r\nA,.94488,-.11811,.94488,-.11811\r\n*A-ISONS09,ISO �����԰� 09 long-dash double-short-dash ____ __ __ ____\r\nA,.94488,-.11811,.94488,-.11811,.94488,-.11811\r\n*A-ISONS10,ISO �����԰� 10 dash dot __ . __ . __ . __ . __ . __ . __ . \r\nA,.47244,-.11811,0,-.11811\r\n*A-ISONS11,ISO �����԰� 11 double-dash dot __ __ . __ __ . __ __ . __ _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811\r\n*A-ISONS12,ISO �����԰� 12 dash double-dot __ . . __ . . __ . . __ . . \r\nA,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS13,ISO �����԰� 13 double-dash double-dot __ __ . . __ __ . . _\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS14,ISO �����԰� 14 dash triple-dot __ . . . __ . . . __ . . . _\r\nA,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n*A-ISONS15,ISO �����԰� 15 double-dash triple-dot __ __ . . . __ __ . .\r\nA,.47244,-.11811,.47244,-.11811,0,-.11811,0,-.11811,0,-.11811\r\n\r\n;; ���CAD ����\r\n*A-BORDR01,�ܰ���(�Ϲ�) Border __ __ . __ __ . __ __ . __ __ . __ __ .\r\nA,.5,-.25,.5,-.25,0,-.25\r\n*A-BORDR01H,�ܰ���(�ݹ��) Border (.5x) __.__.__.__.__.__.__.__.__.__.__.\r\nA,.25,-.125,.25,-.125,0,-.125\r\n*A-BORDR01X,�ܰ���(���) Border (2x) ____ ____ . ____ ____ . ___\r\nA,1.0,-.5,1.0,-.5,0,-.5\r\n*A-CENTR01,�߽ɼ�(�Ϲ�) Center ____ _ ____ _ ____ _ ____ _ ____ _ ____\r\nA,1.25,-.25,.25,-.25\r\n*A-CENTR01H,�߽ɼ�(�ݹ��) Center (.5x) ___ _ ___ _ ___ _ ___ _ ___ _ ___\r\nA,.75,-.125,.125,-.125\r\n*A-CENTR01X,�߽ɼ�(���) Center (2x) ________ __ ________ __ _____\r\nA,2.5,-.5,.5,-.5\r\n*A-DASDT01,�Ĵ�����(�Ϲ�) Dash dot __ . __ . __ . __ . __ . __ . __ . __\r\nA,.5,-.25,0,-.25\r\n*A-DASDT01H,�Ĵ�����(�ݹ��) Dash dot (.5x) _._._._._._._._._._._._._._._.\r\nA,.25,-.125,0,-.125\r\n*A-DASDT01X,�Ĵ�����(���) Dash dot (2x) ____ . ____ . ____ . ___\r\nA,1.0,-.5,0,-.5\r\n*A-DASHD01,�ļ�(�Ϲ�) Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _\r\nA,.5,-.25\r\n*A-DASHD01H,�ļ�(�ݹ��) Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nA,.25,-.125\r\n*A-DASHD01X,�ļ�(���) Dashed (2x) ____ ____ ____ ____ ____ ___\r\nA,1.0,-.5\r\n*A-DIVID01,���Ҽ�(�Ϲ�) Divide ____ . . ____ . . ____ . . ____ . . ____\r\nA,.5,-.25,0,-.25,0,-.25\r\n*A-DIVID01H,���Ҽ�(�ݹ��) Divide (.5x) __..__..__..__..__..__..__..__.._\r\nA,.25,-.125,0,-.125,0,-.125\r\n*A-DIVID01X,���Ҽ�(���) Divide (2x) ________ . . ________ . . _\r\nA,1.0,-.5,0,-.5,0,-.5\r\n*A-DOTLN01,����(�Ϲ�) Dot . . . . . . . . . . . . . . . . . . . . . . . .\r\nA,0,-.25\r\n*A-DOTLN01H,����(�ݹ��) Dot (.5x) ........................................\r\nA,0,-.125\r\n*A-DOTLN01X,����(���) Dot (2x) . . . . . . . . . . . . . .\r\nA,0,-.5\r\n*A-HIDDN01,������(�Ϲ�) Hidden __ __ __ __ __ __ __ __ __ __ __ __ __ __\r\nA,.25,-.125\r\n*A-HIDDN01H,������(�ݹ��) Hidden (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \r\nA,.125,-.0625\r\n*A-HIDDN01X,������(���) Hidden (2x) ____ ____ ____ ____ ____ ____ ____ \r\nA,.5,-.25\r\n*A-PHATM01,����(�Ϲ�) Phantom ______ __ __ ______ __ __ ______ \r\nA,1.25,-.25,.25,-.25,.25,-.25\r\n*A-PHATM01H,����(�ݹ��) Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _\r\nA,.625,-.125,.125,-.125,.125,-.125\r\n*A-PHATM01X,����(���) Phantom (2x) ____________ ____ ____ _\r\nA,2.5,-.5,.5,-.5,.5,-.5\r\n\r\n;; �潺�ü�\r\n*E-FENCE01,�潺(X �ļ�)\r\nA,.85,-.15,[KSC11,kosdic.shx,s=.1],-.15,.85\r\n*E-FENCE02,�潺(X �Ǽ�)\r\nA,1.0,[KSC11,kosdic.shx,s=.1],1.0\r\n*E-FENCE03,�潺(/\\ �ļ�)\r\nA,.875,-.05,[KSC12,kosdic.shx,s=.1],-.2,.875\r\n*E-FENCE04,�潺(+ �ļ�)\r\nA,.85,-.15,[KSC15,kosdic.shx,s=.1],-.15,.85\r\n\r\n;; �������\r\n*E-UTIL01,����(| �Ǽ�)\r\nA,1.0,[KSC01,kosdic.shx,s=.1],1.0\r\n*E-UTIL02,����(|| �ļ�)\r\nA,.05,[KSC01,kosdic.shx,s=.1],-.1,[KSC01,kosdic.shx,s=.1],1.85\r\n*E-UTIL03,����(|| �Ǽ�)\r\nA,.95,[KSC01,kosdic.shx,s=.1],.1,[KSC01,kosdic.shx,s=.1],.95\r\n*E-UTIL04,����(||| �ļ�)\r\nA,.9,[KSC01,kosdic.shx,s=.1],-.1,[KSC01,kosdic.shx,s=.1],-.1,[KSC01,kosdic.shx,s=.1],.9\r\n*E-UTIL05,����(||| �Ǽ�)\r\nA,.9,[KSC01,kosdic.shx,s=.1],.1,[KSC01,kosdic.shx,s=.1],.1,[KSC01,kosdic.shx,s=.1],.9\r\n*E-UTIL06,����(/ �ļ�)\r\nA,.9,-.1,[KSC02,kosdic.shx,s=.1],-.1,.9\r\n*E-UTIL07,����(/ �Ǽ�)\r\nA,1.0,[KSC02,kosdic.shx,s=.1],1.0\r\n*E-UTIL08,����(// �ļ�)\r\nA,.85,-.15,[KSC05,kosdic.shx,s=.1],-.15,.85\r\n*E-UTIL09,����(// �Ǽ�)\r\nA,1.0,[KSC05,kosdic.shx,s=.1],1.0\r\n*E-UTIL10,����(/// �ļ�)\r\nA,.8,-.2,[KSC06,kosdic.shx,s=.1],-.2,.8\r\n*E-UTIL11,����(/// �Ǽ�)\r\nA,1.0,[KSC06,kosdic.shx,s=.1],1.0\r\n\r\n;; �ü�����\r\n*E-FACLN01,�ü�(- ���༱)\r\nA,.25,[KSC01,kosdic.shx,y=-.05,s=.1,r=270],.25\r\n*E-FACLN02,�ü�(= ���༱)\r\nA,.25,[KSC04,kosdic.shx,y=-.05,s=.1],.25\r\n*E-FACLN03,�ü�(O �ļ�)\r\nA,.85,[KSC31,kosdic.shx,x=.075,s=.075],-.15,1.0\r\n*E-FACLN04,�ü�(O �Ǽ�)\r\nA,1.0,[KSC31,kosdic.shx,s=.075],1.0\r\n*E-FACLN05,�ü�(��,����Ʈ)\r\nA,1.0,[KSC32,kosdic.shx,s=.075],1.0\r\n\r\n;; ��������\r\n*E-TOPOG01,����(�� �ļ�)\r\nA,.000002,[KSC16,kosdic.shx,s=.1],-.25\r\n*E-TOPOG02,����(�� �Ǽ�)\r\nA,.000002,[KSC16,kosdic.shx,s=.1],-.2\r\n*E-TOPOG03,����(| �Ǽ�)\r\nA,0,-.1,[KSC15,kosdic.shx,s=.1],-.1\r\n*E-TOPOG04,����(| ����)\r\nA,0,[KSC00,kosdic.shx,y=.1,s=.1],.2,[KSC00,kosdic.shx,s=.1],.2\r\n*E-TOPOG05,����(~. ����)\r\nA,.000005,[KSC1C,kosdic.shx,s=.1],-.45,[KSC30,kosdic.shx,s=.1],-.05\r\n\r\n;; ������� ����\r\n*B-ZIGZG01,�������(��)\r\nA,.000002,[KSC13,kosdic.shx,s=.1],-.2\r\n*B-ZIGZG02,�������(ȣ)\r\nA,.000002,[KSC1B,kosdic.shx,s=.1],-.2\r\n*C-ZIGZG01,�������(�Ǽ�,��)\r\nA,.1,[KSC13,kosdic.shx,s=.1],.1\r\n*C-ZIGZG02,�������(�Ǽ�,ȣ)\r\nA,.1,[KSC1B,kosdic.shx,s=.1],.1\r\n\r\n;; ���� �� ����\r\n*D-SWALL01,����(�ű�)\r\nA,0,[KSC61,kosdic.shx,y=-.1,s=.1],-.2\r\n*X-SWALL01,����(����)\r\nA,0,[KSC71,kosdic.shx,s=.1],-.4\r\n*D-BWALL01,���ϴ�(����)\r\nA,0,[KSC18,kosdic.shx,s=.1],-.25\r\n*D-BWALL02,���ϴ�(�ű�)\r\nA,.000005,[KSC3A,kosdic.shx,s=.05],-.1,[KSC3A,kosdic.shx,s=.05],-.1,[KSC04,kosdic.shx,y=-.025,s=.05],-.1,[KSC04,kosdic.shx,y=-.025,s=.05],-.1,[KSC04,kosdic.shx,y=-.025,s=.05],-.05\r\n\r\n;; ��/�����迭\r\n*E-DOTLN01,���ҹ迭\r\nA,0,[KSC30,kosdic.shx,s=.3],-.15\r\n*E-DOTLN02,���ҹ迭(��)\r\nA,.075,[KSC30,kosdic.shx,s=.3],.075\r\n*E-CIRCL01,�����������Ϲ迭\r\nA,0,[KSC31,kosdic.shx,s=.1],-.2\r\n*E-CIRCL02,ä��������Ϲ迭\r\nA,0,[KSC32,kosdic.shx,s=.1],-.2\r\n*D-RROAD01,ö��(����)\r\nA,0,[KSC3A,kosdic.shx,s=.1],-.2,[KSC04,kosdic.shx,y=-.05,s=.1],-.2\r\n\r\n;; ������\r\n*E-DIGLN01,������\r\nA,.9,-.2,[KSC19,kosdic.shx,s=.1,r=180],.9\r\n*E-DIGLN01H,������(�ݹ��)\r\nA,.45,-.1,[KSC19,kosdic.shx,s=.05,r=180],.45\r\n*E-DIGLN01X,������(���)\r\nA,1.8,-.4,[KSC19,kosdic.shx,s=.2,r=180],1.8\r\n\r\n;; ���\r\n*B-GREEN01,����/�����\r\nA,.000002,[KSC08,kosdic.shx,s=.1],-.2\r\n*B-GREEN02,�����\r\nA,0,[KSC21,kosdic.shx,s=.1],-.45\r\n*E-BOUND01,�������\r\nA,.875,[KSC60,kosdic.shx,s=.1],-.25,.875\r\n\r\n;; ��Ÿ\r\n*B-RANDM01,�ұ�Ģ��\r\nA,0,[KSC1A,kosdic.shx,s=.1],-.2\r\n*E-DEMOL01,ö�Ŵ��\r\nA,.075,[KSC01,kosdic.shx,s=.1,r=30],.075\r\n*X-EARTH01,���ݼ�\r\nA,0,[KSC20,kosdic.shx,s=.1,x=.25,r=180],.25\r\n\r\n;; ���ܼ�\r\n*E-CUTLN01,���ܼ�(��)\r\nA,.95,[KSC14,kosdic.shx,s=.1],-.1,.95\r\n*E-CUTLN02,���ܼ�(ȣ)\r\nA,.95,[KSC22,kosdic.shx,s=.1],-.1,.95\r\n\r\n;; ���帧 ����\r\n*E-WFLOW01,���帧(��, �ļ�)\r\nA,.6,[KSC36,kosdic.shx,s=.1],-.65\r\n*E-WFLOW02,���帧(��, �Ǽ�)\r\nA,1.5,[KSC36,kosdic.shx,s=.1],0\r\n*E-WFLOW03,���帧(ȭ��, �ļ�)\r\nA,.6,[KSC35,kosdic.shx,s=.1],-.65\r\n*E-WFLOW04,���帧(ȭ��, �Ǽ�)\r\nA,1.5,[KSC35,kosdic.shx,s=.1],0\r\n\r\n;; ���������\r\n*G-WATER01,���������(W ����)\r\nA,2.933333,[\"W\",Standard,y=.05,s=.2],.066667\r\n*G-WATER02,���������(W �ļ�)\r\nA,2.75,-.05,[\"W\",Standard,y=-.1,s=.2,r=360],-.2\r\n\r\n;; �¼�����\r\n*G-HWATR01,�¼�����(HWL ����)\r\nA,2.783629,[\"HWL\",Standard,y=.05,s=.2],.216371\r\n*G-HWATR02,�¼�����(HWL �ļ�)\r\nA,2.35,-.05,[\"HWL\",Standard,y=-.1,s=.2],-.6\r\n\r\n;; �ü�����\r\n*G-CWATR01,�ü�����(CWL ����)\r\nA,2.783629,[\"CWL\",Standard,y=.05,s=.2],.216371\r\n*G-CWATR02,�ü�����(CWL �ļ�)\r\nA,2.35,-.05,[\"CWL\",Standard,y=-.1,s=.2],-.6\r\n\r\n;; ��������\r\n*E-SEWER01,��������(����)\r\nA,.95,-.1,[KSC09,kosdic.shx,s=.1,r=180],.95\r\n*E-SEWER01H,��������(����,�ݹ��)\r\nA,.475,-.05,[KSC09,kosdic.shx,s=.05,r=180],.475\r\n*E-SEWER01X,��������(����,���)\r\nA,1.9,-.2,[KSC09,kosdic.shx,s=.2,r=180],1.9\r\n*G-SEWER01,��������(S ����)\r\nA,2.933333,[\"S\",Standard,y=.05,s=.2],.066667\r\n*G-SEWER02,��������(S �ļ�)\r\nA,2.75,-.05,[\"S\",Standard,y=-.1,s=.2,r=360],-.2\r\n\r\n;; ��ż���\r\n*G-TELCM01,��ż���(T ����)\r\nA,2.933333,[\"T\",Standard,y=.05,s=.2],.066667\r\n*G-TELCM02,��ż���(T �ļ�)\r\nA,2.75,-.05,[\"T\",Standard,y=-.1,s=.2,r=360],-.2\r\n\r\n;; ���⼱��\r\n*G-ELECT01,���⼱��(E ����)\r\nA,2.933333,[\"E\",Standard,y=.05,s=.2],.066667\r\n*G-ELECT02,���⼱��(E �ļ�)\r\nA,2.75,-.05,[\"E\",Standard,y=-.1,s=.2,r=360],-.2\r\n\r\n;; ��������\r\n*G-GASLN01,��������(G ����)\r\nA,2.933333,[\"G\",Standard,y=.05,s=.2],.066667\r\n*G-GASLN02,��������(G �ļ�)\r\nA,2.75,-.05,[\"G\",Standard,y=-.1,s=.2,r=360],-.2\r\n*G-GASLN03,��������(GAS ����)\r\nA,2.783629,[\"GAS\",Standard,y=.05,s=.2],.216371\r\n*G-GASLN04,��������(GAS �ļ�)\r\nA,2.35,-.05,[\"GAS\",Standard,y=-.1,s=.2],-.6\r\n\r\n;; �Ϲ�������\r\n;; *CAL421,PIPE\r\n;; A,2.733333,[\"PIPE\",Standard,y=.05,s=.2],.266667\r\n*G-PIPEL01,�Ϲ� ������(PIPE �ļ�)\r\nA,3.25,-.05,[\"PIPE\",Standard,y=-.1,s=.2],-.7\r\n\r\n;; �������\r\n*H-DICHT00,�������\r\nA,1.1,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"T\",Standard,y=-.1,s=.2,r=360],-.216667\r\n*H-DICHT00R,�������(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"T\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; L������\r\n*H-DICHL00,L������ ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.333333,[\"L\",Standard,y=-.1,s=.2],-.216667\r\n*H-DICHL00R,L������ ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"L\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL01,L������ ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.366667,[\"L1\",Standard,y=-.1,s=.2,r=360],-.383333\r\n*H-DICHL01R,L������ ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.633333,[\"L1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHL02,L������ ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"L2\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHL02R,L������ ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"L2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL03,L������ ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"L3\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHL03R,L������ ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"L3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL04,L������ ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"L4\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHL04R,L������ ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"L4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL05,L������ ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"L5\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHL05R,L������ ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"L5\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL06,L������ ����06\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"L6\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHL06R,L������ ����06(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"L6\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL07,L������ ����07\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"L7\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHL07R,L������ ����07(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"L7\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL08,L������ ����08\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"L8\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHL08R,L������ ����08(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"L8\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL09,L������ ����09\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"L9\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHL09R,L������ ����09(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"L9\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHL10,L������ ����10\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"L10\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-DICHL10R,L������ ����10(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"L10\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-DICHL11,L������ ����11\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.4,[\"L11\",Standard,y=-.1,s=.2],-.55\r\n*H-DICHL11R,L������ ����11(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.8,[\"L11\",Standard,y=.1,s=.2,r=180],-.15\r\n*H-DICHL12,L������ ����12\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.366667,[\"L12\",Standard,y=-.1,s=.2],-.583333\r\n*H-DICHL12R,L������ ����12(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.833333,[\"L12\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHL13,L������ ����13\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.366667,[\"L13\",Standard,y=-.1,s=.2,r=360],-.583333\r\n*H-DICHL13R,L������ ����13(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.833333,[\"L13\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHL14,L������ ����14\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.366667,[\"L14\",Standard,y=-.1,s=.2],-.583333\r\n*H-DICHL14R,L������ ����14(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.833333,[\"L14\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHL15,L������ ����15\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.366667,[\"L15\",Standard,y=-.1,s=.2],-.583333\r\n*H-DICHL15R,L������ ����15(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.833333,[\"L15\",Standard,y=.1,s=.2,r=180],-.116667\r\n\r\n;; U������\r\n*H-DICHU00,U������ ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.333333,[\"U\",Standard,y=-.1,s=.2],-.216667\r\n*H-DICHU00R,U������ ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"U\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU01,U������ ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.366667,[\"U1\",Standard,y=-.1,s=.2],-.383333\r\n*H-DICHU01R,U������ ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.633333,[\"U1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHU02,U������ ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"U2\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHU02R,U������ ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU03,U������ ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"U3\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHU03R,U������ ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU04,U������ ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"U4\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHU04R,U������ ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU05,U������ ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"U5\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHU05R,U������ ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U5\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU06,U������ ����06\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"U6\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHU06R,U������ ����06(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U6\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU07,U������ ����07\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"U7\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHU07R,U������ ����07(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U7\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU08,U������ ����08\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"U8\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHU08R,U������ ����08(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U8\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU09,U������ ����09\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"U9\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHU09R,U������ ����09(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"U9\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHU10,U������ ����10\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.383333,[\"U10\",Standard,y=-.1,s=.2],-.566667\r\n*H-DICHU10R,U������ ����10(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"U10\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-DICHU11,U������ ����11\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.4,[\"U11\",Standard,y=-.1,s=.2],-.55\r\n*H-DICHU11R,U������ ����11(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.8,[\"U11\",Standard,y=.1,s=.2,r=180],-.15\r\n*H-DICHU12,U������ ����12\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.366667,[\"U12\",Standard,y=-.1,s=.2],-.583333\r\n*H-DICHU12R,U������ ����12(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.833333,[\"U12\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHU13,U������ ����13\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.366667,[\"U13\",Standard,y=-.1,s=.2,r=360],-.583333\r\n*H-DICHU13R,U������ ����13(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.833333,[\"U13\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHU14,U������ ����14\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.366667,[\"U14\",Standard,y=-.1,s=.2],-.583333\r\n*H-DICHU14R,U������ ����14(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.833333,[\"U14\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHU15,U������ ����15\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.366667,[\"U15\",Standard,y=-.1,s=.2,r=360],-.583333\r\n*H-DICHU15R,U������ ����15(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.833333,[\"U15\",Standard,y=.1,s=.2,r=180],-.116667\r\n\r\n;; V������\r\n*H-DICHV00,V������ ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V\",Standard,y=-.1,s=.2,r=360],-.25\r\n*H-DICHV00R,V������ ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1,r=360],-.5,[\"V\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV01,V������ ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"V1\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHV01R,V������ ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"V1\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHV02,V������ ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.3,[\"V2\",Standard,y=-.1,s=.2],-.45\r\n*H-DICHV02R,V������ ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.7,[\"V2\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV03,V������ ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V3\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV03R,V������ ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.7,[\"V3\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV04,V������ ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V4\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV04R,V������ ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.7,[\"V4\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV05,V������ ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V5\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV05R,V������ ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.7,[\"V5\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV06,V������ ����06\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V6\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV06R,V������ ����06(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.25,0,-.45,[\"V6\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV07,V������ ����07\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V7\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV07R,V������ ����07(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.7,[\"V7\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV08,V������ ����08\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V8\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV08R,V������ ����08(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.7,[\"V8\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV09,V������ ����09\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.3,[\"V9\",Standard,y=-.1,s=.2,r=360],-.45\r\n*H-DICHV09R,V������ ����09(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.7,[\"V9\",Standard,y=.1,s=.2,r=180],-.05\r\n*H-DICHV10,V������ ����10\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.35,[\"V10\",Standard,y=-.1,s=.2],-.6\r\n*H-DICHV10R,V������ ����10(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.85,[\"V10\",Standard,y=.1,s=.2,r=180],-.1\r\n*H-DICHV11,V������ ����11\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.366667,[\"V11\",Standard,y=-.1,s=.2],-.583333\r\n*H-DICHV11R,V������ ����11(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.833333,[\"V11\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHV12,V������ ����12\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"V12\",Standard,y=-.1,s=.2,r=360],-.616667\r\n*H-DICHV12R,V������ ����12(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.866667,[\"V12\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHV13,V������ ����13\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.333333,[\"V13\",Standard,y=-.1,s=.2],-.616667\r\n*H-DICHV13R,V������ ����13(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.866667,[\"V13\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHV14,V������ ����14\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.333333,[\"V14\",Standard,y=-.1,s=.2],-.616667\r\n*H-DICHV14R,V������ ����14(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.866667,[\"V14\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHV15,V������ ����15\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.333333,[\"V15\",Standard,y=-.1,s=.2],-.616667\r\n*H-DICHV15R,V������ ����15(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.866667,[\"V15\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; ����ũ\r\n*H-DICHD00,����ũ ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"D\",Standard,y=-.1,s=.2,r=360],-.216667\r\n*H-DICHD00R,����ũ ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"D\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHD01,����ũ ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.366667,[\"D1\",Standard,y=-.1,s=.2,r=360],-.383333\r\n*H-DICHD01R,����ũ ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.633333,[\"D1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHD02,����ũ ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"D2\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHD02R,����ũ ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"D2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHD03,����ũ ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"D3\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHD03R,����ũ ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"D3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHD04,����ũ ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"D4\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHD04R,����ũ ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"D4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHD05,����ũ ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"D5\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHD05R,����ũ ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"D5\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; �긶�� ����\r\n*H-DICHB00,�긶������ ����00\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.633333,[\"B\",Standard,y=-.1,s=.2],-.216667\r\n*H-DICHB00R,�긶������ ����00(����)\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.766667,[\"B\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHB01,�긶������ ����01\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.666667,[\"B1\",Standard,y=-.1,s=.2],-.383333\r\n*H-DICHB01R,�긶������ ����01(����)\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.933333,[\"B1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHB02,�긶������ ����02\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.633333,[\"B2\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHB02R,�긶������ ����02(����)\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.966667,[\"B2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHB03,�긶������ ����03\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.633333,[\"B3\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHB03R,�긶������ ����03(����)\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.966667,[\"B3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHB04,�긶������ ����04\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.633333,[\"B4\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHB04R,�긶������ ����04(����)\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.966667,[\"B4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHB05,�긶������ ����05\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.633333,[\"B5\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHB05R,�긶������ ����05(����)\r\nA,.00001,[KSC52,kosdic.shx,s=.1],-.966667,[\"B5\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; �;ϰ�\r\n*H-DICHM00,�;ϰ� ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.333333,[\"M\",Standard,y=-.1,s=.2],-.216667\r\n*H-DICHM00R,�;ϰ� ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"M\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHM01,�;ϰ� ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.366667,[\"M1\",Standard,y=-.1,s=.2,r=360],-.383333\r\n*H-DICHM01R,�;ϰ� ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.633333,[\"M1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHM02,�;ϰ� ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"M2\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHM02R,�;ϰ� ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"M2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHM03,�;ϰ� ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"M3\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHM03R,�;ϰ� ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"M3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHM04,�;ϰ� ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"M4\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHM04R,�;ϰ� ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"M4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHM05,�;ϰ� ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"M5\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHM05R,�;ϰ� ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"M5\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; �������\r\n*H-DICHO00,������� ����00\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.333333,[\"L/O\",Standard,y=-.1,s=.2],-.616667\r\n*H-DICHO00R,������� ����00(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.866667,[\"L/O\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHO01,������� ����01\r\nA,.5,[KSC35,kosdic.shx,s=.1],-.366667,[\"L1/O\",Standard,y=-.1,s=.2],-.783333\r\n*H-DICHO01R,������� ����01(����)\r\nA,.5,[KSC35,kosdic.shx,s=.1],-1.033333,[\"L1/O\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHO02,������� ����02\r\nA,.5,[KSC35,kosdic.shx,s=.1],-.333333,[\"L2/O\",Standard,y=-.1,s=.2],-.816667\r\n*H-DICHO02R,������� ����02(����)\r\nA,.5,[KSC35,kosdic.shx,s=.1,r=360],-1.066667,[\"L2/O\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHO03,������� ����03\r\nA,.5,[KSC35,kosdic.shx,s=.1],-.333333,[\"L3/O\",Standard,y=-.1,s=.2],-.816667\r\n*H-DICHO03R,������� ����03(����)\r\nA,.5,[KSC35,kosdic.shx,s=.1],-1.066667,[\"L3/O\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHO04,������� ����04\r\nA,.5,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"L4/O\",Standard,y=-.1,s=.2,r=360],-.816667\r\n*H-DICHO04R,������� ����04(����)\r\nA,.5,[KSC35,kosdic.shx,s=.1],-1.066667,[\"L4/O\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHO05,������� ����05\r\nA,.5,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"L5/O\",Standard,y=-.1,s=.2,r=360],-.816667\r\n*H-DICHO05R,������� ����05(����)\r\nA,.5,[KSC35,kosdic.shx,s=.1],-1.066667,[\"L5/O\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; �Ҵ�����\r\n*H-DICHG00,�Ҵ����� ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.333333,[\"G\",Standard,y=-.1,s=.2],-.216667\r\n*H-DICHG00R,�Ҵ����� ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"G\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHG01,�Ҵ����� ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.366667,[\"G1\",Standard,y=-.1,s=.2],-.383333\r\n*H-DICHG01R,�Ҵ����� ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.633333,[\"G1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHG02,�Ҵ����� ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"G2\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHG02R,�Ҵ����� ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"G2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHG03,�Ҵ����� ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"G3\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHG03R,�Ҵ����� ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"G3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHG04,�Ҵ����� ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"G4\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHG04R,�Ҵ����� ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"G4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHG05,�Ҵ����� ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"G5\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHG05R,�Ҵ����� ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"G5\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; J������\r\n*H-DICHJ00,J������ ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.333333,[\"J\",Standard,y=-.1,s=.2],-.216667\r\n*H-DICHJ00R,J������ ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"J\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHJ01,J������ ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.366667,[\"J1\",Standard,y=-.1,s=.2],-.383333\r\n*H-DICHJ01R,J������ ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.633333,[\"J1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-DICHJ02,J������ ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"J2\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHJ02R,J������ ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"J2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHJ03,J������ ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"J3\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHJ03R,J������ ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"J3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHJ04,J������ ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"J4\",Standard,y=-.1,s=.2],-.416667\r\n*H-DICHJ04R,J������ ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"J4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-DICHJ05,J������ ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"J5\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-DICHJ05R,J������ ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"J5\",Standard,y=.1,s=.2,r=180],-.083333\r\n\r\n;; �����\r\n*H-CANAL00,����� ����00\r\nA,1.1,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"Y\",Standard,y=-.1,s=.2,r=360],-.216667\r\n*H-CANAL00R,����� ����00(����)\r\nA,1.1,[KSC35,kosdic.shx,s=.1],-.466667,[\"Y\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL01,����� ����01\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.366667,[\"Y1\",Standard,y=-.1,s=.2],-.383333\r\n*H-CANAL01R,����� ����01(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.633333,[\"Y1\",Standard,y=.1,s=.2,r=180],-.116667\r\n*H-CANAL02,����� ����02\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"Y2\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-CANAL02R,����� ����02(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"Y2\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL03,����� ����03\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"Y3\",Standard,y=-.1,s=.2],-.416667\r\n*H-CANAL03R,����� ����03(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"Y3\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL04,����� ����04\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"Y4\",Standard,y=-.1,s=.2],-.416667\r\n*H-CANAL04R,����� ����04(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"Y4\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL05,����� ����05\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.333333,[\"Y5\",Standard,y=-.1,s=.2,r=360],-.416667\r\n*H-CANAL05R,����� ����05(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1,r=360],-.666667,[\"Y5\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL06,����� ����06\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"Y6\",Standard,y=-.1,s=.2],-.416667\r\n*H-CANAL06R,����� ����06(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"Y6\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL07,����� ����07\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"Y7\",Standard,y=-.1,s=.2],-.416667\r\n*H-CANAL07R,����� ����07(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"Y7\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL08,����� ����08\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"Y8\",Standard,y=-.1,s=.2],-.416667\r\n*H-CANAL08R,����� ����08(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"Y8\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL09,����� ����09\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.333333,[\"Y9\",Standard,y=-.1,s=.2],-.416667\r\n*H-CANAL09R,����� ����09(����)\r\nA,.9,[KSC35,kosdic.shx,s=.1],-.666667,[\"Y9\",Standard,y=.1,s=.2,r=180],-.083333\r\n*H-CANAL10,����� ����10\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y10\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL10R,����� ����10(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y10\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL11,����� ����11\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y11\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL11R,����� ����11(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y11\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL12,����� ����12\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y12\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL12R,����� ����12(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y12\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL13,����� ����13\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y13\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL13R,����� ����13(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y13\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL14,����� ����14\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y14\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL14R,����� ����14(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y14\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL15,����� ����15\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y15\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL15R,����� ����15(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y15\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL16,����� ����16\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y16\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL16R,����� ����16(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y16\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL17,����� ����17\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y17\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL17R,����� ����17(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y17\",Standard,y=.1,s=.2,r=180],-.133333\r\n*H-CANAL18,����� ����18\r\nA,.7,[KSC35,kosdic.shx,s=.1,r=360],-.383333,[\"Y18\",Standard,y=-.1,s=.2,r=360],-.566667\r\n*H-CANAL18R,����� ����18(����)\r\nA,.7,[KSC35,kosdic.shx,s=.1],-.816667,[\"Y18\",Standard,y=.1,s=.2,r=180],-.133333\r\n\r\n"; diff --git a/src/engine/viewer2d/linParser.ts b/src/engine/viewer2d/linParser.ts new file mode 100644 index 0000000..97d7a11 --- /dev/null +++ b/src/engine/viewer2d/linParser.ts @@ -0,0 +1,390 @@ +/** + * LIN File Parser & Complex Linetype Registry + * Parses AutoCAD LIN file definitions (including text and shape descriptors). + */ + +import { ICAD_LIN, KOSDIC_LIN } from './linData'; + +export interface LinElement { + type: 'dash' | 'gap' | 'shape' | 'text'; + val?: number; // dash length (+) or gap length (-) + shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG + shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx + text?: string; // e.g. "L2", "GAS", "HW" + style?: string; // e.g. Standard + scale: number; // s=... (default 1.0) + rotation: number; // r=... (angle in degrees) + isAbsoluteAngle: boolean;// a=... + isUprightAngle: boolean; // u=... + xOffset: number; // x=... + yOffset: number; // y=... +} + +export interface LinPattern { + name: string; + description: string; + elements: LinElement[]; + patternLength: number; // total length sum of dashes + abs(gaps) +} + +function parseBracketElement(inner: string): LinElement { + const parts: string[] = []; + let cur = ''; + let inQuote = false; + for (let i = 0; i < inner.length; i++) { + const c = inner[i]; + if (c === '"') inQuote = !inQuote; + if (c === ',' && !inQuote) { + parts.push(cur.trim()); + cur = ''; + } else { + cur += c; + } + } + if (cur.trim()) parts.push(cur.trim()); + + let type: 'shape' | 'text' = 'shape'; + let shapeName: string | undefined; + let shxFile: string | undefined; + let text: string | undefined; + let style: string | undefined; + + let scale = 1.0; + let rotation = 0; + let isAbsoluteAngle = false; + let isUprightAngle = false; + let xOffset = 0; + let yOffset = 0; + + if (parts.length > 0) { + const first = parts[0]; + if (first.startsWith('"') && first.endsWith('"')) { + type = 'text'; + text = first.slice(1, -1); + if (parts.length > 1 && !parts[1].includes('=')) style = parts[1]; + } else { + type = 'shape'; + shapeName = first; + if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1]; + } + + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + const eqIdx = part.indexOf('='); + if (eqIdx > 0) { + const key = part.substring(0, eqIdx).trim().toLowerCase(); + const valStr = part.substring(eqIdx + 1).trim(); + const val = parseFloat(valStr); + if (!isNaN(val)) { + if (key === 's') scale = val; + else if (key === 'r') { rotation = val; } + else if (key === 'a') { rotation = val; isAbsoluteAngle = true; } + else if (key === 'u') { rotation = val; isUprightAngle = true; } + else if (key === 'x') xOffset = val; + else if (key === 'y') yOffset = val; + } + } + } + } + + return { + type, + shapeName, + shxFile, + text, + style, + scale, + rotation, + isAbsoluteAngle, + isUprightAngle, + xOffset, + yOffset, + }; +} + +function tokenizeLinPattern(patternStr: string): string[] { + const s = patternStr.replace(/^A\s*,\s*/i, '').trim(); + const tokens: string[] = []; + let current = ''; + let inBracket = false; + let inQuote = false; + + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '"') inQuote = !inQuote; + if (ch === '[' && !inQuote) inBracket = true; + if (ch === ']' && !inQuote) inBracket = false; + + if (ch === ',' && !inBracket && !inQuote) { + if (current.trim()) tokens.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) tokens.push(current.trim()); + return tokens; +} + +export function parseLinContent(content: string): Map { + const result = new Map(); + const lines = content.split(/\r?\n/); + let currentName: string | null = null; + let currentDesc = ''; + + for (let line of lines) { + line = line.trim(); + if (!line || line.startsWith(';')) continue; + if (line.startsWith('*')) { + const commaIdx = line.indexOf(','); + if (commaIdx > 0) { + currentName = line.substring(1, commaIdx).trim(); + currentDesc = line.substring(commaIdx + 1).trim(); + } else { + currentName = line.substring(1).trim(); + currentDesc = ''; + } + } else if (currentName && line.toUpperCase().startsWith('A,')) { + const tokens = tokenizeLinPattern(line); + const elements: LinElement[] = []; + let totalLength = 0; + + for (const tok of tokens) { + if (tok.startsWith('[') && tok.endsWith(']')) { + const elem = parseBracketElement(tok.slice(1, -1)); + elements.push(elem); + } else { + const val = parseFloat(tok); + if (!isNaN(val)) { + if (val > 1e-6) { + elements.push({ + type: 'dash', + val, + scale: 1, + rotation: 0, + isAbsoluteAngle: false, + isUprightAngle: false, + xOffset: 0, + yOffset: 0, + }); + totalLength += val; + } else if (val < -1e-6) { + const absVal = Math.abs(val); + elements.push({ + type: 'gap', + val: -absVal, + scale: 1, + rotation: 0, + isAbsoluteAngle: false, + isUprightAngle: false, + xOffset: 0, + yOffset: 0, + }); + totalLength += absVal; + } else { + // Dot (val === 0) + elements.push({ + type: 'dash', + val: 0.05, + scale: 1, + rotation: 0, + isAbsoluteAngle: false, + isUprightAngle: false, + xOffset: 0, + yOffset: 0, + }); + totalLength += 0.05; + } + } + } + } + + if (elements.length > 0) { + result.set(currentName.toUpperCase(), { + name: currentName, + description: currentDesc, + elements, + patternLength: Math.max(totalLength, 0.1), + }); + } + currentName = null; + } + } + + return result; +} + +// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin +const globalRegistry = new Map(); + +export function registerLinContent(content: string) { + const parsed = parseLinContent(content); + for (const [key, pattern] of parsed.entries()) { + globalRegistry.set(key, pattern); + } +} + +// Warm up registry with bundled LIN files +registerLinContent(ICAD_LIN); +registerLinContent(KOSDIC_LIN); + +const CIVIL_LT_TEXT_MAP: Record = { + 'H-DICHL02': 'L2-2/0', + 'H-DICHL02R': 'L2-2', + 'H-DICHL01': 'L2-3/0', + 'H-DICHL01R': 'L2-3', + 'H-DICHL06': 'L5-1', + 'H-DICHL06R': 'L5-1', + 'H-DICHL05': 'L5', + 'H-DICHL05R': 'L5', + 'H-DICHU05': 'U5', + 'H-DICHU05R': 'U5', + 'H-DICHB01': 'B1', + 'H-DICHB01R': 'B1', + 'H-DICHM03': 'M3', + 'H-DICHM03R': 'M3', + 'H-DICHV01': 'V1', + 'H-DICHV01R': 'V1', + 'H-DICHO01': 'O1', + 'H-DICHO01R': 'O1', + 'H-DICHL03': 'L3', + 'H-DICHL03R': 'L3', + 'H-DICHL04': 'L4', + 'H-DICHL04R': 'L4', + 'H-DICHL08': 'L8', + 'H-DICHL08R': 'L8', +}; + +const NO_SHAPE_LINETYPES = new Set([ + 'H-DICHL05', + 'H-DICHL05R', + 'H-DICHU05', + 'H-DICHU05R', + 'H-DICHB01', + 'H-DICHB01R', + 'H-DICHM03', + 'H-DICHM03R', + 'H-DICHV01', + 'H-DICHV01R', + 'H-DICHO01', + 'H-DICHO01R', +]); + +export function getLinPattern(name: string): LinPattern | undefined { + if (!name) return undefined; + const key = name.toUpperCase().trim(); + const pattern = globalRegistry.get(key); + if (!pattern) return undefined; + + let elements = pattern.elements; + + // Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec + if (NO_SHAPE_LINETYPES.has(key)) { + elements = elements.filter((elem) => elem.type !== 'shape'); + } + + const overrideText = CIVIL_LT_TEXT_MAP[key]; + if (overrideText) { + elements = elements.map((elem) => + elem.type === 'text' ? { ...elem, text: overrideText } : elem + ); + } + + return { + ...pattern, + elements, + }; +} + +export function getAllRegisteredPatterns(): Map { + return globalRegistry; +} + +export function mergeDwgLinetypePattern( + ltName: string, + dwgPattern: number[], + description?: string +): LinPattern | undefined { + if (!ltName) return undefined; + const base = getLinPattern(ltName); + if (!dwgPattern || !dwgPattern.length) return base; + + if (!base) { + const elements: LinElement[] = []; + let totalLength = 0; + for (const val of dwgPattern) { + if (val > 1e-6) { + elements.push({ + type: 'dash', + val, + scale: 1, + rotation: 0, + isAbsoluteAngle: false, + isUprightAngle: false, + xOffset: 0, + yOffset: 0, + }); + totalLength += val; + } else if (val < -1e-6) { + const absVal = Math.abs(val); + elements.push({ + type: 'gap', + val: -absVal, + scale: 1, + rotation: 0, + isAbsoluteAngle: false, + isUprightAngle: false, + xOffset: 0, + yOffset: 0, + }); + totalLength += absVal; + } + } + return { + name: ltName, + description: description || '', + elements, + patternLength: Math.max(totalLength, 0.1), + }; + } + + // Merge DWG numeric dash/gap values into base pattern elements + const newElements: LinElement[] = []; + let dwgValIdx = 0; + let totalLength = 0; + + for (const elem of base.elements) { + if (elem.type === 'dash') { + const dwgVal = + dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0 + ? dwgPattern[dwgValIdx] + : elem.val || 0.05; + if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0) { + dwgValIdx++; + } + newElements.push({ ...elem, val: dwgVal }); + totalLength += dwgVal; + } else if (elem.type === 'gap') { + const dwgVal = + dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0 + ? dwgPattern[dwgValIdx] + : elem.val; + if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0) { + dwgValIdx++; + } + const absVal = Math.abs(dwgVal || 0.1); + newElements.push({ ...elem, val: -absVal }); + totalLength += absVal; + } else { + newElements.push({ ...elem }); + } + } + + return { + ...base, + description: description || base.description, + elements: newElements, + patternLength: Math.max(totalLength, 0.1), + }; +} + diff --git a/src/engine/viewer2d/parseDxf.ts b/src/engine/viewer2d/parseDxf.ts new file mode 100644 index 0000000..798e473 --- /dev/null +++ b/src/engine/viewer2d/parseDxf.ts @@ -0,0 +1,24 @@ +/** + * Parse DXF text/buffer into the same CadParseResult shape as DWG. + */ +import DxfParser from 'dxf-parser'; +import HatchHandler from './dxfHatchHandler.js'; +import { dxfToParseResult } from './dxfAdapter.js'; +import { decodeDxf } from './decodeDxf'; +import type { CadParseResult } from './dwgParser'; + +/** Parse a DXF ArrayBuffer (encoding-aware) → CadParseResult. */ +export function parseDxfBuffer(buf: ArrayBuffer): CadParseResult { + return parseDxfText(decodeDxf(buf)); +} + +/** Parse DXF text already decoded as a string. */ +export function parseDxfText(text: string): CadParseResult { + const parser = new DxfParser(); + // dxf-parser ships no HATCH handler; its EntityName union excludes 'HATCH'. + parser.registerEntityHandler( + HatchHandler as unknown as Parameters[0], + ); + const dxf = parser.parseSync(text); + return dxfToParseResult(dxf); +} diff --git a/src/engine/viewer2d/shims.d.ts b/src/engine/viewer2d/shims.d.ts new file mode 100644 index 0000000..ed70828 --- /dev/null +++ b/src/engine/viewer2d/shims.d.ts @@ -0,0 +1,20 @@ +declare module 'dxf-parser' { + export default class DxfParser { + parseSync(text: string): unknown; + registerEntityHandler(handler: { ForEntityName: string; parseEntity: (...args: unknown[]) => unknown }): void; + } +} + +declare module 'opentype.js' { + export function parse(buffer: ArrayBuffer): unknown; +} + +declare module '*.wasm?url' { + const url: string; + export default url; +} + +declare module '*.wasm' { + const url: string; + export default url; +} diff --git a/src/engine/viewer2d/slugText.ts b/src/engine/viewer2d/slugText.ts new file mode 100644 index 0000000..2c331ba --- /dev/null +++ b/src/engine/viewer2d/slugText.ts @@ -0,0 +1,403 @@ +/** + * slugText — GPU-resident vector text for the 2D viewer, after Eric Lengyel's + * Slug algorithm ("GPU-Centered Font Rendering Directly from Glyph Outlines", + * JCGT 2017; reference shaders at github.com/EricLengyel/Slug — patent + * dedicated to the public domain, attribution retained here). + * + * Replaces the per-string CanvasTexture sprites: TrueType quadratic Bézier + * outlines are uploaded once per UNIQUE GLYPH into a float data texture, and + * the fragment shader computes an antialiased winding number per pixel by + * intersecting horizontal + vertical rays with the glyph's curves (curves + * pre-sorted into 8 bands per axis to bound the per-pixel work). Text stays + * razor sharp at any zoom and memory scales with glyph count, not text count. + * + * Font: public/fonts/NanumGothic-Regular.ttf (OFL) — TrueType glyf outlines + * (quadratic only), Latin + full Hangul. + */ +import * as THREE from 'three'; +import { parse as parseFont } from 'opentype.js'; + +const TEX_W = 1024; // data texture width in texels (RGBA32F) +const BANDS = 8; // bands per axis per glyph +const PAD = 0.08; // em padding around glyph quads (AA spill room) + +interface Curve { x0: number; y0: number; cx: number; cy: number; x1: number; y1: number } + +interface GlyphRec { + base: number; // texel index of the glyph header (-1 when no ink, e.g. space) + advance: number; // em units +} + +/** Default TrueType font URL (host must serve Hangul-capable TTF). */ +let defaultFontUrl = '/fonts/NanumGothic-Regular.ttf'; + +/** Override the default font path before creating CadViewer / Viewer2D. */ +export function setDefaultFontUrl(url: string): void { + defaultFontUrl = url; + // reset shared engine so the next shared() uses the new URL + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (SlugTextEngine as any)._shared = null; +} + +/** One shared font engine (font fetch + parse happens once). */ +export class SlugTextEngine { + private static _shared: Promise | null = null; + + static shared(url: string = defaultFontUrl): Promise { + if (!this._shared) { + this._shared = (async () => { + const buf = await (await fetch(url)).arrayBuffer(); + return new SlugTextEngine(parseFont(buf)); + })(); + // allow a retry on transient fetch failure instead of caching the rejection + this._shared.catch(() => { (SlugTextEngine as any)._shared = null; }); + } + return this._shared; + } + + readonly capHeightEm: number; + readonly ascentEm: number; + readonly descentEm: number; + + private font: any; + private upem: number; + private glyphs = new Map(); + private data = new Float32Array(TEX_W * 4 * 64); // grows ×2 as needed + private used = 0; // texels consumed + private _texture: THREE.DataTexture | null = null; + private _texelsUploaded = 0; + + private constructor(font: any) { + this.font = font; + this.upem = font.unitsPerEm; + this.capHeightEm = (font.tables?.os2?.sCapHeight || font.ascender * 0.88) / this.upem; + this.ascentEm = font.ascender / this.upem; + this.descentEm = font.descender / this.upem; // negative + } + + /** Layout width of one line, in em units. */ + measureEm(line: string): number { + let w = 0; + for (const ch of line) w += this.ensureGlyph(ch.codePointAt(0)!).advance; + return w; + } + + ensureGlyph(cp: number): GlyphRec { + let rec = this.glyphs.get(cp); + if (rec) return rec; + + const glyph = this.font.charToGlyph(String.fromCodePoint(cp)); + const advance = (glyph.advanceWidth ?? this.upem * 0.5) / this.upem; + // getPath(0,0,1) → coordinates scaled to em units but y-DOWN; flip y. + const path = glyph.getPath(0, 0, 1); + const curves: Curve[] = []; + let sx = 0, sy = 0, px = 0, py = 0; + for (const c of path.commands) { + switch (c.type) { + case 'M': px = sx = c.x; py = sy = -c.y; break; + case 'L': curves.push(lineCurve(px, py, c.x, -c.y)); px = c.x; py = -c.y; break; + case 'Q': curves.push({ x0: px, y0: py, cx: c.x1, cy: -c.y1, x1: c.x, y1: -c.y }); px = c.x; py = -c.y; break; + case 'C': { // glyf fonts shouldn't emit cubics; approximate defensively + const mx = (c.x1 + c.x2) / 2, my = (-c.y1 + -c.y2) / 2; + curves.push({ x0: px, y0: py, cx: mx, cy: my, x1: c.x, y1: -c.y }); + px = c.x; py = -c.y; break; + } + case 'Z': if (px !== sx || py !== sy) curves.push(lineCurve(px, py, sx, sy)); px = sx; py = sy; break; + } + } + + if (!curves.length) { + rec = { base: -1, advance }; + this.glyphs.set(cp, rec); + return rec; + } + + // Conservative bbox from all points (control points included). + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; + for (const q of curves) { + x0 = Math.min(x0, q.x0, q.cx, q.x1); x1 = Math.max(x1, q.x0, q.cx, q.x1); + y0 = Math.min(y0, q.y0, q.cy, q.y1); y1 = Math.max(y1, q.y0, q.cy, q.y1); + } + const w = Math.max(x1 - x0, 1e-6), h = Math.max(y1 - y0, 1e-6); + + // Band membership (conservative, via control-point extents). + const hBands: number[][] = Array.from({ length: BANDS }, () => []); + const vBands: number[][] = Array.from({ length: BANDS }, () => []); + curves.forEach((q, i) => { + const cy0 = Math.min(q.y0, q.cy, q.y1), cy1 = Math.max(q.y0, q.cy, q.y1); + const cx0 = Math.min(q.x0, q.cx, q.x1), cx1 = Math.max(q.x0, q.cx, q.x1); + const hb0 = clampBand((cy0 - y0) / h), hb1 = clampBand((cy1 - y0) / h); + for (let b = hb0; b <= hb1; b++) hBands[b].push(i); + const vb0 = clampBand((cx0 - x0) / w), vb1 = clampBand((cx1 - x0) / w); + for (let b = vb0; b <= vb1; b++) vBands[b].push(i); + }); + + // ── Serialize: header(2) + bands(16) + index lists + curves(2/curve) ── + const listTexels = (l: number[]) => Math.ceil(l.length / 4); + const idxTexels = hBands.reduce((s, l) => s + listTexels(l), 0) + + vBands.reduce((s, l) => s + listTexels(l), 0); + const total = 2 + BANDS * 2 + idxTexels + curves.length * 2; + const base = this.alloc(total); + const d = this.data; + const put = (t: number, a: number, b: number, c: number, e: number) => { + d[t * 4] = a; d[t * 4 + 1] = b; d[t * 4 + 2] = c; d[t * 4 + 3] = e; + }; + + const curveBase = base + 2 + BANDS * 2 + idxTexels; + put(base, x0, y0, x1, y1); + put(base + 1, base + 2, base + 2 + BANDS, 0, 0); // hBandBase, vBandBase + + let listCursor = base + 2 + BANDS * 2; + const writeBands = (bands: number[][], texel: number) => { + for (let b = 0; b < BANDS; b++) { + put(texel + b, listCursor, bands[b].length, 0, 0); + const l = bands[b]; + for (let j = 0; j < l.length; j += 4) { + put(listCursor++, + curveBase + l[j] * 2, + j + 1 < l.length ? curveBase + l[j + 1] * 2 : 0, + j + 2 < l.length ? curveBase + l[j + 2] * 2 : 0, + j + 3 < l.length ? curveBase + l[j + 3] * 2 : 0); + } + } + }; + writeBands(hBands, base + 2); + writeBands(vBands, base + 2 + BANDS); + + curves.forEach((q, i) => { + put(curveBase + i * 2, q.x0, q.y0, q.cx, q.cy); + put(curveBase + i * 2 + 1, q.x1, q.y1, 0, 0); + }); + + rec = { base, advance }; + this.glyphs.set(cp, rec); + return rec; + } + + bboxOf(rec: GlyphRec): [number, number, number, number] { + const t = rec.base * 4; + return [this.data[t], this.data[t + 1], this.data[t + 2], this.data[t + 3]]; + } + + private alloc(texels: number): number { + const need = (this.used + texels) * 4; + if (need > this.data.length) { + let cap = this.data.length; + while (cap < need) cap *= 2; + const next = new Float32Array(cap); + next.set(this.data); + this.data = next; + } + const at = this.used; + this.used += texels; + return at; + } + + /** Data texture with all glyph data uploaded (recreated when it grew). */ + texture(): THREE.DataTexture { + const rows = Math.max(1, Math.ceil(this.used / TEX_W)); + if (!this._texture || this._texelsUploaded < this.used) { + this._texture?.dispose(); + const buf = new Float32Array(TEX_W * rows * 4); + buf.set(this.data.subarray(0, Math.min(this.data.length, TEX_W * rows * 4))); + const tex = new THREE.DataTexture(buf, TEX_W, rows, THREE.RGBAFormat, THREE.FloatType); + tex.minFilter = THREE.NearestFilter; + tex.magFilter = THREE.NearestFilter; + tex.generateMipmaps = false; + tex.needsUpdate = true; + this._texture = tex; + this._texelsUploaded = this.used; + } + return this._texture; + } +} + +function lineCurve(x0: number, y0: number, x1: number, y1: number): Curve { + return { x0, y0, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, x1, y1 }; +} + +function clampBand(f: number): number { + return Math.min(BANDS - 1, Math.max(0, Math.floor(f * BANDS))); +} + +// ── Shader (classic syntax; three's GLSL3 prefix maps attribute/varying/gl_FragColor) ── + +const VERT = /* glsl */` + attribute vec2 emuv; + attribute float gbase; + attribute vec3 tcolor; + varying vec2 vEm; + flat varying int vBase; + varying vec3 vColor; + void main() { + vEm = emuv; + vBase = int(gbase); + vColor = tcolor; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const FRAG = /* glsl */` + precision highp float; + uniform sampler2D dataTex; + varying vec2 vEm; + flat varying int vBase; + varying vec3 vColor; + layout(location = 0) out vec4 fragOut; + + vec4 T(int i) { return texelFetch(dataTex, ivec2(i % ${TEX_W}, i / ${TEX_W}), 0); } + + // Lengyel's banded winding-number coverage for one ray along +x from p. + // Curves are fetched via the band's index list; 'swap' mirrors x/y for the + // vertical (+y) ray, whose winding sign flips (handedness reversal). + float rayCoverage(vec2 p, float ppem, int bandTexel, bool swap) { + vec4 band = T(bandTexel); + int count = int(band.y); + float cov = 0.0; + for (int j = 0; j < 256; j++) { + if (j >= count) break; + vec4 idx4 = T(int(band.x) + (j >> 2)); + int cb = int(j % 4 == 0 ? idx4.x : j % 4 == 1 ? idx4.y : j % 4 == 2 ? idx4.z : idx4.w); + vec4 A = T(cb); + vec2 e = T(cb + 1).xy; + // Swap the CURVE points before subtracting: p is already in swapped + // coordinates for the vertical ray ((A.xy - p).yx would be wrong). + vec2 p1 = (swap ? A.yx : A.xy) - p; + vec2 p2 = (swap ? A.wz : A.zw) - p; + vec2 p3 = (swap ? e.yx : e.xy) - p; + + uint code = (0x2E74u >> ((p1.y > 0.0 ? 2u : 0u) + (p2.y > 0.0 ? 4u : 0u) + (p3.y > 0.0 ? 8u : 0u))) & 3u; + if (code != 0u) { + vec2 a = p1 - p2 * 2.0 + p3; + vec2 b = p1 - p2; + float t1, t2; + if (abs(a.y) < 1e-6) { + float t = p1.y / (2.0 * b.y); + t1 = t; t2 = t; + } else { + float d = sqrt(max(b.y * b.y - a.y * p1.y, 0.0)); + t1 = (b.y - d) / a.y; + t2 = (b.y + d) / a.y; + } + if ((code & 1u) != 0u) { + float x = (a.x * t1 - b.x * 2.0) * t1 + p1.x; + cov += clamp(x * ppem + 0.5, 0.0, 1.0); + } + if (code > 1u) { + float x = (a.x * t2 - b.x * 2.0) * t2 + p1.x; + cov -= clamp(x * ppem + 0.5, 0.0, 1.0); + } + } + } + return cov; + } + + void main() { + vec4 bbox = T(vBase); + vec4 bases = T(vBase + 1); + vec2 fw = fwidth(vEm); + + vec2 span = max(bbox.zw - bbox.xy, vec2(1e-6)); + int hb = clamp(int((vEm.y - bbox.y) / span.y * float(${BANDS})), 0, ${BANDS - 1}); + int vb = clamp(int((vEm.x - bbox.x) / span.x * float(${BANDS})), 0, ${BANDS - 1}); + + float covH = rayCoverage(vEm, 1.0 / max(fw.x, 1e-9), int(bases.x) + hb, false); + float covV = -rayCoverage(vEm.yx, 1.0 / max(fw.y, 1e-9), int(bases.y) + vb, true); + float alpha = clamp((covH + covV) * 0.5, 0.0, 1.0); + if (alpha < 0.004) discard; + fragOut = vec4(vColor, alpha); + } +`; + +/** Accumulates positioned strings and builds one merged mesh (single draw call). */ +export class SlugTextBatch { + private pos: number[] = []; + private emuv: number[] = []; + private gbase: number[] = []; + private color: number[] = []; + private index: number[] = []; + + constructor(private engine: SlugTextEngine) {} + + /** + * alignH: 0/3/5=left datum, 1/4=center, 2=right. + * alignV: 0=baseline, 1=bottom, 2=middle, 3=top (cap-height datum, matching + * the previous canvas-sprite behaviour). Multi-line via '\n'. + */ + add(text: string, pos: { x: number; y: number }, height: number, rotation: number, + colorInt: number, alignH: number, alignV: number): void { + const eng = this.engine; + const lines = String(text).split('\n'); + // Glyph outlines are em-scaled (getPath(0,0,1)), so raw cap ink height is + // capHeightEm em, not 1em. Divide by it so the rendered cap height equals + // the DWG `height` field, matching the canvas-sprite fallback's ascent fit. + const scale = height / eng.capHeightEm; + const lineStep = height * 5 / 3; // CAD default MTEXT line spacing + const blockH = (lines.length - 1) * lineStep + height; + + // DXF group 72 = 4 ("Middle") centers both axes on the alignment point; + // group 73 (vertical) is ignored. alignH already centers horizontally (below); + // force vertical to middle here too, else vA=0 text (block-internal plain + // TEXT, e.g. titleblock labels) renders half a line too high. + if (alignH === 4) alignV = 2; + + let baseline0: number; + if (alignV === 3) baseline0 = -height; // top datum + else if (alignV === 2) baseline0 = blockH / 2 - height; // middle + else baseline0 = (lines.length - 1) * lineStep; // baseline/bottom + + const cos = Math.cos(rotation), sin = Math.sin(rotation); + const r = ((colorInt >> 16) & 0xFF) / 255, g = ((colorInt >> 8) & 0xFF) / 255, b = (colorInt & 0xFF) / 255; + + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + if (!line) continue; + const wEm = eng.measureEm(line); + const ox = (alignH === 1 || alignH === 4) ? -wEm * scale / 2 : alignH === 2 ? -wEm * scale : 0; + const oy = baseline0 - li * lineStep; + let pen = 0; + for (const ch of line) { + const rec = eng.ensureGlyph(ch.codePointAt(0)!); + if (rec.base >= 0) { + const [x0, y0, x1, y1] = this.engine.bboxOf(rec); + const qx0 = x0 - PAD, qy0 = y0 - PAD, qx1 = x1 + PAD, qy1 = y1 + PAD; + const v = this.pos.length / 3; + for (const [ex, ey] of [[qx0, qy0], [qx1, qy0], [qx1, qy1], [qx0, qy1]] as const) { + const lx = ox + (pen + ex) * scale; + const ly = oy + ey * scale; + this.pos.push(pos.x + lx * cos - ly * sin, pos.y + lx * sin + ly * cos, 1); + this.emuv.push(ex, ey); + this.gbase.push(rec.base); + this.color.push(r, g, b); + } + this.index.push(v, v + 1, v + 2, v, v + 2, v + 3); + } + pen += rec.advance; + } + } + } + + /** Build the merged mesh; null when nothing was added. */ + build(): THREE.Mesh | null { + if (!this.index.length) return null; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(this.pos, 3)); + geo.setAttribute('emuv', new THREE.Float32BufferAttribute(this.emuv, 2)); + geo.setAttribute('gbase', new THREE.Float32BufferAttribute(this.gbase, 1)); + geo.setAttribute('tcolor', new THREE.Float32BufferAttribute(this.color, 3)); + geo.setIndex(this.index); + const mat = new THREE.ShaderMaterial({ + glslVersion: THREE.GLSL3, + vertexShader: VERT, + fragmentShader: FRAG, + uniforms: { dataTex: { value: this.engine.texture() } }, + transparent: true, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.frustumCulled = true; + return mesh; + } +}