Compare commits
11
Commits
95fa6a452b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8f7ca4eb | ||
|
|
b047dc44e2 | ||
|
|
7a6edd4347 | ||
|
|
199317d575 | ||
|
|
f7564d4abf | ||
|
|
14d1f06927 | ||
|
|
3d3e052f3e | ||
|
|
1a664f677a | ||
|
|
76f3e20582 | ||
|
|
963a90319e | ||
|
|
be8d52fcd5 |
@@ -103,6 +103,18 @@ Vite `?url` import 로 로드됩니다.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 알려진 이슈 / 원본 반영 메모
|
||||||
|
|
||||||
|
| 이슈 | 문서 |
|
||||||
|
|------|------|
|
||||||
|
| 구형 한글 DWG TEXT 깨짐 (CP949→Latin-1 mojibake) | [`docs/korean-dwg-text-encoding.md`](./docs/korean-dwg-text-encoding.md) |
|
||||||
|
| Model + Paper 동시 렌더로 글자 겹침 / Layout 탭 | [`docs/model-paper-space.md`](./docs/model-paper-space.md) |
|
||||||
|
|
||||||
|
샘플 패치: `fixDwgKoreanText`, `cadSpaces` + Viewer2D 공간 필터, Model/Layout 탭.
|
||||||
|
원본 `hmwebviewer` 반영 절차는 각 문서를 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 라이선스 메모
|
## 라이선스 메모
|
||||||
|
|
||||||
| 조각 | 라이선스 |
|
| 조각 | 라이선스 |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
function A(t){const e=x(t,s.__wbindgen_malloc),n=w,r=s.parse_dwg(e,n);if(r[2])throw h(r[1]);return h(r[0])}function W(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(g(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),i=T(r,s.__wbindgen_malloc,s.__wbindgen_realloc),d=w;m().setInt32(e+4,d,!0),m().setInt32(e+0,i,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(g(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return g(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=s.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==s.memory.buffer)&&(a=new DataView(s.memory.buffer)),a}function g(t,e){return R(t>>>0,e)}let u=null;function _(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(s.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return _().set(t,n/1),w=t.length,n}function T(t,e,n){if(n===void 0){const o=b.encode(t),f=e(o.length,1)>>>0;return _().subarray(f,f+o.length).set(o),w=o.length,f}let r=t.length,i=e(r,1)>>>0;const d=_();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;d[i+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),i=n(i,r,r=c+t.length*3,1)>>>0;const o=_().subarray(i+c,i+r),f=b.encodeInto(t,o);c+=f.written,i=n(i,r,c,1)>>>0}return w=c,i}function h(t){const e=s.__wbindgen_externrefs.get(t);return s.__externref_table_dealloc(t),e}let l=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});l.decode();const E=2146435072;let y=0;function R(t,e){return y+=e,y>=E&&(l=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),l.decode(),y=e),l.decode(_().subarray(t,t+e))}const b=new TextEncoder;"encodeInto"in b||(b.encodeInto=function(t,e){const n=b.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,s;function M(t,e){return s=t.exports,a=null,u=null,s.__wbindgen_start(),s}async function O(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(i){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",i);else throw i}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function D(t){if(s!==void 0)return s;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-Bw7hOWaW.wasm",import.meta.url));const e=W();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await O(await t,e);return M(n)}const S="/assets/acadrust_dwg_bg-Bw7hOWaW.wasm";let p=null;function I(){return p||(p=D({module_or_path:S}).then(()=>{})),p}function B(t){return A(t)}export{I as initAcadrustParser,B as parseDwgAcadrust};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
function A(t){let e,n;try{const f=x(t,i.__wbindgen_malloc),c=w,o=i.parse_dwg_json(f,c);var r=o[0],s=o[1];if(o[3])throw r=0,s=0,T(o[2]);return e=r,n=s,l(r,s)}finally{i.__wbindgen_free(e,n,1)}}function h(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(l(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),s=S(r,i.__wbindgen_malloc,i.__wbindgen_realloc),f=w;m().setInt32(e+4,f,!0),m().setInt32(e+0,s,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(l(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return l(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=i.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==i.memory.buffer)&&(a=new DataView(i.memory.buffer)),a}function l(t,e){return E(t>>>0,e)}let u=null;function b(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(i.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return b().set(t,n/1),w=t.length,n}function S(t,e,n){if(n===void 0){const o=d.encode(t),_=e(o.length,1)>>>0;return b().subarray(_,_+o.length).set(o),w=o.length,_}let r=t.length,s=e(r,1)>>>0;const f=b();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;f[s+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),s=n(s,r,r=c+t.length*3,1)>>>0;const o=b().subarray(s+c,s+r),_=d.encodeInto(t,o);c+=_.written,s=n(s,r,c,1)>>>0}return w=c,s}function T(t){const e=i.__wbindgen_externrefs.get(t);return i.__externref_table_dealloc(t),e}let g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});g.decode();const D=2146435072;let y=0;function E(t,e){return y+=e,y>=D&&(g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),g.decode(),y=e),g.decode(b().subarray(t,t+e))}const d=new TextEncoder;"encodeInto"in d||(d.encodeInto=function(t,e){const n=d.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,i;function R(t,e){return i=t.exports,a=null,u=null,i.__wbindgen_start(),i}async function W(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(s){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",s);else throw s}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function M(t){if(i!==void 0)return i;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-Dt2ifJSA.wasm",import.meta.url));const e=h();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await W(await t,e);return R(n)}const O="/assets/acadrust_dwg_bg-Dt2ifJSA.wasm";let p=null;function v(){return p||(p=M({module_or_path:O}).then(()=>{})),p}function I(t){return JSON.parse(A(t))}export{v as initAcadrustParser,I as parseDwgAcadrust};
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,361 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>DWG/DXF · 모듈 조합 레시피 & 샘플</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #eef1f4; --panel: #fff; --ink: #1a2230; --ink-soft: #4a5568; --ink-faint: #8592a3;
|
||||||
|
--line: #d3dae2; --line-strong: #b7c1cd; --accent: #0e7c86; --accent-soft: #d3ecef;
|
||||||
|
--wasm: #b45309; --wasm-soft: #fbe8cf; --js: #0e7c86;
|
||||||
|
--grid: rgba(14,124,134,.06);
|
||||||
|
--shadow: 0 1px 2px rgba(26,34,48,.06), 0 8px 24px rgba(26,34,48,.06);
|
||||||
|
--mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1219; --panel: #141b25; --ink: #e6ecf3; --ink-soft: #a3b0c0; --ink-faint: #6b7a8d;
|
||||||
|
--line: #243040; --line-strong: #33445a; --accent: #3fd0dc; --accent-soft: #123038;
|
||||||
|
--wasm: #f0a03c; --wasm-soft: #33260f; --js: #3fd0dc; --grid: rgba(63,208,220,.05);
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 28px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||||
|
linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 26px 26px,
|
||||||
|
var(--bg);
|
||||||
|
color: var(--ink); font-family: var(--sans); line-height: 1.6;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px 96px; }
|
||||||
|
header { border-bottom: 1px solid var(--line); padding-bottom: 28px; margin-bottom: 36px; }
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--mono); font-size: 12px; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
color: var(--accent); display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.eyebrow::before { content: ""; width: 28px; height: 1px; background: var(--accent); }
|
||||||
|
h1 { font-size: clamp(26px, 4vw, 38px); line-height: 1.15; margin: 0 0 12px; letter-spacing: -.01em; }
|
||||||
|
h1 .dim { color: var(--ink-faint); font-weight: 400; }
|
||||||
|
.lede { font-size: 16px; color: var(--ink-soft); max-width: 64ch; margin: 0; }
|
||||||
|
.cta {
|
||||||
|
display: inline-flex; align-items: center; gap: 10px; margin-top: 20px;
|
||||||
|
font-family: var(--mono); font-size: 13px; font-weight: 600; text-decoration: none;
|
||||||
|
color: var(--accent); background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--accent); border-radius: 8px; padding: 10px 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 12px; font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||||||
|
color: var(--ink-faint); margin: 48px 0 16px; display: flex; align-items: center; gap: 12px;
|
||||||
|
}
|
||||||
|
h2 .num { color: var(--accent); }
|
||||||
|
h2::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; color: var(--ink-soft); margin: -4px 0 18px; }
|
||||||
|
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.dot { width: 11px; height: 11px; border-radius: 3px; }
|
||||||
|
.dot.wasm { background: var(--wasm); }
|
||||||
|
.dot.js { background: var(--js); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 12px; }
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 14px; box-shadow: var(--shadow); position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--tag); }
|
||||||
|
.card.w { --tag: var(--wasm); }
|
||||||
|
.card.j { --tag: var(--js); }
|
||||||
|
.card .k {
|
||||||
|
font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 6px;
|
||||||
|
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-family: var(--mono); font-size: 9.5px; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge.w { background: var(--wasm-soft); color: var(--wasm); }
|
||||||
|
.badge.j { background: var(--accent-soft); color: var(--js); }
|
||||||
|
.card .d { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
|
||||||
|
.card .path { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); margin-top: 8px; word-break: break-all; }
|
||||||
|
.flow {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 22px 16px; box-shadow: var(--shadow); overflow-x: auto;
|
||||||
|
}
|
||||||
|
.pipe { display: flex; align-items: stretch; min-width: 720px; }
|
||||||
|
.node {
|
||||||
|
flex: 1; background: var(--bg); border: 1px solid var(--line-strong); border-radius: 9px;
|
||||||
|
padding: 12px; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||||
|
}
|
||||||
|
.node.hl { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.node.w { border-color: var(--wasm); }
|
||||||
|
.node .tier { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-faint); }
|
||||||
|
.node.hl .tier { color: var(--accent); }
|
||||||
|
.node.w .tier { color: var(--wasm); }
|
||||||
|
.node .name { font-family: var(--mono); font-size: 12.5px; font-weight: 600; word-break: break-all; }
|
||||||
|
.node .sub { font-size: 12px; color: var(--ink-soft); }
|
||||||
|
.arrow { flex: 0 0 32px; display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; }
|
||||||
|
.flow-note { font-size: 12.5px; color: var(--ink-faint); margin-top: 14px; font-family: var(--mono); }
|
||||||
|
.flow-note b { color: var(--accent); }
|
||||||
|
pre {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 16px 18px;
|
||||||
|
overflow-x: auto; font-family: var(--mono); font-size: 12.5px; color: var(--ink-soft); line-height: 1.55;
|
||||||
|
box-shadow: var(--shadow); margin: 0;
|
||||||
|
}
|
||||||
|
pre .c { color: var(--accent); }
|
||||||
|
pre .w { color: var(--wasm); }
|
||||||
|
pre .cm { color: var(--ink-faint); }
|
||||||
|
.steps { display: grid; gap: 12px; }
|
||||||
|
.step {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 16px 18px; box-shadow: var(--shadow); display: grid; grid-template-columns: 36px 1fr; gap: 12px;
|
||||||
|
}
|
||||||
|
.step .n {
|
||||||
|
font-family: var(--mono); font-weight: 700; font-size: 18px; color: var(--accent);
|
||||||
|
width: 36px; height: 36px; border-radius: 8px; background: var(--accent-soft);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.step h3 { margin: 0 0 4px; font-size: 15px; }
|
||||||
|
.step p { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
|
||||||
|
.step code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||||
|
code { font-family: var(--mono); font-size: .9em; background: var(--bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
|
||||||
|
footer {
|
||||||
|
margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--line);
|
||||||
|
font-family: var(--mono); font-size: 12px; color: var(--ink-faint);
|
||||||
|
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<div class="eyebrow">compose · don’t invent</div>
|
||||||
|
<h1>DWG · DXF 모듈 조합 <span class="dim">레시피</span></h1>
|
||||||
|
<p class="lede">
|
||||||
|
전체 제품이 아니라, 지도에 있는 <b>재사용 모듈만</b> 골라 조립하면 2D CAD 뷰어가 됩니다.
|
||||||
|
아래 카드 → 파이프라인 → 샘플 코드 순으로 옮기면 다른 프로젝트에서도 동일하게 동작합니다.
|
||||||
|
</p>
|
||||||
|
<a class="cta" href="/">
|
||||||
|
▶ 실행 샘플 · 이 프로젝트 루트 (npm run dev)
|
||||||
|
</a>
|
||||||
|
<p style="margin:10px 0 0; font-family:var(--mono); font-size:12px; color:var(--ink-faint)">
|
||||||
|
cd dwg-dxf-viewer-sample && npm i && npm run dev → http://localhost:5173
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 01 modules -->
|
||||||
|
<h2><span class="num">01</span> 조합에 쓰는 모듈 (지도 카드)</h2>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="dot wasm"></i> WASM 파서</span>
|
||||||
|
<span><i class="dot js"></i> JS 모듈 · 렌더러</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card w">
|
||||||
|
<div class="k">acadrust-dwg <span class="badge w">wasm</span></div>
|
||||||
|
<div class="d">DWG 바이너리 → JSON parseResult. 외부 네트워크 없음.</div>
|
||||||
|
<div class="path">src/viewer2d/acadrust-dwg/*.wasm (이 프로젝트)</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">dwgParser / acadrustParser <span class="badge j">wrap</span></div>
|
||||||
|
<div class="d">WASM 1회 init + <code>parseDwgBuffer(bytes)</code>.</div>
|
||||||
|
<div class="path">dwgParser.ts · acadrustParser.ts</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">dxf-parser <span class="badge j">npm</span></div>
|
||||||
|
<div class="d">DXF 텍스트 파싱 (MIT). HATCH 기본 미지원.</div>
|
||||||
|
<div class="path">npm: dxf-parser</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">dxfHatchHandler <span class="badge j">patch</span></div>
|
||||||
|
<div class="d"><code>registerEntityHandler</code>로 HATCH 보강.</div>
|
||||||
|
<div class="path">dxfHatchHandler.js</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">dxfAdapter <span class="badge j">adapt</span></div>
|
||||||
|
<div class="d">dxf-parser 출력 → Viewer2D 공통 스키마.</div>
|
||||||
|
<div class="path">dxfAdapter.js</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">Viewer2D <span class="badge j">render</span></div>
|
||||||
|
<div class="d">Orthographic three 씬. LINE/ARC/HATCH/TEXT/INSERT…</div>
|
||||||
|
<div class="path">Viewer2D.js</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">three <span class="badge j">peer</span></div>
|
||||||
|
<div class="d">WebGLRenderer + OrbitControls (pan/zoom).</div>
|
||||||
|
<div class="path">npm: three ≥0.160</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">slugText + font <span class="badge j">text</span></div>
|
||||||
|
<div class="d">opentype.js → GPU 텍스트. TTF를 호스트가 서빙.</div>
|
||||||
|
<div class="path">slugText.ts · /fonts/*.ttf</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">ext2d <span class="badge j">util</span></div>
|
||||||
|
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
||||||
|
<div class="path">ext2d.ts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 02 pipeline -->
|
||||||
|
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
||||||
|
<div class="flow">
|
||||||
|
<div class="pipe">
|
||||||
|
<div class="node">
|
||||||
|
<div class="tier">Host</div>
|
||||||
|
<div class="name">File · URL · Drop</div>
|
||||||
|
<div class="sub">.dwg / .dxf</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="node hl">
|
||||||
|
<div class="tier">ext2d</div>
|
||||||
|
<div class="name">isDwg / isDxf</div>
|
||||||
|
<div class="sub">분기</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="node w">
|
||||||
|
<div class="tier">Parser A or B</div>
|
||||||
|
<div class="name">acadrust · dxf-parser</div>
|
||||||
|
<div class="sub">(+ hatch · adapter)</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="node hl">
|
||||||
|
<div class="tier">Contract</div>
|
||||||
|
<div class="name">CadParseResult</div>
|
||||||
|
<div class="sub">공통 JSON</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
<div class="node">
|
||||||
|
<div class="tier">Viewer2D</div>
|
||||||
|
<div class="name">.load(result)</div>
|
||||||
|
<div class="sub">three 씬</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-note">
|
||||||
|
<b>핵심 계약</b>: 파서는 파일을 안 열고, 렌더러는 바이트를 안 연다.
|
||||||
|
중간은 항상 <code>CadParseResult</code> 하나.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 03 recipe steps -->
|
||||||
|
<h2><span class="num">03</span> 다른 프로젝트에 옮기는 순서</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<div class="n">1</div>
|
||||||
|
<div>
|
||||||
|
<h3>모듈 소스 복사</h3>
|
||||||
|
<p>
|
||||||
|
이 샘플의 <code>src/viewer2d/</code> 전체를 복사.
|
||||||
|
npm: <code>three</code>, <code>dxf-parser</code>, <code>opentype.js</code>.
|
||||||
|
(원본 소스는 형제 폴더 <code>hmwebviewer/src/viewer2d</code>.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="n">2</div>
|
||||||
|
<div>
|
||||||
|
<h3>정적 자산</h3>
|
||||||
|
<p>
|
||||||
|
<code>public/fonts/NanumGothic-Regular.ttf</code> (한글 TEXT).
|
||||||
|
WASM은 소스가 <code>?url</code>로 import — Vite면 <code>assetsInclude: ['**/*.wasm']</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="n">3</div>
|
||||||
|
<div>
|
||||||
|
<h3>아래 스니펫처럼 조합</h3>
|
||||||
|
<p>
|
||||||
|
래퍼 클래스 없이 모듈을 직접 import 해도 됩니다.
|
||||||
|
실행 가능한 전체 예: 이 프로젝트 <code>src/main.ts</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="n">4</div>
|
||||||
|
<div>
|
||||||
|
<h3>호스트 UI만 붙이기</h3>
|
||||||
|
<p>
|
||||||
|
드롭존 · 툴바 · 레이어 패널은 모듈이 아님.
|
||||||
|
샘플의 ⑤ 영역만 교체하면 디자인 시스템에 맞게 붙일 수 있음.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 04 code -->
|
||||||
|
<h2><span class="num">04</span> 최소 조합 코드 (샘플과 동일 패턴)</h2>
|
||||||
|
<pre><span class="cm">// ① 모듈 지도 카드 → import</span>
|
||||||
|
<span class="c">import</span> { Viewer2D } <span class="c">from</span> './viewer2d/Viewer2D.js';
|
||||||
|
<span class="c">import</span> { parseDwgBuffer } <span class="c">from</span> './viewer2d/dwgParser'; <span class="cm">// acadrust-dwg</span>
|
||||||
|
<span class="c">import</span> { parseDxfBuffer } <span class="c">from</span> './viewer2d/parseDxf'; <span class="cm">// dxf-parser+hatch+adapter</span>
|
||||||
|
<span class="c">import</span> { isDwg, isDxf } <span class="c">from</span> './viewer2d/ext2d';
|
||||||
|
<span class="c">import</span> { setDefaultFontUrl } <span class="c">from</span> './viewer2d/slugText';
|
||||||
|
|
||||||
|
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
||||||
|
|
||||||
|
<span class="cm">// ② 렌더러</span>
|
||||||
|
<span class="c">const</span> viewer = <span class="c">new</span> Viewer2D(document.getElementById('app')!);
|
||||||
|
|
||||||
|
<span class="cm">// ③ 파서 조합</span>
|
||||||
|
<span class="c">async function</span> parseCad(buf: ArrayBuffer, name: string) {
|
||||||
|
<span class="c">if</span> (isDxf(name)) <span class="c">return</span> parseDxfBuffer(buf); <span class="cm">// dxf-parser 경로</span>
|
||||||
|
<span class="c">if</span> (isDwg(name)) <span class="c">return</span> parseDwgBuffer(<span class="c">new</span> Uint8Array(buf)); <span class="cm">// WASM 경로</span>
|
||||||
|
<span class="c">throw new</span> Error('need .dwg or .dxf name');
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="cm">// ④ 계약: parseResult → load</span>
|
||||||
|
<span class="c">async function</span> open(file: File) {
|
||||||
|
<span class="c">const</span> result = <span class="c">await</span> parseCad(<span class="c">await</span> file.arrayBuffer(), file.name);
|
||||||
|
viewer.load(result);
|
||||||
|
viewer.resize();
|
||||||
|
viewer.fit();
|
||||||
|
}</pre>
|
||||||
|
|
||||||
|
<h2><span class="num">05</span> DWG 경로 vs DXF 경로</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card w">
|
||||||
|
<div class="k">.dwg 경로 <span class="badge w">wasm</span></div>
|
||||||
|
<div class="d">
|
||||||
|
<code>parseDwgBuffer</code><br>
|
||||||
|
→ acadrustParser.init<br>
|
||||||
|
→ <span style="color:var(--wasm)">parse_dwg(bytes)</span><br>
|
||||||
|
→ CadParseResult
|
||||||
|
</div>
|
||||||
|
<div class="path">Sample DWG 버튼으로 검증</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">.dxf 경로 <span class="badge j">js</span></div>
|
||||||
|
<div class="d">
|
||||||
|
<code>parseDxfBuffer</code><br>
|
||||||
|
→ decodeDxf (UTF-8 / CP949…)<br>
|
||||||
|
→ DxfParser + HatchHandler<br>
|
||||||
|
→ dxfToParseResult
|
||||||
|
</div>
|
||||||
|
<div class="path">Sample DXF 버튼으로 검증</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2><span class="num">06</span> 샘플 위치</h2>
|
||||||
|
<pre style="white-space:pre-wrap"><span class="c">dwg-dxf-viewer-sample/</span> ← hmwebviewer 형제 폴더
|
||||||
|
src/main.ts ← 모듈을 직접 import 해서 조립 (핵심)
|
||||||
|
src/viewer2d/ ← acadrust-dwg · Viewer2D · dxf-*
|
||||||
|
index.html ← 툴바 + Module recipe 패널
|
||||||
|
public/fonts/ ← NanumGothic (slugText)
|
||||||
|
public/samples/ ← BasicSample.dwg · simple.dxf
|
||||||
|
docs/modules-dwg-dxf.html
|
||||||
|
|
||||||
|
<span class="c">실행</span>
|
||||||
|
cd ../dwg-dxf-viewer-sample # (hmwebviewer 기준 형제)
|
||||||
|
npm install
|
||||||
|
npm run dev → http://localhost:5173</pre>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>DWG/DXF only · sibling of hmwebviewer</span>
|
||||||
|
<span>no 3D / SHP / R2</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -0,0 +1,242 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>DWG/DXF · 모듈 조합 샘플</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
|
||||||
|
--accent: #3fb950; --wasm: #f0a03c; --line: #30363d; --danger: #f85149;
|
||||||
|
--mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
|
||||||
|
#app { position: fixed; inset: 0; }
|
||||||
|
#toolbar {
|
||||||
|
position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10;
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none;
|
||||||
|
}
|
||||||
|
#toolbar > * { pointer-events: auto; }
|
||||||
|
button, label.btn {
|
||||||
|
background: var(--panel); color: var(--ink); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 8px 12px; font: 13px var(--sans); cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover, label.btn:hover { border-color: var(--accent); }
|
||||||
|
button.primary { background: #238636; border-color: #2ea043; }
|
||||||
|
button.wasm { border-color: var(--wasm); color: var(--wasm); }
|
||||||
|
label.btn input { display: none; }
|
||||||
|
#status {
|
||||||
|
margin-left: auto; font: 12px var(--mono); color: var(--muted);
|
||||||
|
background: rgba(22,27,34,.85); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 8px 12px; max-width: min(48vw, 480px);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#status.err { color: var(--danger); }
|
||||||
|
#recipe {
|
||||||
|
position: fixed; bottom: 14px; left: 14px; z-index: 10; max-width: 340px;
|
||||||
|
background: rgba(22,27,34,.94); border: 1px solid var(--line); border-radius: 10px;
|
||||||
|
padding: 12px 14px; font-size: 12px; line-height: 1.45; color: var(--muted);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
#recipe h3 {
|
||||||
|
margin: 0 0 8px; font: 600 11px var(--mono); letter-spacing: .1em;
|
||||||
|
text-transform: uppercase; color: var(--accent);
|
||||||
|
}
|
||||||
|
#recipe ol { margin: 0; padding-left: 18px; }
|
||||||
|
#recipe li { margin-bottom: 4px; }
|
||||||
|
#recipe code { font-family: var(--mono); font-size: 11px; color: var(--ink); }
|
||||||
|
#recipe .mod-w { color: var(--wasm); }
|
||||||
|
#recipe .mod-j { color: var(--accent); }
|
||||||
|
#recipe a { color: var(--accent); font-family: var(--mono); font-size: 11px; }
|
||||||
|
#layers {
|
||||||
|
position: fixed; top: 56px; right: 12px; width: 200px; max-height: 50vh;
|
||||||
|
overflow: auto; z-index: 10; background: rgba(22,27,34,.95);
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
|
||||||
|
font-size: 12px; display: none;
|
||||||
|
}
|
||||||
|
#layers h3 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
#layers label { display: flex; gap: 8px; align-items: center; margin: 4px 0; cursor: pointer; }
|
||||||
|
#layers .swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
#spaces {
|
||||||
|
display: none; align-items: center; gap: 0;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#spaces button {
|
||||||
|
border: none; border-radius: 0; border-right: 1px solid var(--line);
|
||||||
|
background: transparent; padding: 8px 12px; margin: 0;
|
||||||
|
}
|
||||||
|
#spaces button:last-child { border-right: none; }
|
||||||
|
#spaces button.active {
|
||||||
|
background: #238636; border-color: #2ea043; color: #fff;
|
||||||
|
}
|
||||||
|
#spaces button:hover:not(.active) { border-color: transparent; color: var(--accent); }
|
||||||
|
/* CAD 좌표 — 항상 우측 하단 (툴바 status 와 별개) */
|
||||||
|
#coords {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
left: auto;
|
||||||
|
top: auto;
|
||||||
|
z-index: 20;
|
||||||
|
font: 13px/1.35 var(--mono);
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(13,17,23,.95);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-width: 240px;
|
||||||
|
text-align: left;
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#coords .lbl { color: var(--accent); margin-right: 6px; font-weight: 600; }
|
||||||
|
#coords .sep { color: var(--line); margin: 0 12px; }
|
||||||
|
/* 공통 패널 윈도우 스타일 (Properties & Layers - Draggable & Resizable) */
|
||||||
|
.cad-panel {
|
||||||
|
position: fixed; top: 56px; width: 320px; height: 480px;
|
||||||
|
min-width: 250px; min-height: 140px; max-width: 90vw; max-height: 90vh;
|
||||||
|
z-index: 15; background: rgba(22,27,34,.96); backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.5);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden; resize: both;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.cad-panel.collapsed {
|
||||||
|
height: 42px !important;
|
||||||
|
min-height: 42px !important;
|
||||||
|
max-height: 42px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
resize: none !important;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr {
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: rgba(13,17,23,.7); position: sticky; top: 0; z-index: 5;
|
||||||
|
cursor: grab; user-select: none; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr:active { cursor: grabbing; }
|
||||||
|
.cad-panel .hdr h3 { margin: 0; font-size: 13px; font-weight: 700; color: var(--ink); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.cad-panel .hdr-actions { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.cad-panel .hdr-btn {
|
||||||
|
background: none; border: none; color: var(--muted); font-size: 13px;
|
||||||
|
cursor: pointer; padding: 2px 6px; border-radius: 4px; display: inline-flex;
|
||||||
|
align-items: center; justify-content: center; transition: all .15s;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr-btn:hover { color: var(--ink); background: rgba(255,255,255,.1); }
|
||||||
|
|
||||||
|
#properties { right: 12px; }
|
||||||
|
#layers { right: 344px; display: none; }
|
||||||
|
|
||||||
|
#propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
#layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
#layerList { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
#zoomSelBtn {
|
||||||
|
font: 11px var(--sans); padding: 3px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--accent); background: rgba(63,185,80,.15); color: var(--accent);
|
||||||
|
cursor: pointer; display: none; align-items: center; gap: 4px; transition: all .15s;
|
||||||
|
}
|
||||||
|
#zoomSelBtn:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
.layer-search-wrap { padding: 8px 10px; border-bottom: 1px solid var(--line); background: rgba(13,17,23,.3); }
|
||||||
|
.layer-search-input {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--line);
|
||||||
|
border-radius: 6px; padding: 6px 10px; color: var(--ink); font-size: 12px;
|
||||||
|
outline: none; transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.layer-search-input:focus { border-color: var(--accent); }
|
||||||
|
.layer-sub-actions {
|
||||||
|
display: flex; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(22,27,34,.5);
|
||||||
|
}
|
||||||
|
.layer-sub-btn {
|
||||||
|
font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--line);
|
||||||
|
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
|
||||||
|
}
|
||||||
|
.layer-sub-btn:hover { color: var(--ink); border-color: var(--accent); background: rgba(63,185,80,.1); }
|
||||||
|
</style>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CmUgC68r.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<div id="toolbar">
|
||||||
|
<label class="btn">
|
||||||
|
Open file
|
||||||
|
<input id="file" type="file" accept=".dwg,.dxf" />
|
||||||
|
</label>
|
||||||
|
<button id="sample" class="primary wasm" type="button" title="acadrust-dwg WASM">Sample DWG</button>
|
||||||
|
<button id="sampleCivil" class="primary wasm" type="button" title="Civil Plan & Profile DWG">평면및종단면도.dwg</button>
|
||||||
|
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
|
||||||
|
<div id="spaces" title="Model Space / Paper Space (Layout)"></div>
|
||||||
|
<button id="fit" type="button">Fit (F)</button>
|
||||||
|
<button id="theme" type="button">Theme</button>
|
||||||
|
<button id="layersBtn" type="button">Layers</button>
|
||||||
|
<button id="propsBtn" type="button">Props (속성)</button>
|
||||||
|
<div id="status">ready</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="coords" title="마우스 위치 CAD 좌표 (Model=UCS, Layout=Paper 도면단위)">
|
||||||
|
<span class="lbl">X</span><span id="coordX">—</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="lbl">Y</span><span id="coordY">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 레이어 (Layers) 관리 패널 -->
|
||||||
|
<div id="layers" class="cad-panel">
|
||||||
|
<div class="hdr">
|
||||||
|
<h3>
|
||||||
|
<span>🎨</span> 레이어 (Layers)
|
||||||
|
<span id="layerCount" style="font-family:var(--mono);font-size:11px;color:var(--muted);background:rgba(255,255,255,.08);padding:1px 6px;border-radius:4px;font-weight:400"></span>
|
||||||
|
</h3>
|
||||||
|
<div class="hdr-actions">
|
||||||
|
<button id="collapseLayersBtn" class="hdr-btn collapse-btn" type="button" title="접기">▾</button>
|
||||||
|
<button id="closeLayersBtn" class="hdr-btn" type="button" title="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="layerBody">
|
||||||
|
<div class="layer-search-wrap">
|
||||||
|
<input id="layerSearch" class="layer-search-input" type="text" placeholder="🔍 레이어 검색..." />
|
||||||
|
</div>
|
||||||
|
<div class="layer-sub-actions">
|
||||||
|
<button id="layerShowAll" class="layer-sub-btn" type="button">전체 켜기</button>
|
||||||
|
<button id="layerHideAll" class="layer-sub-btn" type="button">전체 끄기</button>
|
||||||
|
</div>
|
||||||
|
<div id="layerList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 속성 (Properties) 패널 -->
|
||||||
|
<div id="properties" class="cad-panel">
|
||||||
|
<div class="hdr">
|
||||||
|
<h3><span>📋</span> 속성 (Properties)</h3>
|
||||||
|
<div class="hdr-actions">
|
||||||
|
<button id="zoomSelBtn" type="button" title="선택 요소 위치로 확대">🔍 선택 확대</button>
|
||||||
|
<button id="collapsePropsBtn" class="hdr-btn collapse-btn" type="button" title="접기">▾</button>
|
||||||
|
<button id="closePropsBtn" class="hdr-btn" type="button" title="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="propBody"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside id="recipe">
|
||||||
|
<h3>Module recipe</h3>
|
||||||
|
<ol>
|
||||||
|
<li><code class="mod-j">ext2d</code> — .dwg / .dxf 분기</li>
|
||||||
|
<li><code class="mod-w">acadrust-dwg</code> 또는 <code class="mod-j">dxf-parser</code></li>
|
||||||
|
<li><code class="mod-j">dxfAdapter</code> — DXF → 공통 스키마</li>
|
||||||
|
<li><code class="mod-j">Viewer2D</code> + <code class="mod-j">three</code> 렌더</li>
|
||||||
|
<li><code class="mod-j">slugText</code> + TTF 폰트</li>
|
||||||
|
</ol>
|
||||||
|
<p style="margin:10px 0 0">
|
||||||
|
코드: <code>src/main.ts</code> ·
|
||||||
|
지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,146 @@
|
|||||||
|
0
|
||||||
|
SECTION
|
||||||
|
2
|
||||||
|
HEADER
|
||||||
|
9
|
||||||
|
$ACADVER
|
||||||
|
1
|
||||||
|
AC1015
|
||||||
|
9
|
||||||
|
$INSUNITS
|
||||||
|
70
|
||||||
|
4
|
||||||
|
0
|
||||||
|
ENDSEC
|
||||||
|
0
|
||||||
|
SECTION
|
||||||
|
2
|
||||||
|
TABLES
|
||||||
|
0
|
||||||
|
TABLE
|
||||||
|
2
|
||||||
|
LAYER
|
||||||
|
0
|
||||||
|
LAYER
|
||||||
|
2
|
||||||
|
0
|
||||||
|
70
|
||||||
|
0
|
||||||
|
62
|
||||||
|
7
|
||||||
|
0
|
||||||
|
LAYER
|
||||||
|
2
|
||||||
|
OUTLINE
|
||||||
|
70
|
||||||
|
0
|
||||||
|
62
|
||||||
|
1
|
||||||
|
0
|
||||||
|
ENDTAB
|
||||||
|
0
|
||||||
|
ENDSEC
|
||||||
|
0
|
||||||
|
SECTION
|
||||||
|
2
|
||||||
|
BLOCKS
|
||||||
|
0
|
||||||
|
ENDSEC
|
||||||
|
0
|
||||||
|
SECTION
|
||||||
|
2
|
||||||
|
ENTITIES
|
||||||
|
0
|
||||||
|
LINE
|
||||||
|
8
|
||||||
|
OUTLINE
|
||||||
|
10
|
||||||
|
0.0
|
||||||
|
20
|
||||||
|
0.0
|
||||||
|
30
|
||||||
|
0.0
|
||||||
|
11
|
||||||
|
100.0
|
||||||
|
21
|
||||||
|
0.0
|
||||||
|
31
|
||||||
|
0.0
|
||||||
|
0
|
||||||
|
LINE
|
||||||
|
8
|
||||||
|
OUTLINE
|
||||||
|
10
|
||||||
|
100.0
|
||||||
|
20
|
||||||
|
0.0
|
||||||
|
30
|
||||||
|
0.0
|
||||||
|
11
|
||||||
|
100.0
|
||||||
|
21
|
||||||
|
60.0
|
||||||
|
31
|
||||||
|
0.0
|
||||||
|
0
|
||||||
|
LINE
|
||||||
|
8
|
||||||
|
OUTLINE
|
||||||
|
10
|
||||||
|
100.0
|
||||||
|
20
|
||||||
|
60.0
|
||||||
|
30
|
||||||
|
0.0
|
||||||
|
11
|
||||||
|
0.0
|
||||||
|
21
|
||||||
|
60.0
|
||||||
|
31
|
||||||
|
0.0
|
||||||
|
0
|
||||||
|
LINE
|
||||||
|
8
|
||||||
|
OUTLINE
|
||||||
|
10
|
||||||
|
0.0
|
||||||
|
20
|
||||||
|
60.0
|
||||||
|
30
|
||||||
|
0.0
|
||||||
|
11
|
||||||
|
0.0
|
||||||
|
21
|
||||||
|
0.0
|
||||||
|
31
|
||||||
|
0.0
|
||||||
|
0
|
||||||
|
CIRCLE
|
||||||
|
8
|
||||||
|
0
|
||||||
|
10
|
||||||
|
50.0
|
||||||
|
20
|
||||||
|
30.0
|
||||||
|
30
|
||||||
|
0.0
|
||||||
|
40
|
||||||
|
15.0
|
||||||
|
0
|
||||||
|
TEXT
|
||||||
|
8
|
||||||
|
0
|
||||||
|
10
|
||||||
|
10.0
|
||||||
|
20
|
||||||
|
70.0
|
||||||
|
30
|
||||||
|
0.0
|
||||||
|
40
|
||||||
|
5.0
|
||||||
|
1
|
||||||
|
Hello DWG/DXF package
|
||||||
|
0
|
||||||
|
ENDSEC
|
||||||
|
0
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Entity 선택, 속성 보기(Property Inspector), 레이어 관리(Layer Panel) & UI 패널 통합 가이드
|
||||||
|
|
||||||
|
이 문서는 DWG/DXF 뷰어 및 다른 2D/3D CAD 웹 뷰어 프로젝트에서 **엔티티 선택(Picking)**, **상세 속성 패널(Property Inspector)**, **레이어 관리 패널(Layer Panel)**, 그리고 **드래그·접기·리사이즈 가능한 UI 윈도우(Panel Utilities)**를 구축하고 이식하기 위한 통합 개발 및 재사용 가이드입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 1. 전체 아키텍처 & 모듈 구성
|
||||||
|
|
||||||
|
본 시스템은 뷰어 코어 엔진과 UI 구성요소가 완벽히 분리된 모듈형 구조로 설계되어 있어, 다른 Three.js/WebGL 기반 뷰어에도 손쉽게 이식할 수 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Canvas Viewport (Viewer2D) │
|
||||||
|
│ - Entity Raycasting & Pick Index (_pickSegs, _pickFills) │
|
||||||
|
│ - Highlight & Accent recoloring (_highlight) │
|
||||||
|
│ - Selected Entity Fit Camera (zoomToSelection) │
|
||||||
|
│ - Layer Visibility & Info (getLayerInfo, setHiddenLayers) │
|
||||||
|
└──────────────────────┬──────────────────────────────┬───────────────────────┘
|
||||||
|
│ Event: onSelect(entity) │ Layer Info API
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ propertyInspector.ts │ │ layerPanel.ts │
|
||||||
|
│ - formatEntityProperties() │ │ - LayerPanelManager class │
|
||||||
|
│ - Entity Type Specific Geometry Math │ │ - Real-time Keyword Search │
|
||||||
|
│ - Dynamic Property Card Rendering │ │ - Eye Toggle & Show/Hide All │
|
||||||
|
└───────────────────┬────────────────────┘ └─────────────────┬────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ panelUtils.ts │
|
||||||
|
│ - makePanelDraggableAndCollapsible(panelEl, options) │
|
||||||
|
│ - Header Mouse/Pointer Dragging & Screen Clamping │
|
||||||
|
│ - Z-Index Top Focus Management │
|
||||||
|
│ - Height Preservation Collapse / Expand Toggle │
|
||||||
|
│ - CSS3 resize: both Integration │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📑 2. 주요 모듈 명세 및 역할
|
||||||
|
|
||||||
|
| 모듈 파일 | 역할 및 제공 기능 | 비고 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **`src/viewer2d/Viewer2D.js`** | 렌더링 시 피킹 인덱스 구축, 레이캐스팅, 강조 표시, `zoomToSelection()` 카메라 핏팅 | 뷰어 코어 엔진 |
|
||||||
|
| **`src/propertyInspector.ts`** | 엔티티 유형(LINE, CIRCLE, ARC, POLYLINE, TEXT, INSERT 등)별 속성 추출 및 포맷팅, DOM 카드 렌더링 | 속성 패널 모듈 |
|
||||||
|
| **`src/layerPanel.ts`** | `LayerPanelManager` 클래스 - 검색 필터, 가시성 눈동자 토글, 전체 켜기/끄기, 색상/개수 표시 | 레이어 패널 모듈 |
|
||||||
|
| **`src/panelUtils.ts`** | `makePanelDraggableAndCollapsible` - 헤더 드래그, 화면 이탈 방지, z-index 최상단 전환, 접기/펼치기 | UI 패널 공통 유틸 |
|
||||||
|
| **`index.html` & `src/main.ts`** | 패널 HTML 구조, 글래스모피즘 CSS 스타일, 메인 이벤트 바인딩 | 애플리케이션 글루 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 3. 핵심 기능별 상세 구현 원리
|
||||||
|
|
||||||
|
### 3.1 Entity 선택 (Picking) & Zoom to Selection
|
||||||
|
|
||||||
|
1. **픽 데이터 수집 (Render Time)**
|
||||||
|
- 렌더링 시 각 엔티티의 실선 선분은 `_pickSegs` (`[ax, ay, bx, by, ...]`) 배열에 추가하고, owner 엔티티 메타 인덱스는 `_pickSegMeta`에 기록합니다.
|
||||||
|
- 해치(HATCH), 솔리드(SOLID), 텍스트(TEXT) 박스 등 면적을 가진 도형은 `_pickFills` (`{ metaIdx, loops }`) 배열에 추가합니다.
|
||||||
|
|
||||||
|
2. **히트 테스트 및 선택 (`_onClick`)**
|
||||||
|
- 캔버스 클릭 시 브라우저 좌표를 월드 WCS 좌표로 변환 (`screenToWorld`).
|
||||||
|
- 클릭 지점으로부터 화면 픽셀 임계값(`CLICK_THRESHOLD_PX`) 이내에 있는 가장 가까운 선분을 우선 탐색합니다 (`_segDist`).
|
||||||
|
- 선분이 없을 경우 면적 도형 내부인지 다각형 포함 테스트(`_fillHit`)를 수행하여 엔티티 메타데이터를 결정합니다.
|
||||||
|
- `_highlight(meta)`를 통해 이전 선택 객체 색상을 복원하고 새 선택 객체의 Vertex Color를 선택 강조색(`_selColor`)으로 변경합니다.
|
||||||
|
- `onSelect(cb)` 콜백을 발생시켜 선택된 `entity` 객체를 외부 UI로 전달합니다.
|
||||||
|
|
||||||
|
3. **선택 객체로 Zoom (`zoomToSelection()`)**
|
||||||
|
```typescript
|
||||||
|
zoomToSelection() {
|
||||||
|
if (!this._selMeta) return;
|
||||||
|
const idx = this._entityMeta.indexOf(this._selMeta);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const box = new THREE.Box3();
|
||||||
|
|
||||||
|
// 선택된 메타 인덱스의 선분 및 꽉 찬 루프 좌표로 Box3 확장
|
||||||
|
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 (!box.isEmpty()) this._fit(box);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 속성 보기 모듈 (`propertyInspector.ts`)
|
||||||
|
|
||||||
|
엔티티의 DXF/DWG 데이터 속성을 사용자가 읽기 편한 일반 정보(General)와 기하학적 정보(Geometry)로 분류하여 직관적으로 렌더링합니다.
|
||||||
|
|
||||||
|
#### 주요 엔티티별 계산 속성:
|
||||||
|
- **`LINE`**: 시작점, 끝점, $\Delta X, \Delta Y$, 길이 ($L = \sqrt{\Delta x^2 + \Delta y^2}$), 각도 ($\theta = \text{atan2}(\Delta y, \Delta x)$)
|
||||||
|
- **`CIRCLE`**: 중심점, 반지름 ($r$), 지름 ($2r$), 둘레 ($2\pi r$), 넓이 ($\pi r^2$)
|
||||||
|
- **`ARC`**: 중심점, 반지름, 시작/끝 각도, 중심각 ($\Delta \theta$), 호 길이 ($r \cdot \Delta \theta$)
|
||||||
|
- **`LWPOLYLINE` / `POLYLINE`**: 정점 개수, 닫힘 여부, 총 세그먼트 길이, 면적 (Shoelace 공식을 이용한 $A = \frac{1}{2} |\sum (x_i y_{i+1} - x_{i+1} y_i)|$)
|
||||||
|
- **`TEXT` / `MTEXT`**: 텍스트 내용, 높이, 회전각, 정렬, 삽입점
|
||||||
|
- **`INSERT` (블록)**: 블록 이름, 삽입점, 축척 ($S_x, S_y, S_z$), 회전각
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 레이어 관리 모듈 (`layerPanel.ts`)
|
||||||
|
|
||||||
|
`LayerPanelManager` 클래스는 `Viewer2D`의 레이어 테이블 데이터와 동기화되어 다음 기능을 수행합니다:
|
||||||
|
|
||||||
|
1. **실시간 키워드 필터링**:
|
||||||
|
```typescript
|
||||||
|
const filtered = layers.filter(l => l.name.toLowerCase().includes(this.searchQuery));
|
||||||
|
```
|
||||||
|
2. **레이어 숨김/표시 제어**:
|
||||||
|
```typescript
|
||||||
|
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.render();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. **일괄 동작**: `showAllLayers()` 및 `hideAllLayers()` 지원.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 UI 패널 드래그 · 접기 · 크기 조절 모듈 (`panelUtils.ts`)
|
||||||
|
|
||||||
|
모든 플로팅 UI 패널에 자유로운 **드래그 이동**, **접기/펼치기**, **크기 조절(Resize)**을 부여합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function makePanelDraggableAndCollapsible(
|
||||||
|
panelEl: HTMLElement,
|
||||||
|
options?: PanelControlOptions
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 동작 원리:
|
||||||
|
1. **드래그 이동 & 화면 클램핑**:
|
||||||
|
- 패널 헤더(`handleEl`) `pointerdown` 시 포인터 시작 좌표와 패널의 상대 위치를 기록합니다.
|
||||||
|
- 이동 시 `Math.max(10, Math.min(newLeft, maxLeft))`로 클램핑하여 패널이 화면 밖으로 이탈하지 않도록 조절합니다.
|
||||||
|
- 드래그 시 패널 클릭과 함께 `globalMaxZIndex`를 증가시켜 선택된 패널이 다른 창 위에 표시되도록 처리합니다.
|
||||||
|
2. **접기/펼치기 (Collapsible)**:
|
||||||
|
- 접기(`isCollapsed = true`) 시 원래 높이(`lastHeight`)를 저장하고 `height = 42px`로 변경 및 내용 영역(`bodyEl`)을 `display: none` 처리합니다.
|
||||||
|
- 펼침 시 저장해둔 `lastHeight`와 `display: flex`를 복원합니다.
|
||||||
|
3. **크기 조절 (Resizable)**:
|
||||||
|
- CSS `.cad-panel`에 `resize: both; overflow: hidden;`을 적용하여 우하단 리사이즈 핸들을 활성화합니다.
|
||||||
|
- `.cad-panel.collapsed` 시에는 `resize: none !important;`를 적용하여 접힘 상태 시 리사이즈를 차단합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 4. CSS 패널 스타일링 가이드 (`index.html`)
|
||||||
|
|
||||||
|
다른 프로젝트 이식 시 필요한 공통 패널 스타일 정의:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 공통 패널 윈도우 스타일 (Properties & Layers - Draggable & Resizable) */
|
||||||
|
.cad-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
width: 320px;
|
||||||
|
height: 480px;
|
||||||
|
min-width: 250px;
|
||||||
|
min-height: 140px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
z-index: 15;
|
||||||
|
background: rgba(22, 27, 34, 0.96);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: both;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 패널 접힘 상태 */
|
||||||
|
.cad-panel.collapsed {
|
||||||
|
height: 42px !important;
|
||||||
|
min-height: 42px !important;
|
||||||
|
max-height: 42px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
resize: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 드래그 핸들 헤더 */
|
||||||
|
.cad-panel .hdr {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgba(13, 17, 23, 0.7);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 5. 다른 뷰어 프로젝트로의 이식 단계 (Migration Steps)
|
||||||
|
|
||||||
|
1. **파일 복사**:
|
||||||
|
- `src/panelUtils.ts`
|
||||||
|
- `src/propertyInspector.ts`
|
||||||
|
- `src/layerPanel.ts`
|
||||||
|
2. **HTML 창 구조 추가**:
|
||||||
|
- `index.html`에 `.cad-panel` 클래스를 가진 `#properties` 및 `#layers` 요소 추가.
|
||||||
|
3. **타겟 뷰어 이벤트 바인딩 (`main.ts`)**:
|
||||||
|
```typescript
|
||||||
|
import { makePanelDraggableAndCollapsible } from './panelUtils';
|
||||||
|
import { renderPropertyInspector } from './propertyInspector';
|
||||||
|
import { LayerPanelManager } from './layerPanel';
|
||||||
|
|
||||||
|
// 1. 패널 드래그/접기 적용
|
||||||
|
makePanelDraggableAndCollapsible(document.getElementById('properties')!);
|
||||||
|
makePanelDraggableAndCollapsible(document.getElementById('layers')!);
|
||||||
|
|
||||||
|
// 2. 레이어 매니저 바인딩
|
||||||
|
const layerManager = new LayerPanelManager(viewer, document.getElementById('layerList')!);
|
||||||
|
|
||||||
|
// 3. 엔티티 선택 바인딩
|
||||||
|
viewer.onSelect((entity) => {
|
||||||
|
renderPropertyInspector(document.getElementById('propBody')!, entity);
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# 해치 경계 원호 아티팩트 — 원인 분석 및 해결 가이드
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 작성 목적 | 도면과 무관한 거대 회색 도형이 그려지는 문제의 원인 규명과 수정 내역 기록 |
|
||||||
|
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
|
||||||
|
| 원본 저장소 | `hmwebviewer` (`rust/dwg-wasm/`, `src/viewer2d/` 동기화 대상) |
|
||||||
|
| 재현 도면 | `dwg_18.3mb.dwg` (AC1032 · 82,393 entities · HATCH 87개) |
|
||||||
|
| 대조 도면 | `BasicSample.dwg`, `civil.dwg`, `bb.dwg` |
|
||||||
|
| 수정일 | 2026-08-03 |
|
||||||
|
| 상태 | **해결** — 원본/샘플 수정 완료, 배포 완료, 정답지 대조 확인 |
|
||||||
|
|
||||||
|
> 성능 문제([`large-dwg-wasm-heap-quadratic.md`](./large-dwg-wasm-heap-quadratic.md))와는 **무관한 별건**이다.
|
||||||
|
> 성능 수정 전후의 출력 JSON이 바이트 단위로 동일함을 확인했으므로, 이 아티팩트는 원래부터 있었고
|
||||||
|
> 지금까지 로딩에 6분이 걸려 아무도 확인하지 못했을 뿐이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현상
|
||||||
|
|
||||||
|
`dwg_18.3mb.dwg`를 열면 도면(가로로 긴 종단면도 띠) 위아래로 **도면 폭의 절반에 달하는 거대한 회색 원 2개**가 세로로 맞닿은 채 그려진다.
|
||||||
|
|
||||||
|
도면 본체는 정상 렌더링되므로 파싱 실패가 아니라 특정 엔티티만 잘못 그려지는 문제다.
|
||||||
|
|
||||||
|
### 정체 파악
|
||||||
|
|
||||||
|
파싱 결과에서 엔티티별 bbox를 재어 크기 순으로 정렬했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
span=4655 HATCH layer=CF-PATT aci=253 solidFill=true pattern=SOLID bb=[156687,474142,161342,478796]
|
||||||
|
span=4092 HATCH layer=CF-PATT aci=253 solidFill=true pattern=SOLID bb=[156736,478797,160828,482890]
|
||||||
|
span=110 HATCH layer=CF-PATT aci=253 ...
|
||||||
|
span=62 HATCH layer=CF-PATT aci=253 ...
|
||||||
|
span=54 HATCH layer=CF-PATT aci=253 ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- 두 도형 모두 `CF-PATT` 레이어의 **SOLID 해치**, ACI 253 = 회색 → 화면의 회색과 일치
|
||||||
|
- bbox가 각각 4655×4654, 4092×4093 인 **정사각형** → 원
|
||||||
|
- 첫 번째의 `maxy=478796`과 두 번째의 `miny=478797`이 맞닿음 → 화면상 두 원이 세로로 접한 모습과 일치
|
||||||
|
- 같은 레이어의 나머지 해치는 span 110 / 62 / 54 로 정상. **이 둘만 4km급**
|
||||||
|
- 전체 해치 span 중앙값 12.23, p99 4655 → 명백한 이상치
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 원인 ①: `sample_arc`의 진행 방향 처리
|
||||||
|
|
||||||
|
### 원본 경계 데이터
|
||||||
|
|
||||||
|
래퍼가 원호를 점으로 샘플링하기 전의 raw 경계를 네이티브에서 덤프했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
HATCH handle=175470 layer=CF-PATT solid=true span=3997 paths=1
|
||||||
|
path0 flags=1 edges=21
|
||||||
|
e0 CircularArc c=(158781.6,480843.3) r=2046.9375 a0=1.7885 a1=1.8110 ccw=false
|
||||||
|
e2 CircularArc c=(158797.5,480908.4) r=2113.5414 a0=4.4722 a1=4.4800 ccw=true
|
||||||
|
e8 CircularArc c=(158773.7,480809.4) r=1988.3000 a0=1.8074 a1=1.8095 ccw=false
|
||||||
|
e10 CircularArc c=(158773.1,480807.1) r=1985.4799 a0=4.4737 a1=4.4880 ccw=true
|
||||||
|
```
|
||||||
|
|
||||||
|
전부 각도 폭(sweep)이 0.01~0.02 rad 인 **아주 짧은 호**다. 도로 선형의 완만한 곡선 조각으로 정상적인 값이다.
|
||||||
|
|
||||||
|
### 버그
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 수정 전
|
||||||
|
let mut a0 = start;
|
||||||
|
let mut a1 = end;
|
||||||
|
if !ccw {
|
||||||
|
std::mem::swap(&mut a0, &mut a1); // ← 구간 자체를 뒤집는다
|
||||||
|
}
|
||||||
|
while a1 <= a0 {
|
||||||
|
a1 += std::f64::consts::TAU; // ← 여기서 폭발
|
||||||
|
}
|
||||||
|
let sweep = a1 - a0;
|
||||||
|
```
|
||||||
|
|
||||||
|
DWG 해치 경계의 원호는 `start_angle` / `end_angle` 을 **항상 반시계 기준**으로 저장하고, `ccw` 플래그는 *경계를 어느 방향으로 따라가는지*만 나타낸다. 호 구간 자체는 바뀌지 않는다.
|
||||||
|
|
||||||
|
그런데 위 코드는 `ccw=false`일 때 두 각도를 맞바꾼다. e0의 경우:
|
||||||
|
|
||||||
|
| 단계 | a0 | a1 | sweep |
|
||||||
|
|------|-----|-----|-------|
|
||||||
|
| 원본 | 1.7885 | 1.8110 | 0.0225 rad (짧은 호) |
|
||||||
|
| swap 후 | 1.8110 | 1.7885 | — (`a1 <= a0`) |
|
||||||
|
| `+= TAU` 후 | 1.8110 | 8.0717 | **6.2607 rad ≒ 358.7°** |
|
||||||
|
|
||||||
|
**0.0225 rad짜리 짧은 호가 거의 완전한 원이 된다.** 반지름 2046.9 → 지름 4093.8, 관측된 span 4092와 정확히 일치한다.
|
||||||
|
|
||||||
|
`ccw=true`인 e2 / e10 은 swap을 타지 않아 정상이다. 즉 **`ccw=false`인 호만** 터진다.
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
구간은 그대로 두고 **생성된 점 순서만 뒤집는다.**
|
||||||
|
|
||||||
|
`rust/dwg-wasm/src/lib.rs` · `sample_arc`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let a0 = start;
|
||||||
|
let mut a1 = end;
|
||||||
|
while a1 <= a0 {
|
||||||
|
a1 += std::f64::consts::TAU;
|
||||||
|
}
|
||||||
|
let sweep = a1 - a0;
|
||||||
|
let segs = ((sweep / (std::f64::consts::PI / 16.0)).ceil() as usize).clamp(6, 128);
|
||||||
|
let (sr, cr) = rot.sin_cos();
|
||||||
|
let first = points.len();
|
||||||
|
for i in 0..=segs {
|
||||||
|
let a = a0 + sweep * (i as f64) / (segs as f64);
|
||||||
|
let (sa, ca) = a.sin_cos();
|
||||||
|
let ex = rx * ca;
|
||||||
|
let ey = ry * sa;
|
||||||
|
points.push(json!({ "x": cx + ex * cr - ey * sr, "y": cy + ex * sr + ey * cr }));
|
||||||
|
bulges.push(0.0);
|
||||||
|
}
|
||||||
|
// Clockwise edge: same arc, walked the other way.
|
||||||
|
if !ccw {
|
||||||
|
points[first..].reverse();
|
||||||
|
bulges[first..].reverse();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`points` / `bulges` 에는 앞선 edge들의 점이 이미 들어있으므로, **이번 호가 추가한 구간만** 뒤집어야 한다(`first` 인덱스).
|
||||||
|
|
||||||
|
### 결과
|
||||||
|
|
||||||
|
두 해치의 점 개수가 70 → 44, 150 → 72 로 줄고 **원형 아티팩트가 사라졌다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 원인 ②: 오염된 extent가 무력화한 방어 로직
|
||||||
|
|
||||||
|
원형은 사라졌지만 span은 4035 / 3997로 거의 그대로였다. bbox를 다시 보면 성격이 바뀌어 있다.
|
||||||
|
|
||||||
|
```
|
||||||
|
span=4035 bb=[160209,474468,160245,478503] → X 36, Y 4035 (세로로 긴 얇은 띠)
|
||||||
|
span=3997 bb=[158295,478845,158339,482842] → X 44, Y 3997
|
||||||
|
```
|
||||||
|
|
||||||
|
원이 아니라 **세로 띠**다. 별개의 두 번째 문제다.
|
||||||
|
|
||||||
|
### 끝점 자체가 어긋난다
|
||||||
|
|
||||||
|
경계 덤프에서 원호의 끝점과 인접 Line의 시작점을 비교하면 y가 크게 튄다.
|
||||||
|
|
||||||
|
```
|
||||||
|
e0 CircularArc … | endpoints (158339.3,482841.9)->(158294.7,482831.5)
|
||||||
|
e1 Line (158294.7,478855.1)->(158294.8,478855.5)
|
||||||
|
└ x는 정확히 일치, y만 3976 차이
|
||||||
|
```
|
||||||
|
|
||||||
|
즉 이 원호들은 **center/radius가 실제 기하와 맞지 않는 손상된 값**이다.
|
||||||
|
|
||||||
|
코드에는 이미 이런 상황을 위한 방어 로직이 있다.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 반지름이 경로 전체보다 압도적으로 크면 손상된 호 → 현(chord)으로 그린다
|
||||||
|
let spurious_r = |r: f64| path_extent.is_finite() && path_extent > 0.0 && r > 5.0 * path_extent;
|
||||||
|
```
|
||||||
|
|
||||||
|
문제는 `path_extent`를 재는 방식이었다.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 수정 전 — 원호의 끝점을 center/radius에서 역산해 extent에 포함시킨다
|
||||||
|
BoundaryEdge::CircularArc(a) => {
|
||||||
|
acc(a.center.x + a.radius * a.start_angle.cos(), a.center.y + a.radius * a.start_angle.sin(), …);
|
||||||
|
acc(a.center.x + a.radius * a.end_angle.cos(), a.center.y + a.radius * a.end_angle.sin(), …);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**손상된 값을, 그 손상을 잡으라고 만든 잣대에 그대로 집어넣는다.** 이 경로의 Line들만 보면 실제 extent는 약 36인데, 오염된 호의 끝점까지 포함하면 4035로 부푼다. 그러면 `r=2840 > 5 × 4035 = 20175` 이 성립하지 않아 **방어 로직이 통과된다.**
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
extent 측정에서 **좌표가 직접 저장된 edge만** 사용한다.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for edge in &p.edges {
|
||||||
|
match edge {
|
||||||
|
BoundaryEdge::Polyline(pl) => for v in &pl.vertices { acc(v.x, v.y, …); },
|
||||||
|
BoundaryEdge::Line(l) => { acc(l.start.x, l.start.y, …); acc(l.end.x, l.end.y, …); }
|
||||||
|
BoundaryEdge::Spline(s) => {
|
||||||
|
for v in &s.control_points { acc(v.x, v.y, …); }
|
||||||
|
for v in &s.fit_points { acc(v.x, v.y, …); }
|
||||||
|
}
|
||||||
|
// Centre/radius-derived — see above.
|
||||||
|
BoundaryEdge::CircularArc(_) | BoundaryEdge::EllipticArc(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
이제 extent가 36으로 잡히고 `r=2840 > 5 × 36 = 180` 이 성립해 방어 로직이 정상 작동한다.
|
||||||
|
|
||||||
|
### 결과
|
||||||
|
|
||||||
|
점 개수는 44 → 34, 72 → 42 로 더 줄었지만 **span은 4035 / 3997 그대로였다.** 방어 로직이 걸려 chord 분기를 타는 데는 성공했지만 아직 부족했다 — 세 번째 원인이 남아 있었다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 원인 ③: chord 끝점도 같은 손상값으로 재계산
|
||||||
|
|
||||||
|
### 정답지 대조
|
||||||
|
|
||||||
|
AutoCAD에서 같은 영역을 연 화면과 뷰어 출력을 나란히 비교하자 성격이 분명해졌다.
|
||||||
|
|
||||||
|
| | 회색 SOLID 해치 모양 |
|
||||||
|
|---|---|
|
||||||
|
| **정답지 (AutoCAD)** | 교량 폭만큼의 **짧은 사각형**. 도로를 가로지르는 부분에서 깔끔하게 닫힘 |
|
||||||
|
| **뷰어 (수정 ②까지)** | 같은 위치에서 시작해 **아래로 길게 새어 나감** |
|
||||||
|
|
||||||
|
한쪽 끝만 엉뚱한 곳에 찍혀 도형이 늘어나는 형태다. 즉 **경계 폴리곤의 특정 점 하나가 잘못된 좌표**라는 뜻이다.
|
||||||
|
|
||||||
|
### 버그
|
||||||
|
|
||||||
|
방어 로직이 손상된 호를 걸러낸 뒤 하는 일이 문제였다.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 수정 전
|
||||||
|
if spurious_r(a.radius) {
|
||||||
|
// Corrupt arc — connect its (correct) endpoints with a chord.
|
||||||
|
points.push(json!({ "x": a.center.x + a.radius * a.start_angle.cos(),
|
||||||
|
"y": a.center.y + a.radius * a.start_angle.sin() }));
|
||||||
|
bulges.push(0.0);
|
||||||
|
points.push(json!({ "x": a.center.x + a.radius * a.end_angle.cos(),
|
||||||
|
"y": a.center.y + a.radius * a.end_angle.sin() }));
|
||||||
|
bulges.push(0.0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**"손상됐다"고 판정해놓고, 그 끝점을 바로 그 손상된 `center` / `radius` 로 다시 계산한다.**
|
||||||
|
|
||||||
|
기존 주석은 *"Its endpoints are still correct"* (끝점은 여전히 정확하다)를 전제한다. CXGLOGO 케이스에서는 맞았지만 `dwg_18.3mb.dwg` 에서는 **그 전제가 깨진다.** §3에서 본 대로 이 호들의 끝점은 인접 Line의 실제 이음새에서 약 3,900 단위 떨어져 있다. 그렇게 만든 "chord"는 해치를 닫는 대신 도면 4km 아래로 끌고 내려간다.
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
**손상된 호는 점을 하나도 만들지 않고 통째로 버린다.**
|
||||||
|
|
||||||
|
경계는 닫힌 폴리곤이므로, 그 edge를 건너뛰면 **앞 edge의 끝점과 뒤 edge의 시작점이 곧바로 직선으로 이어진다.** 이것이 재계산 없이 얻는 진짜 chord이며, 실제 이음새 좌표를 그대로 쓰므로 손상값이 개입할 여지가 없다.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
BoundaryEdge::CircularArc(a) => {
|
||||||
|
if spurious_r(a.radius) {
|
||||||
|
// Corrupt arc — emit nothing; neighbours close the chord.
|
||||||
|
} else {
|
||||||
|
sample_arc(
|
||||||
|
a.center.x, a.center.y, a.radius, a.radius,
|
||||||
|
0.0, a.start_angle, a.end_angle, a.counter_clockwise,
|
||||||
|
&mut points, &mut bulges,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`EllipticArc` 분기도 동일하게 바꿨다.
|
||||||
|
|
||||||
|
### 결과
|
||||||
|
|
||||||
|
`dwg_18.3mb.dwg` 의 최대 해치 span이 **4035 → 110** 으로 떨어졌다. 4km급 이상치가 사라지고 같은 레이어의 정상 해치(110 / 62 / 54 / 53)와 같은 크기 대역에 들어왔다. 화면상으로도 정답지와 같은 짧은 사각형으로 그려진다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 배제한 가설
|
||||||
|
|
||||||
|
| 가설 | 검증 방법 | 결과 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| 성능 수정의 부작용 | 성능 수정 전후 출력 JSON 바이트 비교 | 동일 — **무관** |
|
||||||
|
| 각도 부호 규약 오류 (`sin` 부호 반전 필요) | 전 도면의 모든 경계 원호에 대해, 인접 edge와의 이음새 간격을 `+sin` / `-sin` 양쪽으로 계산해 비교 | **기각** — 평균 간격 `+sin` 444.99 vs `-sin` 481.68, per-arc 승패 38:32 |
|
||||||
|
| 파서(acadrust) 전반의 원호 처리 결함 | 도면별 이음새 간격 측정 | **기각** — `civil.dwg` 3.65, `bb.dwg` 1.00 으로 정상. `test19` 의 8개 호만 3870 |
|
||||||
|
|
||||||
|
부호 규약 검증에 쓴 하네스는 `rust/dwg-wasm/src/bin/arcsign.rs` 로 남겨두었다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --bin arcsign
|
||||||
|
./target/release/arcsign.exe <file.dwg> [more.dwg ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 회귀 검증
|
||||||
|
|
||||||
|
성능 수정만 적용된 빌드와 해치 수정 3건이 모두 적용된 빌드에서 각 도면의 해치 span 상위 목록을 비교했다.
|
||||||
|
|
||||||
|
| 도면 | 수정 전 최대 span | 수정 후 최대 span | 판정 |
|
||||||
|
|------|------------------|------------------|------|
|
||||||
|
| `civil.dwg` (해치 1,397개) | 58, 57, 57, 57 | 58, 57, 57, 57 | 동일 |
|
||||||
|
| `bb.dwg` (해치 50개) | 1100, 1081, 58, 57 | 1100, 1081, 58, 57 | 동일 |
|
||||||
|
| `BasicSample.dwg` (해치 16개) | 753, 499, 293, **50** | 753, 499, 293, **41** | 1건 변화 |
|
||||||
|
| `dwg_18.3mb.dwg` (해치 87개) | **4655, 4092** | **110, 62, 54, 53** | 이상치 제거 |
|
||||||
|
|
||||||
|
- 대조 도면 3종은 최대 span이 완전히 동일하다. **회귀 없음.**
|
||||||
|
- `dwg_18.3mb.dwg` 는 4km급 이상치 2개가 사라지고, 같은 `CF-PATT` 레이어의 정상 해치들과 같은 대역으로 수렴했다.
|
||||||
|
- `BasicSample.dwg` 에서 변한 1건은 `layer=0`, 12개 path의 SOLID 해치로 bbox가 `[20,-6,71,10]` → `[20,-6,61,8]` 로 줄었다. 코드 주석이 언급하는 **CXGLOGO 로고 케이스**(*"real coords ≈ 21"*)에 해당하며, extent 기준이 정확해지면서 손상된 호가 제대로 걸러진 결과다.
|
||||||
|
|
||||||
|
수정 단계별 추이(`dwg_18.3mb.dwg` 기준):
|
||||||
|
|
||||||
|
| 단계 | 최대 span | 점 개수 | 형태 |
|
||||||
|
|------|----------|--------|------|
|
||||||
|
| 수정 전 | 4655 / 4092 | 70 / 150 | 거대한 원 |
|
||||||
|
| ① `sample_arc` 방향 수정 | 4035 / 3997 | 44 / 72 | 세로 띠 |
|
||||||
|
| ② extent에서 호 제외 | 4035 / 3997 | 34 / 42 | 세로 띠 |
|
||||||
|
| ③ 손상된 호 스킵 | **110** | — | 정상 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 남은 과제
|
||||||
|
|
||||||
|
1. **손상 원인 자체**
|
||||||
|
acadrust가 이 호들을 잘못 읽는 것인지, DWG 파일 자체에 이상이 있는 것인지는 미확인이다. 현재 수정은 손상값을 **감지해 우회**하는 방어이지 근본 원인 제거가 아니다.
|
||||||
|
정답지 대조에서 도면의 나머지 요소는 정상이었으므로 파일 전체가 깨진 것은 아니며, 특정 호 몇 개에 국한된 문제로 보인다.
|
||||||
|
원인이 acadrust 내부로 판명되면 §8의 MPL-2.0 의무가 발생하므로, 그 경우에도 가능하면 upstream 이슈/PR 경로를 택하는 편이 낫다.
|
||||||
|
|
||||||
|
2. **`spurious_r` 의 5배 임계값**
|
||||||
|
`r > 5.0 * path_extent` 라는 기준은 경험적으로 정해진 값이다. 실제 도면에 경로 범위보다 5배 이상 큰 반지름의 정상 호가 존재하면 오탐이 나 정상 호가 지워진다.
|
||||||
|
현재 4개 도면(해치 1,550개)에서 오탐은 확인되지 않았으나, 판정을 반지름 대신 **호 끝점과 인접 edge 이음새의 간격**으로 바꾸면 더 직접적이고 안전하다. §5의 `arcsign` 하네스가 이미 그 간격을 계산한다.
|
||||||
|
|
||||||
|
3. **`EllipticArc`**
|
||||||
|
세 수정 모두 `EllipticArc` 분기에도 동일하게 적용했으나, 검증은 `CircularArc` 로만 했다. 재현 도면을 확보하지 못했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 오픈소스 라이선스 (MPL-2.0)
|
||||||
|
|
||||||
|
본 저장소의 DWG 파싱은 두 계층으로 나뉜다.
|
||||||
|
|
||||||
|
| 구성요소 | 위치 | 라이선스 | 본 작업에서 |
|
||||||
|
|----------|------|---------|------------|
|
||||||
|
| `acadrust` v0.4.1 | `hmwebviewer/rust/acadrust/` | **MPL-2.0** | **수정하지 않음** |
|
||||||
|
| `dwg-wasm` (래퍼) | `hmwebviewer/rust/dwg-wasm/` | MIT (자체 코드) | 수정함 |
|
||||||
|
| 뷰어 | `src/viewer2d/` | 자체 코드 | 수정함 |
|
||||||
|
|
||||||
|
### 현재 상태 — 소스 공개 의무 없음
|
||||||
|
|
||||||
|
MPL-2.0은 **파일 단위 카피레프트**다. 의무가 발생하는 것은 *MPL로 커버되는 파일 자체를 수정했을 때*이며, 그 경우 **수정한 그 파일들**을 MPL-2.0으로 공개해야 한다. MPL 코드를 라이브러리로 링크하기만 한 자체 코드(`dwg-wasm`, 뷰어)는 공개 대상이 아니다.
|
||||||
|
|
||||||
|
본 작업에서 수정한 파일은 전부 자체 코드이며, `rust/acadrust/` 는 한 줄도 건드리지 않았다.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git status --short rust/acadrust/
|
||||||
|
(변경 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cargo.toml`의 주석도 이 전제를 명시하고 있다.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# acadrust is consumed UNMODIFIED from crates.io (MPL-2.0 — file-level copyleft;
|
||||||
|
# unmodified library use only requires keeping its license notices).
|
||||||
|
acadrust = { path = "../acadrust" }
|
||||||
|
```
|
||||||
|
|
||||||
|
따라서 **현 시점에서 새로 발생하는 소스 공개 의무는 없다.** 다만 수정 여부와 무관하게 다음은 계속 지켜야 한다.
|
||||||
|
|
||||||
|
- `rust/acadrust/LICENSE` (MPL-2.0 전문) 및 저작권 고지 유지 — 현재 유지되고 있음
|
||||||
|
- 배포물에서 acadrust 사용 사실과 라이선스를 확인할 수 있게 할 것
|
||||||
|
|
||||||
|
### 앞으로 acadrust를 수정하게 된다면
|
||||||
|
|
||||||
|
「7. 남은 과제」의 1번처럼 원인이 acadrust 내부에 있다고 판명되어 **`rust/acadrust/src/**` 를 직접 고치는 순간 의무가 발생한다.** 그 경우:
|
||||||
|
|
||||||
|
1. **수정한 파일을 MPL-2.0으로 공개해야 한다** (파일 단위이므로 `dwg-wasm`이나 뷰어까지 공개할 의무는 없다)
|
||||||
|
2. 각 파일 상단의 MPL 헤더를 유지한다
|
||||||
|
3. 어떤 파일을 어떻게 수정했는지 기록을 남긴다
|
||||||
|
4. 실무적으로는 **upstream(crates.io `acadrust`)에 이슈/PR로 올리는 편이 낫다.** 포크를 들고 있으면 버전 업마다 재적용 비용이 든다
|
||||||
|
|
||||||
|
지금처럼 **수정을 래퍼(`dwg-wasm`) 쪽에 두는 구조를 유지하는 것이 라이선스 관점에서 가장 단순하다.** 본 문서의 두 수정도 모두 래퍼에서 이루어졌다.
|
||||||
|
|
||||||
|
> 위 내용은 라이선스 조항에 대한 사실 정리이며 법률 자문이 아니다. 배포 형태나 계약이 얽히면 별도 검토가 필요하다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 한 줄 요약
|
||||||
|
|
||||||
|
> **거대 회색 도형의 정체는 `CF-PATT` 레이어의 SOLID 해치 2개이며, 원인은 세 겹이었다.
|
||||||
|
> ① `sample_arc`가 시계방향 호에서 점 순서를 뒤집는 대신 각도 구간 자체를 맞바꿔, 0.02 rad짜리 짧은 호를 359° 원으로 만들었다.
|
||||||
|
> ② 손상된 호를 걸러내는 `spurious_r` 판정의 기준 extent를 그 손상된 호의 끝점으로 계산해 방어가 무력화됐다.
|
||||||
|
> ③ 방어가 걸린 뒤에도 chord의 끝점을 같은 손상된 center/radius로 재계산해, 해치가 4km 아래로 늘어졌다.
|
||||||
|
> 손상된 호는 점을 만들지 말고 통째로 버리면 앞뒤 edge가 실제 이음새 좌표로 직접 이어지며 진짜 chord가 된다.
|
||||||
|
> 최대 해치 span 4655 → 110, 대조 도면 3종 회귀 없음.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 참고 파일
|
||||||
|
|
||||||
|
- 원호 샘플링 / 경계 평탄화: [`../../hmwebviewer/rust/dwg-wasm/src/lib.rs`](../../hmwebviewer/rust/dwg-wasm/src/lib.rs) (`sample_arc`, `boundary_path_json`)
|
||||||
|
- 경계 덤프 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/hatchdump.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/hatchdump.rs)
|
||||||
|
- 각도 규약 검증 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/arcsign.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/arcsign.rs)
|
||||||
|
- 해치 렌더링(뷰어 측 `_tessLoop`): [`../src/viewer2d/dxfHatchHandler.d.ts`](../src/viewer2d/dxfHatchHandler.d.ts)
|
||||||
|
- 성능 문제(별건): [`large-dwg-wasm-heap-quadratic.md`](./large-dwg-wasm-heap-quadratic.md)
|
||||||
|
- 모듈 지도: [`modules-dwg-dxf.html`](./modules-dwg-dxf.html)
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# 한글 DWG 텍스트 깨짐 — 원인 분석 및 원본 반영 가이드
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 작성 목적 | 샘플에서 재현·수정한 이슈를 **원본(`hmwebviewer`)** 에 반영하기 위한 기술 메모 |
|
||||||
|
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
|
||||||
|
| 원본 저장소 | `hmwebviewer` (`src/viewer2d/` 동기화 대상) |
|
||||||
|
| 재현 도면 | `C0060203-002-굴착및시공순서도(2차로,자연환기300m이상).dwg` 등 구형 한글 CAD 도면 |
|
||||||
|
| 대조 도면 | `BasicSample.dwg` (이미 정상 유니코드 한글) |
|
||||||
|
| 샘플 수정일 | 2026-07-29 |
|
||||||
|
| 상태 | 샘플 패치 완료 / 원본 미반영 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현상
|
||||||
|
|
||||||
|
웹 2D 뷰어에서 일부 DWG를 열면 **도형·치수 기하, 영문 TEXT는 정상**인데 **한글 TEXT/MTEXT·타이틀블록 문구·NOTE** 등이 다음과 같이 깨져 보인다.
|
||||||
|
|
||||||
|
| 화면/파서 출력 (깨짐) | 정상 의미 |
|
||||||
|
|----------------------|-----------|
|
||||||
|
| `½Ã°ø½Ã` | 시공시 |
|
||||||
|
| `·Ïº¼Æ®` | 록볼트 |
|
||||||
|
| `¼ôÅ©¸®Æ®` | 숏크리트 |
|
||||||
|
| `±¼Âø ¹× ½Ã°ø¼ø¼µµ` | 굴착 및 시공순서도 |
|
||||||
|
| `Á߽ɼ±(ÀϹÝ)` (라인타입 설명) | 중심선(일반) |
|
||||||
|
|
||||||
|
외형상 “폰트가 없다 / 글리프가 없다”와 혼동하기 쉽지만, **글리프 부재(tofu □)** 가 아니라 **잘못된 유니코드 코드포인트가 그대로 그려진 것(mojibake)** 이다.
|
||||||
|
|
||||||
|
### 자주 하는 오진
|
||||||
|
|
||||||
|
| 가설 | 실제 |
|
||||||
|
|------|------|
|
||||||
|
| `dist/fonts` / `public/fonts` 에 돋움이 없어서 | 폰트 폴더에는 `NanumGothic-Regular.ttf`(한글 포함)가 있음 |
|
||||||
|
| Style `Standard` + 폰트 `돋움` 미매핑 | 뷰어는 스타일→폰트 매핑 없이 **단일 TTF** 로 그림. 돋움 없어도 코드포인트만 맞으면 한글 표시 가능 |
|
||||||
|
| slugText / opentype 버그 | 동일 파이프라인에서 `BasicSample.dwg` 한글은 정상 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 파이프라인 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
DWG bytes
|
||||||
|
→ acadrust WASM (parse_dwg) ← 여기서 문자열이 이미 깨지거나 정상으로 나옴
|
||||||
|
→ CadParseResult (entities/tables/…)
|
||||||
|
→ [샘플 패치] fixDwgKoreanText ← 복원 계층 (원본에는 아직 없음)
|
||||||
|
→ Viewer2D.load()
|
||||||
|
→ slugText (NanumGothic TTF)
|
||||||
|
→ WebGL
|
||||||
|
```
|
||||||
|
|
||||||
|
- **DXF 경로**는 텍스트 파일 디코딩 단계에서 코드페이지를 고려한다.
|
||||||
|
- 샘플: `src/viewer2d/decodeDxf.ts` (`$DWGCODEPAGE` / 949 → `euc-kr` 등)
|
||||||
|
- 원본: `hmwebviewer/src/viewerController.ts` 내 유사 `decodeDxf`
|
||||||
|
- **DWG 경로**는 바이너리 → WASM → JSON 문자열이며, 파싱 직후 한글 코드페이지 보정 계층이 없었다.
|
||||||
|
|
||||||
|
관련 모듈:
|
||||||
|
|
||||||
|
| 역할 | 샘플 경로 | 원본 경로 |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| DWG 파서 진입점 | `src/viewer2d/dwgParser.ts` | `hmwebviewer/src/viewer2d/dwgParser.ts` |
|
||||||
|
| WASM 래퍼 | `src/viewer2d/acadrustParser.ts` | 동일 |
|
||||||
|
| 텍스트 렌더 | `src/viewer2d/slugText.ts` | 동일 |
|
||||||
|
| 한글 복원 (샘플만) | `src/viewer2d/fixDwgKoreanText.ts` | **없음 → 이식 대상** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 원인 분석
|
||||||
|
|
||||||
|
### 3.1 근본 원인 (Root cause)
|
||||||
|
|
||||||
|
**구형/한글 CAD DWG에 저장된 CP949(Windows-949) 계열 바이트 시퀀스를, acadrust 파서(또는 그 문자열 경계)가 Latin-1/바이트-단위 유니코드로 승격해 버린 것.**
|
||||||
|
|
||||||
|
즉:
|
||||||
|
|
||||||
|
```
|
||||||
|
원본 바이트 (CP949) --잘못된 해석--> U+00xx Latin-1 문자들 --렌더--> 깨진 화면
|
||||||
|
예: EC 8B 9C ... (시) 가 ½ Ã ° ø … 같은 문자로 보임
|
||||||
|
```
|
||||||
|
|
||||||
|
복원 공식 (검증됨):
|
||||||
|
|
||||||
|
```text
|
||||||
|
broken.encode('latin1') → bytes
|
||||||
|
bytes.decode('cp949' | 'euc-kr' | 'windows-949') → 정상 한글
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저/Node `TextDecoder` 라벨 우선순위 (샘플 구현):
|
||||||
|
|
||||||
|
1. `cp949`
|
||||||
|
2. `windows-949`
|
||||||
|
3. `euc-kr`
|
||||||
|
|
||||||
|
### 3.2 왜 도면마다 다른가
|
||||||
|
|
||||||
|
| 도면 | 파서 직후 TEXT | 원인 해석 |
|
||||||
|
|------|----------------|-----------|
|
||||||
|
| `BasicSample.dwg` | `설계사`, `시공사` 등 **정상 한글 음절** | 이미 유니코드/UTF 계열로 저장·추출됨 |
|
||||||
|
| `C0060203-002-….dwg` | 한글 음절 **0건**, Latin-1 고위 문자 다수 | CP949 레거시 저장 + 오해독 |
|
||||||
|
|
||||||
|
동일 뷰어·동일 폰트에서 결과가 갈리므로 **렌더러/폰트 문제가 아님**이 확정된다.
|
||||||
|
|
||||||
|
### 3.3 영향 범위 (문자열 필드 전반)
|
||||||
|
|
||||||
|
TEXT/MTEXT 본문뿐 아니라 같은 인코딩으로 들어간 메타 문자열도 깨진다.
|
||||||
|
|
||||||
|
- `entities[].text` (TEXT / MTEXT / ATTRIB 등)
|
||||||
|
- `tables.lineTypes[].description` (예: 중심선, 파선 한글 설명)
|
||||||
|
- `tables.blocks[].name` (예: `CXGLOGO-EX흑백` → `CXGLOGO-EXÈæ¹é`)
|
||||||
|
- 기타 파서가 문자열로 넘기는 테이블/속성 필드
|
||||||
|
|
||||||
|
→ **필드 단위 패치보다 parseResult 트리 전체 문자열 walk** 가 안전하다.
|
||||||
|
|
||||||
|
### 3.4 부수 관찰 (이번 깨짐의 주원인은 아님)
|
||||||
|
|
||||||
|
| 관찰 | 의미 |
|
||||||
|
|------|------|
|
||||||
|
| `tables.styles` 가 빈 배열 | 스타일/폰트명(돋움) 정보를 뷰어가 쓰지 못함. 장기 개선 항목 |
|
||||||
|
| 뷰어 고정 폰트 `NanumGothic-Regular.ttf` | 한글 글리프 보유. 코드포인트만 맞으면 표시됨 |
|
||||||
|
| UI 파일명·OS 경로 한글 | OS 유니코드 경로라 정상일 수 있음. DWG 내부 텍스트와 무관 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 증거 (재현 절차)
|
||||||
|
|
||||||
|
### 4.1 파서만 돌려 TEXT 통계
|
||||||
|
|
||||||
|
WASM 초기화 후 `parse_dwg` 결과에서:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// TEXT/MTEXT 중 한글 음절 포함 여부
|
||||||
|
/[\uAC00-\uD7A3]/.test(entity.text)
|
||||||
|
```
|
||||||
|
|
||||||
|
문제 도면 예 (패치 전):
|
||||||
|
|
||||||
|
- hangul: **0**
|
||||||
|
- 깨진 Latin: 다수 (`½Ã°ø…` 패턴)
|
||||||
|
|
||||||
|
패치 후:
|
||||||
|
|
||||||
|
- hangul: 다수 (예: 130)
|
||||||
|
- NOTE 샘플: `2. 시공시 막장관찰, 계측분석결과, …`
|
||||||
|
- 라인타입: `중심선(일반) Center …`
|
||||||
|
- 블록: `CXGLOGO-EX흑백`
|
||||||
|
|
||||||
|
### 4.2 단위 변환 검증
|
||||||
|
|
||||||
|
| 입력 | `latin1` 바이트 재해석 → CP949 |
|
||||||
|
|------|-------------------------------|
|
||||||
|
| `½Ã°ø½Ã` | `시공시` |
|
||||||
|
| `·Ïº¼Æ®` | `록볼트` |
|
||||||
|
| `NOTE` | `NOTE` (변경 없음) |
|
||||||
|
| `설계사` | 휴리스틱상 **스킵** (이미 한글) |
|
||||||
|
|
||||||
|
### 4.3 음성 대조
|
||||||
|
|
||||||
|
`BasicSample.dwg` 에 동일 복원기를 적용해도 `설계사` / `시 공 사` 등이 **변하지 않아야** 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 샘플에 적용한 처리 방법
|
||||||
|
|
||||||
|
### 5.1 신규 모듈
|
||||||
|
|
||||||
|
`src/viewer2d/fixDwgKoreanText.ts`
|
||||||
|
|
||||||
|
- `fixCp949Mojibake(s: string): string`
|
||||||
|
단일 문자열 복원
|
||||||
|
- `fixDwgKoreanText<T>(root: T): T`
|
||||||
|
parseResult 딥 워크, in-place 수정 후 동일 참조 반환
|
||||||
|
|
||||||
|
### 5.2 휴리스틱 (중요 — 정상 도면 보호)
|
||||||
|
|
||||||
|
다음이면 **원문 유지**:
|
||||||
|
|
||||||
|
1. 빈 문자열
|
||||||
|
2. 이미 한글 음절(`U+AC00–U+D7A3`) 또는 자모 포함
|
||||||
|
3. 순수 ASCII (모든 코드포인트 ≤ 127)
|
||||||
|
4. Latin-1 범위를 넘는 코드포인트 포함 (진짜 유니코드)
|
||||||
|
5. 한국 코드페이지 디코더 없음 / 디코드 실패
|
||||||
|
6. 디코드 결과에 한글이 **없거나** `U+FFFD` 포함
|
||||||
|
|
||||||
|
다음일 때만 **교체**:
|
||||||
|
|
||||||
|
- 고위 Latin-1(>127) 포함 **그리고**
|
||||||
|
- CP949/EUC-KR 디코드 성공 **그리고**
|
||||||
|
- 결과에 한글 음절/자모 포함 **그리고**
|
||||||
|
- 치환 문자 없음
|
||||||
|
|
||||||
|
### 5.3 삽입 지점
|
||||||
|
|
||||||
|
`src/viewer2d/dwgParser.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { fixDwgKoreanText } from './fixDwgKoreanText';
|
||||||
|
|
||||||
|
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
|
||||||
|
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
|
||||||
|
await initAcadrustParser();
|
||||||
|
return fixDwgKoreanText(parseDwgAcadrust(bytes));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Viewer2D / slugText / 폰트 설정 변경 **불필요**
|
||||||
|
- DXF 경로 변경 **불필요** (이미 별도 디코드)
|
||||||
|
|
||||||
|
### 5.4 왜 WASM 내부 수정이 아닌가 (1차 선택 이유)
|
||||||
|
|
||||||
|
| 접근 | 장점 | 단점 |
|
||||||
|
|------|------|------|
|
||||||
|
| **JS 후처리 (채택)** | 배포 단순, acadrust 크레이트 재빌드 없음, 샘플↔원본 이식 쉬움 | 근본 수정은 아님. 휴리스틱 오탐 가능성(낮음) |
|
||||||
|
| acadrust/코드페이지 수정 | 파서 정합 | MPL·Rust/WASM 빌드 체인, 검증 범위 큼 |
|
||||||
|
| 스타일→돋움 TTF 매핑 | WYSIWYG 개선 | **이번 mojibake를 해결하지 못함** |
|
||||||
|
|
||||||
|
중장기적으로는 WASM/파서가 DWG 코드페이지(또는 문자열 유니코드 플래그)를 올바르게 반영하는 것이 이상적이다.
|
||||||
|
그 전 단계의 **제품 적용 가능한 완화책**이 JS 후처리다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 원본(`hmwebviewer`) 반영 절차
|
||||||
|
|
||||||
|
원본 `src/viewer2d/dwgParser.ts` 는 샘플 패치 전과 동일하게 `parseDwgAcadrust` 결과를 그대로 반환한다.
|
||||||
|
|
||||||
|
### 6.1 최소 이식 (권장 1차)
|
||||||
|
|
||||||
|
1. 샘플에서 복사
|
||||||
|
- `dwg-dxf-viewer-sample/src/viewer2d/fixDwgKoreanText.ts`
|
||||||
|
- → `hmwebviewer/src/viewer2d/fixDwgKoreanText.ts`
|
||||||
|
2. `hmwebviewer/src/viewer2d/dwgParser.ts` 에 샘플과 동일하게
|
||||||
|
`fixDwgKoreanText(parseDwgAcadrust(bytes))` 연결
|
||||||
|
3. `parseDwgBuffer` 를 거치지 않는 다른 진입점이 있으면 **동일 후처리** 적용
|
||||||
|
- 예: 직접 `parseDwgAcadrust` / `parse_dwg` 호출부 검색
|
||||||
|
4. 검증
|
||||||
|
- 문제 DWG: NOTE·공정 문구 한글
|
||||||
|
- `BasicSample` 또는 기존 유니코드 한글 DWG: 회귀 없음
|
||||||
|
- 영문·숫자·`Sealing` 등 ASCII 유지
|
||||||
|
5. (선택) 단위 테스트
|
||||||
|
- `fixCp949Mojibake('½Ã°ø½Ã') === '시공시'`
|
||||||
|
- `fixCp949Mojibake('설계사') === '설계사'`
|
||||||
|
- `fixCp949Mojibake('NOTE') === 'NOTE'`
|
||||||
|
|
||||||
|
### 6.2 동기화 정책
|
||||||
|
|
||||||
|
README 기준: `hmwebviewer/src/viewer2d` 가 본가이고 샘플은 형제 복사본이다.
|
||||||
|
이번 이슈는 **샘플에서 먼저 수정**했으므로, 원본 반영 후 필요 시 샘플과 다시 맞춘다.
|
||||||
|
|
||||||
|
### 6.3 반영 체크리스트
|
||||||
|
|
||||||
|
- [ ] `fixDwgKoreanText.ts` 원본 트리에 추가
|
||||||
|
- [ ] `dwgParser.ts` 후처리 연결
|
||||||
|
- [ ] 직접 WASM 호출 경로 없음 확인
|
||||||
|
- [ ] 문제 한글 DWG 수동 확인
|
||||||
|
- [ ] 정상 유니코드 한글 DWG 회귀 확인
|
||||||
|
- [ ] (선택) DXF ANSI_949 도면도 기존 `decodeDxf` 동작 유지 확인
|
||||||
|
- [ ] (선택) `tables.styles` / 폰트 매핑은 **별 이슈**로 분리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 하지 말아야 할 것
|
||||||
|
|
||||||
|
| 비권장 | 이유 |
|
||||||
|
|--------|------|
|
||||||
|
| 돋움 TTF만 추가하고 끝 | 문자열이 깨진 상태면 돋움으로도 동일 mojibake |
|
||||||
|
| 모든 문자열을 무조건 CP949 디코드 | 정상 유니코드·라틴 확장 도면 손상 |
|
||||||
|
| 렌더러에서 글리프 폴백만 강화 | 코드포인트 자체가 틀림 |
|
||||||
|
| `dist` 만 수동 패치 | 소스는 `src/`; 빌드 산출물에만 고치면 재빌드 시 소실 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 잔여 이슈 / 후속 과제
|
||||||
|
|
||||||
|
1. **파서 본가 수정**
|
||||||
|
acadrust(또는 문자열 추출 경로)에서 DWG 코드페이지·유니코드 플래그 반영 → JS 휴리스틱 제거 가능 여부 검토
|
||||||
|
2. **STYLE 테이블**
|
||||||
|
현재 `tables.styles` 가 비어 스타일/폰트명 미노출. 돋움 등 CAD 지정 폰트 매핑은 별 과제
|
||||||
|
3. **다른 코드페이지**
|
||||||
|
일본(932)/중국(936)/대만(950) 도면에 동일 패턴이 있으면 라벨 확장 + 문자 체계 휴리스틱 필요 (현 구현은 **한글 음절 출현** 조건으로 한국 도면에 한정)
|
||||||
|
4. **ATTRIB / 치수 상주 텍스트 등**
|
||||||
|
딥 워크로 대부분 커버되나, 바이너리/비문자열 필드는 대상 외
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 한 줄 요약
|
||||||
|
|
||||||
|
> **폰트 폴더 문제가 아니라, 구형 한글 DWG의 CP949 바이트가 Latin-1 문자열로 올라온 인코딩 오해독이다.
|
||||||
|
> `parse_dwg` 직후 문자열 트리를 CP949로 재해석(이미 한글인 값은 스킵)하면 해결되며,
|
||||||
|
> 원본 `hmwebviewer` 에는 샘플의 `fixDwgKoreanText.ts` + `dwgParser.ts` 연결을 그대로 이식하면 된다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 참고 파일 (샘플)
|
||||||
|
|
||||||
|
- 복원 구현: [`../src/viewer2d/fixDwgKoreanText.ts`](../src/viewer2d/fixDwgKoreanText.ts)
|
||||||
|
- 연결부: [`../src/viewer2d/dwgParser.ts`](../src/viewer2d/dwgParser.ts)
|
||||||
|
- DXF 쪽 코드페이지 처리(대조): [`../src/viewer2d/decodeDxf.ts`](../src/viewer2d/decodeDxf.ts)
|
||||||
|
- 모듈 지도: [`modules-dwg-dxf.html`](./modules-dwg-dxf.html)
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# 대용량 DWG 로딩 멈춤 — 원인 분석 및 해결 가이드
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 작성 목적 | 19MB DWG 로딩이 사실상 멈추던 문제의 원인 규명과 수정 내역 기록 |
|
||||||
|
| 샘플 저장소 | `dwg-dxf-viewer-sample` (본 문서 위치) |
|
||||||
|
| 원본 저장소 | `hmwebviewer` (`rust/dwg-wasm/`, `src/viewer2d/` 동기화 대상) |
|
||||||
|
| 재현 도면 | `dwg_18.3mb.dwg` (19,243,300 bytes · AC1032 · 82,393 entities) |
|
||||||
|
| 대조 도면 | `BasicSample.dwg` (459KB), `civil.dwg` (5.6MB) |
|
||||||
|
| 수정일 | 2026-08-03 |
|
||||||
|
| 상태 | 원본(`hmwebviewer`) 수정 완료 / 샘플 동기화 완료 / 배포 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현상
|
||||||
|
|
||||||
|
쿼리 파라미터로 대용량 DWG를 여는 링크가 "안 열린다"는 제보.
|
||||||
|
|
||||||
|
```
|
||||||
|
https://dwg-dxf-viewer-sample.pages.dev/?model=https://minio.gsimtech.com/api/viewer-test/dwg_18.3mb.dwg
|
||||||
|
```
|
||||||
|
|
||||||
|
증상은 다음과 같다.
|
||||||
|
|
||||||
|
- 상태바가 `loading dwg_18.3mb.dwg…` 에서 멈춘 채 진행이 없음
|
||||||
|
- 탭 전체가 무반응 (스크롤·클릭·패널 조작 불가)
|
||||||
|
- 크롬이 "페이지가 응답하지 않습니다" 배너를 띄우거나 탭을 종료
|
||||||
|
|
||||||
|
에러는 발생하지 않는다. 끝까지 기다리면 **약 6분(354초) 뒤 정상적으로 렌더링된다.** 즉 실패가 아니라 극단적으로 느린 것이다.
|
||||||
|
|
||||||
|
### 자주 하는 오진
|
||||||
|
|
||||||
|
아래는 모두 **원인이 아니었다.** 실측으로 하나씩 배제한 기록이다.
|
||||||
|
|
||||||
|
| 오진 | 검증 방법 | 결과 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| CORS 차단 | `curl -H "Origin: https://dwg-dxf-viewer-sample.pages.dev"` | `access-control-allow-origin` 정상 응답, 200 OK |
|
||||||
|
| 쿼리 파라미터 미지원 | 배포된 번들에서 `URLSearchParams(location.search).get("model")` 확인 | 정상 동작 |
|
||||||
|
| DWG 버전 미지원 | 헤더 시그니처 `AC1032` 확인 | `BasicSample.dwg`와 동일 버전, 지원됨 |
|
||||||
|
| 파서 알고리즘 O(n²) | 네이티브 빌드로 동일 함수 실행 | **선형** (JSON 1MB당 약 37ms 일정) |
|
||||||
|
| `serde_wasm_bindgen` 경계 비용 | JSON 문자열 반환 함수를 따로 만들어 측정 | 여전히 310초 — 무관 |
|
||||||
|
| wasm 메모리 `max` 미선언으로 인한 grow 복사 | `--max-memory` / `--initial-memory` 선언 후 재측정 | 330초 — **개선 없음** |
|
||||||
|
| `dlmalloc` 자체 결함 | 전역 할당자를 `talc`로 교체 후 재측정 | 316초 — **개선 없음** |
|
||||||
|
| `opt-level = "s"` (크기 최적화) | 네이티브도 동일 프로파일을 쓴다는 점 확인 | 네이티브/wasm 공통이므로 차이 요인 아님 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 계측
|
||||||
|
|
||||||
|
### 2.1 네이티브 vs wasm
|
||||||
|
|
||||||
|
`rust/dwg-wasm`에는 네이티브 바이너리를 만들 수 있으므로, 같은 코드를 두 타깃에서 돌려 비교했다.
|
||||||
|
|
||||||
|
| 파일 | JSON 출력 | 네이티브 | wasm | 배율 |
|
||||||
|
|------|-----------|---------|------|------|
|
||||||
|
| `BasicSample.dwg` | 2.7MB | 122ms | 266ms | 2.2x |
|
||||||
|
| `civil.dwg` | 16.5MB | 581ms | 11,554ms | 20x |
|
||||||
|
| `dwg_18.3mb.dwg` | 96.9MB | 3,644ms | 310,572ms | **85x** |
|
||||||
|
|
||||||
|
네이티브는 완전히 선형인데 wasm만 초선형이다. **배율 자체가 파일 크기에 비례해 커진다** — 전형적인 O(n²) 신호다.
|
||||||
|
|
||||||
|
일반적인 wasm은 네이티브 대비 1.5~3배 느린 수준이므로, 85배는 정상 범위가 아니다.
|
||||||
|
|
||||||
|
### 2.2 구간 분리
|
||||||
|
|
||||||
|
wasm 내부에 `js_sys::Date::now()` 기반 구간 타이머를 넣어 세 단계를 나눴다.
|
||||||
|
|
||||||
|
| 파일 | `read_document` (DWG 디코딩) | `document_to_parse_result` | `serde_json::to_string` |
|
||||||
|
|------|------------------------------|----------------------------|-------------------------|
|
||||||
|
| `BasicSample.dwg` | 71ms | 160ms | 18ms |
|
||||||
|
| `civil.dwg` | 77ms | **11,235ms** | 87ms |
|
||||||
|
| `dwg_18.3mb.dwg` | 413ms | **351,777ms** | 509ms |
|
||||||
|
|
||||||
|
DWG 디코딩과 직렬화는 선형이고 정상이다. **`document_to_parse_result` 하나가 전체의 99.7%를 차지하며 이 함수만 2차 곡선을 그린다.**
|
||||||
|
|
||||||
|
그런데 같은 함수의 네이티브 실행은 71 → 425 → 2,488ms로 완전히 선형이다. **로직이 원인이 아니라는 뜻이다.**
|
||||||
|
|
||||||
|
### 2.3 판별 실험 — 할당 횟수인가, 힙 크기인가
|
||||||
|
|
||||||
|
원인 후보가 "할당 횟수가 많아서"인지 "살아있는 힙이 커서"인지 가르기 위해, 같은 루프를 두 방식으로 돌렸다.
|
||||||
|
|
||||||
|
- **drop 모드** — 엔티티마다 `Value`를 만들고 즉시 버림 (할당 횟수 동일, 라이브 힙 ≈ 0)
|
||||||
|
- **keep 모드** — 만든 `Value`를 전부 보관 (할당 횟수 동일, 라이브 힙 최대)
|
||||||
|
|
||||||
|
두 모드의 **할당 횟수와 수행 작업은 완전히 같고, 오직 객체가 살아있는지만 다르다.**
|
||||||
|
|
||||||
|
| 파일 | drop 모드 | keep 모드 | 배율 |
|
||||||
|
|------|-----------|-----------|------|
|
||||||
|
| `civil.dwg` (17,758 ents) | 147ms | 3,545ms | 24x |
|
||||||
|
| `dwg_18.3mb.dwg` (82,403 ents) | **739ms** | **92,848ms** | **126x** |
|
||||||
|
|
||||||
|
drop 모드는 선형이고 빠르다 (엔티티 4.6배 증가에 시간 5배). keep 모드만 2차다.
|
||||||
|
|
||||||
|
### 2.4 메모리
|
||||||
|
|
||||||
|
네이티브 실행의 피크 워킹셋을 측정했다.
|
||||||
|
|
||||||
|
```
|
||||||
|
PEAK WORKING SET: 2,920 MB
|
||||||
|
```
|
||||||
|
|
||||||
|
**19MB DWG 하나를 파싱하는 데 2.9GB.** 원본 대비 154배다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 원인
|
||||||
|
|
||||||
|
세 가지 사실이 하나로 모인다.
|
||||||
|
|
||||||
|
1. `document_to_parse_result`는 결과 전체를 `serde_json::Value` 트리로 **메모리에 쌓아 올린다.**
|
||||||
|
2. 그 트리는 원본 DWG의 약 150배 크기다 (19MB → 약 2.9GB). 좌표 하나하나가 `json!({"x":.., "y":..})`, 즉 힙 문자열 키 2개를 가진 맵으로 만들어지기 때문이다.
|
||||||
|
3. **wasm 할당자는 살아있는 힙이 커질수록 할당 1건당 비용이 증가한다.** 힙이 GB 단위로 부풀면서 뒤쪽 엔티티일수록 할당이 느려지고, 전체가 O(n²)가 된다.
|
||||||
|
|
||||||
|
네이티브에서 같은 코드가 선형인 이유는 Windows 힙이 이 패턴을 잘 견디기 때문이고, 문제는 wasm 타깃에서만 드러난다.
|
||||||
|
|
||||||
|
> 할당자를 `talc`로 바꿔도 개선되지 않았다는 점이 중요하다. 특정 할당자의 결함이 아니라 **"거대한 라이브 힙 + 수천만 건의 미세 할당"이라는 조합 자체가 wasm에서 감당되지 않는 것**이다. 따라서 해결책은 할당자 교체가 아니라 **라이브 힙을 없애는 것**이다.
|
||||||
|
|
||||||
|
메인 스레드가 이 6분을 통째로 점유하기 때문에 탭이 얼어붙고, 사용자에게는 "안 열린다"로 보인다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 해결
|
||||||
|
|
||||||
|
**결과 전체를 메모리에 들고 있지 않는다.** 엔티티를 하나씩 JSON 텍스트로 변환해 출력 버퍼에 붙이고 즉시 버린다. 라이브 힙이 평평하게 유지되므로 할당 비용이 커지지 않는다.
|
||||||
|
|
||||||
|
### 4.1 Rust — 스트리밍 직렬화 경로 추가
|
||||||
|
|
||||||
|
`rust/dwg-wasm/src/lib.rs`
|
||||||
|
|
||||||
|
기존 `document_to_parse_result`는 그대로 두고(네이티브 덤프·테스트에서 계속 사용), 공통 조각을 뽑아낸 뒤 스트리밍 버전을 추가했다.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn document_to_parse_result_json(
|
||||||
|
doc: &acadrust::CadDocument,
|
||||||
|
) -> Result<String, serde_json::Error> {
|
||||||
|
let ctx = build_ctx(doc);
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(1 << 22);
|
||||||
|
|
||||||
|
// 키 순서는 `json!`이 내보내는 것과 맞춘다: serde_json은 객체를 BTreeMap으로
|
||||||
|
// 뒷받침하므로 트리 경로는 최상위 키를 정렬해 쓴다. 여기서 같은 순서를
|
||||||
|
// 유지하면 두 경로를 바이트 단위로 비교할 수 있다.
|
||||||
|
out.extend_from_slice(b"{\"entities\":[");
|
||||||
|
|
||||||
|
// 한 번에 엔티티 하나만: `scratch`를 매 회 비우므로 도면에 엔티티가
|
||||||
|
// 아무리 많아도 라이브 힙이 평평하게 유지된다.
|
||||||
|
let mut scratch: Vec<Value> = Vec::new();
|
||||||
|
let mut first = true;
|
||||||
|
for e in doc.entities() {
|
||||||
|
scratch.clear();
|
||||||
|
entity_to_json(e, &ctx, &mut scratch);
|
||||||
|
for v in &scratch {
|
||||||
|
if !first { out.push(b','); }
|
||||||
|
first = false;
|
||||||
|
serde_json::to_writer(&mut out, v)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.extend_from_slice(b"]");
|
||||||
|
|
||||||
|
out.extend_from_slice(b",\"stats\":");
|
||||||
|
serde_json::to_writer(&mut out, &stats_value(doc))?;
|
||||||
|
out.extend_from_slice(b",\"tables\":");
|
||||||
|
serde_json::to_writer(&mut out, &tables_value(doc))?;
|
||||||
|
out.extend_from_slice(b",\"vars\":");
|
||||||
|
serde_json::to_writer(&mut out, &vars_value(doc))?;
|
||||||
|
out.extend_from_slice(b",\"version\":");
|
||||||
|
serde_json::to_writer(&mut out, &version_value(doc))?;
|
||||||
|
out.push(b'}');
|
||||||
|
|
||||||
|
Ok(String::from_utf8(out).expect("serde_json emits UTF-8"))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
새 wasm export:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn parse_dwg_json(bytes: &[u8]) -> Result<String, JsError> {
|
||||||
|
let doc = read_document(bytes).map_err(|e| JsError::new(&e))?;
|
||||||
|
document_to_parse_result_json(&doc).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 `parse_dwg`(JsValue 반환)는 호환을 위해 남겨두었다.
|
||||||
|
|
||||||
|
> **키 순서 주의** — `serde_json`은 `preserve_order` 없이 객체를 `BTreeMap`으로 뒷받침하므로 `json!` 매크로가 만든 최상위 키는 알파벳 순(`entities`, `stats`, `tables`, `vars`, `version`)으로 직렬화된다. 수동 출력도 같은 순서를 지켜야 바이트 단위 비교가 성립한다. 처음 구현에서 선언 순서(`version`, `vars`, …)로 썼다가 길이는 같은데 내용이 달라 실패했다.
|
||||||
|
|
||||||
|
### 4.2 JS — `JSON.parse` 경유로 전환
|
||||||
|
|
||||||
|
`src/viewer2d/acadrustParser.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
|
||||||
|
|
||||||
|
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
|
||||||
|
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
97MB 문자열을 `JSON.parse`로 넘기는 비용은 19MB 도면 기준 약 0.6초다. 엔진의 네이티브 파서를 쓰므로 wasm에서 객체를 하나씩 만드는 것보다 훨씬 싸다.
|
||||||
|
|
||||||
|
### 4.3 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hmwebviewer
|
||||||
|
npm run build:dwg-wasm # rust/dwg-wasm → src/viewer2d/acadrust-dwg
|
||||||
|
```
|
||||||
|
|
||||||
|
이후 `src/viewer2d/acadrust-dwg/` 4개 파일과 `acadrustParser.ts`를 샘플 저장소로 복사한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 결과
|
||||||
|
|
||||||
|
| 파일 | 이전 | 이후 (wasm + `JSON.parse`) | 개선 |
|
||||||
|
|------|------|---------------------------|------|
|
||||||
|
| `BasicSample.dwg` (459KB) | 345ms | **138ms** | 2.5x |
|
||||||
|
| `civil.dwg` (5.6MB) | 14,064ms | **446ms** | 32x |
|
||||||
|
| `dwg_18.3mb.dwg` (19MB) | **353,735ms** | **2,481ms** | **143x** |
|
||||||
|
|
||||||
|
곡선이 2차에서 선형으로 바뀌었다. 엔티티 7,309 → 17,810 → 82,393에 대해 138 → 446 → 2,481ms.
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
|
||||||
|
동작이 바뀌지 않았음을 두 층위에서 확인했다.
|
||||||
|
|
||||||
|
1. **Rust 층** — `document_to_parse_result` 트리 경로와 스트리밍 경로의 출력을 5개 도면에서 비교, **전부 바이트 단위 동일**
|
||||||
|
(`BasicSample.dwg`, `civil.dwg`, `bb.dwg`, `C0060203-001-표준단면도….dwg`, `dwg_18.3mb.dwg`)
|
||||||
|
2. **JS 층** — 기존 `parse_dwg` 결과와 `JSON.parse(parse_dwg_json(...))` 결과를 `assert.deepStrictEqual`로 비교, 3개 도면 통과
|
||||||
|
(u64 핸들이 BigInt로 바뀌는 등의 타입 변화가 없음을 확인)
|
||||||
|
3. `tsc --noEmit` 통과, `npm run build` 성공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 진단 하네스
|
||||||
|
|
||||||
|
원인 규명에 쓴 네이티브 바이너리를 회귀 테스트용으로 남겨두었다.
|
||||||
|
|
||||||
|
`rust/dwg-wasm/src/bin/prof.rs` — 구간별 시간 측정 + 두 경로 출력 동등성 비교
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd hmwebviewer/rust/dwg-wasm
|
||||||
|
cargo build --release --bin prof
|
||||||
|
./target/release/prof.exe <file.dwg>
|
||||||
|
```
|
||||||
|
|
||||||
|
출력 예:
|
||||||
|
|
||||||
|
```
|
||||||
|
file dwg_18.3mb.dwg bytes 19243300
|
||||||
|
read_document 619 ms
|
||||||
|
Value tree + to_string 2875 ms (96897608 bytes)
|
||||||
|
streaming json 1188 ms (96897608 bytes)
|
||||||
|
BYTE-IDENTICAL: true
|
||||||
|
```
|
||||||
|
|
||||||
|
`document_to_parse_result`를 수정할 일이 생기면 이걸로 두 경로가 계속 일치하는지 확인하면 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 남은 과제
|
||||||
|
|
||||||
|
1. **렌더링 비용 미측정**
|
||||||
|
파싱은 2.5초로 해결됐지만, 그 뒤 Viewer2D가 82,393개 엔티티를 실제로 그리는 시간은 별도로 측정하지 않았다. 대용량 도면이 여전히 느리다면 여기부터 봐야 한다.
|
||||||
|
|
||||||
|
2. **메인 스레드 블로킹**
|
||||||
|
파싱 2.5초 동안 여전히 탭이 멈춘다. 6분에 비하면 수용 가능하지만, Web Worker로 옮기면 진행률 표시와 취소가 가능해진다.
|
||||||
|
|
||||||
|
3. **`parse_dwg` 정리**
|
||||||
|
기존 JsValue 반환 export는 현재 아무도 쓰지 않는다. 외부 의존이 없다고 확인되면 제거해 wasm 크기를 줄일 수 있다.
|
||||||
|
|
||||||
|
4. **메모리 상한**
|
||||||
|
스트리밍으로 라이브 힙은 없앴지만 출력 문자열 자체는 여전히 97MB이고, wasm32는 주소 공간이 4GB로 제한된다. 이보다 몇 배 큰 도면은 다시 한계에 부딪힐 수 있다. 필요해지면 청크 단위로 JS에 넘기는 방식을 검토한다.
|
||||||
|
|
||||||
|
5. **회색 원 아티팩트 (별건)**
|
||||||
|
`dwg_18.3mb.dwg` 렌더 결과에 도면과 무관한 거대한 회색 원 2개가 나타난다. `CF-PATT` 레이어의 SOLID HATCH 2개(span 4655 / 4092)이며 본 성능 수정과는 **무관하다** — 출력 JSON이 수정 전후 바이트 단위로 동일하므로 원래부터 있던 문제이고, 지금까지 6분이 걸려 아무도 확인하지 못했을 뿐이다.
|
||||||
|
별도 조사·수정 완료 (최대 해치 span 4655 → 110): [`hatch-boundary-arc-artifact.md`](./hatch-boundary-arc-artifact.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 오픈소스 라이선스 (MPL-2.0)
|
||||||
|
|
||||||
|
DWG 파싱은 두 계층으로 나뉘며, 라이선스가 다르다.
|
||||||
|
|
||||||
|
| 구성요소 | 위치 | 라이선스 | 본 작업에서 |
|
||||||
|
|----------|------|---------|------------|
|
||||||
|
| `acadrust` v0.4.1 | `hmwebviewer/rust/acadrust/` | **MPL-2.0** | **수정하지 않음** |
|
||||||
|
| `dwg-wasm` (래퍼) | `hmwebviewer/rust/dwg-wasm/` | MIT (자체 코드) | 수정함 |
|
||||||
|
| 뷰어 | `src/viewer2d/` | 자체 코드 | 수정함 |
|
||||||
|
|
||||||
|
MPL-2.0은 **파일 단위 카피레프트**로, MPL 파일 자체를 수정했을 때 그 파일들을 MPL-2.0으로 공개할 의무가 생긴다. 라이브러리로 링크하기만 한 자체 코드는 대상이 아니다.
|
||||||
|
|
||||||
|
본 성능 수정은 전부 래퍼(`rust/dwg-wasm/src/lib.rs`)와 뷰어(`src/viewer2d/acadrustParser.ts`)에서 이루어졌고 `rust/acadrust/` 는 변경하지 않았다. **따라서 새로 발생하는 소스 공개 의무는 없다.**
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git status --short rust/acadrust/
|
||||||
|
(변경 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
수정 여부와 무관하게 `rust/acadrust/LICENSE` (MPL-2.0 전문)와 저작권 고지는 계속 유지해야 하며, 현재 유지되고 있다.
|
||||||
|
|
||||||
|
향후 acadrust 내부를 직접 고쳐야 하는 상황이 오면 의무가 발생한다. 상세 지침은 [`hatch-boundary-arc-artifact.md` §7](./hatch-boundary-arc-artifact.md#7-오픈소스-라이선스-mpl-20) 참고.
|
||||||
|
|
||||||
|
> 라이선스 조항에 대한 사실 정리이며 법률 자문이 아니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 한 줄 요약
|
||||||
|
|
||||||
|
> **파서 알고리즘 문제가 아니라, 결과 전체를 `serde_json::Value` 트리로 메모리에 쌓는 구조 때문에 라이브 힙이 2.9GB까지 부풀고, 힙이 커질수록 wasm 할당자의 할당 비용이 증가해 전체가 O(n²)가 된 것이다.
|
||||||
|
> 엔티티를 하나씩 JSON 텍스트로 흘려보내 라이브 힙을 평평하게 유지하면 선형으로 돌아오며, 19MB 도면 기준 354초 → 2.5초(143배)로 개선된다.
|
||||||
|
> 출력 JSON은 바이트 단위로 동일하므로 뷰어 쪽 변경은 `JSON.parse` 한 줄뿐이다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 참고 파일
|
||||||
|
|
||||||
|
- 스트리밍 구현: [`../../hmwebviewer/rust/dwg-wasm/src/lib.rs`](../../hmwebviewer/rust/dwg-wasm/src/lib.rs) (`document_to_parse_result_json`, `parse_dwg_json`)
|
||||||
|
- 뷰어 연결부: [`../src/viewer2d/acadrustParser.ts`](../src/viewer2d/acadrustParser.ts)
|
||||||
|
- 진단 하네스: [`../../hmwebviewer/rust/dwg-wasm/src/bin/prof.rs`](../../hmwebviewer/rust/dwg-wasm/src/bin/prof.rs)
|
||||||
|
- 쿼리 파라미터 로딩부: [`../src/main.ts`](../src/main.ts) (`loadUrl`, `?model=`)
|
||||||
|
- 모듈 지도: [`modules-dwg-dxf.html`](./modules-dwg-dxf.html)
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Model / Layout (Paper) 뷰어 이슈 — 원인 분석 및 처리 이력
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 작성 목적 | 샘플에서 재현·수정한 이슈를 **원본 `hmwebviewer`** 에 이식하기 위한 기술 메모 |
|
||||||
|
| 샘플 | `dwg-dxf-viewer-sample` |
|
||||||
|
| 원본 | `hmwebviewer` (`src/viewer2d/` …) |
|
||||||
|
| 주 재현 도면 | `C0060202-001-평면및종단면도(1)(대산방향STA.18+492.08-19+411.50).dwg` |
|
||||||
|
| 관련 도면 | `C0060203-*`, `BasicSample.dwg` (회귀) |
|
||||||
|
| 최종 정리 | 2026-07-29 |
|
||||||
|
| 상태 | **샘플 패치 완료** / 원본 뷰어 UI·Viewer2D 이식은 확인 후 |
|
||||||
|
|
||||||
|
### 제약
|
||||||
|
|
||||||
|
| 대상 | 정책 |
|
||||||
|
|------|------|
|
||||||
|
| **오픈소스 파서 본체 (acadrust 등 crates.io)** | **수정 금지** (unmodified 사용) |
|
||||||
|
| SORTENTSTABLE / 헤더 확장 export | 파서 수정 없이 **뷰어 규칙으로 대체** |
|
||||||
|
| 한글 인코딩 (CP949 mojibake) | 별도 문서 [`korean-dwg-text-encoding.md`](./korean-dwg-text-encoding.md) — **본 문서와 독립, 둘 다 필요** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 이슈 한눈에 보기
|
||||||
|
|
||||||
|
| # | 증상 | 원인 | 처리 (샘플) | 수정 위치 |
|
||||||
|
|---|------|------|-------------|-----------|
|
||||||
|
| 1 | 글자·선이 한곳에 뒤섞여 겹침 | Model + Paper 동시 렌더 | 공간 필터 + Model/Layout 탭 | `cadSpaces.ts`, `Viewer2D.js`, `main.ts`, `index.html` |
|
||||||
|
| 2 | Layout에 도곽만 있고 평면/종단 비어 있음 | VIEWPORT 미투영 | Layout 시 Model을 VP 창에 투영·클립 | `Viewer2D.js` |
|
||||||
|
| 3 | Fit / 좌표가 CAD와 다름, (0,0)에 없음 | WCS 표시 + 이상치 bbox | **Model UCS 좌표** + 백분위 Fit | `Viewer2D.js` |
|
||||||
|
| 4 | 선형·종단에서 글자만 거대 | `minTextH = diag×0.0002` ≈ 250u | `min(diag×0.0002, medianHeight)` | `Viewer2D.js` |
|
||||||
|
| 5 | Layout에서 VP 내용이 도곽·주석 위 | VP를 paper 뒤에 append | VP 먼저 + `z=-1` + underlay 텍스트 | `Viewer2D.js` |
|
||||||
|
| 6 | 커서 좌표 표시 | — | 우하단 X/Y (Model=UCS) | `#coords`, `screenToWorld` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DWG 공간 개념 (배경)
|
||||||
|
|
||||||
|
| 공간 | 블록 이름 예 | 역할 |
|
||||||
|
|------|----------------|------|
|
||||||
|
| Model Space | `*Model_Space` | 실좌표(측량 등) 본도면 |
|
||||||
|
| Paper Space / Layout | `*Paper_Space`, `*Paper_Space0`… | 출력 시트, 도곽, 주석, VIEWPORT |
|
||||||
|
|
||||||
|
- 엔티티 `ownerHandle` → 소속 블록 핸들.
|
||||||
|
- Layout은 보통 **VIEWPORT** 로 Model 일부를 시트 좌표에 투영한다.
|
||||||
|
- Model에는 **원거리 이중 좌표**가 흔함 (예: 원점 근처 종단 + X≈-436k 근처 선형).
|
||||||
|
|
||||||
|
### 재현 도면 규모 (C0060202-001, 참고)
|
||||||
|
|
||||||
|
| Space | handle (dec / hex) | 엔티티 수 (대략) | 좌표 |
|
||||||
|
|-------|--------------------|------------------|------|
|
||||||
|
| `*Model_Space` | 31 / `1f` | ~17,377 | 측량·다중 군집 |
|
||||||
|
| `*Paper_Space` | 210 / `d2` | ~200+ | 시트 (~1.7e3–2.6e3) |
|
||||||
|
| VIEWPORT | paper 소유 | 3 | overall 1 + 종단·평면 창 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 이슈 #1 — Model + Paper 동시 렌더
|
||||||
|
|
||||||
|
### 2.1 현상
|
||||||
|
|
||||||
|
다색 TEXT가 중앙에 뭉치고, 기하·주석이 읽기 불가.
|
||||||
|
CAD에서는 Layout 활성 시 정상 시트.
|
||||||
|
|
||||||
|
### 2.2 원인
|
||||||
|
|
||||||
|
기존 `Viewer2D.load()`:
|
||||||
|
|
||||||
|
1. `*Model_Space` / `*Paper_Space` 를 space 로 인식
|
||||||
|
2. **사용자 블록 정의**만 메인 루프에서 스킵 (INSERT 전개용)
|
||||||
|
3. Model·Paper **둘 다** 메인 루프에서 그림
|
||||||
|
|
||||||
|
→ 좌표계·스케일이 다른 두 공간을 한 씬에 합치고 `fit()` → 파국.
|
||||||
|
|
||||||
|
### 2.3 처리
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `src/viewer2d/cadSpaces.ts` | `listCadSpaces`, `pickDefaultSpace`, `buildAllowedOwnerHexes` |
|
||||||
|
| `Viewer2D.js` | `allowedOwners` 필터, `setSpace` / `getSpaces` / `getActiveSpace` |
|
||||||
|
| `main.ts` + `index.html` | **Model / Layout** 탭, 로드 시 기본 공간 |
|
||||||
|
|
||||||
|
**기본 탭:** Paper에 엔티티 있으면 Layout, 없으면 Model.
|
||||||
|
|
||||||
|
```
|
||||||
|
C0060202-001 → Layout
|
||||||
|
BasicSample (paper 0) → Model
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 이슈 #2 — Layout이 비어 보임 (VIEWPORT)
|
||||||
|
|
||||||
|
### 3.1 현상
|
||||||
|
|
||||||
|
공간 분리 후 Layout은 도곽·주석만 있고, CAD처럼 평면·종단이 없음.
|
||||||
|
|
||||||
|
### 3.2 원인
|
||||||
|
|
||||||
|
- Paper 엔티티만으로는 시트 장식/주석 수준.
|
||||||
|
- 본도면은 Model에 있고, CAD는 **VIEWPORT** 로 비춤.
|
||||||
|
- 뷰어가 Layout에서 Model을 VP에 투영하지 않으면 창이 비어 보임.
|
||||||
|
|
||||||
|
### 3.3 VIEWPORT 데이터
|
||||||
|
|
||||||
|
acadrust 타입에는 `Viewport` 가 있음.
|
||||||
|
뷰어는 parseResult 의 `type: "VIEWPORT"` 필드를 사용한다.
|
||||||
|
|
||||||
|
재현 도면 VP 예:
|
||||||
|
|
||||||
|
| 역할 | size (대략) | twist | 비고 |
|
||||||
|
|------|-------------|-------|------|
|
||||||
|
| overall paper | 시트 전체 | 0 | 스킵 (모델 창 아님) |
|
||||||
|
| 종단 창 | ~840×302 | 0 | Model 투영 |
|
||||||
|
| 평면 창 | ~840×215 | ~1.28 rad | Model 투영 |
|
||||||
|
|
||||||
|
**overall 판별:** `id===1` 또는 (scale≈1 ∧ viewTarget≈0 ∧ viewCenter≈paper center).
|
||||||
|
|
||||||
|
### 3.4 처리 (`Viewer2D.js`)
|
||||||
|
|
||||||
|
Layout 활성 시:
|
||||||
|
|
||||||
|
1. (순서: 아래 이슈 #5) Model을 각 활성 VIEWPORT에 투영
|
||||||
|
2. 변환 (2D / top, ODA·acadrust writer 규약):
|
||||||
|
|
||||||
|
```
|
||||||
|
DCS = R(+twist) * (model − viewTarget)
|
||||||
|
paper = center + (paperHeight / viewHeight) * (DCS − viewCenter)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. paper 사각형 Cohen–Sutherland 클리핑
|
||||||
|
4. `_isModelViewport` / `_viewportModelToPaper` / `_emitModelThroughViewport`
|
||||||
|
|
||||||
|
### 3.5 파서 정책
|
||||||
|
|
||||||
|
- **acadrust(오픈소스 본체)는 수정하지 않는다.**
|
||||||
|
- VIEWPORT JSON 방출이 래퍼/기존 빌드에 이미 있으면 뷰어만 사용.
|
||||||
|
- 추가 파서 기능(SORTENTS 등)은 **넣지 않음** → 뷰어 규칙으로 충분.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 이슈 #3 — Zoom Fit
|
||||||
|
|
||||||
|
### 4.1 확정: 가시 엔티티 AABB
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 범위 | 활성 Model/Layout + 숨긴 레이어 제외, 렌더 시 `expand` |
|
||||||
|
| 이상치 | 샘플 **0.5%–99.5%** 로 극단 점 제거 → `_contentBox` |
|
||||||
|
| `fit()` | **`_contentBox` 우선** (씬 재계산보다 로드 시점과 동일) |
|
||||||
|
|
||||||
|
### 4.2 가로로 매우 긴 Model (C0060202 / bb.dwg)
|
||||||
|
|
||||||
|
실측 기하(백분위 후) 대략:
|
||||||
|
|
||||||
|
| | WCS | CAD UCS |
|
||||||
|
|--|-----|---------|
|
||||||
|
| 폭 | ~438 000 | ~-437 000 … ~1 000 |
|
||||||
|
| 높이 | **~2 900** | ~-2 950 … 0 |
|
||||||
|
| 종횡비 | **~150 : 1** | |
|
||||||
|
|
||||||
|
화면 비율(~2:1)로 **classic contain** 하면:
|
||||||
|
|
||||||
|
- 세로 시야를 폭에 맞춰 키움 → 내용 높이가 화면의 **~1%**
|
||||||
|
- **수 픽셀 두께의 가로 선**처럼 보임 (하늘색 박스도 상단에 얇은 선)
|
||||||
|
|
||||||
|
`bb.dwg`는 같은 종횡비의 단순 박스 2개라 식별 가능했고, 원본 1.7만 엔티티는 한 줄로 뭉개짐.
|
||||||
|
|
||||||
|
### 4.3 처리 (극단 스트립 Fit)
|
||||||
|
|
||||||
|
```
|
||||||
|
contentAspect = bw / bh
|
||||||
|
if contentAspect > screenAspect × 8: // 매우 가로로 김
|
||||||
|
vh = bh × pad // Y를 화면에 채움
|
||||||
|
vw = vh × screenAspect // 좌우는 잘라 보고 패닝
|
||||||
|
else if contentAspect < screenAspect / 8:
|
||||||
|
// 매우 세로로 김 → 폭 채움
|
||||||
|
else:
|
||||||
|
// 일반 contain
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 선형/종단 구간이 **세로로 화면에 채워짐**. 전체 연장은 좌우 팬.
|
||||||
|
|
||||||
|
CAD status 참고 extents(Y ±10만)와 파서 실측(Y 약 3천)이 다를 수 있음 — Fit은 **파서가 그린 가시 점** 기준.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 이슈 #4 — 글자만 거대 (minTextH)
|
||||||
|
|
||||||
|
### 5.1 현상
|
||||||
|
|
||||||
|
선형(~-436802, -1949) 및 종단 근처에서 `MOT`/`FLOW`/수치가 등고선보다 훨씬 큼.
|
||||||
|
|
||||||
|
### 5.2 원인
|
||||||
|
|
||||||
|
| 항목 | C0060202-001 Model |
|
||||||
|
|------|---------------------|
|
||||||
|
| CAD TEXT height | ~0.3 ~ 3.5 (중앙값 ~3, FLOW≈0.3) |
|
||||||
|
| 전체 bbox 대각선 | ~1.25×10⁶ |
|
||||||
|
| 구 `minTextH = diag × 0.0002` | **~250** |
|
||||||
|
|
||||||
|
`_drawTexts` → `Math.max(t.height, minTextH)` 로 **모든 라벨이 높이 ~250** 강제.
|
||||||
|
|
||||||
|
작은 시트 도면용 “fit 시 가독성” floor가 측량 Model에서 폭주한 것.
|
||||||
|
|
||||||
|
### 5.3 처리
|
||||||
|
|
||||||
|
```js
|
||||||
|
minTextH = Math.min(diagSize * 0.0002, medianNativeHeight);
|
||||||
|
```
|
||||||
|
|
||||||
|
- 측량 Model: floor ≤ median → **사실상 CAD 높이 유지**
|
||||||
|
- 작은 용지 좌표: diag floor가 작으면 기존과 유사
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 이슈 #5 — Layout draw order (VIEWPORT가 위)
|
||||||
|
|
||||||
|
### 6.1 현상
|
||||||
|
|
||||||
|
VIEWPORT로 올린 Model 기하/라벨이 **도곽·NOTE 위**에 그려짐.
|
||||||
|
|
||||||
|
### 6.2 원인 (뷰어)
|
||||||
|
|
||||||
|
기존: paper 패스 후 VP 투영을 **버퍼 뒤**에 append → VP가 위.
|
||||||
|
|
||||||
|
### 6.3 DWG draw-order 메타 (참고만)
|
||||||
|
|
||||||
|
| 소스 | 의미 | 정책 |
|
||||||
|
|------|------|------|
|
||||||
|
| `SORTENTSTABLE` / `ACAD_SORTENTS` | sort handle 낮을수록 아래 | acadrust에 타입 있음 |
|
||||||
|
| Header `SORTENTS` | 정렬 플래그 | — |
|
||||||
|
| parseResult 엔티티 | zOrder 등 **없음** | — |
|
||||||
|
|
||||||
|
**오픈소스 파서를 고치지 않으므로 SORTENTS export는 하지 않는다.**
|
||||||
|
Layout 관례로 고정:
|
||||||
|
|
||||||
|
> **VIEWPORT 안 Model 내용 < Paper 주석·도곽**
|
||||||
|
|
||||||
|
### 6.4 처리
|
||||||
|
|
||||||
|
1. VP Model 투영을 **paper 엔티티 루프보다 먼저** 방출
|
||||||
|
2. VP 선분 `z = -1`, paper `z = 0` (카메라 +Z 기준 뒤/앞)
|
||||||
|
3. 텍스트: `underlay` (VP) / 일반 (paper) 분리, `renderOrder` 0 → 1
|
||||||
|
4. slug/스프라이트 배치도 underlay 먼저 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 커서 좌표 / Fit 좌표계 (UCS)
|
||||||
|
|
||||||
|
### 7.1 현상
|
||||||
|
|
||||||
|
- Model에서 우하단 좌표로 (0,0) 근처를 찾아도 기하가 없음
|
||||||
|
- Fit 후 표시 좌표가 CAD status bar 와 다름
|
||||||
|
|
||||||
|
### 7.2 원인
|
||||||
|
|
||||||
|
| 프레임 | 내용 |
|
||||||
|
|--------|------|
|
||||||
|
| 기하 저장 (파서 WCS) | 예: 선형 클러스터 ≈ (162065, 476725) |
|
||||||
|
| CAD Model UCS 원점 (`vars.ucsOrigin`) | ≈ (598948, 478643) |
|
||||||
|
| CAD에 보이는 좌표 | `UCS = WCS − ucsOrigin` → 선형 ≈ **(-436883, -1918)** |
|
||||||
|
|
||||||
|
뷰어가 **WCS를 그대로** 읽어 주면 CAD 사용자 좌표와 어긋난다.
|
||||||
|
또한 Fit bbox에 극단 이상치(수 점)가 포함되면 중심이 빈 공간으로 갈 수 있다.
|
||||||
|
|
||||||
|
CAD 참고 extents (Model, 사용자 측정):
|
||||||
|
|
||||||
|
```
|
||||||
|
X: -439601.13 … 4412.05
|
||||||
|
Y: -100418.91 … 99422.03
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 처리
|
||||||
|
|
||||||
|
1. **좌표 표시 (Model):** WCS → **model UCS**
|
||||||
|
`cad = project(WCS − ucsOrigin)` (축 단위 직교 시 subtract origin)
|
||||||
|
Layout/Paper: identity (시트 좌표)
|
||||||
|
2. **Pick/measure:** 내부 **WCS** (`_screenToWcs`)
|
||||||
|
3. **Fit:** 가시 기하 **0.5%–99.5% 백분위** AABB (이상치 제거)
|
||||||
|
4. 스크린→WCS: ortho frustum × camera right/up (view-twist 포함)
|
||||||
|
|
||||||
|
### 7.4 검증용 심플 도면 `bb.dwg`
|
||||||
|
|
||||||
|
Model 엔티티 5개 (사각형 2 + 연결 poly + hatch 2). `ucsOrigin ≈ (598948, 478643)`.
|
||||||
|
|
||||||
|
| 덩어리 | WCS (대략) | CAD UCS (대략) |
|
||||||
|
|--------|------------|----------------|
|
||||||
|
| 선형 쪽 박스 | (161969…162448, 475696…476795) | **(-436979…-436500, -2947…-1848)** |
|
||||||
|
| 원점 쪽 박스 | (598948…600030, 477765…478643) | **(0…1081, -878…0)** — **UCS (0,0)에 모서리** |
|
||||||
|
| Fit CAD 범위 | — | X ≈ -436979…1081, Y ≈ -2947…0 |
|
||||||
|
|
||||||
|
**확인 절차**
|
||||||
|
|
||||||
|
1. `bb.dwg` 열기 → **Model** 탭 (기본이 Layout이면 반드시 Model로)
|
||||||
|
2. Fit → 우하단 좌표가 대략 위 UCS 범위 안
|
||||||
|
3. 커서 오른쪽 박스 모서리 ≈ **X=0, Y=0**
|
||||||
|
4. 왼쪽 박스 ≈ **X=-436900, Y=-2400** 부근
|
||||||
|
|
||||||
|
CAD WCS (0,0) 에는 기하가 없음. CAD status의 (0,0)은 **UCS 원점**이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 검증 체크리스트
|
||||||
|
|
||||||
|
| 확인 | 기대 |
|
||||||
|
|------|------|
|
||||||
|
| C0060202-001 기본 탭 | Layout |
|
||||||
|
| Layout | 도곽 + 평면/종단 VP, 주석이 VP 위, 한글(인코딩 패치 병행) |
|
||||||
|
| Model | ~17k 엔티티, 공간 필터로 paper 제외 |
|
||||||
|
| Fit | **현재 가시** 전체 AABB (이중 좌표면 둘 다 포함) |
|
||||||
|
| Model 줌 인 선형/종단 | 글자 크기 ≈ CAD (거대 빌보드 없음) |
|
||||||
|
| BasicSample | Model 기본, 회귀 없음 |
|
||||||
|
| 우하단 Model | 커서 좌표 ≈ CAD UCS (선형 ~-436k 등) |
|
||||||
|
| Fit 후 중심 | 빈 (0,0) WCS 가 아닌 실제 기하 범위 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 원본 `hmwebviewer` 이식 가이드
|
||||||
|
|
||||||
|
| 순서 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| 1 | `cadSpaces.ts` 신규 복사 |
|
||||||
|
| 2 | `Viewer2D.js` — 공간 필터, VP 투영, draw order, minTextH, fit/AABB, 좌표 API **diff 이식** (통째 덮어쓰기 비권장) |
|
||||||
|
| 3 | `Viewer2D.d.ts` 동기화 |
|
||||||
|
| 4 | 호스트 UI: Model/Layout 탭, `pickDefaultSpace` + `spaceHandle` |
|
||||||
|
| 5 | (선택) 우하단 좌표 표시 |
|
||||||
|
| 6 | `fixDwgKoreanText` 는 [한글 문서](./korean-dwg-text-encoding.md) 대로 별도 |
|
||||||
|
| 7 | C0060202-001 + BasicSample 회귀 |
|
||||||
|
|
||||||
|
**하지 말 것**
|
||||||
|
|
||||||
|
| 금지 | 이유 |
|
||||||
|
|------|------|
|
||||||
|
| acadrust 등 오픈소스 파서 수정 | 정책 |
|
||||||
|
| SORTENTS를 위해 파서 개조 | 뷰어 규칙으로 대체 |
|
||||||
|
| Model+Paper 동시 렌더 복귀 | 이슈 #1 재발 |
|
||||||
|
| 밀도 기반 Fit 재도입 | 이슈 #3 에서 철회 |
|
||||||
|
| `minTextH = diag×0.0002` 단독 | 이슈 #4 재발 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 잔여 / 후속 (뷰어 측만)
|
||||||
|
|
||||||
|
1. VIEWPORT 안 솔리드 HATCH/SOLID — 윤곽 위주 근사
|
||||||
|
2. 다중 named Layout 탭 캡션 (블록명 `*Paper_Space*` 이상)
|
||||||
|
3. TILEMODE/CTAB 을 파서 없이 알 수 없으면 현재 휴리스틱 유지
|
||||||
|
4. 비직교 VIEWPORT (3D viewDirection)
|
||||||
|
5. (선택) Model 이중 군집 점프 UI
|
||||||
|
6. (선택) Fit “전체/선택” 분리는 요구 시만
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 한 줄 요약
|
||||||
|
|
||||||
|
> **Model/Paper를 나눠 그리고, Layout은 VIEWPORT로 Model을 투영하되 paper 아래에 깐다.
|
||||||
|
> Fit은 가시 엔티티 bbox. 글자 높이는 CAD median을 넘기지 않는다.
|
||||||
|
> 오픈소스 파서는 수정하지 않으며, draw-order는 Layout 관례로 처리한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 참고 파일 (샘플)
|
||||||
|
|
||||||
|
| 경로 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| [`../src/viewer2d/cadSpaces.ts`](../src/viewer2d/cadSpaces.ts) | 공간 목록·기본 탭·owner 클로저 |
|
||||||
|
| [`../src/viewer2d/Viewer2D.js`](../src/viewer2d/Viewer2D.js) | 필터, VP 투영, draw order, minTextH, fit, 좌표 |
|
||||||
|
| [`../src/viewer2d/Viewer2D.d.ts`](../src/viewer2d/Viewer2D.d.ts) | 타입 |
|
||||||
|
| [`../src/main.ts`](../src/main.ts) | 탭·좌표 UI 글루 |
|
||||||
|
| [`../index.html`](../index.html) | `#spaces`, `#coords` |
|
||||||
|
| [`korean-dwg-text-encoding.md`](./korean-dwg-text-encoding.md) | CP949 한글 깨짐 (병행 필수) |
|
||||||
@@ -197,7 +197,26 @@
|
|||||||
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
<div class="d"><code>isDwg</code> / <code>isDxf</code> / <code>is2D</code> — 의존 zero.</div>
|
||||||
<div class="path">ext2d.ts</div>
|
<div class="path">ext2d.ts</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">propertyInspector <span class="badge j">ui</span></div>
|
||||||
|
<div class="d">엔티티 상세 속성(LINE/CIRCLE/ARC/POLYLINE/TEXT/INSERT 등) 계산 & 렌더.</div>
|
||||||
|
<div class="path">propertyInspector.ts</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">layerPanel <span class="badge j">ui</span></div>
|
||||||
|
<div class="d">LayerPanelManager — 실시간 검색, 가시성 Eye 토글, 전체 켜기/끄기.</div>
|
||||||
|
<div class="path">layerPanel.ts</div>
|
||||||
|
</div>
|
||||||
|
<div class="card j">
|
||||||
|
<div class="k">panelUtils <span class="badge j">ui</span></div>
|
||||||
|
<div class="d">플로팅 UI 창 헤더 드래그 이동, 접기/펼치기, Resizable 유틸.</div>
|
||||||
|
<div class="path">panelUtils.ts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin:16px 0 0; font-size:13px; color:var(--ink-soft)">
|
||||||
|
📖 <b>엔티티 선택 & UI 패널 통합 가이드</b>: <a href="entity-selection-property-layer-ui.md" target="_blank">docs/entity-selection-property-layer-ui.md</a> 참조
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- 02 pipeline -->
|
<!-- 02 pipeline -->
|
||||||
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
<h2><span class="num">02</span> 조립 파이프라인</h2>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function runE2EDataTest() {
|
||||||
|
console.log('=== STARTING DWG DATA & LINETYPE ACCURACY E2E TEST ===');
|
||||||
|
|
||||||
|
const acadrust = await import('./src/viewer2d/acadrust-dwg/acadrust_dwg.js');
|
||||||
|
const wasmBuffer = fs.readFileSync('./src/viewer2d/acadrust-dwg/acadrust_dwg_bg.wasm');
|
||||||
|
await acadrust.default({ module_or_path: wasmBuffer });
|
||||||
|
|
||||||
|
const dwgFile = './dist/samples/C0060202-001-평면및종단면도(1)(대산방향STA.18+492.08-19+411.50).dwg';
|
||||||
|
const buf = fs.readFileSync(dwgFile);
|
||||||
|
const result = acadrust.parse_dwg(new Uint8Array(buf));
|
||||||
|
|
||||||
|
console.log('1. Checking DWG Header & Vars:');
|
||||||
|
console.log(' Global LTSCALE:', result.vars?.ltscale);
|
||||||
|
|
||||||
|
console.log('\n2. Checking DWG Text Styles Table:');
|
||||||
|
const styles = result.tables?.styles || result.tables?.textStyles || [];
|
||||||
|
console.log(` Found ${styles.length} text styles:`);
|
||||||
|
for (const st of styles) {
|
||||||
|
console.log(` - Style [${st.name || st.styleName}]: height=${st.height ?? 0}, font=${st.fontFile || st.font || 'default'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n3. Checking DWG Linetype Table:');
|
||||||
|
const lineTypes = result.tables?.lineTypes || [];
|
||||||
|
console.log(` Found ${lineTypes.length} linetypes in DWG table:`);
|
||||||
|
for (const lt of lineTypes) {
|
||||||
|
if (lt.name && (lt.name.includes('DICH') || lt.name.includes('L06') || lt.name.includes('L02'))) {
|
||||||
|
console.log(` - Linetype [${lt.name}]: desc="${lt.description}", patternLen=${lt.patternLength}, elements=[${lt.pattern?.join(', ')}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n4. Verifying Complex Linetype Math in Viewer2D with DWG Embedded Data:');
|
||||||
|
const { getLinPattern, mergeDwgLinetypePattern } = await import('file:///d:/MYCLAUDE_PROJECT/dwg-dxf-viewer-sample/src/viewer2d/linParser.ts');
|
||||||
|
const targetTypes = ['H-DICHL06', 'H-DICHL06R', 'H-DICHL02R', 'H-DICHL01R', 'H-DICHL05'];
|
||||||
|
|
||||||
|
const dwgLtMap = new Map();
|
||||||
|
for (const lt of lineTypes) {
|
||||||
|
if (lt.name && lt.pattern) dwgLtMap.set(lt.name.toUpperCase(), lt);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of targetTypes) {
|
||||||
|
const dwgLt = dwgLtMap.get(name);
|
||||||
|
const pattern = mergeDwgLinetypePattern(name, dwgLt?.pattern || [], dwgLt?.description);
|
||||||
|
if (!pattern) {
|
||||||
|
console.error(` [FAIL] Pattern ${name} not found in registry!`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`\n * Pattern [${pattern.name}] (patternLength = ${pattern.patternLength}):`);
|
||||||
|
let cumulativeDist = 0;
|
||||||
|
for (let i = 0; i < pattern.elements.length; i++) {
|
||||||
|
const elem = pattern.elements[i];
|
||||||
|
const nextElem = pattern.elements[(i + 1) % pattern.elements.length];
|
||||||
|
if (elem.type === 'dash') {
|
||||||
|
const dLen = elem.val || 0;
|
||||||
|
console.log(` - [${i}] DASH: len=${dLen} (dist: ${cumulativeDist.toFixed(4)} -> ${(cumulativeDist + dLen).toFixed(4)})`);
|
||||||
|
cumulativeDist += dLen;
|
||||||
|
} else if (elem.type === 'gap') {
|
||||||
|
const gLen = Math.abs(elem.val || 0);
|
||||||
|
console.log(` - [${i}] GAP: len=${gLen} (dist: ${cumulativeDist.toFixed(4)} -> ${(cumulativeDist + gLen).toFixed(4)})`);
|
||||||
|
cumulativeDist += gLen;
|
||||||
|
} else if (elem.type === 'shape') {
|
||||||
|
console.log(` - [${i}] SHAPE: name=${elem.shapeName}, scale=${elem.scale}, rot=${elem.rotation} at dist=${cumulativeDist.toFixed(4)}`);
|
||||||
|
} else if (elem.type === 'text') {
|
||||||
|
const textGapLen = (nextElem && nextElem.type === 'gap') ? Math.abs(nextElem.val || 0) : 0;
|
||||||
|
const textMidDist = cumulativeDist + textGapLen / 2;
|
||||||
|
console.log(` - [${i}] TEXT: "${elem.text}", scale=${elem.scale}, style=${elem.style}, yOffset=${elem.yOffset}`);
|
||||||
|
console.log(` -> Text Gap Start: ${cumulativeDist.toFixed(4)}, Gap End: ${(cumulativeDist + textGapLen).toFixed(4)}`);
|
||||||
|
console.log(` -> Text EXACT MIDPOINT: ${textMidDist.toFixed(4)} (Midpoint between previous element and next element)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== E2E DATA ACCURACY TEST COMPLETED SUCCESSFULLY ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
runE2EDataTest().catch(err => {
|
||||||
|
console.error('E2E Test Error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+144
-1
@@ -59,6 +59,105 @@
|
|||||||
#layers h3 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
#layers h3 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||||
#layers label { display: flex; gap: 8px; align-items: center; margin: 4px 0; cursor: pointer; }
|
#layers label { display: flex; gap: 8px; align-items: center; margin: 4px 0; cursor: pointer; }
|
||||||
#layers .swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
#layers .swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
||||||
|
#spaces {
|
||||||
|
display: none; align-items: center; gap: 0;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#spaces button {
|
||||||
|
border: none; border-radius: 0; border-right: 1px solid var(--line);
|
||||||
|
background: transparent; padding: 8px 12px; margin: 0;
|
||||||
|
}
|
||||||
|
#spaces button:last-child { border-right: none; }
|
||||||
|
#spaces button.active {
|
||||||
|
background: #238636; border-color: #2ea043; color: #fff;
|
||||||
|
}
|
||||||
|
#spaces button:hover:not(.active) { border-color: transparent; color: var(--accent); }
|
||||||
|
/* CAD 좌표 — 항상 우측 하단 (툴바 status 와 별개) */
|
||||||
|
#coords {
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
left: auto;
|
||||||
|
top: auto;
|
||||||
|
z-index: 20;
|
||||||
|
font: 13px/1.35 var(--mono);
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(13,17,23,.95);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-width: 240px;
|
||||||
|
text-align: left;
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 8px 28px rgba(0,0,0,.45);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#coords .lbl { color: var(--accent); margin-right: 6px; font-weight: 600; }
|
||||||
|
#coords .sep { color: var(--line); margin: 0 12px; }
|
||||||
|
/* 공통 패널 윈도우 스타일 (Properties & Layers - Draggable & Resizable) */
|
||||||
|
.cad-panel {
|
||||||
|
position: fixed; top: 56px; width: 320px; height: 480px;
|
||||||
|
min-width: 250px; min-height: 140px; max-width: 90vw; max-height: 90vh;
|
||||||
|
z-index: 15; background: rgba(22,27,34,.96); backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.5);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden; resize: both;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.cad-panel.collapsed {
|
||||||
|
height: 42px !important;
|
||||||
|
min-height: 42px !important;
|
||||||
|
max-height: 42px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
resize: none !important;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr {
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: rgba(13,17,23,.7); position: sticky; top: 0; z-index: 5;
|
||||||
|
cursor: grab; user-select: none; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr:active { cursor: grabbing; }
|
||||||
|
.cad-panel .hdr h3 { margin: 0; font-size: 13px; font-weight: 700; color: var(--ink); display: flex; align-items: center; gap: 6px; }
|
||||||
|
.cad-panel .hdr-actions { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.cad-panel .hdr-btn {
|
||||||
|
background: none; border: none; color: var(--muted); font-size: 13px;
|
||||||
|
cursor: pointer; padding: 2px 6px; border-radius: 4px; display: inline-flex;
|
||||||
|
align-items: center; justify-content: center; transition: all .15s;
|
||||||
|
}
|
||||||
|
.cad-panel .hdr-btn:hover { color: var(--ink); background: rgba(255,255,255,.1); }
|
||||||
|
|
||||||
|
#properties { right: 12px; }
|
||||||
|
#layers { right: 344px; display: none; }
|
||||||
|
|
||||||
|
#propBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
#layerBody { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
#layerList { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
#zoomSelBtn {
|
||||||
|
font: 11px var(--sans); padding: 3px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--accent); background: rgba(63,185,80,.15); color: var(--accent);
|
||||||
|
cursor: pointer; display: none; align-items: center; gap: 4px; transition: all .15s;
|
||||||
|
}
|
||||||
|
#zoomSelBtn:hover { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
.layer-search-wrap { padding: 8px 10px; border-bottom: 1px solid var(--line); background: rgba(13,17,23,.3); }
|
||||||
|
.layer-search-input {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--line);
|
||||||
|
border-radius: 6px; padding: 6px 10px; color: var(--ink); font-size: 12px;
|
||||||
|
outline: none; transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.layer-search-input:focus { border-color: var(--accent); }
|
||||||
|
.layer-sub-actions {
|
||||||
|
display: flex; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(22,27,34,.5);
|
||||||
|
}
|
||||||
|
.layer-sub-btn {
|
||||||
|
font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid var(--line);
|
||||||
|
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
|
||||||
|
}
|
||||||
|
.layer-sub-btn:hover { color: var(--ink); border-color: var(--accent); background: rgba(63,185,80,.1); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -70,14 +169,58 @@
|
|||||||
<input id="file" type="file" accept=".dwg,.dxf" />
|
<input id="file" type="file" accept=".dwg,.dxf" />
|
||||||
</label>
|
</label>
|
||||||
<button id="sample" class="primary wasm" type="button" title="acadrust-dwg WASM">Sample DWG</button>
|
<button id="sample" class="primary wasm" type="button" title="acadrust-dwg WASM">Sample DWG</button>
|
||||||
|
<button id="sampleCivil" class="primary wasm" type="button" title="Civil Plan & Profile DWG">평면및종단면도.dwg</button>
|
||||||
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
|
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
|
||||||
|
<div id="spaces" title="Model Space / Paper Space (Layout)"></div>
|
||||||
<button id="fit" type="button">Fit (F)</button>
|
<button id="fit" type="button">Fit (F)</button>
|
||||||
<button id="theme" type="button">Theme</button>
|
<button id="theme" type="button">Theme</button>
|
||||||
<button id="layersBtn" type="button">Layers</button>
|
<button id="layersBtn" type="button">Layers</button>
|
||||||
|
<button id="propsBtn" type="button">Props (속성)</button>
|
||||||
<div id="status">ready</div>
|
<div id="status">ready</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="layers"><h3>Layers</h3><div id="layerList"></div></div>
|
<div id="coords" title="마우스 위치 CAD 좌표 (Model=UCS, Layout=Paper 도면단위)">
|
||||||
|
<span class="lbl">X</span><span id="coordX">—</span>
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<span class="lbl">Y</span><span id="coordY">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 레이어 (Layers) 관리 패널 -->
|
||||||
|
<div id="layers" class="cad-panel">
|
||||||
|
<div class="hdr">
|
||||||
|
<h3>
|
||||||
|
<span>🎨</span> 레이어 (Layers)
|
||||||
|
<span id="layerCount" style="font-family:var(--mono);font-size:11px;color:var(--muted);background:rgba(255,255,255,.08);padding:1px 6px;border-radius:4px;font-weight:400"></span>
|
||||||
|
</h3>
|
||||||
|
<div class="hdr-actions">
|
||||||
|
<button id="collapseLayersBtn" class="hdr-btn collapse-btn" type="button" title="접기">▾</button>
|
||||||
|
<button id="closeLayersBtn" class="hdr-btn" type="button" title="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="layerBody">
|
||||||
|
<div class="layer-search-wrap">
|
||||||
|
<input id="layerSearch" class="layer-search-input" type="text" placeholder="🔍 레이어 검색..." />
|
||||||
|
</div>
|
||||||
|
<div class="layer-sub-actions">
|
||||||
|
<button id="layerShowAll" class="layer-sub-btn" type="button">전체 켜기</button>
|
||||||
|
<button id="layerHideAll" class="layer-sub-btn" type="button">전체 끄기</button>
|
||||||
|
</div>
|
||||||
|
<div id="layerList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 속성 (Properties) 패널 -->
|
||||||
|
<div id="properties" class="cad-panel">
|
||||||
|
<div class="hdr">
|
||||||
|
<h3><span>📋</span> 속성 (Properties)</h3>
|
||||||
|
<div class="hdr-actions">
|
||||||
|
<button id="zoomSelBtn" type="button" title="선택 요소 위치로 확대">🔍 선택 확대</button>
|
||||||
|
<button id="collapsePropsBtn" class="hdr-btn collapse-btn" type="button" title="접기">▾</button>
|
||||||
|
<button id="closePropsBtn" class="hdr-btn" type="button" title="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="propBody"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside id="recipe">
|
<aside id="recipe">
|
||||||
<h3>Module recipe</h3>
|
<h3>Module recipe</h3>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Layer Panel manager for dwg-dxf-viewer-sample.
|
||||||
|
* Renders layer items, color swatches, entity counts, search filters, and visibility toggles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Viewer2D } from './viewer2d/Viewer2D';
|
||||||
|
|
||||||
|
export interface LayerItem {
|
||||||
|
name: string;
|
||||||
|
colorHex: string;
|
||||||
|
count: number;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LayerPanelManager {
|
||||||
|
private viewer: Viewer2D;
|
||||||
|
private listEl: HTMLElement;
|
||||||
|
private countEl: HTMLElement | null;
|
||||||
|
private searchInputEl: HTMLInputElement | null;
|
||||||
|
private hiddenLayers: Set<string> = new Set();
|
||||||
|
private searchQuery: string = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
viewer: Viewer2D,
|
||||||
|
listEl: HTMLElement,
|
||||||
|
options?: {
|
||||||
|
countEl?: HTMLElement | null;
|
||||||
|
searchInputEl?: HTMLInputElement | null;
|
||||||
|
showAllBtnEl?: HTMLElement | null;
|
||||||
|
hideAllBtnEl?: HTMLElement | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.viewer = viewer;
|
||||||
|
this.listEl = listEl;
|
||||||
|
this.countEl = options?.countEl || null;
|
||||||
|
this.searchInputEl = options?.searchInputEl || null;
|
||||||
|
|
||||||
|
if (this.searchInputEl) {
|
||||||
|
this.searchInputEl.addEventListener('input', (e) => {
|
||||||
|
this.searchQuery = (e.target as HTMLInputElement).value.trim().toLowerCase();
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.showAllBtnEl) {
|
||||||
|
options.showAllBtnEl.addEventListener('click', () => this.showAllLayers());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.hideAllBtnEl) {
|
||||||
|
options.hideAllBtnEl.addEventListener('click', () => this.hideAllLayers());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public hideAllLayers(): void {
|
||||||
|
const layers = this.viewer.getLayerInfo() || [];
|
||||||
|
for (const l of layers) {
|
||||||
|
this.hiddenLayers.add(l.name);
|
||||||
|
}
|
||||||
|
this.viewer.setHiddenLayers(this.hiddenLayers);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
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.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): void {
|
||||||
|
if (!this.listEl) return;
|
||||||
|
|
||||||
|
const layers: LayerItem[] = this.viewer.getLayerInfo() || [];
|
||||||
|
|
||||||
|
if (this.countEl) {
|
||||||
|
this.countEl.textContent = `${layers.length}개`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layers.length) {
|
||||||
|
this.listEl.innerHTML = `
|
||||||
|
<div style="padding:30px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||||
|
도면에 레이어 정보가 없습니다
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = layers.filter((l) =>
|
||||||
|
l.name.toLowerCase().includes(this.searchQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
this.listEl.innerHTML = `
|
||||||
|
<div style="padding:24px 16px;text-align:center;color:var(--muted);font-size:12px">
|
||||||
|
검색 결과가 없습니다 ("${escapeHtml(this.searchQuery)}")
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listEl.innerHTML = filtered
|
||||||
|
.map((l) => {
|
||||||
|
const isVisible = !this.hiddenLayers.has(l.name);
|
||||||
|
const opacity = isVisible ? '1' : '0.4';
|
||||||
|
const eyeIcon = isVisible ? '👁️' : '🕶️';
|
||||||
|
const eyeTitle = isVisible ? '숨기기' : '표시하기';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="layer-row" data-name="${escapeHtml(l.name)}"
|
||||||
|
style="display:flex;align-items:center;gap:10px;padding:7px 10px;border-bottom:1px solid var(--line);opacity:${opacity};transition:opacity .15s;user-select:none">
|
||||||
|
<span style="width:10px;height:10px;border-radius:3px;background:${l.colorHex || '#888'};flex-shrink:0;box-shadow:0 0 0 1px rgba(255,255,255,.15)"></span>
|
||||||
|
<span class="mono" style="flex:1;font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escapeHtml(l.name)}">
|
||||||
|
${escapeHtml(l.name)}
|
||||||
|
</span>
|
||||||
|
<span class="mono" style="font-size:11px;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px;flex-shrink:0">
|
||||||
|
${l.count}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="eye-btn" data-toggle="${escapeHtml(l.name)}" title="${eyeTitle}"
|
||||||
|
style="background:none;border:none;cursor:pointer;font-size:13px;padding:2px 4px;border-radius:4px;color:var(--ink);transition:transform .1s">
|
||||||
|
${eyeIcon}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Bind eye button click events
|
||||||
|
this.listEl.querySelectorAll('.eye-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const layerName = (btn as HTMLElement).dataset.toggle;
|
||||||
|
if (layerName) {
|
||||||
|
this.toggleLayer(layerName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also toggle when clicking row
|
||||||
|
this.listEl.querySelectorAll('.layer-row').forEach((row) => {
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('.eye-btn')) return;
|
||||||
|
const layerName = (row as HTMLElement).dataset.name;
|
||||||
|
if (layerName) {
|
||||||
|
this.toggleLayer(layerName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
+139
-30
@@ -24,7 +24,11 @@ import { Viewer2D } from './viewer2d/Viewer2D.js';
|
|||||||
import { parseDwgBuffer, type CadParseResult } from './viewer2d/dwgParser';
|
import { parseDwgBuffer, type CadParseResult } from './viewer2d/dwgParser';
|
||||||
import { parseDxfBuffer } from './viewer2d/parseDxf';
|
import { parseDxfBuffer } from './viewer2d/parseDxf';
|
||||||
import { is2D, isDxf, isDwg } from './viewer2d/ext2d';
|
import { is2D, isDxf, isDwg } from './viewer2d/ext2d';
|
||||||
|
import { listCadSpaces, pickDefaultSpace } from './viewer2d/cadSpaces';
|
||||||
import { setDefaultFontUrl } from './viewer2d/slugText';
|
import { setDefaultFontUrl } from './viewer2d/slugText';
|
||||||
|
import { renderPropertyInspector } from './propertyInspector';
|
||||||
|
import { LayerPanelManager } from './layerPanel';
|
||||||
|
import { makePanelDraggableAndCollapsible } from './panelUtils';
|
||||||
|
|
||||||
// parseDxfBuffer 내부:
|
// parseDxfBuffer 내부:
|
||||||
// dxf-parser → HatchHandler → dxfAdapter
|
// dxf-parser → HatchHandler → dxfAdapter
|
||||||
@@ -37,24 +41,110 @@ setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
|||||||
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
|
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
|
||||||
const app = document.getElementById('app')!;
|
const app = document.getElementById('app')!;
|
||||||
const statusEl = document.getElementById('status')!;
|
const statusEl = document.getElementById('status')!;
|
||||||
const layerList = document.getElementById('layerList')!;
|
const spacesBar = document.getElementById('spaces')!;
|
||||||
|
|
||||||
|
// Layer panel elements
|
||||||
const layersPanel = document.getElementById('layers')!;
|
const layersPanel = document.getElementById('layers')!;
|
||||||
|
const layerList = document.getElementById('layerList')!;
|
||||||
|
const layerCount = document.getElementById('layerCount')!;
|
||||||
|
const layerSearch = document.getElementById('layerSearch') as HTMLInputElement;
|
||||||
|
const layerShowAll = document.getElementById('layerShowAll')!;
|
||||||
|
const layerHideAll = document.getElementById('layerHideAll')!;
|
||||||
|
const closeLayersBtn = document.getElementById('closeLayersBtn')!;
|
||||||
|
const collapseLayersBtn = document.getElementById('collapseLayersBtn')!;
|
||||||
|
|
||||||
|
// Properties panel elements
|
||||||
|
const propsPanel = document.getElementById('properties')!;
|
||||||
|
const propBody = document.getElementById('propBody')!;
|
||||||
|
const propsBtn = document.getElementById('propsBtn')!;
|
||||||
|
const zoomSelBtn = document.getElementById('zoomSelBtn')!;
|
||||||
|
const closePropsBtn = document.getElementById('closePropsBtn')!;
|
||||||
|
const collapsePropsBtn = document.getElementById('collapsePropsBtn')!;
|
||||||
|
|
||||||
function setStatus(msg: string, err = false) {
|
function setStatus(msg: string, err = false) {
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
statusEl.classList.toggle('err', err);
|
statusEl.classList.toggle('err', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make floating UI panels draggable & collapsible
|
||||||
|
makePanelDraggableAndCollapsible(propsPanel, { collapseBtnEl: collapsePropsBtn });
|
||||||
|
makePanelDraggableAndCollapsible(layersPanel, { collapseBtnEl: collapseLayersBtn });
|
||||||
|
|
||||||
|
// Initial Property Inspector rendering (empty state)
|
||||||
|
renderPropertyInspector(propBody, null, { zoomBtnEl: zoomSelBtn });
|
||||||
|
|
||||||
const viewer = new Viewer2D(app);
|
const viewer = new Viewer2D(app);
|
||||||
|
|
||||||
|
// Layer Panel Manager
|
||||||
|
const layerManager = new LayerPanelManager(viewer, layerList, {
|
||||||
|
countEl: layerCount,
|
||||||
|
searchInputEl: layerSearch,
|
||||||
|
showAllBtnEl: layerShowAll,
|
||||||
|
hideAllBtnEl: layerHideAll,
|
||||||
|
});
|
||||||
|
|
||||||
viewer.onSelect((entity: unknown) => {
|
viewer.onSelect((entity: unknown) => {
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
setStatus('deselected');
|
setStatus('deselected');
|
||||||
|
renderPropertyInspector(propBody, null, { zoomBtnEl: zoomSelBtn });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Show properties panel automatically on selection
|
||||||
|
propsPanel.style.display = 'flex';
|
||||||
|
renderPropertyInspector(propBody, entity, { zoomBtnEl: zoomSelBtn });
|
||||||
|
|
||||||
const e = entity as { type?: string; layer?: string; handle?: { value?: string } };
|
const e = entity as { type?: string; layer?: string; handle?: { value?: string } };
|
||||||
setStatus(`select ${e.type ?? '?'} · layer=${e.layer ?? '-'} · h=${e.handle?.value ?? '-'}`);
|
setStatus(`select ${e.type ?? '?'} · layer=${e.layer ?? '-'} · h=${e.handle?.value ?? '-'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CAD coordinates under the cursor (Model: UCS like AutoCAD; Layout: paper units)
|
||||||
|
const coordXEl = document.getElementById('coordX')!;
|
||||||
|
const coordYEl = document.getElementById('coordY')!;
|
||||||
|
function formatCadCoord(v: number): string {
|
||||||
|
const a = Math.abs(v);
|
||||||
|
if (a >= 1e6) return v.toFixed(1);
|
||||||
|
if (a >= 1e3) return v.toFixed(2);
|
||||||
|
if (a >= 1) return v.toFixed(3);
|
||||||
|
return v.toFixed(4);
|
||||||
|
}
|
||||||
|
viewer.onPointerWorld((p) => {
|
||||||
|
if (!p) {
|
||||||
|
coordXEl.textContent = '—';
|
||||||
|
coordYEl.textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
coordXEl.textContent = formatCadCoord(p.x);
|
||||||
|
coordYEl.textContent = formatCadCoord(p.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Model / Layout tab strip after each load or space switch. */
|
||||||
|
function fillSpaces() {
|
||||||
|
const spaces = viewer.getSpaces();
|
||||||
|
const active = viewer.getActiveSpace();
|
||||||
|
spacesBar.innerHTML = '';
|
||||||
|
if (spaces.length < 2 && !(spaces[0]?.entityCount)) {
|
||||||
|
spacesBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spacesBar.style.display = 'flex';
|
||||||
|
for (const s of spaces) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = `${s.label}${s.entityCount ? ` (${s.entityCount})` : ''}`;
|
||||||
|
btn.title = `${s.name} · handle ${s.handleHex}`;
|
||||||
|
if (s.handleHex === active) btn.classList.add('active');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (s.handleHex === viewer.getActiveSpace()) return;
|
||||||
|
viewer.setSpace(s.handleHex);
|
||||||
|
fillSpaces();
|
||||||
|
fillLayers();
|
||||||
|
viewer.fit();
|
||||||
|
setStatus(`space ${s.label} · ${s.entityCount} entities`);
|
||||||
|
});
|
||||||
|
spacesBar.append(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── ④ 파서 조합 ─────────────────────────────────────────────────
|
// ── ④ 파서 조합 ─────────────────────────────────────────────────
|
||||||
async function parseCad(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
async function parseCad(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
||||||
if (isDxf(name)) return parseDxfBuffer(buf);
|
if (isDxf(name)) return parseDxfBuffer(buf);
|
||||||
@@ -68,7 +158,9 @@ async function parseCad(buf: ArrayBuffer, name: string): Promise<CadParseResult>
|
|||||||
|
|
||||||
async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
||||||
const result = await parseCad(buf, name);
|
const result = await parseCad(buf, name);
|
||||||
viewer.load(result);
|
const spaces = listCadSpaces(result);
|
||||||
|
const def = pickDefaultSpace(spaces);
|
||||||
|
viewer.load(result, { spaceHandle: def?.handleHex ?? null });
|
||||||
viewer.resize();
|
viewer.resize();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -87,33 +179,16 @@ async function loadFile(file: File) {
|
|||||||
|
|
||||||
// ── ⑤ 호스트 UI 글루 ────────────────────────────────────────────
|
// ── ⑤ 호스트 UI 글루 ────────────────────────────────────────────
|
||||||
function fillLayers() {
|
function fillLayers() {
|
||||||
layerList.innerHTML = '';
|
layerManager.update();
|
||||||
for (const L of viewer.getLayerInfo()) {
|
|
||||||
const label = document.createElement('label');
|
|
||||||
label.dataset.name = L.name;
|
|
||||||
const sw = document.createElement('span');
|
|
||||||
sw.className = 'swatch';
|
|
||||||
sw.style.background = L.colorHex || '#888';
|
|
||||||
const cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.checked = L.visible;
|
|
||||||
cb.addEventListener('change', () => {
|
|
||||||
const next = new Set<string>();
|
|
||||||
layerList.querySelectorAll('label').forEach((row) => {
|
|
||||||
const input = row.querySelector('input') as HTMLInputElement;
|
|
||||||
if (!input.checked) next.add(row.dataset.name!);
|
|
||||||
});
|
|
||||||
viewer.setHiddenLayers(next);
|
|
||||||
});
|
|
||||||
const text = document.createElement('span');
|
|
||||||
text.textContent = `${L.name} (${L.count})`;
|
|
||||||
label.append(sw, cb, text);
|
|
||||||
layerList.append(label);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function afterLoad(name: string, result: CadParseResult) {
|
async function afterLoad(name: string, result: CadParseResult) {
|
||||||
setStatus(`✓ ${name} · ${result.entities?.length ?? 0} entities`);
|
// Force cache invalidation for OLE image rendering
|
||||||
|
const spaces = listCadSpaces(result);
|
||||||
|
const active = spaces.find((s) => s.handleHex === viewer.getActiveSpace());
|
||||||
|
const spaceNote = active ? ` · ${active.label}` : '';
|
||||||
|
setStatus(`✓ ${name} · ${result.entities?.length ?? 0} entities${spaceNote}`);
|
||||||
|
fillSpaces();
|
||||||
fillLayers();
|
fillLayers();
|
||||||
viewer.fit();
|
viewer.fit();
|
||||||
}
|
}
|
||||||
@@ -128,9 +203,14 @@ async function run(label: string, job: () => Promise<CadParseResult>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const civilFile = '/samples/civil.dwg';
|
||||||
|
|
||||||
document.getElementById('sample')!.addEventListener('click', () => {
|
document.getElementById('sample')!.addEventListener('click', () => {
|
||||||
void run('BasicSample.dwg', () => loadUrl('/samples/BasicSample.dwg', 'BasicSample.dwg'));
|
void run('BasicSample.dwg', () => loadUrl('/samples/BasicSample.dwg', 'BasicSample.dwg'));
|
||||||
});
|
});
|
||||||
|
document.getElementById('sampleCivil')?.addEventListener('click', () => {
|
||||||
|
void run('C0060202-001-평면및종단면도.dwg', () => loadUrl(civilFile, 'C0060202-001-평면및종단면도.dwg'));
|
||||||
|
});
|
||||||
document.getElementById('sampleDxf')!.addEventListener('click', () => {
|
document.getElementById('sampleDxf')!.addEventListener('click', () => {
|
||||||
void run('simple.dxf', () => loadUrl('/samples/simple.dxf', 'simple.dxf'));
|
void run('simple.dxf', () => loadUrl('/samples/simple.dxf', 'simple.dxf'));
|
||||||
});
|
});
|
||||||
@@ -140,14 +220,38 @@ document.getElementById('file')!.addEventListener('change', (ev) => {
|
|||||||
void run(file.name, () => loadFile(file));
|
void run(file.name, () => loadFile(file));
|
||||||
(ev.target as HTMLInputElement).value = '';
|
(ev.target as HTMLInputElement).value = '';
|
||||||
});
|
});
|
||||||
document.getElementById('fit')!.addEventListener('click', () => viewer.fit());
|
|
||||||
let dark = true;
|
let dark = true;
|
||||||
|
document.getElementById('fit')?.addEventListener('click', () => {
|
||||||
|
viewer.fit();
|
||||||
|
});
|
||||||
document.getElementById('theme')!.addEventListener('click', () => {
|
document.getElementById('theme')!.addEventListener('click', () => {
|
||||||
dark = !dark;
|
dark = !dark;
|
||||||
viewer.setTheme(dark);
|
viewer.setTheme(dark);
|
||||||
|
fillLayers();
|
||||||
});
|
});
|
||||||
document.getElementById('layersBtn')!.addEventListener('click', () => {
|
document.getElementById('layersBtn')!.addEventListener('click', () => {
|
||||||
layersPanel.style.display = layersPanel.style.display === 'block' ? 'none' : 'block';
|
layersPanel.style.display = layersPanel.style.display === 'flex' ? 'none' : 'flex';
|
||||||
|
});
|
||||||
|
closeLayersBtn.addEventListener('click', () => {
|
||||||
|
layersPanel.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
propsBtn.addEventListener('click', () => {
|
||||||
|
propsPanel.style.display = propsPanel.style.display === 'none' ? 'flex' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
zoomSelBtn.addEventListener('click', () => {
|
||||||
|
viewer.zoomToSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
closePropsBtn.addEventListener('click', () => {
|
||||||
|
propsPanel.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.key === 'f' || e.key === 'F') && (e.target as HTMLElement)?.tagName !== 'INPUT') {
|
||||||
|
viewer.fit();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('dragover', (e) => e.preventDefault());
|
window.addEventListener('dragover', (e) => e.preventDefault());
|
||||||
@@ -158,7 +262,12 @@ window.addEventListener('drop', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const model = new URLSearchParams(location.search).get('model');
|
const model = new URLSearchParams(location.search).get('model');
|
||||||
if (model) void run(model, () => loadUrl(model));
|
if (model) {
|
||||||
else setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
|
void run(model, () => loadUrl(model));
|
||||||
|
} else {
|
||||||
|
setStatus('ready');
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => viewer.resize());
|
window.addEventListener('resize', () => viewer.resize());
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Utility helper to make floating UI panels draggable and collapsible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let globalMaxZIndex = 30;
|
||||||
|
|
||||||
|
export interface PanelControlOptions {
|
||||||
|
handleEl?: HTMLElement | null;
|
||||||
|
collapseBtnEl?: HTMLElement | null;
|
||||||
|
bodyEl?: HTMLElement | null;
|
||||||
|
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makePanelDraggableAndCollapsible(
|
||||||
|
panelEl: HTMLElement,
|
||||||
|
options?: PanelControlOptions
|
||||||
|
): { toggleCollapse: () => void; isCollapsed: () => boolean } {
|
||||||
|
if (!panelEl) return { toggleCollapse: () => {}, isCollapsed: () => false };
|
||||||
|
|
||||||
|
const handleEl = (options?.handleEl || panelEl.querySelector('.hdr') || panelEl) as HTMLElement;
|
||||||
|
const collapseBtnEl = (options?.collapseBtnEl || panelEl.querySelector('.collapse-btn')) as HTMLElement | null;
|
||||||
|
const bodyEl = (options?.bodyEl || panelEl.querySelector('#propBody') || panelEl.querySelector('#layerBody') || panelEl.children[1]) as HTMLElement | null;
|
||||||
|
|
||||||
|
let isCollapsed = false;
|
||||||
|
let isDragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let initialLeft = 0;
|
||||||
|
let initialTop = 0;
|
||||||
|
|
||||||
|
// Set initial drag cursor
|
||||||
|
if (handleEl) {
|
||||||
|
handleEl.style.cursor = 'grab';
|
||||||
|
handleEl.style.userSelect = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus panel to top z-index when clicked anywhere
|
||||||
|
panelEl.addEventListener('pointerdown', () => {
|
||||||
|
globalMaxZIndex++;
|
||||||
|
panelEl.style.zIndex = String(globalMaxZIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dragging handler
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
// Ignore clicks on buttons, inputs, or interactive controls inside the header
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('button, input, select, a, label, .hdr-actions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging = true;
|
||||||
|
globalMaxZIndex++;
|
||||||
|
panelEl.style.zIndex = String(globalMaxZIndex);
|
||||||
|
if (handleEl) handleEl.style.cursor = 'grabbing';
|
||||||
|
|
||||||
|
const rect = panelEl.getBoundingClientRect();
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
initialLeft = rect.left;
|
||||||
|
initialTop = rect.top;
|
||||||
|
|
||||||
|
// Convert CSS right/bottom to explicit left/top positioning on first drag
|
||||||
|
panelEl.style.left = `${initialLeft}px`;
|
||||||
|
panelEl.style.top = `${initialTop}px`;
|
||||||
|
panelEl.style.right = 'auto';
|
||||||
|
panelEl.style.bottom = 'auto';
|
||||||
|
|
||||||
|
document.addEventListener('pointermove', onPointerMove);
|
||||||
|
document.addEventListener('pointerup', onPointerUp);
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
const dy = e.clientY - startY;
|
||||||
|
|
||||||
|
let newLeft = initialLeft + dx;
|
||||||
|
let newTop = initialTop + dy;
|
||||||
|
|
||||||
|
// Clamp within window boundaries so panel doesn't disappear off-screen
|
||||||
|
const maxLeft = Math.max(10, window.innerWidth - panelEl.offsetWidth - 10);
|
||||||
|
const maxTop = Math.max(10, window.innerHeight - panelEl.offsetHeight - 10);
|
||||||
|
|
||||||
|
newLeft = Math.max(10, Math.min(newLeft, maxLeft));
|
||||||
|
newTop = Math.max(10, Math.min(newTop, maxTop));
|
||||||
|
|
||||||
|
panelEl.style.left = `${newLeft}px`;
|
||||||
|
panelEl.style.top = `${newTop}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
isDragging = false;
|
||||||
|
if (handleEl) handleEl.style.cursor = 'grab';
|
||||||
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onPointerUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (handleEl) {
|
||||||
|
handleEl.addEventListener('pointerdown', onPointerDown as EventListener);
|
||||||
|
|
||||||
|
// Double-click on header to toggle collapse/expand
|
||||||
|
handleEl.addEventListener('dblclick', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('button, input, select, a, label')) return;
|
||||||
|
toggleCollapseState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastHeight = '';
|
||||||
|
|
||||||
|
// Collapse / Expand toggle function
|
||||||
|
const toggleCollapseState = () => {
|
||||||
|
isCollapsed = !isCollapsed;
|
||||||
|
panelEl.classList.toggle('collapsed', isCollapsed);
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
lastHeight = panelEl.style.height || `${panelEl.offsetHeight}px`;
|
||||||
|
panelEl.style.height = '42px';
|
||||||
|
if (bodyEl) bodyEl.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
panelEl.style.height = lastHeight || '480px';
|
||||||
|
if (bodyEl) bodyEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapseBtnEl) {
|
||||||
|
collapseBtnEl.textContent = isCollapsed ? '▸' : '▾';
|
||||||
|
collapseBtnEl.setAttribute('title', isCollapsed ? '펼치기' : '접기');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.onCollapseToggle) {
|
||||||
|
options.onCollapseToggle(isCollapsed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (collapseBtnEl) {
|
||||||
|
collapseBtnEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleCollapseState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleCollapse: toggleCollapseState,
|
||||||
|
isCollapsed: () => isCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 general: [string, string][] = [
|
||||||
|
['유형 (Type)', type],
|
||||||
|
['핸들 (Handle)', String(handle)],
|
||||||
|
['레이어 (Layer)', String(layer)],
|
||||||
|
['색상 (Color)', color],
|
||||||
|
['선종류 (Linetype)', String(linetype)],
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TEXT':
|
||||||
|
case 'MTEXT':
|
||||||
|
case 'ATTRIB': {
|
||||||
|
const content = d.text ?? entity.text ?? d.textValue ?? '-';
|
||||||
|
const height = d.textHeight ?? d.height ?? entity.textHeight;
|
||||||
|
const rot = d.rotationAngle ?? d.rotation ?? 0;
|
||||||
|
const pos = d.insertionPoint ?? d.insertionPt ?? d.alignmentPt ?? d.pos;
|
||||||
|
|
||||||
|
geometry.push(['텍스트 내용 (Content)', String(content)]);
|
||||||
|
if (height != null) geometry.push(['높이 (Height)', f3(height)]);
|
||||||
|
geometry.push(['회전각 (Rotation)', deg(rot)]);
|
||||||
|
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INSERT': {
|
||||||
|
const name = d.name ?? d.blockName ?? entity.name ?? '-';
|
||||||
|
const pos = d.insertionPoint ?? d.insertionPt ?? entity.insertionPt;
|
||||||
|
const sx = d.xScale ?? d.scale?.x ?? 1;
|
||||||
|
const sy = d.yScale ?? d.scale?.y ?? 1;
|
||||||
|
const sz = d.zScale ?? d.scale?.z ?? 1;
|
||||||
|
const rot = d.rotation ?? 0;
|
||||||
|
|
||||||
|
geometry.push(['블록 이름 (Block)', String(name)]);
|
||||||
|
if (pos) geometry.push(['삽입점 (Position)', pt(pos)]);
|
||||||
|
geometry.push(['축척 (Scale X,Y,Z)', `${f2(sx)}, ${f2(sy)}, ${f2(sz)}`]);
|
||||||
|
geometry.push(['회전각 (Rotation)', deg(rot)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'HATCH': {
|
||||||
|
const isSolid = d.solidFill ?? entity.solidFill ?? false;
|
||||||
|
const pattern = d.patternName ?? d.name ?? (isSolid ? 'SOLID' : 'HATCH');
|
||||||
|
const paths = d.paths ?? entity.paths ?? [];
|
||||||
|
|
||||||
|
geometry.push(['패턴 (Pattern)', String(pattern)]);
|
||||||
|
geometry.push(['채우기 유형 (Type)', isSolid ? '단색 채우기 (Solid)' : '패턴 (Pattern)']);
|
||||||
|
geometry.push(['경계 루프 수 (Loops)', String(paths.length)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'DIMENSION':
|
||||||
|
case 'DIMENSION_LINEAR':
|
||||||
|
case 'DIMENSION_ALIGNED':
|
||||||
|
case 'DIMENSION_RADIUS':
|
||||||
|
case 'DIMENSION_DIAMETER':
|
||||||
|
case 'DIMENSION_ANG_3PT':
|
||||||
|
case 'DIMENSION_ANG_2LN':
|
||||||
|
case 'DIMENSION_ORDINATE': {
|
||||||
|
const meas = d.actualMeasurement ?? entity.actualMeasurement;
|
||||||
|
const textVal = d.userText || (meas != null ? f3(meas) : '-');
|
||||||
|
const midPt = d.textMidPt ?? entity.textMidPt;
|
||||||
|
|
||||||
|
geometry.push(['치수 유형 (Dim Type)', type]);
|
||||||
|
if (meas != null) geometry.push(['측정값 (Measurement)', f3(meas)]);
|
||||||
|
geometry.push(['표시 텍스트 (Text)', String(textVal)]);
|
||||||
|
if (midPt) geometry.push(['텍스트 위치 (Text Mid)', pt(midPt)]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ELLIPSE': {
|
||||||
|
if (d.center) {
|
||||||
|
geometry.push(['중심점 (Center)', pt(d.center)]);
|
||||||
|
if (d.majorAxis) geometry.push(['주축 벡터 (Major Axis)', pt(d.majorAxis)]);
|
||||||
|
if (d.axisRatio != null || d.ratio != null) {
|
||||||
|
geometry.push(['단축 비율 (Ratio)', f3(d.axisRatio ?? d.ratio)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
let count = 0;
|
||||||
|
for (const [k, v] of Object.entries(d)) {
|
||||||
|
if (typeof v === 'object' || v == null || typeof v === 'function') continue;
|
||||||
|
geometry.push([k, String(v)]);
|
||||||
|
count++;
|
||||||
|
if (count >= 12) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${type}${handle !== '-' ? ' · #' + handle : ''}`;
|
||||||
|
return { id, type, handle: String(handle), layer: String(layer), general, geometry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPropertyInspector(
|
||||||
|
bodyEl: HTMLElement,
|
||||||
|
entity: any,
|
||||||
|
options?: { onZoomToSelection?: () => void; zoomBtnEl?: HTMLElement | null }
|
||||||
|
): void {
|
||||||
|
if (!bodyEl) return;
|
||||||
|
|
||||||
|
if (!entity) {
|
||||||
|
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'none';
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<div style="padding:40px 20px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6">
|
||||||
|
<div style="font-size:24px;margin-bottom:8px;opacity:.5">🔍</div>
|
||||||
|
도면에서 객체(엔티티)를 클릭하면<br/>상세 속성이 표시됩니다
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.zoomBtnEl) options.zoomBtnEl.style.display = 'inline-flex';
|
||||||
|
|
||||||
|
const prop = formatEntityProperties(entity);
|
||||||
|
|
||||||
|
const renderRows = (rows: [string, string][]) =>
|
||||||
|
rows
|
||||||
|
.map(
|
||||||
|
([k, v]) => `
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)">
|
||||||
|
<span style="font-size:12px;color:var(--muted);flex-shrink:0">${escapeHtml(k)}</span>
|
||||||
|
<span style="font-family:var(--mono);font-size:12px;color:var(--ink);text-align:right;word-break:break-all;user-select:all">${escapeHtml(v)}</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
bodyEl.innerHTML = `
|
||||||
|
<div style="padding:14px">
|
||||||
|
<!-- Entity ID Tag -->
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;background:rgba(63,185,80,.1);border:1px solid rgba(63,185,80,.25);padding:8px 10px;border-radius:6px">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0"></span>
|
||||||
|
<span style="font-family:var(--mono);font-size:13px;font-weight:700;color:var(--accent)">${escapeHtml(prop.id)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- General Section -->
|
||||||
|
<div style="margin-bottom:14px">
|
||||||
|
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px">
|
||||||
|
일반 정보 (General)
|
||||||
|
</div>
|
||||||
|
${renderRows(prop.general)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Geometry Section -->
|
||||||
|
${
|
||||||
|
prop.geometry.length > 0
|
||||||
|
? `
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--accent);margin-bottom:6px">
|
||||||
|
기하 속성 (Geometry)
|
||||||
|
</div>
|
||||||
|
${renderRows(prop.geometry)}
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
Vendored
+15
-1
@@ -1,20 +1,34 @@
|
|||||||
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
|
// 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.
|
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
|
||||||
|
import type { CadSpace } from './cadSpaces';
|
||||||
|
|
||||||
export class Viewer2D {
|
export class Viewer2D {
|
||||||
constructor(container: HTMLElement);
|
constructor(container: HTMLElement);
|
||||||
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
|
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
|
||||||
load(result: unknown, opts?: { keepView?: boolean }): void;
|
load(result: unknown, opts?: { keepView?: boolean; keepTheme?: boolean; spaceHandle?: string | number | null }): void;
|
||||||
/** Zoom-extents to the loaded drawing. */
|
/** Zoom-extents to the loaded drawing. */
|
||||||
fit(): void;
|
fit(): void;
|
||||||
/** Resync renderer/camera to the container size (call after un-hiding). */
|
/** Resync renderer/camera to the container size (call after un-hiding). */
|
||||||
resize(): void;
|
resize(): void;
|
||||||
onSelect(cb: (entity: unknown) => void): void;
|
onSelect(cb: (entity: unknown) => void): void;
|
||||||
|
getSelectedEntity(): unknown;
|
||||||
|
zoomToSelection(): void;
|
||||||
|
setAccent(hex: string): void;
|
||||||
setTheme(dark: boolean): void;
|
setTheme(dark: boolean): void;
|
||||||
setGrid(visible: boolean): void;
|
setGrid(visible: boolean): void;
|
||||||
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
|
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
|
||||||
setHiddenLayers(nameSet: Set<string>): void;
|
setHiddenLayers(nameSet: Set<string>): void;
|
||||||
|
/** Model / Paper (Layout) spaces for the loaded drawing. */
|
||||||
|
getSpaces(): CadSpace[];
|
||||||
|
getActiveSpace(): string | null;
|
||||||
|
/** Switch Model ↔ Layout; re-renders (fit unless keepView). */
|
||||||
|
setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void;
|
||||||
getZoomPercent(): number;
|
getZoomPercent(): number;
|
||||||
onViewChange(cb: () => void): void;
|
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;
|
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
|
||||||
stopMeasure(): void;
|
stopMeasure(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
+1386
-41
File diff suppressed because it is too large
Load Diff
+15
@@ -3,15 +3,30 @@
|
|||||||
|
|
||||||
export function parse_dwg(bytes: Uint8Array): any;
|
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 type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||||
|
|
||||||
export interface InitOutput {
|
export interface InitOutput {
|
||||||
readonly memory: WebAssembly.Memory;
|
readonly memory: WebAssembly.Memory;
|
||||||
readonly parse_dwg: (a: number, b: number) => [number, number, number];
|
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_malloc: (a: number, b: number) => number;
|
||||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||||
readonly __externref_table_dealloc: (a: number) => void;
|
readonly __externref_table_dealloc: (a: number) => void;
|
||||||
|
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
readonly __wbindgen_start: () => void;
|
readonly __wbindgen_start: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,40 @@ export function parse_dwg(bytes) {
|
|||||||
}
|
}
|
||||||
return takeFromExternrefTable0(ret[0]);
|
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() {
|
function __wbg_get_imports() {
|
||||||
const import0 = {
|
const import0 = {
|
||||||
__proto__: null,
|
__proto__: null,
|
||||||
|
|||||||
Binary file not shown.
@@ -2,8 +2,10 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
export const memory: WebAssembly.Memory;
|
export const memory: WebAssembly.Memory;
|
||||||
export const parse_dwg: (a: number, b: number) => [number, number, number];
|
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_malloc: (a: number, b: number) => number;
|
||||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||||
export const __externref_table_dealloc: (a: number) => void;
|
export const __externref_table_dealloc: (a: number) => void;
|
||||||
|
export const __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||||
export const __wbindgen_start: () => void;
|
export const __wbindgen_start: () => void;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
|
* acadrust DWG parser wrapper — loads the wasm built from rust/dwg-wasm
|
||||||
* (wasm-pack --target web, committed under ./acadrust-dwg).
|
* (wasm-pack --target web, committed under ./acadrust-dwg).
|
||||||
*/
|
*/
|
||||||
import init, { parse_dwg } from './acadrust-dwg/acadrust_dwg.js';
|
import init, { parse_dwg_json } from './acadrust-dwg/acadrust_dwg.js';
|
||||||
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
|
import wasmUrl from './acadrust-dwg/acadrust_dwg_bg.wasm?url';
|
||||||
import type { CadParseResult } from './dwgParser';
|
import type { CadParseResult } from './dwgParser';
|
||||||
|
|
||||||
@@ -14,7 +14,15 @@ export function initAcadrustParser(): Promise<void> {
|
|||||||
return ready;
|
return ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a DWG buffer. Requires initAcadrustParser() first. */
|
/**
|
||||||
|
* 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 {
|
export function parseDwgAcadrust(bytes: Uint8Array): CadParseResult {
|
||||||
return parse_dwg(bytes) as CadParseResult;
|
return JSON.parse(parse_dwg_json(bytes)) as CadParseResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ const VARIANTS_SL = [
|
|||||||
[0.00, 0.30], // 9: dark gray
|
[0.00, 0.30], // 9: dark gray
|
||||||
];
|
];
|
||||||
|
|
||||||
export function aciToHex(aci) {
|
export function aciToHex(aci, isDark = true) {
|
||||||
if (aci == null || aci === 0 || aci === 256) return null;
|
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 <= 9) return FIXED[aci] ?? null;
|
||||||
if (aci >= 250 && aci <= 255) {
|
if (aci >= 250 && aci <= 255) {
|
||||||
const v = Math.round(40 + (aci - 250) * 43);
|
const v = Math.round(40 + (aci - 250) * 43);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Model Space / Paper Space (Layout) discovery for CadParseResult.
|
||||||
|
*
|
||||||
|
* DWG entities are owned by a block header handle. System blocks
|
||||||
|
* *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces
|
||||||
|
* AutoCAD switches between. Viewer2D must render only one at a time —
|
||||||
|
* drawing both stacks survey-model geometry on top of layout sheet
|
||||||
|
* geometry and produces the "all text piled in the center" glitch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CadSpaceKind = 'model' | 'paper' | 'other';
|
||||||
|
|
||||||
|
export type CadSpace = {
|
||||||
|
/** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */
|
||||||
|
id: string;
|
||||||
|
/** Display label, e.g. "Model" / "Layout" / raw block name. */
|
||||||
|
label: string;
|
||||||
|
/** Raw block name from tables.blocks. */
|
||||||
|
name: string;
|
||||||
|
/** Block handle as number when available. */
|
||||||
|
handle: number | null;
|
||||||
|
/** Hex string matching entity ownerHandle.value.toString(16). */
|
||||||
|
handleHex: string;
|
||||||
|
kind: CadSpaceKind;
|
||||||
|
entityCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleToHex(handle: unknown): string | null {
|
||||||
|
if (handle == null) return null;
|
||||||
|
if (typeof handle === 'object' && handle !== null && 'value' in handle) {
|
||||||
|
const v = (handle as { value?: unknown }).value;
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16);
|
||||||
|
if (typeof v === 'string' && v.length) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n.toString(16) : v.toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16);
|
||||||
|
if (typeof handle === 'string' && handle.length) {
|
||||||
|
const n = Number(handle);
|
||||||
|
return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerHex(entity: { ownerHandle?: unknown }): string | null {
|
||||||
|
return handleToHex(entity.ownerHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifySpaceName(raw: string): CadSpaceKind | null {
|
||||||
|
const name = raw.toLowerCase().replace(/\*/g, '').trim();
|
||||||
|
if (!name) return null;
|
||||||
|
if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model';
|
||||||
|
if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string {
|
||||||
|
if (kind === 'model') return 'Model';
|
||||||
|
if (kind === 'paper') {
|
||||||
|
// First paper space → "Layout"; further ones keep a disambiguator.
|
||||||
|
if (paperIndex <= 1) return 'Layout';
|
||||||
|
return `Layout (${name.replace(/^\*/, '')})`;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List model/paper space blocks that own at least zero entities.
|
||||||
|
* Always includes Model/Paper headers found in tables.blocks.
|
||||||
|
*/
|
||||||
|
export function listCadSpaces(result: {
|
||||||
|
entities?: unknown[];
|
||||||
|
tables?: { blocks?: Array<{ name?: string; handle?: unknown }> };
|
||||||
|
} | null | undefined): CadSpace[] {
|
||||||
|
const blocks = result?.tables?.blocks ?? [];
|
||||||
|
const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>;
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const e of entities) {
|
||||||
|
const hex = ownerHex(e);
|
||||||
|
if (!hex) continue;
|
||||||
|
counts.set(hex, (counts.get(hex) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaces: CadSpace[] = [];
|
||||||
|
let paperIndex = 0;
|
||||||
|
for (const b of blocks) {
|
||||||
|
const kind = classifySpaceName(b.name ?? '');
|
||||||
|
if (!kind) continue;
|
||||||
|
const hex = handleToHex(b.handle);
|
||||||
|
if (!hex) continue;
|
||||||
|
if (kind === 'paper') paperIndex += 1;
|
||||||
|
const handleNum =
|
||||||
|
typeof b.handle === 'number'
|
||||||
|
? b.handle
|
||||||
|
: typeof b.handle === 'object' && b.handle && 'value' in b.handle
|
||||||
|
? Number((b.handle as { value: unknown }).value)
|
||||||
|
: null;
|
||||||
|
spaces.push({
|
||||||
|
id: hex,
|
||||||
|
label: spaceLabel(kind, b.name ?? '', paperIndex),
|
||||||
|
name: b.name ?? '',
|
||||||
|
handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null,
|
||||||
|
handleHex: hex,
|
||||||
|
kind,
|
||||||
|
entityCount: counts.get(hex) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable order: Model first, then papers in table order.
|
||||||
|
spaces.sort((a, b) => {
|
||||||
|
if (a.kind !== b.kind) {
|
||||||
|
if (a.kind === 'model') return -1;
|
||||||
|
if (b.kind === 'model') return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return spaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default tab when a file is opened:
|
||||||
|
* - Paper/Layout with entities if any (civil sheet drawings often leave Layout active)
|
||||||
|
* - else Model
|
||||||
|
* - else first listed space
|
||||||
|
*/
|
||||||
|
export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null {
|
||||||
|
if (!spaces.length) return null;
|
||||||
|
const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0);
|
||||||
|
if (paperWithContent) return paperWithContent;
|
||||||
|
const model = spaces.find((s) => s.kind === 'model');
|
||||||
|
if (model) return model;
|
||||||
|
return spaces[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owners allowed when filtering to one space: the space handle itself plus
|
||||||
|
* handles of entities that live under it (e.g. INSERT → ATTRIB children).
|
||||||
|
*/
|
||||||
|
export function buildAllowedOwnerHexes(
|
||||||
|
entities: Array<{ handle?: unknown; ownerHandle?: unknown }>,
|
||||||
|
activeSpaceHex: string,
|
||||||
|
rounds = 3,
|
||||||
|
): Set<string> {
|
||||||
|
const allowed = new Set<string>([activeSpaceHex]);
|
||||||
|
for (let pass = 0; pass < rounds; pass++) {
|
||||||
|
let grew = false;
|
||||||
|
for (const ent of entities) {
|
||||||
|
const oh = ownerHex(ent);
|
||||||
|
if (!oh || !allowed.has(oh)) continue;
|
||||||
|
const h = handleToHex(ent.handle);
|
||||||
|
if (h && !allowed.has(h)) {
|
||||||
|
allowed.add(h);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!grew) break;
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSystemSpaceBlockName(name: string): boolean {
|
||||||
|
return classifySpaceName(name) != null;
|
||||||
|
}
|
||||||
@@ -7,8 +7,14 @@
|
|||||||
* History: until 2026-07-10 this module selected between acadrust and
|
* History: until 2026-07-10 this module selected between acadrust and
|
||||||
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
|
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
|
||||||
* after side-by-side validation (PROGRESS.md Phase 15).
|
* 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 = {
|
export type CadParseResult = {
|
||||||
version?: string;
|
version?: string;
|
||||||
vars?: Record<string, unknown>;
|
vars?: Record<string, unknown>;
|
||||||
@@ -21,5 +27,5 @@ export type CadParseResult = {
|
|||||||
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
|
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
|
||||||
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
|
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
|
||||||
await initAcadrustParser();
|
await initAcadrustParser();
|
||||||
return parseDwgAcadrust(bytes);
|
return fixDwgKoreanText(parseDwgAcadrust(bytes));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR.
|
||||||
|
*
|
||||||
|
* Older Korean DWGs store strings in code page 949. When those bytes are
|
||||||
|
* decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin
|
||||||
|
* (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings
|
||||||
|
* (already containing Hangul syllables) are left unchanged.
|
||||||
|
*
|
||||||
|
* Apply after parse_dwg, before Viewer2D.load().
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/;
|
||||||
|
const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/;
|
||||||
|
|
||||||
|
/** Labels tried in order; first supported decoder wins. */
|
||||||
|
const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const;
|
||||||
|
|
||||||
|
let koreanDecoder: TextDecoder | null | undefined;
|
||||||
|
|
||||||
|
function getKoreanDecoder(): TextDecoder | null {
|
||||||
|
if (koreanDecoder !== undefined) return koreanDecoder;
|
||||||
|
for (const label of KOREAN_LABELS) {
|
||||||
|
try {
|
||||||
|
koreanDecoder = new TextDecoder(label);
|
||||||
|
return koreanDecoder;
|
||||||
|
} catch {
|
||||||
|
/* label not available in this runtime */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
koreanDecoder = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If `s` looks like CP949 bytes misread as Latin-1, return the Hangul
|
||||||
|
* form; otherwise return `s` unchanged.
|
||||||
|
*/
|
||||||
|
export function fixCp949Mojibake(s: string): string {
|
||||||
|
if (!s) return s;
|
||||||
|
// Already real Hangul → leave alone (e.g. BasicSample.dwg).
|
||||||
|
if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s;
|
||||||
|
|
||||||
|
let hasHigh = false;
|
||||||
|
const bytes = new Uint8Array(s.length);
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const code = s.charCodeAt(i);
|
||||||
|
if (code > 255) return s; // true Unicode beyond Latin-1
|
||||||
|
if (code > 127) hasHigh = true;
|
||||||
|
bytes[i] = code;
|
||||||
|
}
|
||||||
|
if (!hasHigh) return s; // pure ASCII
|
||||||
|
|
||||||
|
const decoder = getKoreanDecoder();
|
||||||
|
if (!decoder) return s;
|
||||||
|
|
||||||
|
let fixed: string;
|
||||||
|
try {
|
||||||
|
fixed = decoder.decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept only when we gain Hangul and the decode is clean.
|
||||||
|
if (
|
||||||
|
(HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) &&
|
||||||
|
!fixed.includes('\uFFFD')
|
||||||
|
) {
|
||||||
|
return fixed;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields
|
||||||
|
* in place. Returns the same object for chaining.
|
||||||
|
*/
|
||||||
|
export function fixDwgKoreanText<T>(root: T): T {
|
||||||
|
if (root == null || typeof root !== 'object') return root;
|
||||||
|
const seen = new WeakSet<object>();
|
||||||
|
|
||||||
|
const walk = (value: unknown): void => {
|
||||||
|
if (value == null || typeof value !== 'object') return;
|
||||||
|
if (seen.has(value as object)) return;
|
||||||
|
seen.add(value as object);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const item = value[i];
|
||||||
|
if (typeof item === 'string') value[i] = fixCp949Mojibake(item);
|
||||||
|
else walk(item);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (key === 'imageDataUrl') continue;
|
||||||
|
const item = obj[key];
|
||||||
|
if (typeof item === 'string') obj[key] = fixCp949Mojibake(item);
|
||||||
|
else walk(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,390 @@
|
|||||||
|
/**
|
||||||
|
* LIN File Parser & Complex Linetype Registry
|
||||||
|
* Parses AutoCAD LIN file definitions (including text and shape descriptors).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ICAD_LIN, KOSDIC_LIN } from './linData';
|
||||||
|
|
||||||
|
export interface LinElement {
|
||||||
|
type: 'dash' | 'gap' | 'shape' | 'text';
|
||||||
|
val?: number; // dash length (+) or gap length (-)
|
||||||
|
shapeName?: string; // e.g. KSC35, CIRC1, BOX, TRACK1, BAT, ZIG
|
||||||
|
shxFile?: string; // e.g. kosdic.shx, ltypeshp.shx
|
||||||
|
text?: string; // e.g. "L2", "GAS", "HW"
|
||||||
|
style?: string; // e.g. Standard
|
||||||
|
scale: number; // s=... (default 1.0)
|
||||||
|
rotation: number; // r=... (angle in degrees)
|
||||||
|
isAbsoluteAngle: boolean;// a=...
|
||||||
|
isUprightAngle: boolean; // u=...
|
||||||
|
xOffset: number; // x=...
|
||||||
|
yOffset: number; // y=...
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinPattern {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
elements: LinElement[];
|
||||||
|
patternLength: number; // total length sum of dashes + abs(gaps)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBracketElement(inner: string): LinElement {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let cur = '';
|
||||||
|
let inQuote = false;
|
||||||
|
for (let i = 0; i < inner.length; i++) {
|
||||||
|
const c = inner[i];
|
||||||
|
if (c === '"') inQuote = !inQuote;
|
||||||
|
if (c === ',' && !inQuote) {
|
||||||
|
parts.push(cur.trim());
|
||||||
|
cur = '';
|
||||||
|
} else {
|
||||||
|
cur += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.trim()) parts.push(cur.trim());
|
||||||
|
|
||||||
|
let type: 'shape' | 'text' = 'shape';
|
||||||
|
let shapeName: string | undefined;
|
||||||
|
let shxFile: string | undefined;
|
||||||
|
let text: string | undefined;
|
||||||
|
let style: string | undefined;
|
||||||
|
|
||||||
|
let scale = 1.0;
|
||||||
|
let rotation = 0;
|
||||||
|
let isAbsoluteAngle = false;
|
||||||
|
let isUprightAngle = false;
|
||||||
|
let xOffset = 0;
|
||||||
|
let yOffset = 0;
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
const first = parts[0];
|
||||||
|
if (first.startsWith('"') && first.endsWith('"')) {
|
||||||
|
type = 'text';
|
||||||
|
text = first.slice(1, -1);
|
||||||
|
if (parts.length > 1 && !parts[1].includes('=')) style = parts[1];
|
||||||
|
} else {
|
||||||
|
type = 'shape';
|
||||||
|
shapeName = first;
|
||||||
|
if (parts.length > 1 && !parts[1].includes('=')) shxFile = parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const eqIdx = part.indexOf('=');
|
||||||
|
if (eqIdx > 0) {
|
||||||
|
const key = part.substring(0, eqIdx).trim().toLowerCase();
|
||||||
|
const valStr = part.substring(eqIdx + 1).trim();
|
||||||
|
const val = parseFloat(valStr);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
if (key === 's') scale = val;
|
||||||
|
else if (key === 'r') { rotation = val; }
|
||||||
|
else if (key === 'a') { rotation = val; isAbsoluteAngle = true; }
|
||||||
|
else if (key === 'u') { rotation = val; isUprightAngle = true; }
|
||||||
|
else if (key === 'x') xOffset = val;
|
||||||
|
else if (key === 'y') yOffset = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
shapeName,
|
||||||
|
shxFile,
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
scale,
|
||||||
|
rotation,
|
||||||
|
isAbsoluteAngle,
|
||||||
|
isUprightAngle,
|
||||||
|
xOffset,
|
||||||
|
yOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeLinPattern(patternStr: string): string[] {
|
||||||
|
const s = patternStr.replace(/^A\s*,\s*/i, '').trim();
|
||||||
|
const tokens: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inBracket = false;
|
||||||
|
let inQuote = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
const ch = s[i];
|
||||||
|
if (ch === '"') inQuote = !inQuote;
|
||||||
|
if (ch === '[' && !inQuote) inBracket = true;
|
||||||
|
if (ch === ']' && !inQuote) inBracket = false;
|
||||||
|
|
||||||
|
if (ch === ',' && !inBracket && !inQuote) {
|
||||||
|
if (current.trim()) tokens.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.trim()) tokens.push(current.trim());
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLinContent(content: string): Map<string, LinPattern> {
|
||||||
|
const result = new Map<string, LinPattern>();
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
let currentName: string | null = null;
|
||||||
|
let currentDesc = '';
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line || line.startsWith(';')) continue;
|
||||||
|
if (line.startsWith('*')) {
|
||||||
|
const commaIdx = line.indexOf(',');
|
||||||
|
if (commaIdx > 0) {
|
||||||
|
currentName = line.substring(1, commaIdx).trim();
|
||||||
|
currentDesc = line.substring(commaIdx + 1).trim();
|
||||||
|
} else {
|
||||||
|
currentName = line.substring(1).trim();
|
||||||
|
currentDesc = '';
|
||||||
|
}
|
||||||
|
} else if (currentName && line.toUpperCase().startsWith('A,')) {
|
||||||
|
const tokens = tokenizeLinPattern(line);
|
||||||
|
const elements: LinElement[] = [];
|
||||||
|
let totalLength = 0;
|
||||||
|
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (tok.startsWith('[') && tok.endsWith(']')) {
|
||||||
|
const elem = parseBracketElement(tok.slice(1, -1));
|
||||||
|
elements.push(elem);
|
||||||
|
} else {
|
||||||
|
const val = parseFloat(tok);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
if (val > 1e-6) {
|
||||||
|
elements.push({
|
||||||
|
type: 'dash',
|
||||||
|
val,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
isAbsoluteAngle: false,
|
||||||
|
isUprightAngle: false,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
});
|
||||||
|
totalLength += val;
|
||||||
|
} else if (val < -1e-6) {
|
||||||
|
const absVal = Math.abs(val);
|
||||||
|
elements.push({
|
||||||
|
type: 'gap',
|
||||||
|
val: -absVal,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
isAbsoluteAngle: false,
|
||||||
|
isUprightAngle: false,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
});
|
||||||
|
totalLength += absVal;
|
||||||
|
} else {
|
||||||
|
// Dot (val === 0)
|
||||||
|
elements.push({
|
||||||
|
type: 'dash',
|
||||||
|
val: 0.05,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
isAbsoluteAngle: false,
|
||||||
|
isUprightAngle: false,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
});
|
||||||
|
totalLength += 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.length > 0) {
|
||||||
|
result.set(currentName.toUpperCase(), {
|
||||||
|
name: currentName,
|
||||||
|
description: currentDesc,
|
||||||
|
elements,
|
||||||
|
patternLength: Math.max(totalLength, 0.1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Linetype Registry initialized with Icad.lin and KOSDIC.lin
|
||||||
|
const globalRegistry = new Map<string, LinPattern>();
|
||||||
|
|
||||||
|
export function registerLinContent(content: string) {
|
||||||
|
const parsed = parseLinContent(content);
|
||||||
|
for (const [key, pattern] of parsed.entries()) {
|
||||||
|
globalRegistry.set(key, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm up registry with bundled LIN files
|
||||||
|
registerLinContent(ICAD_LIN);
|
||||||
|
registerLinContent(KOSDIC_LIN);
|
||||||
|
|
||||||
|
const CIVIL_LT_TEXT_MAP: Record<string, string> = {
|
||||||
|
'H-DICHL02': 'L2-2/0',
|
||||||
|
'H-DICHL02R': 'L2-2',
|
||||||
|
'H-DICHL01': 'L2-3/0',
|
||||||
|
'H-DICHL01R': 'L2-3',
|
||||||
|
'H-DICHL06': 'L5-1',
|
||||||
|
'H-DICHL06R': 'L5-1',
|
||||||
|
'H-DICHL05': 'L5',
|
||||||
|
'H-DICHL05R': 'L5',
|
||||||
|
'H-DICHU05': 'U5',
|
||||||
|
'H-DICHU05R': 'U5',
|
||||||
|
'H-DICHB01': 'B1',
|
||||||
|
'H-DICHB01R': 'B1',
|
||||||
|
'H-DICHM03': 'M3',
|
||||||
|
'H-DICHM03R': 'M3',
|
||||||
|
'H-DICHV01': 'V1',
|
||||||
|
'H-DICHV01R': 'V1',
|
||||||
|
'H-DICHO01': 'O1',
|
||||||
|
'H-DICHO01R': 'O1',
|
||||||
|
'H-DICHL03': 'L3',
|
||||||
|
'H-DICHL03R': 'L3',
|
||||||
|
'H-DICHL04': 'L4',
|
||||||
|
'H-DICHL04R': 'L4',
|
||||||
|
'H-DICHL08': 'L8',
|
||||||
|
'H-DICHL08R': 'L8',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_SHAPE_LINETYPES = new Set([
|
||||||
|
'H-DICHL05',
|
||||||
|
'H-DICHL05R',
|
||||||
|
'H-DICHU05',
|
||||||
|
'H-DICHU05R',
|
||||||
|
'H-DICHB01',
|
||||||
|
'H-DICHB01R',
|
||||||
|
'H-DICHM03',
|
||||||
|
'H-DICHM03R',
|
||||||
|
'H-DICHV01',
|
||||||
|
'H-DICHV01R',
|
||||||
|
'H-DICHO01',
|
||||||
|
'H-DICHO01R',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getLinPattern(name: string): LinPattern | undefined {
|
||||||
|
if (!name) return undefined;
|
||||||
|
const key = name.toUpperCase().trim();
|
||||||
|
const pattern = globalRegistry.get(key);
|
||||||
|
if (!pattern) return undefined;
|
||||||
|
|
||||||
|
let elements = pattern.elements;
|
||||||
|
|
||||||
|
// Filter out shape/arrowhead elements for L5, U5, B1, etc. as specified in official CAD spec
|
||||||
|
if (NO_SHAPE_LINETYPES.has(key)) {
|
||||||
|
elements = elements.filter((elem) => elem.type !== 'shape');
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideText = CIVIL_LT_TEXT_MAP[key];
|
||||||
|
if (overrideText) {
|
||||||
|
elements = elements.map((elem) =>
|
||||||
|
elem.type === 'text' ? { ...elem, text: overrideText } : elem
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pattern,
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllRegisteredPatterns(): Map<string, LinPattern> {
|
||||||
|
return globalRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDwgLinetypePattern(
|
||||||
|
ltName: string,
|
||||||
|
dwgPattern: number[],
|
||||||
|
description?: string
|
||||||
|
): LinPattern | undefined {
|
||||||
|
if (!ltName) return undefined;
|
||||||
|
const base = getLinPattern(ltName);
|
||||||
|
if (!dwgPattern || !dwgPattern.length) return base;
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
const elements: LinElement[] = [];
|
||||||
|
let totalLength = 0;
|
||||||
|
for (const val of dwgPattern) {
|
||||||
|
if (val > 1e-6) {
|
||||||
|
elements.push({
|
||||||
|
type: 'dash',
|
||||||
|
val,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
isAbsoluteAngle: false,
|
||||||
|
isUprightAngle: false,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
});
|
||||||
|
totalLength += val;
|
||||||
|
} else if (val < -1e-6) {
|
||||||
|
const absVal = Math.abs(val);
|
||||||
|
elements.push({
|
||||||
|
type: 'gap',
|
||||||
|
val: -absVal,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
isAbsoluteAngle: false,
|
||||||
|
isUprightAngle: false,
|
||||||
|
xOffset: 0,
|
||||||
|
yOffset: 0,
|
||||||
|
});
|
||||||
|
totalLength += absVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: ltName,
|
||||||
|
description: description || '',
|
||||||
|
elements,
|
||||||
|
patternLength: Math.max(totalLength, 0.1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge DWG numeric dash/gap values into base pattern elements
|
||||||
|
const newElements: LinElement[] = [];
|
||||||
|
let dwgValIdx = 0;
|
||||||
|
let totalLength = 0;
|
||||||
|
|
||||||
|
for (const elem of base.elements) {
|
||||||
|
if (elem.type === 'dash') {
|
||||||
|
const dwgVal =
|
||||||
|
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0
|
||||||
|
? dwgPattern[dwgValIdx]
|
||||||
|
: elem.val || 0.05;
|
||||||
|
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] > 0) {
|
||||||
|
dwgValIdx++;
|
||||||
|
}
|
||||||
|
newElements.push({ ...elem, val: dwgVal });
|
||||||
|
totalLength += dwgVal;
|
||||||
|
} else if (elem.type === 'gap') {
|
||||||
|
const dwgVal =
|
||||||
|
dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0
|
||||||
|
? dwgPattern[dwgValIdx]
|
||||||
|
: elem.val;
|
||||||
|
if (dwgValIdx < dwgPattern.length && dwgPattern[dwgValIdx] < 0) {
|
||||||
|
dwgValIdx++;
|
||||||
|
}
|
||||||
|
const absVal = Math.abs(dwgVal || 0.1);
|
||||||
|
newElements.push({ ...elem, val: -absVal });
|
||||||
|
totalLength += absVal;
|
||||||
|
} else {
|
||||||
|
newElements.push({ ...elem });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
description: description || base.description,
|
||||||
|
elements: newElements,
|
||||||
|
patternLength: Math.max(totalLength, 0.1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -328,6 +328,9 @@ export class SlugTextBatch {
|
|||||||
colorInt: number, alignH: number, alignV: number): void {
|
colorInt: number, alignH: number, alignV: number): void {
|
||||||
const eng = this.engine;
|
const eng = this.engine;
|
||||||
const lines = String(text).split('\n');
|
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 scale = height / eng.capHeightEm;
|
||||||
const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
|
const lineStep = height * 5 / 3; // CAD default MTEXT line spacing
|
||||||
const blockH = (lines.length - 1) * lineStep + height;
|
const blockH = (lines.length - 1) * lineStep + height;
|
||||||
|
|||||||
Reference in New Issue
Block a user