fix: resolve workspace integration blockers (#5)

This commit is contained in:
2026-07-29 10:01:17 +09:00
parent 3279ac099e
commit cadbd5fb60
37 changed files with 1817 additions and 73 deletions
+5
View File
@@ -1,4 +1,9 @@
node_modules/
dist/
dist-subpath/
*.log
.DS_Store
.serena/
.playwright-mcp/
playwright-report/
test-results/
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+96
View File
@@ -0,0 +1,96 @@
Copyright (c) 2010, NHN Corporation (http://www.nhncorp.com),
with Reserved Font Name Nanum, Naver Nanum, NanumGothic, Naver
NanumGothic, NanumMyeongjo, Naver NanumMyeongjo, NanumBrush, Naver
NanumBrush, NanumPen, Naver NanumPen.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+405 -11
View File
@@ -1,21 +1,415 @@
# hmwebviewer workspace
# dwg-dxf-viewer-sample
Three.js 기반 3D viewer와 DWG/DXF 2D viewer sample을 한 저장소에서 관리합니다.
DWG/DXF 2D viewer module과 다중 포맷 3D viewer sample을 함께 관리하는
Three.js 기반 npm workspace입니다.
이 저장소의 구조 통합과 검증 계획은 Gitea issue #5를 기준으로 진행합니다.
현재 구현은 파일을 브라우저에서 파싱하고 화면에 표시하는 기술 검증용입니다.
좌표, 단위, 원점, 형상 허용오차, topology와 CAD/BIM 의미 정보의 보존을
보장하는 운영용 engineering viewer로는 아직 검증되지 않았습니다.
## Workspace
## 검토 기준
- `@hmwebviewer/viewer-3d`: 기존 3D viewer application
- `@hmwebviewer/viewer-2d-sample`: DWG/DXF sample application
- `@hmwebviewer/viewer2d`: 재사용 가능한 DWG/DXF parser와 renderer module
| 항목 | 내용 |
|---|---|
| 기준일 | 2026-07-29 |
| 기준 commit | `3279ac099ec30bb52363710c73fbd4fe174b707b` |
| 기준 tree | 위 commit과 2026-07-29 작업 tree의 미커밋 변경 포함 |
| 대상 | `cyhan/dwg-dxf-viewer-sample` |
| 참조 | 형제 저장소 `hmwebviewer/README.md`의 분석 구조와 3D asset provenance |
| 검토 범위 | workspace 구조, 2D/3D 기능, source·test·build 설정, 입력 경계, npm dependency, WASM·font·decoder·fixture license |
| 기능 판정 | **내부 개발·기능 검증 조건부 GO** |
| 외부 배포 판정 | **NO-GO — license·source 제공·asset provenance blocker 존재** |
## 실행
외부 배포 판정은 기술적인 compliance 준비 상태를 뜻합니다. 법률 자문이 아니며,
실제 계약·특허·상표와 배포 승인은 조직의 법무·오픈소스 정책으로 확정해야 합니다.
## Workspace 구성
| Workspace | 역할 | 주요 runtime |
|---|---|---|
| `apps/viewer-2d-sample` | DWG/DXF sample UI와 입력 정책 | `@hmwebviewer/viewer2d`, Three.js |
| `packages/viewer2d` | 재사용 가능한 2D parser·renderer module | `dxf-parser`, `opentype.js`, Three.js peer, acadrust WASM |
| `apps/viewer-3d` | 다중 포맷 3D static web application | Three.js, `web-ifc` |
```text
dwg-dxf-viewer-sample/
├─ apps/
│ ├─ viewer-2d-sample/ # 2D sample application
│ └─ viewer-3d/ # 3D sample application과 offline 도구
├─ packages/
│ └─ viewer2d/ # 2D public module
├─ tests/ # Vitest와 Playwright
├─ LICENSES/ # 현재 MPL-2.0, OFL-1.1 전문
├─ THIRD_PARTY_NOTICES.md
└─ docs/PROVENANCE.md
```
세 workspace는 `"private": true`이며 npm registry 배포용 package가 아닙니다.
`@hmwebviewer/viewer2d`의 export도 현재 TypeScript source를 직접 가리키므로,
독립 배포 package가 아니라 이 workspace 안에서 재사용하는 module입니다.
## 현재 제공 기능
### DWG/DXF 2D viewer
- `.dwg`, `.dxf` URL과 로컬 파일 load
- 파일 선택, Drag & Drop, `?model=<url>` 진입 경로
- DWG는 acadrust WebAssembly parser, DXF는 `dxf-parser`와 adapter 사용
- Three.js orthographic renderer와 pan·zoom
- Zoom Fit, dark/light theme, layer별 표시·숨김
- entity click 선택과 type·layer·handle 표시
- TEXT/MTEXT glyph용 NanumGothic TTF load
- module API의 grid, zoom speed, view-change 구독, entity/block 통계,
WebP snapshot, 2점 거리 측정, accent color
- page 종료 시 RAF, listener, WebGL resource와 canvas dispose
renderer에는 LINE, CIRCLE, ARC, ELLIPSE, POINT, POLYLINE/LWPOLYLINE,
SPLINE, TEXT/MTEXT/ATTRIB, INSERT, HATCH, DIMENSION, LEADER, XLINE/RAY 등
여러 entity 경로가 있습니다. 모든 DWG/DXF version과 entity 조합의 정확도를
보장한다는 의미는 아닙니다.
2D 입력 경계는 application layer에서 다음과 같이 제한합니다.
- URL은 HTTP(S)와 viewer 동일 origin만 허용
- `Content-Length`와 streaming 누적 크기를 모두 확인
- 로컬·URL 입력 최대 50 MiB
- URL fetch timeout 15초
### 다중 포맷 3D viewer
- `?model=<url>` server asset load와 로컬 Drag & Drop
- GLB, GLTF, OBJ, FBX, DAE, IFC, PLY load
- OBJ와 함께 드롭한 MTL 및 PNG/JPEG/BMP/GIF/WebP/TGA texture 연결
- 300 MiB 초과 OBJ의 2-pass streaming parser
- 큰 절대 좌표를 가진 OBJ의 float64 source 단계 재중심화
- Draco geometry와 KTX2/Basis texture runtime decode
- IFC single-thread WebAssembly parse와 geometry 변환
- PLY mesh·point cloud 처리 및 Float64 attribute의 Float32 정규화
- OrbitControls, Zoom Fit, 원근·직교 projection, outline 표시
- FPS overlay와 지속적인 저 FPS에서 pixel ratio를 낮추는 adaptive quality
- model URL과 이름이 같은 360° WebP preview를 먼저 표시한 뒤 canvas로 전환
- Vite `BASE_URL`을 따르는 decoder·preview 경로와 subpath 배포
- 교체 model의 geometry·material·texture와 Blob URL 정리
WebP preview는 미리 생성된 정적 파일입니다. Vite가 HTML shell을 제공한 뒤
브라우저에서 preview와 model을 load하므로 server-side 3D rendering 또는 SSR
구현은 없습니다.
## 실행 구조
```mermaid
flowchart TD
A["2D File / same-origin URL"] --> B["cadInputPolicy"]
B --> C["parseCad"]
C --> D["acadrust WASM<br/>DWG"]
C --> E["dxf-parser + adapter<br/>DXF"]
D --> F["CadParseResult"]
E --> F
F --> G["Viewer2D / Three.js"]
H["3D File / ?model URL"] --> I["ThreeDViewer"]
I --> J["modelLoader"]
J --> K["Three.js format loaders"]
J --> L["web-ifc JS + WASM"]
K --> M["THREE.Object3D"]
L --> M
M --> N["WebGLRenderer"]
```
2D application은 `packages/viewer2d` 내부 파일을 직접 import하지 않고
`@hmwebviewer/viewer2d` public interface를 사용합니다. 3D `modelLoader`
확장자로 loader를 선택하고 결과를 `THREE.Object3D`로 정규화합니다.
`npm ci`의 root `postinstall`은 Three.js의 Draco·Basis 파일과 `web-ifc` WASM을
`apps/viewer-3d/public` 아래에 staging합니다. runtime에서는 설치된 Three.js와
동일한 decoder를 사용합니다. `web-ifc-mt.wasm`도 복사되지만 현재 code path는
single-thread `web-ifc.wasm`만 사용합니다.
## 지원 범위와 알려진 제한
### 2D
| 포맷 | URL | 로컬 파일 | 현재 검증과 제한 |
|---|---:|---:|---|
| DWG | 동일 origin 지원 | 지원 | `BasicSample.dwg` 1개 fixture 중심. acadrust WASM wrapper의 재현 build source가 없음 |
| DXF | 동일 origin 지원 | 지원 | `simple.dxf`와 public parser contract 검증. binary DXF 및 전체 entity fidelity 미검증 |
### 3D
| 포맷 | `?model` URL | 로컬 파일 | 알려진 제한 |
|---|---:|---:|---|
| GLB | 지원 | 지원 | Draco/KTX2 지원. 손실 압축 허용오차와 원본 대비 fidelity 기준 없음 |
| GLTF | 지원 | 제한적 | 로컬 `.bin`·texture sidecar를 함께 해석하는 file-set session 없음 |
| OBJ | 지원 | 지원 | URL 경로의 MTL 자동 load 없음. 로컬만 MTL·texture sidecar 지원 |
| OBJ > 300 MiB | 지원 | 지원 | streaming path는 smooth normal과 MTL `Kd` color만 사용하고 texture·hard edge fidelity를 포기 |
| FBX | 지원 | 지원 | 축·단위·animation·외부 texture 보존 계약 없음 |
| DAE | 지원 | 제한적 | 로컬 외부 texture sidecar 미지원 |
| IFC | 지원 | 지원 | geometry와 color만 Three.js object로 변환. property/semantic API와 원점 이동 기록 미보존 |
| PLY | 지원 | 지원 | mesh 또는 point cloud. double attribute는 GPU용 Float32로 변환되어 정밀도 손실 가능 |
여기서 “지원”은 sample을 파싱해 renderable object를 만들 수 있다는 뜻입니다.
CAD/BIM 원본과의 좌표·단위·원점·topology·재질·속성 일치를 의미하지 않습니다.
## 신뢰 경계와 운영 위험
2D 경로는 same-origin, 50 MiB, 15초 제한을 적용하지만 magic bytes, MIME,
압축 해제 후 entity 수와 geometry 복잡도 quota는 없습니다.
3D `?model`은 URL scheme·origin·크기·timeout을 제한하지 않습니다. browser CORS가
허용하면 외부 origin도 요청할 수 있고, GLTF/DAE/FBX/OBJ가 참조하는 URI가 추가
network request를 만들 수 있습니다. 로컬 3D 파일에도 최대 크기와 decode 후
vertex·texture·memory quota가 없습니다.
두 viewer 모두 parser와 상당한 geometry 처리를 browser main thread에서 수행합니다.
운영 배포에는 format별 Worker 격리, URL allowlist, CSP, MIME·signature 검사,
streaming byte limit, decode complexity quota, timeout과 cancellation이 필요합니다.
동시에 여러 load를 시작했을 때 이전 요청을 취소하거나 latest-wins를 보장하는
session 규약도 없습니다. 느린 이전 요청이 나중 요청 뒤에 완료되어 화면을
덮을 수 있습니다.
현재 E2E는 sample render, finite vertex, canvas lifecycle과 subpath decoder URL을
검사합니다. 공식 validator, golden geometry, 좌표·단위 왕복, topology,
material·IFC property contract와 장시간 memory soak는 release gate에 없습니다.
## 외부 의존성·라이선스 감사
> **배포 준비 판정: 외부 demo·고객 전달·public CDN·commercial SaaS `NO-GO`,
> 조직 내부 개발·검증 조건부 `GO`**
>
> dependency 자체는 대부분 상업 사용 가능한 license이지만, 프로젝트 소유 코드의
> license, MPL source 제공, Apache/MIT 고지와 sample provenance가 완결되지 않았습니다.
감사 범위는 `package-lock.json``node_modules/*` entry, 실제 browser bundle,
`public` 정적 파일, bundled WASM·font, sample과 파생 preview, repository가
호출하거나 언급하는 system tool입니다.
lockfile에는 142개 `node_modules/*` entry가 있습니다. 이 중 3개는 이 저장소의
workspace link이고 제3자 package는 139개입니다. 제3자 entry의 license metadata
누락은 0개입니다. runtime entry는 5개, build/dev entry는 134개이며 그중 57개는
platform별 optional package입니다.
### Browser runtime과 직접 배포되는 구성요소
| 구성요소 | 버전·출처 | 역할·배포 위치 | License | 현재 compliance 상태 |
|---|---|---|---|---|
| Three.js | `0.185.1` | 두 viewer의 JS bundle, loader·controls | MIT | package license는 확인. 배포 artifact에 통합 MIT notice 없음 |
| dxf-parser | `1.1.2` | DXF parser JS bundle | MIT | package license는 확인. 통합 notice 없음 |
| loglevel | `1.9.2`, `dxf-parser` transitive | DXF runtime logging | MIT | lockfile·package license 확인 |
| opentype.js | `2.0.0` | TTF glyph 처리 JS bundle | MIT | package license는 확인. 통합 notice 없음 |
| acadrust | WASM 내부 `0.4.1` | `acadrust_dwg_bg.wasm`, DWG parse | MPL-2.0 | MPL 전문과 checksum은 있음. wrapper source·toolchain과 재현 build가 없음 |
| NanumGothic | Google Fonts 원본 checksum 일치 | `NanumGothic-Regular.ttf` | OFL-1.1 | 전문과 checksum은 있음. root license 파일이 app dist에는 자동 포함되지 않음 |
| web-ifc | `0.0.77` | dynamic JS bundle, `/web-ifc/*.wasm` | MPL-2.0 | package 전문은 확인. exact covered source 취득 안내가 배포물에 없음 |
| Draco decoder | Three.js r185 배포본 | `/draco/*.js`, `/draco/*.wasm` | Apache-2.0 | source와 byte-for-byte staging test 있음. Apache 전문·NOTICE가 app dist에 없음 |
| Basis Universal transcoder | Three.js r185 배포본 | `/basis/*.js`, `/basis/*.wasm` | Apache-2.0 | `basis/README.md`에 upstream link만 있음. Apache 전문·NOTICE가 app dist에 없음 |
저장소의 [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md)는 acadrust,
NanumGothic과 주요 runtime library를 기록하고, [`LICENSES/`](./LICENSES/)에는
MPL-2.0과 OFL-1.1 전문이 있습니다. 그러나 이 파일들은 두 Vite application의
`public` 아래에 있지 않아 현재 `npm run build` 결과에는 포함되지 않습니다.
### 전체 npm license 집계
| License | 제3자 package entry 수 | 주 사용 범위 | 상업 사용 | source 공개 영향 |
|---|---:|---|---:|---|
| MIT | 86 | runtime 4개와 build/dev | 가능 | 프로젝트 source 공개 의무 없음. copyright·license notice 보존 |
| Apache-2.0 | 31 | TypeScript, Playwright, Puppeteer와 platform package | 가능 | 프로젝트 source 공개 의무 없음. license·변경·NOTICE·patent 조건 적용 |
| MPL-2.0 | 13 | `web-ifc` runtime 1개, Lightning CSS 계열 dev 12개 | 가능 | 외부 executable 배포 시 covered source와 취득 안내 필요 |
| ISC | 6 | build/dev | 가능 | copyright·permission notice 보존 |
| BSD-3-Clause | 2 | build/dev | 가능 | notice·면책 보존, 이름을 홍보에 사용하지 않음 |
| 0BSD | 1 | optional build/dev | 가능 | source 공개와 attribution 의무 없음 |
위 집계에는 npm 밖의 acadrust WASM, NanumGothic, Draco, Basis와 sample asset
license가 포함되지 않습니다. `UNKNOWN` 3개처럼 보이는 lockfile entry는
`@hmwebviewer/*` first-party workspace link이며 제3자 license 누락이 아닙니다.
### Build·test·offline 도구
| 구성요소 | lockfile 기준 | 역할 | License·배포 판단 |
|---|---:|---|---|
| Vite | `8.1.5` | dev server와 production build | MIT. build output에는 runtime third-party notice를 별도로 넣어야 함 |
| TypeScript | `7.0.2` | typecheck | Apache-2.0. compiler를 고객에게 함께 전달하지 않으면 제품 source 공개 영향 없음 |
| Vitest | `4.1.10` | unit test | MIT, 제품 runtime 아님 |
| Playwright | `1.62.0` | browser E2E | Apache-2.0, 제품 runtime 아님 |
| Puppeteer Core | `25.4.0` resolved | 3D smoke·preview capture | Apache-2.0. Chrome binary를 포함하지 않음 |
| Lightning CSS | `1.33.0` 계열 12 entry | Vite build transitive | MPL-2.0. build host에서만 사용하며 browser runtime에는 포함되지 않음 |
| Node.js·npm | Node >=24, npm >=11 요구 | install, build, tool runtime | 함께 재배포하면 각 runtime과 bundled dependency license를 별도 감사 |
| Chrome | system executable | Playwright/Puppeteer 실행 | repository가 bundle하지 않음. CI image·설치형 배포에 포함하면 vendor 조건 별도 검토 |
| ffmpeg | optional, lockfile 밖 | PNG frame을 animated WebP로 결합 | binary를 bundle할 때 실제 build option의 LGPL/GPL/nonfree 조건 확인 |
| KTX-Software `toktx` | 도입 안내만 존재 | 선택적 KTX2 encoding | 현재 dependency가 아님. 도입 시 exact binary BOM과 license 재검토 |
`apps/viewer-3d/tools/preprocess.mjs``@gltf-transform/core`,
`@gltf-transform/functions`, `@gltf-transform/extensions`, `draco3d`를 import하지만
현재 `package.json`과 lockfile에는 이 package들이 없습니다. 따라서 clean install의
정식 toolchain으로 재현되지 않습니다.
현재 script가 구현한 texture output은 WebP/JPEG/PNG이며 KTX2 encoding은 하지
않습니다. KTX2는 별도 glTF Transform CLI와 `toktx`가 필요합니다.
하위 `tools/README.md`의 “KTX2 default” 설명은 현재 code와 일치하지 않습니다.
### MPL·Apache·OFL 적용 범위
MPL-2.0은 file-level copyleft입니다. 별도 파일로 결합한 이 application 전체를
MPL로 공개할 필요는 없지만, 조직 밖으로 browser JS/WASM executable을 전달하면
수령자가 해당 MPL covered source와 수정분을 합리적인 방법으로 얻을 수 있게
알려야 합니다. browser로 전송되는 client code도 배포에 해당합니다.
- `web-ifc@0.0.77`: exact source archive 또는 고정 commit/tag URL과 수정 여부,
build 가능한 covered source 취득 안내가 필요
- acadrust DWG WASM: crate source만으로는 현재 binary wrapper를 재현할 수 없으므로
wrapper source·Cargo/wasm-bindgen 설정·toolchain을 복구하기 전 외부 배포 보류
- Lightning CSS: `node_modules`나 build container를 외부에 전달하지 않고 build
host에서만 실행하면 application source 공개 범위를 만들지 않음
Apache-2.0의 Draco와 Basis는 source 공개 의무는 없지만 license 사본, 기존
attribution과 upstream NOTICE, 수정 시 변경 고지를 보존해야 합니다. OFL font는
software와 bundle할 수 있지만 copyright와 OFL 전문을 함께 제공해야 하며,
Reserved Font Name 조건을 지켜야 합니다.
참조:
- [Mozilla MPL 2.0 FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)
- [acadrust upstream](https://github.com/hakanaktt/acadrust)
- [web-ifc upstream](https://github.com/ThatOpen/engine_web-ifc)
- [Three.js r185 MIT license](https://github.com/mrdoob/three.js/blob/r185/LICENSE)
- [Google Draco Apache-2.0 license](https://github.com/google/draco/blob/main/LICENSE)
- [Basis Universal](https://github.com/BinomialLLC/basis_universal)
- [NanumGothic OFL](https://github.com/google/fonts/blob/main/ofl/nanumgothic/OFL.txt)
### Sample asset과 파생 preview
Sample과 preview는 software dependency license와 별개의 저작물입니다.
optimized model과 회전 preview도 원본 asset의 license·attribution·변경 표시를
계승해야 합니다.
| 자산 | 확인된 출처·license | 현재 판단 |
|---|---|---|
| `BasicSample.dwg` | 2D standalone initial commit에서 이관, 원출처 불명 | 내부 회귀 test 전용. 외부 demo 배포 보류 |
| `simple.dxf` | 2D standalone initial commit의 최소 fixture | 권리자·license 명시가 없어 외부 재배포 승인 필요 |
| `Box.glb` | Khronos sample, Cesium 제공, CC BY 4.0 | 상업 사용 가능하나 Cesium attribution, license link와 변경 여부 고지가 현재 배포물에 없음 |
| `Duck.glb` | Copyright 2006 Sony Computer Entertainment Inc., SCEA Shared Source License 1.0 | 비표준 license. 내부 법무 승인 전 외부 배포 보류 |
| `Duck.optimized.glb`, `Duck.webp` | Duck의 Draco/WebP·preview 파생물 | 원본 SCEA 승인과 derivative 변경 표시에 종속 |
| `Cube.obj`, `Cube.dae`, `Cube.ifc`, `DoublePrecision.ply` | 파일·commit에 hand-authored/project fixture 정황 | 프로젝트 권리자와 first-party license가 선언되지 않아 외부 배포 보류 |
| `Cube.fbx`, `sample.mtl` | history에는 Three.js `morph_test`와 Blender 정황만 있고 exact 원본 URL·asset license 없음 | provenance 복구 또는 제거 전 외부 배포 보류 |
| `Cube.*.webp` | 각 Cube model에서 생성한 24-frame preview | 원본 model의 권리·license와 변경 표시에 종속 |
Box와 Duck의 upstream license:
- [Khronos Box — CC BY 4.0](https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Box/README.md)
- [Khronos Duck — SCEA Shared Source License 1.0](https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Duck/README.md)
fixture checksum과 source history는 [`docs/PROVENANCE.md`](./docs/PROVENANCE.md),
acadrust·font checksum은 [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md)를
기준으로 관리합니다. 현재 provenance 문서는 2D fixture 중심이며 3D asset과
preview derivative chain은 아직 통합되지 않았습니다.
### 프로젝트 license와 외부 release gate
루트에는 프로젝트 소유 코드에 적용되는 `LICENSE`와 copyright holder 선언이
없습니다. `package.json``"private": true`는 npm publish 방지 설정일 뿐
외부 이용·수정·재배포 권한을 부여하지 않습니다. 하위 과거 README의
“Viewer2D 포트·샘플 글루 MIT” 문구도 현재 repository의 license grant로 볼 수
없습니다.
다음 조건을 완료하기 전에는 외부 demo, 고객 전달, 설치형 package, public CDN과
commercial SaaS 배포를 승인하지 않습니다.
- 프로젝트 소유 코드의 권리자와 proprietary/open-source license 결정
- 2D·3D build artifact에 runtime `THIRD_PARTY_NOTICES`와 필요한 license 전문 포함
- acadrust wrapper source·toolchain 복구와 exact WASM 재현 build
- acadrust 및 `web-ifc@0.0.77` covered source의 고정 archive/URL과 취득 안내 제공
- Draco·Basis Apache license와 upstream NOTICE 보존
- Box attribution과 sample별 원본 URL·권리자·license·checksum·derivative chain 기록
- Duck의 내부 법무 승인 또는 승인된 CC0/CC BY asset으로 교체
- `BasicSample.dwg`, `Cube.fbx`, `sample.mtl` 등 출처 미완료 fixture의 승인 또는 제거
- 미사용 `web-ifc-mt.wasm`과 중복 decoder artifact의 배포 필요성 결정
- runtime/build/sample을 구분한 SBOM과 CI의 unknown-license/provenance gate
## 설치와 실행
요구 환경은 Node.js 24 이상, npm 11 이상과 WebGL을 지원하는 browser입니다.
```bash
npm ci
npm run dev:2d
npm run dev:3d
```
전체 검증은 `npm run verify`로 실행합니다.
`npm ci`는 workspace dependency를 설치한 뒤 3D decoder와 WASM을 staging합니다.
```bash
npm run dev:2d
# http://127.0.0.1:5173
npm run dev:3d
# http://127.0.0.1:3333
```
URL load 예시:
```text
http://127.0.0.1:5173/?model=/samples/simple.dxf
http://127.0.0.1:3333/?model=/samples/Box.glb
```
Production build와 preview:
```bash
npm run build
npm --workspace @hmwebviewer/viewer-2d-sample run preview
npm --workspace @hmwebviewer/viewer-3d run preview
```
## 검증
```bash
npm run typecheck
npm run test:unit
npm run build
npm run test:e2e
```
전체 순차 검증:
```bash
npm run verify
```
현재 test가 확인하는 범위:
- 2D public package interface의 DXF parse
- 2D same-origin·50 MiB 입력 정책
- canonical module map 문서 drift
- decoder WASM의 installed dependency 대비 checksum과 실행 권한
- Vite subpath의 public asset URL
- 2D DWG/DXF sample과 local file load
- 3D GLB, Draco GLB, OBJ, FBX, DAE, IFC, PLY sample render
- 2D/3D canvas dispose와 반복 page transition
- production subpath의 Draco·IFC decoder request
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
engineering 정확도와 외부 배포 compliance를 승인하는 test는 아닙니다.
## 문서
- [Third-party notices](./THIRD_PARTY_NOTICES.md)
- [Source와 fixture provenance](./docs/PROVENANCE.md)
- [DWG/DXF module map](./docs/modules-dwg-dxf.html)
- [2D sample 안내](./apps/viewer-2d-sample/README.md)
- [3D architecture](./apps/viewer-3d/docs/architecture.html)
- [3D 구현 계획](./apps/viewer-3d/PLAN.md)
- [3D 검증 기록](./apps/viewer-3d/PROGRESS.md)
- [3D asset 도구](./apps/viewer-3d/tools/README.md)
- [초기 3D 설계서](./apps/viewer-3d/3d_viewer_architecture_spec.pdf)
하위 2D README에는 통합 전 standalone layout이, 3D tools README에는 과거 KTX2
계획이 남아 있습니다. 현재 workspace의 canonical 개요와 배포 판단은 이 문서를
기준으로 하며, 하위 문서는 후속 정합성 정리가 필요합니다.
## 현재 판정
2D와 3D sample의 주요 load path, lifecycle, decoder staging과 subpath 배포를
검증하는 자동화가 있습니다. 내부 기능 검증용 workspace로는 사용할 수 있습니다.
운영 release에는 format별 정확도 contract, 3D 입력 제한·cancellation·Worker 격리,
프로젝트 license, MPL source 제공, third-party 고지와 sample provenance blocker가
남아 있습니다. 특히 “상업 사용 가능한 dependency”와 “현재 artifact를 조건 없이
상업 배포할 수 있음”은 같은 뜻이 아닙니다.
+44
View File
@@ -0,0 +1,44 @@
# Third-party notices
이 문서는 저장소에 직접 포함되어 배포되는 주요 third-party 자산의 출처와
라이선스를 기록합니다. package manager가 설치하는 전체 dependency 목록은
lockfile을 기준으로 별도 감사합니다.
## acadrust 0.4.1 / DWG WASM
- 역할: DWG binary parsing
- upstream: <https://github.com/hakanaktt/acadrust>
- crate source: <https://crates.io/crates/acadrust/0.4.1>
- license: Mozilla Public License 2.0
- license text: [`LICENSES/MPL-2.0.txt`](./LICENSES/MPL-2.0.txt)
- shipped WASM SHA-256:
`aeb211f98e7e4aacfeb2d8430a1ca0fb14b507885f17af13137bd24ed0eca7a3`
WASM binary의 embedded source path에서 `acadrust-0.4.1` 사용을 확인했습니다.
현재 binary를 최초 생성한 Rust wrapper source와 toolchain은 기존 두 upstream
repository에 남아 있지 않습니다. 이 저장소는 해당 binary를 standalone commit
`95fa6a452b50394ff11fba60db482dd038fe2355`에서 checksum을 유지한 채 이관했습니다.
재현 build와 외부 package 배포는 Gitea issue #3에서 별도로 관리합니다.
## NanumGothic-Regular.ttf
- 역할: DWG/DXF TEXT와 MTEXT glyph rendering
- upstream: <https://github.com/google/fonts/tree/main/ofl/nanumgothic>
- copyright: Copyright 2010 The Nanum Project Authors
- license: SIL Open Font License 1.1
- license text: [`LICENSES/OFL-1.1.txt`](./LICENSES/OFL-1.1.txt)
- shipped font SHA-256:
`76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31`
위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을
2026-07-29에 확인했습니다.
## Runtime libraries
- Three.js — MIT, <https://github.com/mrdoob/three.js>
- dxf-parser — MIT, <https://github.com/gdsestimating/dxf-parser>
- opentype.js — MIT, <https://github.com/opentypejs/opentype.js>
- web-ifc — MPL-2.0, <https://github.com/ThatOpen/engine_web-ifc>
각 library의 license text는 clean install 후 해당 package에 포함된 license
파일에서도 확인할 수 있습니다.
+2 -1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
<title>DWG/DXF · 모듈 조합 샘플</title>
<style>
:root {
@@ -90,7 +91,7 @@
</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>
지도: <a href="./docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
</p>
</aside>
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port 5173 --strictPort",
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
@@ -0,0 +1,72 @@
export const MAX_CAD_BYTES = 50 * 1024 * 1024;
export const CAD_FETCH_TIMEOUT_MS = 15_000;
export function assertCadByteLength(byteLength: number): void {
if (!Number.isFinite(byteLength) || byteLength < 0) {
throw new Error('CAD 파일 크기를 확인할 수 없습니다.');
}
if (byteLength > MAX_CAD_BYTES) {
throw new Error('CAD 파일은 50 MiB 이하여야 합니다.');
}
}
export function resolveCadUrl(input: string, base: URL): URL {
const url = new URL(input, base);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('CAD URL은 HTTP(S)만 허용합니다.');
}
if (url.origin !== base.origin) {
throw new Error('CAD URL은 viewer와 같은 origin만 허용합니다.');
}
return url;
}
export async function fetchCadBuffer(url: URL): Promise<ArrayBuffer> {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), CAD_FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, {
credentials: 'same-origin',
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > 0) {
assertCadByteLength(declaredLength);
}
if (!response.body) {
const buffer = await response.arrayBuffer();
assertCadByteLength(buffer.byteLength);
return buffer;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
assertCadByteLength(total);
chunks.push(value);
}
const data = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
data.set(chunk, offset);
offset += chunk.byteLength;
}
return data.buffer;
} catch (error) {
if (controller.signal.aborted) {
throw new Error('CAD 파일 요청 시간이 초과되었습니다.');
}
throw error;
} finally {
window.clearTimeout(timeout);
}
}
+33 -7
View File
@@ -26,6 +26,11 @@ import {
parseCad,
type CadParseResult,
} from '@hmwebviewer/viewer2d';
import {
assertCadByteLength,
fetchCadBuffer,
resolveCadUrl,
} from './cadInputPolicy';
// parseDxfBuffer 내부:
// dxf-parser → HatchHandler → dxfAdapter
@@ -50,6 +55,12 @@ function setStatus(msg: string, err = false) {
const viewer = createViewer2D(app, {
fontUrl: publicUrl('fonts/NanumGothic-Regular.ttf'),
});
declare global {
interface Window {
__viewer2d?: typeof viewer;
}
}
window.__viewer2d = viewer;
viewer.onSelect((entity: unknown) => {
if (!entity) {
setStatus('deselected');
@@ -68,14 +79,14 @@ async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseRe
}
async function loadUrl(url: string, nameHint?: string) {
const name = nameHint ?? url.split('?')[0]!.split('/').pop() ?? url;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return loadAndRender(await res.arrayBuffer(), name);
const resolved = resolveCadUrl(url, new URL(window.location.href));
const name = nameHint ?? resolved.pathname.split('/').pop() ?? resolved.href;
return loadAndRender(await fetchCadBuffer(resolved), name);
}
async function loadFile(file: File) {
if (!isCad2DFile(file.name)) throw new Error(`not .dwg/.dxf: ${file.name}`);
assertCadByteLength(file.size);
return loadAndRender(await file.arrayBuffer(), file.name);
}
@@ -154,7 +165,22 @@ window.addEventListener('drop', (e) => {
});
const model = new URLSearchParams(location.search).get('model');
if (model) void run(model, () => loadUrl(model));
else setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
if (model) {
try {
const resolved = resolveCadUrl(model, new URL(window.location.href));
void run(model, () => loadUrl(resolved.href));
} catch (error) {
setStatus((error as Error).message, true);
}
} else {
setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
}
window.addEventListener('resize', () => viewer.resize());
window.addEventListener(
'pagehide',
() => {
viewer.dispose();
delete window.__viewer2d;
},
{ once: true },
);
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
<title>hmwebviewer — 3D Viewer</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
+3
View File
@@ -6,7 +6,10 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build:subpath:e2e": "vite build --base=/viewer-3d/ --outDir=dist-subpath",
"preview": "vite preview",
"preview:subpath:e2e": "vite preview --base=/viewer-3d/ --outDir=dist-subpath --host 127.0.0.1 --port 43174 --strictPort",
"serve:subpath:e2e": "npm run build:subpath:e2e && npm run preview:subpath:e2e",
"typecheck": "tsc --noEmit",
"stage:decoders": "node tools/copy-decoders.mjs"
},
+16
View File
@@ -0,0 +1,16 @@
# Hand-authored cube fixture
o Cube
v -1 -1 -1
v 1 -1 -1
v 1 1 -1
v -1 1 -1
v -1 -1 1
v 1 -1 1
v 1 1 1
v -1 1 1
f 1 4 3 2
f 5 6 7 8
f 1 2 6 5
f 2 3 7 6
f 3 4 8 7
f 4 1 5 8
View File
View File
+14 -3
View File
@@ -1,6 +1,7 @@
import { ThreeDViewer } from './viewer/ThreeDViewer';
import { initDropzone } from './dnd/dropzone';
import { setStatus } from './ui/progress';
import { publicAssetUrl } from './runtimeBase';
/**
* Entry point. Wires the ThreeDViewer (renderer + loaders + hydration) and the
@@ -62,11 +63,21 @@ if (modelUrl) {
// SSR placeholder: a pre-rendered 360° animated WebP (tools/prerender.mjs)
// shows instantly, then the hydration gate fades it out once WebGL + model ready.
const base = modelUrl.split('/').pop()!.replace(/\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i, '');
previewEl.src = '/previews/' + base + '.webp';
previewEl.src = publicAssetUrl(`previews/${base}.webp`);
previewEl.classList.remove('hidden');
previewEl.onerror = () => previewEl.classList.add('hidden');
viewer.loadServerAsset(modelUrl);
}
// Exposed for tooling (tools/prerender.mjs captures rotating frames).
(window as unknown as { __viewer?: ThreeDViewer }).__viewer = viewer;
// tools/prerender.mjs가 회전 frame을 캡처할 때 사용하는 공개 reference입니다.
const viewerWindow = window as Window & { __viewer?: ThreeDViewer };
viewerWindow.__viewer = viewer;
window.addEventListener(
'pagehide',
() => {
viewer.dispose();
delete viewerWindow.__viewer;
},
{ once: true },
);
+12
View File
@@ -0,0 +1,12 @@
/**
* Vite의 배포 base 아래에 있는 public asset의 절대 URL을 반환합니다.
*/
export function publicAssetUrl(
path: string,
base = import.meta.env.BASE_URL,
origin = window.location.origin,
): string {
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const normalizedPath = path.replace(/^\/+/, '');
return new URL(normalizedPath, new URL(normalizedBase, `${origin}/`)).href;
}
+5 -2
View File
@@ -2,6 +2,7 @@ import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
import { publicAssetUrl } from '../runtimeBase';
export interface ViewerLoaders {
gltf: GLTFLoader;
@@ -16,8 +17,10 @@ let _cache: ViewerLoaders | null = null;
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
if (_cache) return _cache;
const draco = new DRACOLoader().setDecoderPath('/draco/');
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const draco = new DRACOLoader().setDecoderPath(publicAssetUrl('draco/'));
const ktx2 = new KTX2Loader()
.setTranscoderPath(publicAssetUrl('basis/'))
.detectSupport(renderer);
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
_cache = { gltf, draco, ktx2 };
return _cache;
+2 -1
View File
@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { publicAssetUrl } from '../runtimeBase';
import { getLoaders } from './loaders';
/**
@@ -244,7 +245,7 @@ async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Obje
const WebIFC = await import('web-ifc');
if (!_ifcApi) {
_ifcApi = new WebIFC.IfcAPI();
_ifcApi.SetWasmPath('/web-ifc/', true);
_ifcApi.SetWasmPath(publicAssetUrl('web-ifc/'), true);
await _ifcApi.Init(undefined, true);
}
const api = _ifcApi;
+23 -3
View File
@@ -2,13 +2,30 @@
// runtime paths /draco/ and /basis/ resolve (version-matched to installed three).
// Also copies the web-ifc single-thread WASM to public/web-ifc/ for the IFC path.
// Runs automatically via `npm install` (postinstall). Safe to re-run.
import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import {
chmodSync,
cpSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
function normalizePublicModes(directory) {
chmodSync(directory, 0o755);
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = resolve(directory, entry.name);
if (entry.isDirectory()) normalizePublicModes(path);
else if (entry.isFile()) chmodSync(path, 0o644);
}
}
const threeEntry = fileURLToPath(import.meta.resolve('three'));
const threeRoot = resolve(dirname(threeEntry), '..');
const libs = resolve(threeRoot, 'examples/jsm/libs');
@@ -30,6 +47,7 @@ for (const [src, dest] of targets) {
rmSync(dest, { force: true, recursive: true });
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
normalizePublicModes(dest);
console.log(`[copy-decoders] ${src} -> ${dest}`);
}
@@ -42,8 +60,10 @@ if (existsSync(ifcSrcDir)) {
for (const wasm of ['web-ifc.wasm', 'web-ifc-mt.wasm']) {
const src = resolve(ifcSrcDir, wasm);
if (!existsSync(src)) continue;
copyFileSync(src, resolve(ifcDest, wasm));
console.log(`[copy-decoders] ${src} -> ${resolve(ifcDest, wasm)}`);
const dest = resolve(ifcDest, wasm);
copyFileSync(src, dest);
chmodSync(dest, 0o644);
console.log(`[copy-decoders] ${src} -> ${dest}`);
}
} else {
console.warn('[copy-decoders] web-ifc not installed yet — skipping IFC wasm.');
+1 -1
View File
@@ -14,7 +14,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["three"]
"types": ["three", "vite/client"]
},
"include": ["src", "tools"]
}
+33
View File
@@ -0,0 +1,33 @@
# 소스 및 fixture provenance
## Repository history
- 2D standalone source: `cyhan/dwg-dxf-viewer-sample`
- upstream fork source: `kimminsung/dwg-dxf-viewer-sample`
- exact commit: `95fa6a452b50394ff11fba60db482dd038fe2355`
- 두 Gitea remote가 동일 commit을 가리키는 것을 확인했습니다.
- 3D source: `cyhan/hmwebviewer`
- upstream fork source: `kimminsung/hmwebviewer`
- exact commit: `a71790070dd479abc2707a5dd63b0e8c872db412`
- 두 Gitea remote가 동일 commit을 가리키는 것을 확인했습니다.
3D source는 non-squash subtree commit으로 전체 tree와 commit ancestry를
보존해 가져왔습니다. 2D standalone rollback tag와 3D import provenance tag를
로컬 Git에 생성했습니다.
## 2D fixture
| Fixture | SHA-256 | 상태 |
|---|---|---|
| `BasicSample.dwg` | `ef9f1a225931df62cd25a7897771d0d4fcebf0f79d312e3b7888bba9834b7429` | standalone initial commit에서 이관. 외부 재배포 원출처 확인 필요 |
| `simple.dxf` | `ce8fc0735b78e912e1ddd26c1535e3e53c429332a7495ae50b749687892bd336` | standalone initial commit의 최소 ASCII DXF test fixture |
원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에
소유권이 확인된 fixture로 교체하거나 명시적 재배포 승인을 기록해야 합니다.
## Policy
- source와 binary는 exact commit 또는 SHA-256으로 고정합니다.
- 출처가 불명확한 binary와 fixture를 새 외부 package나 공개 demo에 추가하지 않습니다.
- generated decoder는 설치된 exact dependency에서 매 clean install마다 다시 staging합니다.
- package 전체의 공개 배포 자동화와 SBOM은 Gitea issue #3의 범위입니다.
+1 -1
View File
@@ -14,7 +14,7 @@
"typecheck": "npm run typecheck --workspaces --if-present",
"test": "npm run test:unit",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:e2e": "node tools/run-e2e.mjs",
"verify": "npm run typecheck && npm run test:unit && npm run build && npm run test:e2e",
"postinstall": "npm --workspace @hmwebviewer/viewer-3d run stage:decoders"
},
+9 -3
View File
@@ -1,5 +1,4 @@
// 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.
// 포팅된 JavaScript 2D 엔진의 공개 interface입니다.
export class Viewer2D {
constructor(container: HTMLElement);
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
@@ -9,12 +8,19 @@ export class Viewer2D {
/** Resync renderer/camera to the container size (call after un-hiding). */
resize(): void;
onSelect(cb: (entity: unknown) => void): void;
setZoomSpeed(speed: number): void;
getZoomSpeed(): number;
setTheme(dark: boolean): void;
setGrid(visible: boolean): void;
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void;
getZoomPercent(): number;
onViewChange(cb: () => void): void;
onViewChange(cb: () => void): () => void;
getStats(): { entities: number; blocks: number };
snapshotWebPBlob(): Promise<Blob | null>;
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
stopMeasure(): void;
setAccent(hex: string): void;
/** RAF, DOM listener, WebGL 자원과 canvas를 멱등으로 해제합니다. */
dispose(): void;
}
+94 -14
View File
@@ -32,8 +32,25 @@ function stripMText(s) {
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
}
function disposeMaterial(material) {
for (const item of (Array.isArray(material) ? material : [material])) {
item?.map?.dispose();
item?.dispose();
}
}
function disposeObject(root) {
root?.traverse?.((object) => {
object.geometry?.dispose();
if (object.material) disposeMaterial(object.material);
});
}
export class Viewer2D {
constructor(container) {
this._disposed = false;
this._rafId = null;
this._viewChangeCallbacks = new Set();
this._container = container;
this._entityMeta = [];
this._layerByHandle = new Map();
@@ -74,25 +91,42 @@ export class Viewer2D {
}
}
window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); });
this._handleKeyDown = (e) => {
if (e.key === 'f' || e.key === 'F') this.fit();
};
window.addEventListener('keydown', this._handleKeyDown);
this._group = new THREE.Group();
this._scene.add(this._group);
window.addEventListener('resize', () => this._onResize());
this._handleResize = () => this._onResize();
window.addEventListener('resize', this._handleResize);
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
this._renderer.domElement.addEventListener('pointerdown', (e) => {
this._handlePointerDown = (e) => {
if (e.button === 1) e.preventDefault();
});
this._renderer.domElement.addEventListener('mousedown', (e) => {
};
this._handleMouseDown = (e) => {
this._downXY = [e.clientX, e.clientY];
if (e.button === 1) e.preventDefault();
});
this._renderer.domElement.addEventListener('click', (e) => this._onClick(e));
};
this._handleClick = (e) => this._onClick(e);
this._renderer.domElement.addEventListener('pointerdown', this._handlePointerDown);
this._renderer.domElement.addEventListener('mousedown', this._handleMouseDown);
this._renderer.domElement.addEventListener('click', this._handleClick);
this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 };
this._gridVisible = false;
this._gridMesh = null;
this._controls.addEventListener('change', () => { if (this._gridVisible) this._rebuildGrid(); });
this._handleControlsChange = () => {
if (this._gridVisible) this._rebuildGrid();
for (const callback of this._viewChangeCallbacks) callback();
};
this._controls.addEventListener('change', this._handleControlsChange);
this._onAnimationFrame = () => {
if (this._disposed) return;
this._controls.update();
this._renderer.render(this._scene, this._camera);
this._rafId = requestAnimationFrame(this._onAnimationFrame);
};
this._animate();
}
@@ -683,7 +717,11 @@ export class Viewer2D {
getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); }
/** Subscribe to pan/zoom changes (for live zoom readout). */
onViewChange(cb) { this._controls?.addEventListener('change', cb); }
onViewChange(cb) {
if (this._disposed) return () => {};
this._viewChangeCallbacks.add(cb);
return () => this._viewChangeCallbacks.delete(cb);
}
/** Hide entities on the given layer names (Set<string>); re-renders keeping the view. */
setHiddenLayers(nameSet) {
@@ -2349,11 +2387,53 @@ export class Viewer2D {
this._controls.update();
}
dispose() {
if (this._disposed) return;
this._disposed = true;
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
window.removeEventListener('keydown', this._handleKeyDown);
window.removeEventListener('resize', this._handleResize);
const canvas = this._renderer.domElement;
canvas.removeEventListener('pointerdown', this._handlePointerDown);
canvas.removeEventListener('mousedown', this._handleMouseDown);
canvas.removeEventListener('click', this._handleClick);
this._controls.removeEventListener('change', this._handleControlsChange);
this._viewChangeCallbacks.clear();
this.stopMeasure();
this.setGrid(false);
this._clear();
this._scene.remove(this._group);
this._controls.dispose();
this._renderer.dispose();
this._renderer.forceContextLoss();
canvas.remove();
this._scene.clear();
this._lastResult = null;
this._onSelectCb = null;
this._measureCb = null;
this._entityMeta = [];
this._pickSegs = [];
this._pickSegMeta = [];
this._pickFills = [];
this._layerByHandle.clear();
this._layerByName.clear();
this._layerNameByHandle?.clear();
this._linetypeByName?.clear();
this._hiddenLayers?.clear();
this._texCache?.clear();
}
_clear() {
for (let i = this._group.children.length - 1; i >= 0; i--) {
const o = this._group.children[i];
o.geometry?.dispose();
if (o.material) { o.material.map?.dispose(); o.material.dispose(); }
disposeObject(o);
this._group.remove(o);
}
// Shared text textures were disposed via the sprites above (re-dispose is a
@@ -2363,6 +2443,7 @@ export class Viewer2D {
}
_onResize() {
if (this._disposed) return;
const w = this._container.clientWidth, h = this._container.clientHeight;
if (!w || !h) return;
this._renderer.setSize(w, h);
@@ -2376,8 +2457,7 @@ export class Viewer2D {
}
_animate() {
requestAnimationFrame(() => this._animate());
this._controls.update();
this._renderer.render(this._scene, this._camera);
if (this._disposed || this._rafId !== null) return;
this._rafId = requestAnimationFrame(this._onAnimationFrame);
}
}
@@ -3,7 +3,7 @@
"type": "module",
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
"version": "0.1.0",
"license": "MIT",
"license": "MPL-2.0",
"files": [
"acadrust_dwg_bg.wasm",
"acadrust_dwg.js",
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
timeout: 30_000,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:5173',
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
screenshot: 'only-on-failure',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? '/usr/bin/google-chrome',
args: ['--no-sandbox'],
},
},
webServer: process.env.E2E_SERVERS_READY
? undefined
: [
{
command: 'npm run dev:2d',
port: 5173,
reuseExistingServer: true,
timeout: 30_000,
},
{
command: 'npm run dev:3d',
port: 3333,
reuseExistingServer: true,
timeout: 30_000,
},
{
command: 'npm --workspace @hmwebviewer/viewer-3d run serve:subpath:e2e',
port: 43174,
reuseExistingServer: true,
timeout: 60_000,
},
],
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import {
assertCadByteLength,
resolveCadUrl,
} from '../apps/viewer-2d-sample/src/cadInputPolicy';
describe('CAD input policy', () => {
it('accepts same-origin URLs and rejects cross-origin model URLs', () => {
expect(resolveCadUrl('/samples/simple.dxf', new URL('https://viewer.example/app/')).href).toBe(
'https://viewer.example/samples/simple.dxf',
);
expect(() =>
resolveCadUrl('https://attacker.example/oversized.dxf', new URL('https://viewer.example/')),
).toThrow('같은 origin');
});
it('rejects CAD input larger than 50 MiB', () => {
expect(() => assertCadByteLength(50 * 1024 * 1024)).not.toThrow();
expect(() => assertCadByteLength(50 * 1024 * 1024 + 1)).toThrow('50 MiB');
});
});
+40
View File
@@ -0,0 +1,40 @@
import { createHash } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
function sha256(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
describe('decoder staging', () => {
it('copies decoder binaries byte-for-byte without executable permission', () => {
const threeRoot = resolve(
dirname(fileURLToPath(import.meta.resolve('three'))),
'..',
);
const ifcRoot = dirname(fileURLToPath(import.meta.resolve('web-ifc')));
const pairs = [
[
resolve(threeRoot, 'examples/jsm/libs/draco/gltf/draco_decoder.wasm'),
resolve(projectRoot, 'apps/viewer-3d/public/draco/draco_decoder.wasm'),
],
[
resolve(threeRoot, 'examples/jsm/libs/basis/basis_transcoder.wasm'),
resolve(projectRoot, 'apps/viewer-3d/public/basis/basis_transcoder.wasm'),
],
[
resolve(ifcRoot, 'web-ifc.wasm'),
resolve(projectRoot, 'apps/viewer-3d/public/web-ifc/web-ifc.wasm'),
],
];
for (const [source, staged] of pairs) {
expect(sha256(staged)).toBe(sha256(source));
expect(statSync(staged).mode & 0o111).toBe(0);
}
});
});
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('canonical documentation', () => {
it('keeps the served 2D module map identical to docs source', () => {
const canonical = readFileSync(resolve('docs/modules-dwg-dxf.html'));
const served = readFileSync(
resolve('apps/viewer-2d-sample/public/docs/modules-dwg-dxf.html'),
);
expect(served.equals(canonical)).toBe(true);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { mkdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { expect, test } from '@playwright/test';
const evidenceDirectory = resolve('test-results/evidence');
test.beforeAll(() => {
mkdirSync(evidenceDirectory, { recursive: true });
});
test('2D DWG와 DXF 구동 증거를 캡처합니다', async ({ page }) => {
await page.goto('http://127.0.0.1:5173/');
await page.getByRole('button', { name: 'Sample DWG' }).click();
await expect(page.locator('#status')).toContainText('7309 entities');
await page.screenshot({
path: resolve(evidenceDirectory, 'viewer2d-dwg.png'),
fullPage: true,
});
await page.getByRole('button', { name: 'Sample DXF' }).click();
await expect(page.locator('#status')).toContainText('6 entities');
await page.screenshot({
path: resolve(evidenceDirectory, 'viewer2d-dxf.png'),
fullPage: true,
});
});
for (const [label, fixture, output] of [
['GLB', 'Box.glb', 'viewer3d-three-r185-glb.png'],
['PLY', 'DoublePrecision.ply', 'viewer3d-three-r185-ply.png'],
] as const) {
test(`Three.js r185 ${label} 구동 증거를 캡처합니다`, async ({ page }) => {
await page.goto(`http://127.0.0.1:3333/?model=/samples/${fixture}`);
await page.waitForFunction(
() =>
Boolean(
(
window as Window & {
__viewer?: { current?: object };
}
).__viewer?.current,
),
undefined,
{ timeout: 20_000 },
);
await expect(page.locator('canvas')).toHaveCount(1);
await page.screenshot({
path: resolve(evidenceDirectory, output),
fullPage: true,
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import path from 'node:path';
import { expect, test } from '@playwright/test';
test('viewer2d loads DWG, DXF, and an uploaded file', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Sample DWG' }).click();
await expect(page.locator('#status')).toContainText('✓ BasicSample.dwg · 7309 entities');
await page.getByRole('button', { name: 'Layers' }).click();
await expect(page.locator('#layerList label').first()).toBeVisible();
await page.getByRole('button', { name: 'Sample DXF' }).click();
await expect(page.locator('#status')).toContainText('✓ simple.dxf · 6 entities');
await page.locator('#file').setInputFiles(
path.resolve('apps/viewer-2d-sample/public/samples/simple.dxf'),
);
await expect(page.locator('#status')).toContainText('✓ simple.dxf · 6 entities');
});
test('viewer2d rejects cross-origin and oversized CAD input', async ({ page }) => {
await page.goto('/?model=https://attacker.example/oversized.dxf');
await expect(page.locator('#status')).toContainText('같은 origin');
await page.goto('/');
await page.evaluate(() => {
const file = new File([new Uint8Array(50 * 1024 * 1024 + 1)], 'oversized.dxf');
const transfer = new DataTransfer();
transfer.items.add(file);
window.dispatchEvent(new DragEvent('drop', { dataTransfer: transfer }));
});
await expect(page.locator('#status')).toContainText('50 MiB');
});
+96
View File
@@ -0,0 +1,96 @@
import { expect, test } from '@playwright/test';
test('viewer2d releases its canvas when disposed', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Sample DXF' }).click();
await expect(page.locator('#status')).toContainText('✓ simple.dxf');
await expect(page.locator('canvas')).toHaveCount(1);
const lifecycle = await page.evaluate(() => {
const viewer = (
window as Window & {
__viewer2d?: { dispose?: () => void };
}
).__viewer2d;
return {
exposed: Boolean(viewer),
disposable: typeof viewer?.dispose === 'function',
};
});
expect(lifecycle).toEqual({ exposed: true, disposable: true });
await page.evaluate(() => {
(
window as Window & {
__viewer2d?: { dispose?: () => void };
}
).__viewer2d?.dispose?.();
});
await page.evaluate(() => {
(
window as Window & {
__viewer2d?: { dispose?: () => void };
}
).__viewer2d?.dispose?.();
});
await expect(page.locator('canvas')).toHaveCount(0);
});
test('viewer3d releases its canvas on pagehide and remains disposable', async ({ page }) => {
await page.goto('http://127.0.0.1:3333/?model=/samples/Box.glb');
await page.waitForFunction(
() =>
Boolean(
(
window as Window & {
__viewer?: { current?: object };
}
).__viewer?.current,
),
);
const result = await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent('pagehide'));
const exposed = (window as Window & { __viewer?: { dispose: () => void } }).__viewer;
exposed?.dispose();
return {
canvases: document.querySelectorAll('canvas').length,
exposed: Boolean(exposed),
};
});
expect(result).toEqual({ canvases: 0, exposed: false });
});
test('viewer2d and viewer3d survive ten mount transitions without duplicate canvases', async ({
page,
}) => {
test.setTimeout(180_000);
const runtimeErrors: string[] = [];
page.on('pageerror', (error) => runtimeErrors.push(error.message));
for (let transition = 0; transition < 10; transition += 1) {
await page.goto('http://127.0.0.1:5173/');
await page.getByRole('button', { name: 'Sample DWG' }).click();
await expect(page.locator('#status')).toContainText('7309 entities', { timeout: 20_000 });
await expect(page.locator('canvas')).toHaveCount(1);
await page.goto('http://127.0.0.1:3333/?model=/samples/Box.glb');
await page.waitForFunction(
() =>
Boolean(
(
window as Window & {
__viewer?: { current?: object };
}
).__viewer?.current,
),
undefined,
{ timeout: 20_000 },
);
await expect(page.locator('canvas')).toHaveCount(1);
}
expect(runtimeErrors).toEqual([]);
});
+89
View File
@@ -0,0 +1,89 @@
import { expect, test } from '@playwright/test';
const fixtures = [
['GLB', 'Box.glb'],
['Draco GLB', 'Duck.optimized.glb'],
['OBJ', 'Cube.obj'],
['FBX', 'Cube.fbx'],
['DAE', 'Cube.dae'],
['IFC', 'Cube.ifc'],
['double PLY', 'DoublePrecision.ply'],
] as const;
for (const [label, fixture] of fixtures) {
test(`viewer3d renders ${label} with Three.js r185`, async ({ page }) => {
const runtimeErrors: string[] = [];
page.on('pageerror', (error) => runtimeErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') runtimeErrors.push(message.text());
});
await page.goto(`http://127.0.0.1:3333/?model=/samples/${fixture}`);
await page.waitForFunction(
() =>
Boolean(
(
window as Window & {
__viewer?: { current?: object };
}
).__viewer?.current,
),
undefined,
{ timeout: 20_000 },
);
const scene = await page.evaluate(() => {
const current = (
window as Window & {
__viewer?: {
current?: {
traverse: (visitor: (node: unknown) => void) => void;
};
};
}
).__viewer?.current;
let renderables = 0;
let vertices = 0;
let invalidValues = 0;
let float64Attributes = 0;
current?.traverse((node: unknown) => {
const geometry = (
node as {
geometry?: {
attributes?: {
position?: {
array: ArrayLike<number>;
count: number;
};
};
};
}
).geometry;
const position = geometry?.attributes?.position;
if (!position) return;
renderables += 1;
vertices += position.count;
if (position.array instanceof Float64Array) float64Attributes += 1;
for (let index = 0; index < position.array.length; index += 1) {
if (!Number.isFinite(position.array[index])) invalidValues += 1;
}
});
return {
canvases: document.querySelectorAll('canvas').length,
float64Attributes,
invalidValues,
renderables,
vertices,
};
});
expect(scene.canvases).toBe(1);
expect(scene.renderables).toBeGreaterThan(0);
expect(scene.vertices).toBeGreaterThan(0);
expect(scene.invalidValues).toBe(0);
expect(scene.float64Attributes).toBe(0);
expect(runtimeErrors).toEqual([]);
});
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
test('viewer3d resolves Draco and IFC decoders under a production subpath', async ({ page }) => {
const failedRequests: string[] = [];
const responseUrls: string[] = [];
page.on('requestfailed', (request) => failedRequests.push(request.url()));
page.on('response', (response) => responseUrls.push(response.url()));
for (const fixture of ['Duck.optimized.glb', 'Cube.ifc']) {
await page.goto(
`http://127.0.0.1:43174/viewer-3d/?model=/viewer-3d/samples/${fixture}`,
);
await page.waitForFunction(
() =>
Boolean(
(
window as Window & {
__viewer?: { current?: object };
}
).__viewer?.current,
),
undefined,
{ timeout: 20_000 },
);
}
expect(responseUrls.some((url) => url.includes('/viewer-3d/draco/'))).toBe(true);
expect(responseUrls.some((url) => url.endsWith('/viewer-3d/web-ifc/web-ifc.wasm'))).toBe(true);
expect(failedRequests).toEqual([]);
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { publicAssetUrl } from '../apps/viewer-3d/src/runtimeBase';
describe('3D public asset URL', () => {
it('keeps decoder and preview assets under the configured deployment subpath', () => {
expect(publicAssetUrl('draco/', '/viewer-3d/', 'https://viewer.example')).toBe(
'https://viewer.example/viewer-3d/draco/',
);
expect(publicAssetUrl('/previews/Box.webp', '/viewer-3d/', 'https://viewer.example')).toBe(
'https://viewer.example/viewer-3d/previews/Box.webp',
);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const serverSpecs = [
{ name: 'viewer-2d', args: ['run', 'dev:2d'] },
{ name: 'viewer-3d', args: ['run', 'dev:3d'] },
{
name: 'viewer-3d-subpath',
args: ['--workspace', '@hmwebviewer/viewer-3d', 'run', 'serve:subpath:e2e'],
},
];
const servers = [];
let stopping = false;
function stopServers() {
if (stopping) return;
stopping = true;
for (const server of servers) {
if (!server.pid) continue;
try {
// npm과 그 아래 Vite process를 같은 process group 단위로 종료합니다.
process.kill(-server.pid, 'SIGTERM');
} catch {
// 이미 종료된 server는 별도 처리하지 않습니다.
}
}
}
function startServer({ name, args }) {
return new Promise((resolveReady, rejectReady) => {
const child = spawn('npm', args, {
cwd: projectRoot,
detached: true,
env: {
...process.env,
NO_PROXY: '127.0.0.1,localhost',
no_proxy: '127.0.0.1,localhost',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
servers.push(child);
let ready = false;
const timeout = setTimeout(() => {
rejectReady(new Error(`${name} server가 90초 안에 준비되지 않았습니다.`));
}, 90_000);
const inspect = (chunk) => {
const output = chunk.toString();
process.stderr.write(`[${name}] ${output}`);
if (!ready && output.includes('Local:')) {
ready = true;
clearTimeout(timeout);
resolveReady();
}
};
child.stdout.on('data', inspect);
child.stderr.on('data', inspect);
child.once('exit', (code, signal) => {
clearTimeout(timeout);
if (!ready) {
rejectReady(
new Error(`${name} server가 준비 전에 종료됐습니다. code=${code} signal=${signal}`),
);
}
});
});
}
process.once('SIGINT', () => {
stopServers();
process.exit(130);
});
process.once('SIGTERM', () => {
stopServers();
process.exit(143);
});
try {
await Promise.all(serverSpecs.map(startServer));
const playwright = spawn(
resolve(projectRoot, 'node_modules/.bin/playwright'),
['test', ...process.argv.slice(2)],
{
cwd: projectRoot,
env: {
...process.env,
E2E_SERVERS_READY: '1',
NO_PROXY: '127.0.0.1,localhost',
no_proxy: '127.0.0.1,localhost',
},
stdio: 'inherit',
},
);
const exitCode = await new Promise((resolveExit) => {
playwright.once('exit', (code, signal) => {
if (signal) resolveExit(1);
else resolveExit(code ?? 1);
});
});
process.exitCode = exitCode;
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
stopServers();
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
},
});