fix: resolve workspace integration blockers (#5)
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-subpath/
|
||||
*.log
|
||||
.DS_Store
|
||||
.serena/
|
||||
.playwright-mcp/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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를 조건 없이
|
||||
상업 배포할 수 있음”은 같은 뜻이 아닙니다.
|
||||
|
||||
@@ -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
|
||||
파일에서도 확인할 수 있습니다.
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
Executable → Regular
Executable → Regular
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["three"]
|
||||
"types": ["three", "vite/client"]
|
||||
},
|
||||
"include": ["src", "tools"]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Vendored
+9
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const ARC_SEGS = 64;
|
||||
const ELLIPSE_SEGS = 72;
|
||||
const CLICK_THRESHOLD_PX = 14;
|
||||
|
||||
function stripMText(s) {
|
||||
function stripMText(s) {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.replace(/\\A\d+;/g, '') // \A1; vertical alignment
|
||||
@@ -30,11 +30,28 @@ function stripMText(s) {
|
||||
.replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n)
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
|
||||
}
|
||||
|
||||
export class Viewer2D {
|
||||
constructor(container) {
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
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();
|
||||
this._layerByName = new Map();
|
||||
@@ -74,26 +91,43 @@ 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());
|
||||
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
|
||||
this._renderer.domElement.addEventListener('pointerdown', (e) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
});
|
||||
this._renderer.domElement.addEventListener('mousedown', (e) => {
|
||||
this._downXY = [e.clientX, e.clientY];
|
||||
if (e.button === 1) e.preventDefault();
|
||||
});
|
||||
this._renderer.domElement.addEventListener('click', (e) => this._onClick(e));
|
||||
this._handleResize = () => this._onResize();
|
||||
window.addEventListener('resize', this._handleResize);
|
||||
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
|
||||
this._handlePointerDown = (e) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
};
|
||||
this._handleMouseDown = (e) => {
|
||||
this._downXY = [e.clientX, e.clientY];
|
||||
if (e.button === 1) e.preventDefault();
|
||||
};
|
||||
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._animate();
|
||||
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();
|
||||
}
|
||||
|
||||
onSelect(cb) { this._onSelectCb = cb; }
|
||||
@@ -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) {
|
||||
@@ -2328,7 +2366,7 @@ export class Viewer2D {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
_fit(box) {
|
||||
_fit(box) {
|
||||
const c = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const w = this._container.clientWidth || 800;
|
||||
@@ -2346,15 +2384,57 @@ export class Viewer2D {
|
||||
this._camera.up.set(-Math.sin(a), Math.cos(a), 0);
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._controls.target.set(c.x, c.y, 0);
|
||||
this._controls.update();
|
||||
}
|
||||
|
||||
_clear() {
|
||||
for (let i = this._group.children.length - 1; i >= 0; i--) {
|
||||
const o = this._group.children[i];
|
||||
o.geometry?.dispose();
|
||||
if (o.material) { o.material.map?.dispose(); o.material.dispose(); }
|
||||
this._group.remove(o);
|
||||
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];
|
||||
disposeObject(o);
|
||||
this._group.remove(o);
|
||||
}
|
||||
// Shared text textures were disposed via the sprites above (re-dispose is a
|
||||
// no-op); drop the cache so the next load rasterizes fresh.
|
||||
@@ -2362,8 +2442,9 @@ export class Viewer2D {
|
||||
this._textGen++; // invalidate any in-flight async text flush
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const w = this._container.clientWidth, h = this._container.clientHeight;
|
||||
_onResize() {
|
||||
if (this._disposed) return;
|
||||
const w = this._container.clientWidth, h = this._container.clientHeight;
|
||||
if (!w || !h) return;
|
||||
this._renderer.setSize(w, h);
|
||||
// Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords.
|
||||
@@ -2375,9 +2456,8 @@ export class Viewer2D {
|
||||
this._camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(() => this._animate());
|
||||
this._controls.update();
|
||||
this._renderer.render(this._scene, this._camera);
|
||||
}
|
||||
}
|
||||
_animate() {
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user