Compare commits
11
Commits
fb8f7ca4eb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d6b0e687 | ||
|
|
0c656808e0 | ||
|
|
fc2871ce4c | ||
|
|
110bd59118 | ||
|
|
41a94cc181 | ||
|
|
8ae7c45e9a | ||
|
|
cadbd5fb60 | ||
|
|
3279ac099e | ||
|
|
bedb357259 | ||
|
|
5e10bb1be4 | ||
|
|
a71790070d |
@@ -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,125 +1,541 @@
|
||||
# dwg-dxf-viewer-sample
|
||||
|
||||
**hmwebviewer 형제 폴더**에 분리된 DWG/DXF 2D 뷰어 샘플입니다.
|
||||
DWG/DXF 2D viewer module과 다중 포맷 3D viewer sample을 함께 관리하는
|
||||
Three.js 기반 npm workspace입니다.
|
||||
|
||||
`docs/modules-dwg-dxf.html` 모듈 지도의 카드
|
||||
(`acadrust-dwg`, `dxf-parser`, `Viewer2D` …)를 **직접 조합**해 동작하는 독립 프로젝트입니다.
|
||||
`hmwebviewer` 저장소 안에 의존하지 않습니다.
|
||||
현재 구현은 파일을 브라우저에서 파싱하고 화면에 표시하는 기술 검증용입니다.
|
||||
좌표, 단위, 원점, 형상 허용오차, topology와 CAD/BIM 의미 정보의 보존을
|
||||
보장하는 운영용 engineering viewer로는 아직 검증되지 않았습니다.
|
||||
|
||||
```
|
||||
D:\MYCLAUDE_PROJECT\
|
||||
hmwebviewer\ ← 본 제품
|
||||
dwg-dxf-viewer-sample\ ← 이 샘플 (형제)
|
||||
## 검토 기준
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 기준일 | 2026-07-29 |
|
||||
| 기준 commit | `cadbd5fb60d300dbb3fdc891fdbbabfbcd5b9109` |
|
||||
| 기준 tree | 위 commit 이후 이 README 변경 포함 |
|
||||
| 대상 | `kimminsung/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 준비 상태를 뜻합니다. 법률 자문이 아니며,
|
||||
실제 계약·특허·상표와 배포 승인은 조직의 법무·오픈소스 정책으로 확정해야 합니다.
|
||||
업무용 프로젝트라기보다 취미용 개발 프로젝트에 가까운 성과물입니다.
|
||||
|
||||
## 현재 판정
|
||||
|
||||
운영 release에는 format별 정확도 contract, 3D 입력 제한·cancellation·Worker 격리,
|
||||
프로젝트 license, MPL source 제공, third-party 고지와 sample provenance blocker가
|
||||
남아 있습니다. 특히 “상업 사용 가능한 dependency”와 “현재 artifact를 조건 없이
|
||||
상업 배포할 수 있음”은 같은 뜻이 아닙니다.
|
||||
|
||||
|
||||
## 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`로 정규화합니다.
|
||||
|
||||
Three.js의 Draco·Basis decoder는 loader import가 생성하는 version 일치 Vite
|
||||
hashed asset을 사용합니다. `npm ci`의 root `postinstall`은 현재 code path가
|
||||
사용하는 single-thread `web-ifc.wasm`만 `apps/viewer-3d/public/web-ifc` 아래에
|
||||
staging합니다. COOP/COEP가 필요한 `web-ifc-mt.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 | 제한적 | 지원 | URL에는 size 기반 streaming 분기가 없음. 로컬 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 배포본 | Vite `/assets/draco_*` hashed asset | Apache-2.0 | 설치된 Three.js source에서 build. Apache 전문·NOTICE가 app dist에 없음 |
|
||||
| Basis Universal transcoder | Three.js r185 배포본 | Vite `/assets/basis_*` hashed asset | Apache-2.0 | 설치된 Three.js source에서 build. 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 누락이 아닙니다.
|
||||
|
||||
#### MPL-2.0 npm package 전체 목록
|
||||
|
||||
`package-lock.json`에서 `license: "MPL-2.0"`으로 선언된 13개 entry를 전부
|
||||
나열하면 다음과 같습니다. 이는 **13개의 독립 codebase**를 의미하지 않습니다.
|
||||
`web-ifc` 1개와, 동일한 Lightning CSS source에서 배포되는 core package 1개 및
|
||||
platform별 optional native package 11개로 구성됩니다.
|
||||
|
||||
| npm package | Version | 설치·실행 범위 | 공식 source repository |
|
||||
|---|---:|---|---|
|
||||
| `web-ifc` | `0.0.77` | production runtime. IFC parser JS가 bundle되고 single-thread WASM이 application public asset으로 staging됨 | [ThatOpen/engine_web-ifc](https://github.com/ThatOpen/engine_web-ifc) |
|
||||
| `lightningcss` | `1.33.0` | Vite build dependency. CSS parse·transform·minify core와 현재 platform용 native binding 선택 | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-android-arm64` | `1.33.0` | optional dev package, Android ARM64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-darwin-arm64` | `1.33.0` | optional dev package, macOS ARM64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-darwin-x64` | `1.33.0` | optional dev package, macOS x86_64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-freebsd-x64` | `1.33.0` | optional dev package, FreeBSD x86_64 native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-linux-arm-gnueabihf` | `1.33.0` | optional dev package, Linux ARM hard-float GNU native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-linux-arm64-gnu` | `1.33.0` | optional dev package, Linux ARM64 glibc native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-linux-arm64-musl` | `1.33.0` | optional dev package, Linux ARM64 musl native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-linux-x64-gnu` | `1.33.0` | optional dev package, Linux x86_64 glibc native binding. 현재 표준 container 대상 | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-linux-x64-musl` | `1.33.0` | optional dev package, Linux x86_64 musl native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-win32-arm64-msvc` | `1.33.0` | optional dev package, Windows ARM64 MSVC native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
| `lightningcss-win32-x64-msvc` | `1.33.0` | optional dev package, Windows x86_64 MSVC native binding | [parcel-bundler/lightningcss v1.33.0](https://github.com/parcel-bundler/lightningcss/tree/v1.33.0) |
|
||||
|
||||
Lightning CSS의 12개 entry는 모두 동일한 MPL-2.0 source tree와 release version을
|
||||
가리키지만 lockfile 및 SBOM에서는 서로 다른 배포 package로 유지합니다. 실제 install
|
||||
시 npm은 host OS·CPU·libc와 맞는 optional binding만 선택합니다. 이 project의
|
||||
production browser artifact에는 Lightning CSS code가 포함되지 않지만, build
|
||||
container 또는 `node_modules`를 제3자에게 전달한다면 선택된 binding과 covered
|
||||
source 제공 방법을 함께 관리해야 합니다.
|
||||
|
||||
`web-ifc@0.0.77` package가 선언한 공식 repository는 위 root URL이며 package
|
||||
metadata에는 exact source commit(`gitHead`)이 없습니다. 따라서 외부 배포 전에
|
||||
registry tarball과 대응하는 source commit/archive를 별도로 고정하고, 수정 여부와
|
||||
covered source 취득 방법을 배포 고지에 기록해야 합니다.
|
||||
|
||||
### 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의 승인 또는 제거
|
||||
- runtime/build/sample을 구분한 SBOM과 CI의 unknown-license/provenance gate
|
||||
|
||||
|
||||
## 개발 스택 및 구성 정책
|
||||
|
||||
이 저장소의 표준 개발 대상은 **Linux x86_64 container**입니다. Windows host에서는
|
||||
Docker Desktop 또는 WSL2를 container runtime 진입점으로만 사용하며, Windows drive
|
||||
path와 host의 `node_modules`를 표준 build 입력으로 사용하지 않습니다.
|
||||
|
||||
| 구분 | 표준 stack | 구성 정책 |
|
||||
|---|---|---|
|
||||
| Host·container | Linux x86_64, glibc 기반 image | Chrome과 native npm package 호환성이 확인될 때까지 Debian/Ubuntu 계열을 기준으로 하며 Alpine/musl은 별도 검증 없이 사용하지 않음 |
|
||||
| JavaScript runtime | Node.js 24 이상, npm 11 이상 | `package.json#engines`가 지원 하한이며 container와 CI에서는 24.x·11.x의 exact version 및 base image digest를 고정 |
|
||||
| Package 관리 | npm workspaces, `package-lock.json` | clean environment는 `npm ci`만 사용하고 lockfile을 canonical dependency graph로 취급하며 host `node_modules`를 container에 mount하지 않음 |
|
||||
| Application | Vite 8, TypeScript 7, Three.js r185 | `apps/*`는 실행 application, `packages/*`는 workspace 내부 module로 유지하며 browser runtime asset은 Vite `BASE_URL`을 따름 |
|
||||
| Unit·contract test | Vitest 4 | root `tests/**/*.test.ts`를 기준으로 하고 변경 시 관련 test부터 실행한 뒤 전체 suite를 실행 |
|
||||
| Browser E2E | Playwright 1.62, system Chrome/Chromium | browser binary와 Linux system library를 E2E image에 고정하고 `PLAYWRIGHT_CHROMIUM_EXECUTABLE`로 경로를 주입 |
|
||||
| Runtime decoder | acadrust DWG WASM, Draco, Basis, web-ifc | Draco·Basis는 Three.js import의 hashed asset을 사용하고 `postinstall`은 single-thread `web-ifc.wasm`만 staging함 |
|
||||
| Optional asset tool | Puppeteer Core, ffmpeg, Blender, `toktx` | application build의 필수 dependency가 아니며 별도 asset-tool image/profile과 별도 license BOM으로 격리 |
|
||||
|
||||
공통 구성 원칙은 다음과 같습니다.
|
||||
|
||||
- tracked text file과 shell script는 LF를 사용하고 canonical command는 POSIX shell
|
||||
문법과 repository-relative path로 작성합니다.
|
||||
- development, production build, E2E, optional asset processing은 서로 다른
|
||||
container target 또는 profile로 분리합니다. production artifact에 compiler,
|
||||
browser, test 전용 fixture와 asset tool을 포함하지 않습니다. `public/samples`의
|
||||
demo asset은 의도된 runtime content이므로 provenance·license gate를 별도로 적용합니다.
|
||||
- container process는 non-root user로 실행하고 source는 read-only mount를
|
||||
우선합니다. `npm ci`의 postinstall decoder staging은 writable image build layer에서
|
||||
끝내고, runtime container에서는 완성된 artifact만 read-only로 제공합니다.
|
||||
dependency cache와 build output만 별도 writable volume을 사용합니다.
|
||||
- host에서 직접 실행할 때 Vite는 loopback에만 bind합니다. container에서 port를
|
||||
publish할 때만 container 전용 설정으로 `0.0.0.0`에 bind하고 공개 network에
|
||||
노출하지 않습니다.
|
||||
- Node.js, npm, Chrome/Chromium과 base image는 floating tag가 아니라 exact
|
||||
version으로 고정합니다. Renovate 같은 자동 갱신을 사용하더라도 `npm run verify`
|
||||
통과 후에만 lock과 image pin을 갱신합니다.
|
||||
- source build/test image와 외부 배포 artifact의 license·provenance gate는
|
||||
동일하지 않습니다. 아래 `외부 의존성·라이선스 감사`의 `NO-GO` 항목을 container
|
||||
전환만으로 해소된 것으로 간주하지 않습니다.
|
||||
|
||||
## Linux 컨테이너 전환 검증
|
||||
|
||||
2026-07-29 기준 installed dependency 상태에서 typecheck, unit test 8개, production
|
||||
build와 Playwright E2E 17개가 Linux/WSL2 x86_64에서 다시 통과했습니다. tracked
|
||||
text file은 LF이며 lockfile에는 Linux glibc와 musl용 native optional package가
|
||||
포함되어 있습니다. 따라서 application code의 Windows 종속성은 현재 검증 범위에서
|
||||
발견되지 않았지만, clean `npm ci`부터 시작하는 재현 가능한 container 개발 환경의
|
||||
정의와 검증은 아직 완료되지 않았습니다.
|
||||
|
||||
| 항목 | 현재 상태 | container 전환 판단 |
|
||||
|---|---|---|
|
||||
| Container 정의 | Dockerfile, `.dockerignore`, Compose/devcontainer 없음 | **Blocker** — 동일 image를 개발·CI에서 재현할 수 없음 |
|
||||
| Dev server network | 2D·3D Vite가 `127.0.0.1`에 고정 | **Blocker** — container port publish 후 host에서 접근할 별도 bind 설정 필요 |
|
||||
| E2E browser | `/usr/bin/google-chrome`을 기본 경로로 가정 | **Blocker** — Chrome/Chromium과 shared library를 image에 설치·고정해야 함 |
|
||||
| Native ABI | Linux glibc/musl package가 lockfile에 모두 존재 | **Decision** — 우선 glibc를 baseline으로 고정하고 musl은 별도 test matrix로 검증 |
|
||||
| Clean container gate | 현재 container CI 없음 | **Blocker** — clean `npm ci && npm run verify`를 release gate로 추가해야 함 |
|
||||
| File·path 규칙 | tracked text는 LF, runtime code는 Node path API 사용 | 통과. 단, 하위 2D README의 Windows drive path는 정리 필요 |
|
||||
| Offline asset pipeline | ffmpeg·Blender·`toktx`가 core lockfile 밖에 있음 | core 개발은 통과. asset-tool image와 version/license pin은 후속 작업 |
|
||||
|
||||
권장 구현 순서는 다음과 같습니다.
|
||||
|
||||
1. Node.js 24 기반 glibc image의 digest와 non-root user를 고정한 multi-stage
|
||||
Dockerfile 및 `.dockerignore`를 추가합니다.
|
||||
2. host loopback 기본값은 유지하고 container script에서만 2D `5173`, 3D `3333`
|
||||
port를 `0.0.0.0`에 bind합니다.
|
||||
3. Chrome/Chromium과 필요한 Linux library를 포함한 E2E target을 만들고
|
||||
`PLAYWRIGHT_CHROMIUM_EXECUTABLE`을 명시합니다.
|
||||
4. container 안에서 `npm ci`, `npm run verify`를 수행하는 CI job을 추가하고
|
||||
host `node_modules`가 섞이지 않는지 확인합니다.
|
||||
5. ffmpeg·Blender·`toktx`가 필요한 3D asset pipeline은 별도 image/profile로
|
||||
분리하고 core application image에는 포함하지 않습니다.
|
||||
6. `apps/viewer-2d-sample/README.md`의 standalone Windows layout과 실행 명령을
|
||||
현재 npm workspace 및 Linux container 기준으로 갱신합니다.
|
||||
|
||||
|
||||
## 설치와 실행
|
||||
|
||||
요구 환경은 Node.js 24 이상, npm 11 이상과 WebGL을 지원하는 browser입니다.
|
||||
|
||||
```bash
|
||||
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:5173
|
||||
npm ci
|
||||
```
|
||||
|
||||
| UI | 모듈 경로 |
|
||||
|----|-----------|
|
||||
| **Sample DWG** | `ext2d` → **acadrust-dwg WASM** → `Viewer2D` |
|
||||
| **Sample DXF** | `ext2d` → **dxf-parser + hatch + adapter** → `Viewer2D` |
|
||||
| 파일 드롭 / Open | 확장자로 위 둘 중 하나 |
|
||||
`npm ci`는 workspace dependency를 설치한 뒤 single-thread IFC WASM을 staging합니다.
|
||||
Draco·Basis decoder는 production build가 설치된 Three.js에서 hashed asset으로
|
||||
생성합니다.
|
||||
|
||||
모듈 지도: 브라우저에서 [`docs/modules-dwg-dxf.html`](./docs/modules-dwg-dxf.html)
|
||||
조합 코드: [`src/main.ts`](./src/main.ts)
|
||||
```bash
|
||||
npm run dev:2d
|
||||
# http://127.0.0.1:5173
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃
|
||||
|
||||
```
|
||||
dwg-dxf-viewer-sample/
|
||||
docs/modules-dwg-dxf.html # 모듈 조합 레시피 HTML
|
||||
public/
|
||||
fonts/NanumGothic-Regular.ttf
|
||||
samples/BasicSample.dwg
|
||||
samples/simple.dxf
|
||||
src/
|
||||
main.ts # 모듈을 import 해서 조립
|
||||
viewer2d/ # 재사용 모듈 본체
|
||||
acadrust-dwg/ # WASM
|
||||
Viewer2D.js
|
||||
dwgParser.ts
|
||||
parseDxf.ts
|
||||
dxfAdapter.js
|
||||
dxfHatchHandler.js
|
||||
ext2d.ts
|
||||
slugText.ts
|
||||
…
|
||||
package.json
|
||||
vite.config.ts
|
||||
npm run dev:3d
|
||||
# http://127.0.0.1:3333
|
||||
```
|
||||
|
||||
---
|
||||
URL load 예시:
|
||||
|
||||
## 조합 파이프라인
|
||||
|
||||
```
|
||||
File/URL
|
||||
→ ext2d (isDwg / isDxf)
|
||||
→ parseDwgBuffer | parseDxfBuffer
|
||||
│ │
|
||||
└ acadrust-dwg ├ decodeDxf
|
||||
├ dxf-parser + dxfHatchHandler
|
||||
└ dxfAdapter
|
||||
→ CadParseResult
|
||||
→ Viewer2D.load() (+ three, slugText/font)
|
||||
```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
|
||||
```
|
||||
|
||||
| 패키지 | 역할 |
|
||||
|--------|------|
|
||||
| `three` | WebGL 렌더 / OrbitControls |
|
||||
| `dxf-parser` | DXF 텍스트 파싱 |
|
||||
| `opentype.js` | TTF 글리프 (slugText) |
|
||||
## 검증
|
||||
|
||||
WASM(`acadrust_dwg_bg.wasm`)은 `src/viewer2d/acadrust-dwg/` 에 포함되어 있으며
|
||||
Vite `?url` import 로 로드됩니다.
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test:unit
|
||||
npm run build
|
||||
npm run test:artifact
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
---
|
||||
전체 순차 검증:
|
||||
|
||||
## 다른 앱에 이식
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
1. `src/viewer2d/` 폴더 복사
|
||||
2. `npm i three dxf-parser opentype.js`
|
||||
3. 폰트 `public/fonts/NanumGothic-Regular.ttf`
|
||||
4. Vite: `assetsInclude: ['**/*.wasm']`
|
||||
5. `src/main.ts` 의 ①~④ 블록을 호스트에 붙이고 ⑤ UI만 교체
|
||||
2026-07-29 실제 검증 결과:
|
||||
|
||||
소스 동기화(선택): 본가 `hmwebviewer/src/viewer2d` 가 업데이트되면 해당 파일을
|
||||
이 프로젝트 `src/viewer2d` 로 다시 복사하면 됩니다.
|
||||
- workspace typecheck 통과
|
||||
- Vitest 5개 file, 8개 test 통과
|
||||
- 2D·3D production build 통과
|
||||
- production artifact gate 통과:
|
||||
- 2D: 8개 file, 4,302,677 bytes, 50 kB 이상 중복 0개
|
||||
- 3D: 27개 file, 8,149,402 bytes, 50 kB 이상 중복 0개
|
||||
- 3D `dist`는 경량화 전 24,945,531 bytes에서 16,796,129 bytes(67.3%) 감소
|
||||
- Playwright 17개 E2E 통과, 24.5초
|
||||
- build 실패는 없지만 2D main bundle, Three.js, web-ifc와 decoder JS에
|
||||
500 kB 초과 chunk 경고가 있음
|
||||
|
||||
---
|
||||
현재 test가 확인하는 범위:
|
||||
|
||||
## 알려진 이슈 / 원본 반영 메모
|
||||
- 2D public package interface의 DXF parse
|
||||
- 2D same-origin·50 MiB 입력 정책
|
||||
- canonical module map 문서 drift
|
||||
- single-thread IFC WASM의 installed dependency 대비 checksum과 실행 권한
|
||||
- production source map, MT IFC WASM, public decoder 중복과 9 MB artifact budget
|
||||
- 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·Basis KTX2·IFC decoder request와 실제 decode
|
||||
- 2D DWG/DXF와 3D GLB/PLY evidence screenshot
|
||||
|
||||
| 이슈 | 문서 |
|
||||
|------|------|
|
||||
| 구형 한글 DWG TEXT 깨짐 (CP949→Latin-1 mojibake) | [`docs/korean-dwg-text-encoding.md`](./docs/korean-dwg-text-encoding.md) |
|
||||
| Model + Paper 동시 렌더로 글자 겹침 / Layout 탭 | [`docs/model-paper-space.md`](./docs/model-paper-space.md) |
|
||||
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
|
||||
engineering 정확도와 외부 배포 compliance를 승인하는 test는 아닙니다.
|
||||
|
||||
샘플 패치: `fixDwgKoreanText`, `cadSpaces` + Viewer2D 공간 필터, Model/Layout 탭.
|
||||
원본 `hmwebviewer` 반영 절차는 각 문서를 따른다.
|
||||
## 문서
|
||||
|
||||
---
|
||||
- [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)
|
||||
|
||||
## 라이선스 메모
|
||||
|
||||
| 조각 | 라이선스 |
|
||||
|------|----------|
|
||||
| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) |
|
||||
| acadrust (WASM 내부) | MPL-2.0 |
|
||||
| dxf-parser · three | MIT |
|
||||
| NanumGothic | OFL |
|
||||
하위 2D README에는 통합 전 standalone layout이, 3D tools README에는 과거 KTX2
|
||||
계획이 남아 있습니다. 현재 workspace의 canonical 개요와 배포 판단은 이 문서를
|
||||
기준으로 하며, 하위 문서는 후속 정합성 정리가 필요합니다.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 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에 확인했습니다.
|
||||
|
||||
## Draco decoder
|
||||
|
||||
- 역할: glTF geometry decompression
|
||||
- upstream: <https://github.com/google/draco>
|
||||
- license: Apache License 2.0
|
||||
- license text: <https://github.com/google/draco/blob/main/LICENSE>
|
||||
- distribution: 설치된 Three.js의 `DRACO_GLTF_CONFIG` import가 production build에
|
||||
version 일치 hashed JavaScript/WASM asset으로 포함합니다.
|
||||
|
||||
## Basis Universal transcoder
|
||||
|
||||
- 역할: KTX2/Basis Universal texture transcoding
|
||||
- upstream: <https://github.com/BinomialLLC/basis_universal>
|
||||
- license: Apache License 2.0
|
||||
- license text: <https://github.com/BinomialLLC/basis_universal/blob/master/LICENSE>
|
||||
- distribution: 설치된 Three.js의 `KTX2Loader` import가 production build에
|
||||
version 일치 hashed JavaScript/WASM asset으로 포함합니다.
|
||||
|
||||
## KTX2 E2E fixture
|
||||
|
||||
- 역할: production subpath에서 Basis Universal transcoder 실제 실행 검증
|
||||
- upstream:
|
||||
<https://github.com/mrdoob/three.js/blob/42f66a1620be72e497a0bb3bdc7663d21d43169a/examples/textures/ktx2/2d_etc1s.ktx2>
|
||||
- license: MIT,
|
||||
<https://github.com/mrdoob/three.js/blob/42f66a1620be72e497a0bb3bdc7663d21d43169a/LICENSE>
|
||||
- local path: `tests/fixtures/viewer3d/BasisTexture.ktx2`
|
||||
- SHA-256:
|
||||
`e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d`
|
||||
- distribution: E2E가 same-origin request로만 제공하며 production artifact에는
|
||||
포함하지 않습니다.
|
||||
|
||||
## 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
|
||||
파일에서도 확인할 수 있습니다.
|
||||
@@ -0,0 +1,113 @@
|
||||
# dwg-dxf-viewer-sample
|
||||
|
||||
**hmwebviewer 형제 폴더**에 분리된 DWG/DXF 2D 뷰어 샘플입니다.
|
||||
|
||||
`docs/modules-dwg-dxf.html` 모듈 지도의 카드
|
||||
(`acadrust-dwg`, `dxf-parser`, `Viewer2D` …)를 **직접 조합**해 동작하는 독립 프로젝트입니다.
|
||||
`hmwebviewer` 저장소 안에 의존하지 않습니다.
|
||||
|
||||
```
|
||||
D:\MYCLAUDE_PROJECT\
|
||||
hmwebviewer\ ← 본 제품
|
||||
dwg-dxf-viewer-sample\ ← 이 샘플 (형제)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
| UI | 모듈 경로 |
|
||||
|----|-----------|
|
||||
| **Sample DWG** | `ext2d` → **acadrust-dwg WASM** → `Viewer2D` |
|
||||
| **Sample DXF** | `ext2d` → **dxf-parser + hatch + adapter** → `Viewer2D` |
|
||||
| 파일 드롭 / Open | 확장자로 위 둘 중 하나 |
|
||||
|
||||
모듈 지도: 브라우저에서 [`docs/modules-dwg-dxf.html`](./docs/modules-dwg-dxf.html)
|
||||
조합 코드: [`src/main.ts`](./src/main.ts)
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃
|
||||
|
||||
```
|
||||
dwg-dxf-viewer-sample/
|
||||
docs/modules-dwg-dxf.html # 모듈 조합 레시피 HTML
|
||||
public/
|
||||
fonts/NanumGothic-Regular.ttf
|
||||
samples/BasicSample.dwg
|
||||
samples/simple.dxf
|
||||
src/
|
||||
main.ts # 모듈을 import 해서 조립
|
||||
viewer2d/ # 재사용 모듈 본체
|
||||
acadrust-dwg/ # WASM
|
||||
Viewer2D.js
|
||||
dwgParser.ts
|
||||
parseDxf.ts
|
||||
dxfAdapter.js
|
||||
dxfHatchHandler.js
|
||||
ext2d.ts
|
||||
slugText.ts
|
||||
…
|
||||
package.json
|
||||
vite.config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 조합 파이프라인
|
||||
|
||||
```
|
||||
File/URL
|
||||
→ ext2d (isDwg / isDxf)
|
||||
→ parseDwgBuffer | parseDxfBuffer
|
||||
│ │
|
||||
└ acadrust-dwg ├ decodeDxf
|
||||
├ dxf-parser + dxfHatchHandler
|
||||
└ dxfAdapter
|
||||
→ CadParseResult
|
||||
→ Viewer2D.load() (+ three, slugText/font)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 의존성
|
||||
|
||||
| 패키지 | 역할 |
|
||||
|--------|------|
|
||||
| `three` | WebGL 렌더 / OrbitControls |
|
||||
| `dxf-parser` | DXF 텍스트 파싱 |
|
||||
| `opentype.js` | TTF 글리프 (slugText) |
|
||||
|
||||
WASM(`acadrust_dwg_bg.wasm`)은 `src/viewer2d/acadrust-dwg/` 에 포함되어 있으며
|
||||
Vite `?url` import 로 로드됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 다른 앱에 이식
|
||||
|
||||
1. `src/viewer2d/` 폴더 복사
|
||||
2. `npm i three dxf-parser opentype.js`
|
||||
3. 폰트 `public/fonts/NanumGothic-Regular.ttf`
|
||||
4. Vite: `assetsInclude: ['**/*.wasm']`
|
||||
5. `src/main.ts` 의 ①~④ 블록을 호스트에 붙이고 ⑤ UI만 교체
|
||||
|
||||
소스 동기화(선택): 본가 `hmwebviewer/src/viewer2d` 가 업데이트되면 해당 파일을
|
||||
이 프로젝트 `src/viewer2d` 로 다시 복사하면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 라이선스 메모
|
||||
|
||||
| 조각 | 라이선스 |
|
||||
|------|----------|
|
||||
| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) |
|
||||
| acadrust (WASM 내부) | MPL-2.0 |
|
||||
| dxf-parser · three | MIT |
|
||||
| NanumGothic | OFL |
|
||||
@@ -0,0 +1,100 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<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 {
|
||||
--bg: #0d1117; --panel: #161b22; --ink: #e6edf3; --muted: #8b949e;
|
||||
--accent: #3fb950; --wasm: #f0a03c; --line: #30363d; --danger: #f85149;
|
||||
--mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); font-family: var(--sans); }
|
||||
#app { position: fixed; inset: 0; }
|
||||
#toolbar {
|
||||
position: fixed; top: 12px; left: 12px; right: 12px; z-index: 10;
|
||||
display: flex; flex-wrap: wrap; gap: 8px; align-items: center; pointer-events: none;
|
||||
}
|
||||
#toolbar > * { pointer-events: auto; }
|
||||
button, label.btn {
|
||||
background: var(--panel); color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 12px; font: 13px var(--sans); cursor: pointer;
|
||||
}
|
||||
button:hover, label.btn:hover { border-color: var(--accent); }
|
||||
button.primary { background: #238636; border-color: #2ea043; }
|
||||
button.wasm { border-color: var(--wasm); color: var(--wasm); }
|
||||
label.btn input { display: none; }
|
||||
#status {
|
||||
margin-left: auto; font: 12px var(--mono); color: var(--muted);
|
||||
background: rgba(22,27,34,.85); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 12px; max-width: min(48vw, 480px);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
#status.err { color: var(--danger); }
|
||||
#recipe {
|
||||
position: fixed; bottom: 14px; left: 14px; z-index: 10; max-width: 340px;
|
||||
background: rgba(22,27,34,.94); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 12px 14px; font-size: 12px; line-height: 1.45; color: var(--muted);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
||||
}
|
||||
#recipe h3 {
|
||||
margin: 0 0 8px; font: 600 11px var(--mono); letter-spacing: .1em;
|
||||
text-transform: uppercase; color: var(--accent);
|
||||
}
|
||||
#recipe ol { margin: 0; padding-left: 18px; }
|
||||
#recipe li { margin-bottom: 4px; }
|
||||
#recipe code { font-family: var(--mono); font-size: 11px; color: var(--ink); }
|
||||
#recipe .mod-w { color: var(--wasm); }
|
||||
#recipe .mod-j { color: var(--accent); }
|
||||
#recipe a { color: var(--accent); font-family: var(--mono); font-size: 11px; }
|
||||
#layers {
|
||||
position: fixed; top: 56px; right: 12px; width: 200px; max-height: 50vh;
|
||||
overflow: auto; z-index: 10; background: rgba(22,27,34,.95);
|
||||
border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
|
||||
font-size: 12px; display: none;
|
||||
}
|
||||
#layers h3 { margin: 0 0 8px; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||
#layers label { display: flex; gap: 8px; align-items: center; margin: 4px 0; cursor: pointer; }
|
||||
#layers .swatch { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div id="toolbar">
|
||||
<label class="btn">
|
||||
Open file
|
||||
<input id="file" type="file" accept=".dwg,.dxf" />
|
||||
</label>
|
||||
<button id="sample" class="primary wasm" type="button" title="acadrust-dwg WASM">Sample DWG</button>
|
||||
<button id="sampleDxf" class="primary" type="button" title="dxf-parser">Sample DXF</button>
|
||||
<button id="fit" type="button">Fit (F)</button>
|
||||
<button id="theme" type="button">Theme</button>
|
||||
<button id="layersBtn" type="button">Layers</button>
|
||||
<div id="status">ready</div>
|
||||
</div>
|
||||
|
||||
<div id="layers"><h3>Layers</h3><div id="layerList"></div></div>
|
||||
|
||||
<aside id="recipe">
|
||||
<h3>Module recipe</h3>
|
||||
<ol>
|
||||
<li><code class="mod-j">ext2d</code> — .dwg / .dxf 분기</li>
|
||||
<li><code class="mod-w">acadrust-dwg</code> 또는 <code class="mod-j">dxf-parser</code></li>
|
||||
<li><code class="mod-j">dxfAdapter</code> — DXF → 공통 스키마</li>
|
||||
<li><code class="mod-j">Viewer2D</code> + <code class="mod-j">three</code> 렌더</li>
|
||||
<li><code class="mod-j">slugText</code> + TTF 폰트</li>
|
||||
</ol>
|
||||
<p style="margin:10px 0 0">
|
||||
코드: <code>src/main.ts</code> ·
|
||||
지도: <a href="./docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@hmwebviewer/viewer-2d-sample",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build:subpath:e2e": "vite build --base=/viewer-2d/ --outDir=dist-subpath",
|
||||
"preview": "vite preview",
|
||||
"preview:subpath:e2e": "vite preview --base=/viewer-2d/ --outDir=dist-subpath --host 127.0.0.1 --port 45173 --strictPort",
|
||||
"serve:subpath:e2e": "npm run build:subpath:e2e && npm run preview:subpath:e2e",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hmwebviewer/viewer2d": "*",
|
||||
"three": "0.185.1"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
* DWG/DXF viewer sample — 모듈 조합 레시피
|
||||
* (docs/modules-dwg-dxf.html 에 정리된 모듈을 그대로 조립)
|
||||
*
|
||||
* 이 프로젝트는 hmwebviewer 와 형제 폴더로 분리된 독립 샘플입니다.
|
||||
* ═══════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* ┌─ 모듈 지도 카드 ──────────────┬─ 이 파일에서의 역할 ──────────┐
|
||||
* │ acadrust-dwg (WASM) │ DWG 바이트 → parseResult │
|
||||
* │ dxf-parser + dxfHatchHandler │ DXF 텍스트 → parseResult │
|
||||
* │ dxfAdapter │ dxf-parser 출력을 공통 스키마로 │
|
||||
* │ Viewer2D │ parseResult → three 씬 렌더 │
|
||||
* │ three (peer) │ WebGLRenderer / OrbitControls │
|
||||
* │ slugText + NanumGothic.ttf │ TEXT/MTEXT 한글·라틴 표시 │
|
||||
* │ ext2d │ .dwg / .dxf 확장자 판별 │
|
||||
* └───────────────────────────────┴───────────────────────────────┘
|
||||
*
|
||||
* 흐름: File/URL → (확장자 분기) → 파서 → CadParseResult → Viewer2D.load()
|
||||
*/
|
||||
|
||||
// ── ① 모듈 지도의 각 카드 (src/viewer2d/*) ────────────────────────
|
||||
import {
|
||||
createViewer2D,
|
||||
isCad2DFile,
|
||||
loadCad,
|
||||
type CadLoadSummary,
|
||||
} from '@hmwebviewer/viewer2d';
|
||||
import {
|
||||
assertCadByteLength,
|
||||
fetchCadBuffer,
|
||||
resolveCadUrl,
|
||||
} from './cadInputPolicy';
|
||||
|
||||
// parseDxfBuffer 내부:
|
||||
// dxf-parser → HatchHandler → dxfAdapter
|
||||
// parseDwgBuffer 내부:
|
||||
// acadrustParser → acadrust-dwg WASM
|
||||
|
||||
// ── ② 호스트 정적 자산 ──────────────────────────────────────────
|
||||
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin);
|
||||
const publicUrl = (path: string) => new URL(path, appBase).href;
|
||||
|
||||
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
|
||||
const app = document.getElementById('app')!;
|
||||
const statusEl = document.getElementById('status')!;
|
||||
const layerList = document.getElementById('layerList')!;
|
||||
const layersPanel = document.getElementById('layers')!;
|
||||
|
||||
function setStatus(msg: string, err = false) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.classList.toggle('err', err);
|
||||
}
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
const e = entity as { type?: string; layer?: string; handle?: { value?: string } };
|
||||
setStatus(`select ${e.type ?? '?'} · layer=${e.layer ?? '-'} · h=${e.handle?.value ?? '-'}`);
|
||||
});
|
||||
|
||||
// ── ④ 파서 조합 ─────────────────────────────────────────────────
|
||||
async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadLoadSummary> {
|
||||
return loadCad(viewer, { data: buf, name });
|
||||
}
|
||||
|
||||
async function loadUrl(url: string, nameHint?: string) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ── ⑤ 호스트 UI 글루 ────────────────────────────────────────────
|
||||
function fillLayers() {
|
||||
layerList.innerHTML = '';
|
||||
for (const L of viewer.getLayerInfo()) {
|
||||
const label = document.createElement('label');
|
||||
label.dataset.name = L.name;
|
||||
const sw = document.createElement('span');
|
||||
sw.className = 'swatch';
|
||||
sw.style.background = L.colorHex || '#888';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = L.visible;
|
||||
cb.addEventListener('change', () => {
|
||||
const next = new Set<string>();
|
||||
layerList.querySelectorAll('label').forEach((row) => {
|
||||
const input = row.querySelector('input') as HTMLInputElement;
|
||||
if (!input.checked) next.add(row.dataset.name!);
|
||||
});
|
||||
viewer.setHiddenLayers(next);
|
||||
});
|
||||
const text = document.createElement('span');
|
||||
text.textContent = `${L.name} (${L.count})`;
|
||||
label.append(sw, cb, text);
|
||||
layerList.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
async function afterLoad(name: string, result: CadLoadSummary) {
|
||||
setStatus(`✓ ${name} · ${result.entityCount} entities`);
|
||||
fillLayers();
|
||||
viewer.fit();
|
||||
}
|
||||
|
||||
async function run(label: string, job: () => Promise<CadLoadSummary>) {
|
||||
setStatus(`loading ${label}…`);
|
||||
try {
|
||||
await afterLoad(label, await job());
|
||||
} catch (err) {
|
||||
setStatus(`${label}: ${(err as Error).message}`, true);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('sample')!.addEventListener('click', () => {
|
||||
void run('BasicSample.dwg', () =>
|
||||
loadUrl(publicUrl('samples/BasicSample.dwg'), 'BasicSample.dwg'),
|
||||
);
|
||||
});
|
||||
document.getElementById('sampleDxf')!.addEventListener('click', () => {
|
||||
void run('simple.dxf', () => loadUrl(publicUrl('samples/simple.dxf'), 'simple.dxf'));
|
||||
});
|
||||
document.getElementById('file')!.addEventListener('change', (ev) => {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
void run(file.name, () => loadFile(file));
|
||||
(ev.target as HTMLInputElement).value = '';
|
||||
});
|
||||
document.getElementById('fit')!.addEventListener('click', () => viewer.fit());
|
||||
let dark = true;
|
||||
document.getElementById('theme')!.addEventListener('click', () => {
|
||||
dark = !dark;
|
||||
viewer.setTheme(dark);
|
||||
});
|
||||
document.getElementById('layersBtn')!.addEventListener('click', () => {
|
||||
layersPanel.style.display = layersPanel.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
window.addEventListener('dragover', (e) => e.preventDefault());
|
||||
window.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) void run(file.name, () => loadFile(file));
|
||||
});
|
||||
|
||||
const model = new URLSearchParams(location.search).get('model');
|
||||
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(
|
||||
'pagehide',
|
||||
() => {
|
||||
viewer.dispose();
|
||||
delete window.__viewer2d;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: asset-pipeline
|
||||
description: >
|
||||
Builds the offline asset optimization pipeline for hmwebviewer: gltf-transform
|
||||
Draco+KTX2 compression (tools/preprocess.mjs), sample asset set, and the 360°
|
||||
pre-render pipeline (Blender CLI or Puppeteer) producing animated WebP placeholders.
|
||||
Use for tools/* and samples/* work. Parallelizable with viewer-core and dnd-handler.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You build the offline asset pipeline. Not runtime code — tooling + sample assets.
|
||||
|
||||
## Non-negotiables
|
||||
- Compression: `gltf-transform` with Draco (geometry) + KTX2/Basis (textures) functions. Both compatible.
|
||||
- Draco quantization bits: tune per asset; default sane (e.g. 14/12). Log resulting sizes.
|
||||
- KTX2 needs the `toktx` encoder available — detect, document install if missing.
|
||||
- Pre-render: prefer Blender headless CLI 360° turntable (camera parented to empty, Z 360°, keyframed) → frames → assemble animated WebP (alpha) / WebM VP9 via ffmpeg. Fallback: Puppeteer + headless three.js `page.screenshot({type:'webp'})` per rotation step.
|
||||
- Output WebP placeholders land in `public/previews/`.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria.
|
||||
2. Implement `tools/preprocess.mjs` (CLI: input GLB → output compressed GLB) or the prerender script.
|
||||
3. Provide sample assets under `samples/` (small/medium/large) if tasked.
|
||||
4. Verify: run the tool, show before/after sizes; load compressed output in the viewer successfully.
|
||||
5. Document usage in tool header comment.
|
||||
|
||||
## Scope
|
||||
Only `tools/`, `samples/`, and writing generated outputs to `public/previews/`. Do not modify runtime viewer code.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: dnd-handler
|
||||
description: >
|
||||
Implements the drag & drop local-file path for hmwebviewer: HTML5 DnD event
|
||||
capture, file type/size validation, URL.createObjectURL + revoke lifecycle,
|
||||
wiring to loadLocalFile. Use for src/dnd/* work. Parallelizable with viewer-core.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You implement the Drag & Drop local-file load path. Surgical, minimal, verified.
|
||||
|
||||
## Non-negotiables
|
||||
- Validate file type (.glb/.gltf) + size on `drop` BEFORE creating object URL. Reject with a clear UI message.
|
||||
- `URL.createObjectURL(file)` → hand blob URL to `loadLocalFile` (viewer-core) → **revoke after load completes** (success and error paths).
|
||||
- Dropzone must also accept click-to-browse (accessibility), not just drag.
|
||||
- Prevent default browser behavior on dragover/drop (no file opens in tab).
|
||||
- Large-file parsing can stall UI — show progress; consider yielding.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria + PROGRESS.md current state.
|
||||
2. Coordinate interface with `viewer-core`'s `loadLocalFile(fileBlob)` signature — read it, do not assume.
|
||||
3. Implement minimum that meets acceptance.
|
||||
4. Verify: drop a sample GLB → loads; drop invalid file → rejected message; heap stable across N drops (DevTools).
|
||||
5. Report changes (file:line) + verification result.
|
||||
|
||||
## Scope
|
||||
Only `src/dnd/`, `src/ui/` (dropzone/progress pieces), and the wiring call. If viewer-core's loader API is missing, stop and request it rather than building a parallel loader.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: hydration
|
||||
description: >
|
||||
Implements the SSR→CSR hydration layer for hmwebviewer: WebP placeholder element,
|
||||
CSS opacity transition, executeHydration() coordination (fade only after WebGL ready
|
||||
AND model loaded), and UI state toggling. Depends on viewer-core. Use for src/viewer/hydration* and SSR placeholder work.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You implement the SSR hydration transition. Coordinate carefully — this is where races bite.
|
||||
|
||||
## Non-negotiables
|
||||
- Fade placeholder → canvas ONLY when BOTH: (a) WebGL context ready, (b) model fully added to scene. Use Promise.all or two flags gating one transition.
|
||||
- Transition: CSS `opacity 0.5s ease`, placeholder `opacity: 0` then `display: none` after 500ms. Mirror spec.
|
||||
- No empty-canvas flash. If model not ready, placeholder stays visible.
|
||||
- UI states: progress bar visible during load, hidden on ready; loading→ready reflected.
|
||||
- Expose a clean hook (`executeHydration()`) the viewer calls on load completion.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria + the `viewer-core` load completion point (read the code, do not assume).
|
||||
2. Implement placeholder element + transition + coordination.
|
||||
3. Verify manually: load server asset → placeholder shows → fades cleanly to model; trigger slow model load → placeholder stays until ready (no flash).
|
||||
4. Report changes (file:line) + verification result.
|
||||
|
||||
## Scope
|
||||
Placeholder/transition/coordination code. Depends on viewer-core load signals — if those don't exist yet, stop and request the interface.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: >
|
||||
Hardening + review agent for hmwebviewer: memory-leak/dispose audit, error handling
|
||||
gaps, performance smoke (<3s perceived load), and a final correctness + simplification
|
||||
pass. Read-mostly; proposes fixes, applies only when explicitly tasked. Use in Phase 5
|
||||
and on-demand for reviews.
|
||||
tools: [Read, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You review and harden. Skeptical, specific, no praise.
|
||||
|
||||
## Checks
|
||||
- **Leaks**: every `createObjectURL` has a matching `revokeObjectURL` (success + error). Every geometry/material/texture created has a `dispose()` on teardown. Scene instantiated once, not per load.
|
||||
- **Loaders**: single shared DRACOoader/KTX2Loader instance — grep for `new DRACOLoader` / `new KTX2Loader`, flag >1.
|
||||
- **Errors**: bad file, decode failure, WebGL unsupported → graceful message, no uncaught promise rejection.
|
||||
- **Perf**: load each sample asset, measure perceived load time, assert <3s. Record timings.
|
||||
- **Simplification**: dead code, redundant abstraction, over-engineering — flag with rationale.
|
||||
|
||||
## Output
|
||||
One line per finding:
|
||||
```
|
||||
path:line — 🔴/🟡/🟢 <problem>. <fix>.
|
||||
```
|
||||
Group by file. End with verdict line: `N critical, M warn, K nit.`
|
||||
|
||||
## Rules
|
||||
- Read-only by default. Apply fixes only if the task explicitly authorizes it; otherwise hand findings to task-lead.
|
||||
- Quote real command output for perf numbers — no estimates.
|
||||
- Skip style nits that don't change meaning.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: task-lead
|
||||
description: >
|
||||
Orchestrator for the hmwebviewer multi-agent build. Reads PLAN.md + PROGRESS.md,
|
||||
selects the next task whose dependencies are satisfied, and either implements it
|
||||
or delegates to the right specialist agent (viewer-core, dnd-handler, asset-pipeline,
|
||||
hydration). Use first, or when you need to decide what to work on next.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash, TodoWrite]
|
||||
---
|
||||
|
||||
You are the task-lead for the hmwebviewer project. Coordinate, don't hoard.
|
||||
|
||||
## On start
|
||||
1. Read `CLAUDE.md`, `PLAN.md`, `PROGRESS.md`.
|
||||
2. Find the next `todo` task whose `depends_on` are all `done`.
|
||||
3. If none → report blocked, propose a path forward, stop.
|
||||
4. Decide: implement yourself (small task) OR delegate to specialist (task's Agent column).
|
||||
|
||||
## Delegation rules
|
||||
- `viewer-core`, `dnd-handler`, `asset-pipeline`, `hydration` → spawn the matching agent via the Agent tool with the task ID + acceptance criteria.
|
||||
- Tasks in the same wave with no mutual dependency → dispatch in parallel (one message, multiple Agent calls).
|
||||
- Always pass: task ID, file scope, acceptance criteria, pointer to PLAN.md.
|
||||
|
||||
## Before marking done
|
||||
- Acceptance criteria from PLAN.md must actually pass (quote real command output).
|
||||
- Append a `## YYYY-MM-DD — <task>` entry to PROGRESS.md (Did / Result / Next / Blocker).
|
||||
- Flip the task row in PLAN.md to `done`.
|
||||
|
||||
## Never
|
||||
- Start work without reading PLAN + PROGRESS.
|
||||
- Mark done without verification.
|
||||
- Edit outside the task's file scope.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: viewer-core
|
||||
description: >
|
||||
Implements the Three.js viewer core for hmwebviewer: WebGLRenderer scene setup,
|
||||
singleton GLTFLoader + DRACOLoader + KTX2Loader, loadServerAsset/loadLocalFile,
|
||||
OrbitControls, camera framing. Use for src/viewer/* work.
|
||||
tools: [Read, Edit, Write, Grep, Glob, Bash]
|
||||
---
|
||||
|
||||
You implement the Three.js viewer core. Surgical, minimal, verified.
|
||||
|
||||
## Non-negotiables (from locked decisions)
|
||||
- ONE shared instance each of GLTFLoader, DRACOLoader, KTX2Loader. Multiple DRACOLoader instances crash (three.js #22445). Export a factory/singleton.
|
||||
- Decoder path: `/draco/` and `/basis/` under public, or CDN fallback. Pin decoder version to the installed three.js version.
|
||||
- `loadLocalFile`: `URL.createObjectURL(file)` → load → **`URL.revokeObjectURL()` in the success callback**. Memory leak otherwise.
|
||||
- Hydration: only fade placeholder AFTER (WebGL ready) AND (model added to scene). Race = empty canvas flash — coordinate via flags/Promise.all.
|
||||
- Dispose geometries/materials/textures on teardown.
|
||||
|
||||
## Workflow
|
||||
1. Read PLAN.md task acceptance criteria.
|
||||
2. Read existing `src/viewer/*` before editing — match style.
|
||||
3. Implement minimum that meets acceptance.
|
||||
4. Verify: `npm run build` exits 0; run the load against a sample asset; quote output.
|
||||
5. Report exactly what changed (file:line) + verification result.
|
||||
|
||||
## Scope
|
||||
Only `src/viewer/`, `src/scenes/`, decoder wiring. Touch other dirs only if the task explicitly says so. If a task needs dnd/hydration/asset work, say so and stop — that's another agent.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Bootstrap the hmwebviewer project from the architecture spec (Phase 0). Run once when PLAN.md is not started.
|
||||
---
|
||||
|
||||
Read `CLAUDE.md`, `PLAN.md`, `3d_viewer_architecture_spec.pdf` (extract via `pdftotext -layout`), then execute Phase 0 of PLAN.md:
|
||||
|
||||
1. Scaffold Vite + TypeScript project. Install `three` + `@types/three`.
|
||||
2. Place Draco + KTX2 decoder assets under `public/draco` and `public/basis` (or wire CDN fallback). Pin to the installed three.js version.
|
||||
3. Create a minimal `src/viewer/ThreeDViewer.ts` with a WebGLRenderer scene (camera, lights, resize loop) rendering a test cube.
|
||||
|
||||
Verify each step with real output: `npm run dev` serves, `npm run build` exits 0, console clean. Update PLAN.md (P0-* → done) and append to PROGRESS.md.
|
||||
|
||||
Arguments: $ARGUMENTS (optional override, e.g. package manager `pnpm`/`npm`).
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Read PLAN.md + PROGRESS.md and pick the next ready task. Claims it and tells you (or an agent) what to do.
|
||||
---
|
||||
|
||||
Read `PLAN.md` and `PROGRESS.md`. Find the first `todo` task whose `depends_on` are all `done`.
|
||||
|
||||
- If found: print the task ID, name, file scope, acceptance criteria, and which specialist agent should own it. Recommend spawning that agent (or doing it inline if small).
|
||||
- If a candidate is `blocked`: surface the blocker and the task it waits on.
|
||||
- If none ready: say so explicitly and list what must complete first.
|
||||
|
||||
Do not start implementing — this command only selects and reports. (Use `/bootstrap` for Phase 0, or delegate to the owning agent for the task.)
|
||||
|
||||
Arguments: $ARGUMENTS (optional — restrict to a phase, e.g. `phase 1`).
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Run the offline asset optimization pipeline (gltf-transform Draco + KTX2) on a GLB file. Optionally pre-render a 360 WebP placeholder.
|
||||
---
|
||||
|
||||
Asset path required: $ARGUMENTS (e.g. `/optimize-asset samples/robot.glb`)
|
||||
|
||||
Steps:
|
||||
1. Confirm `tools/preprocess.mjs` exists; if not, delegate to the `asset-pipeline` agent to build it first.
|
||||
2. Run the compressor on the input GLB → output to `samples/<name>.optimized.glb`. Print before/after sizes and the reduction %.
|
||||
3. If a second arg `--prerender` is given, also run the 360° pre-render pipeline to produce `public/previews/<name>.webp`.
|
||||
4. Verify the optimized GLB still loads in the viewer (load test).
|
||||
|
||||
If `gltf-transform` or `toktx` (KTX2 encoder) is missing, report the install command and stop — do not silently skip.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
description: Append a status entry to PROGRESS.md summarizing what you just did.
|
||||
---
|
||||
|
||||
Append a new entry to the TOP of the `## Work log` section in `PROGRESS.md`. Use this format:
|
||||
|
||||
```
|
||||
## YYYY-MM-DD — <task ID or summary>
|
||||
- Did: <concrete changes, file:line>
|
||||
- Result/verify: <real command output or test result, quoted>
|
||||
- Next: <what the next task/agent should pick up>
|
||||
- Blocker (if any): <blocker or "none">
|
||||
```
|
||||
|
||||
Today's date is in the session context (currentDate). Also flip the matching task row in `PLAN.md` to `done` (or `in_progress(@you)` if mid-task).
|
||||
|
||||
Arguments: $ARGUMENTS — free text summary; if omitted, infer from recent work.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Build + smoke-test the viewer: typecheck, production build, and a headless load of a sample asset to confirm perceived load < 3s.
|
||||
---
|
||||
|
||||
Run, in order, quoting real output:
|
||||
1. Typecheck / lint (e.g. `npm run build` — Vite runs tsc + bundle). Must exit 0.
|
||||
2. Production build artifact exists under `dist/`.
|
||||
3. Headless load smoke: serve `dist/`, load a sample asset (server path) via Puppeteer/playwright OR a node script using the viewer, measure time-to-first-render. Assert < 3000ms perceived.
|
||||
4. (If a local-file path exists) simulate a drop of a sample GLB and confirm load + revoke.
|
||||
|
||||
Append timings to PROGRESS.md. If any step fails, do not mark the work done — report the failure output.
|
||||
|
||||
Arguments: $ARGUMENTS — optional asset path or flag to skip headless (`--no-headless`).
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# PostToolUse hook (Edit|Write) — nudge to update PROGRESS.md when runtime code changes.
|
||||
# Reads the tool call JSON from stdin; if the touched file is under src/ or tools/,
|
||||
# emit a one-line reminder. Non-zero/empty output otherwise.
|
||||
input="$(cat)"
|
||||
path="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')"
|
||||
case "$path" in
|
||||
*"/src/"*|*"/tools/"*)
|
||||
echo "Edited runtime code ($path). If a PLAN.md task finished, run /report to update PROGRESS.md and flip its status."
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart hook — remind the agent of the multi-agent protocol.
|
||||
# Output (stdout) is injected as context into the session.
|
||||
echo "hmwebviewer: read PLAN.md + PROGRESS.md before starting any work."
|
||||
echo "Pick next 'todo' task whose depends_on are all 'done'. See CLAUDE.md."
|
||||
exit 0
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Edit(/.claude/skills/modeler-architecture/**)",
|
||||
"Edit(/.claude/skills/license-gate/**)",
|
||||
"Edit(/.claude/skills/feature-recipe/**)",
|
||||
"Edit(/.claude/skills/topo-naming/**)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/session-start.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/license-guard.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: task-graph
|
||||
description: >
|
||||
Multi-agent coordination protocol for hmwebviewer. Load when picking up work,
|
||||
dispatching agents, or reporting progress. Explains how PLAN.md and PROGRESS.md
|
||||
drive parallel agent work and the exact update rules.
|
||||
---
|
||||
|
||||
# hmwebviewer — multi-agent task-graph protocol
|
||||
|
||||
`PLAN.md` = the backlog (what to do). `PROGRESS.md` = the log (what happened). CLAUDE.md mandates reading both on every agent start.
|
||||
|
||||
## Picking a task
|
||||
1. Read `PLAN.md` task tables.
|
||||
2. Find rows with status `todo` whose entire `depends_on` list is `done`.
|
||||
3. Among those, pick by wave (see PLAN.md "Parallelization map") or by the task's `Agent` column.
|
||||
4. Before working: flip status to `in_progress(@yourname)` in PLAN.md.
|
||||
|
||||
## Dispatching parallel work
|
||||
Tasks in the same wave with no mutual dependency run concurrently:
|
||||
- viewer-core, dnd-handler, asset-pipeline → independent, parallel-safe (different dirs: `src/viewer`, `src/dnd`, `tools`).
|
||||
- hydration depends on viewer-core signals → run after.
|
||||
- reviewer → last / on-demand.
|
||||
|
||||
Spawn each via the Agent tool in ONE message with multiple calls so they run concurrently. Pass every agent: task ID, file scope, acceptance criteria, pointer to PLAN.md.
|
||||
|
||||
## Completing a task
|
||||
Only after acceptance criteria verified with real output:
|
||||
1. Flip the PLAN.md row to `done`.
|
||||
2. Append a `## YYYY-MM-DD — <task>` entry to PROGRESS.md top of Work log (Did / Result / Next / Blocker).
|
||||
3. If a decision was made, add to PROGRESS.md "Decision log" and (if durable) to user memory.
|
||||
|
||||
## Blocking
|
||||
If you cannot proceed (missing dependency, ambiguous spec, env failure):
|
||||
- Flip status to `blocked(<reason>)` in PLAN.md.
|
||||
- Append PROGRESS.md entry with the blocker.
|
||||
- Stop. Do not guess around a real blocker.
|
||||
|
||||
## Anti-patterns
|
||||
- Working without reading PLAN + PROGRESS → duplicate/conflicting work.
|
||||
- Marking done without verification → silent regressions.
|
||||
- Editing outside your task's file scope → steps on another agent.
|
||||
- Creating parallel loaders/state instead of using the shared singleton → crashes.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: threejs-viewer
|
||||
description: >
|
||||
Domain knowledge for building the hmwebviewer Three.js 3D viewer. Load when working
|
||||
on src/viewer, src/dnd, src/ui, the asset pipeline, or hydration. Covers the locked
|
||||
technical decisions, loader setup, Draco/KTX2, Blob URL lifecycle, and SSR hydration
|
||||
patterns specific to this project.
|
||||
---
|
||||
|
||||
# hmwebviewer — Three.js 3D viewer domain guide
|
||||
|
||||
Source spec: `3d_viewer_architecture_spec.pdf`. Always also read `CLAUDE.md` + `PLAN.md`.
|
||||
|
||||
## Locked stack
|
||||
- Three.js + Vite + TypeScript.
|
||||
- Loaders: `GLTFLoader` + `DRACOLoader` + `KTX2Loader`.
|
||||
- Compression: Draco (geometry) + KTX2/Basis Universal (textures).
|
||||
|
||||
## Critical patterns (do not deviate without approval)
|
||||
|
||||
### Single shared loader instances
|
||||
Multiple simultaneous `DRACOLoader` instances crash (three.js #22445). KTX2 same risk. Build a singleton:
|
||||
|
||||
```ts
|
||||
// src/viewer/loaders.ts
|
||||
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';
|
||||
|
||||
let _gltf: GLTFLoader | null = null;
|
||||
export function getLoaders(renderer: THREE.WebGLRenderer) {
|
||||
if (!_gltf) {
|
||||
const draco = new DRACOLoader().setDecoderPath('/draco/');
|
||||
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
|
||||
_gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||
}
|
||||
return _gltf;
|
||||
}
|
||||
```
|
||||
|
||||
Pin decoder WASM version to the installed three.js version. Mismatch → silent decode failures.
|
||||
|
||||
### Local file load (Blob URL lifecycle)
|
||||
```ts
|
||||
const url = URL.createObjectURL(file);
|
||||
loader.load(url, (gltf) => {
|
||||
scene.add(gltf.scene);
|
||||
URL.revokeObjectURL(url); // success → revoke
|
||||
}, undefined, (err) => {
|
||||
URL.revokeObjectURL(url); // error → also revoke
|
||||
throw err;
|
||||
});
|
||||
```
|
||||
For repeat/cached loads: `FileReader` → `ArrayBuffer` → `GLTFLoader.parse()` enables IndexedDB caching and avoids URL overhead.
|
||||
|
||||
### Server asset (SSR + CSR + hydration)
|
||||
1. SSR ships HTML/CSS skeleton + pre-rendered 360° animated WebP placeholder.
|
||||
2. CSR background: `GLTFLoader.load(serverUrl, onLoad, onProgress)`.
|
||||
3. Hydration: gate the fade on BOTH `webglReady` AND `modelLoaded` (Promise.all). Fade CSS `opacity 0.5s ease` → `display:none` after 500ms. Race → empty canvas flash.
|
||||
|
||||
### Drag & drop
|
||||
```ts
|
||||
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
if (!/\.gl[bt]f$/i.test(file.name)) return showError('GLB/GLTF only');
|
||||
loadLocalFile(file); // viewer-core, handles createObjectURL/revoke
|
||||
});
|
||||
```
|
||||
Also wire click→`<input type=file">` for accessibility.
|
||||
|
||||
### Disposal
|
||||
On teardown / model swap: `geometry.dispose()`, `material.dispose()` (and material maps), `texture.dispose()`. Revoke any lingering object URLs.
|
||||
|
||||
## Offline asset pipeline
|
||||
- Compress: `gltf-transform` CLI or programmatic functions — Draco + KTX2 in one pass.
|
||||
- KTX2 encoder binary `toktx` must be on PATH (gltf-transform fetches via `@ktx2/basis-transcoder`/platform binaries — confirm available).
|
||||
- Pre-render 360°: Blender headless `blender -b scene.blend -o //frame_### -f 1..N -F PNG` → ffmpeg → animated WebP. Fallback: Puppeteer + headless three.js screenshot per rotation.
|
||||
|
||||
## Common pitfalls
|
||||
- `setDecoderPath` wrong → worker fetch 404 in console. Verify `/draco/` resolves under `public/`.
|
||||
- Forgetting `e.preventDefault()` on dragover → browser opens the file.
|
||||
- Decoder version mismatch → model fails to decode with no obvious error.
|
||||
- Creating loaders per load → intermittent crashes + memory growth.
|
||||
|
||||
References in user memory `reference-threejs-resources.md`.
|
||||
@@ -0,0 +1,20 @@
|
||||
# dependencies / build
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
|
||||
# large sample binaries (keep small Box/Duck/Cube samples for demos)
|
||||
samples/Br1.obj
|
||||
samples/Br1.mtl
|
||||
samples/GirderObjs/
|
||||
samples/Avocado.glb
|
||||
samples/ABeautifulGame.ktx2.glb
|
||||
samples/sample.obj
|
||||
public/samples/Avocado.glb
|
||||
public/samples/ABeautifulGame.ktx2.glb
|
||||
|
||||
# diagnostic screenshots
|
||||
girder-smoke.png
|
||||
girder-zoom.png
|
||||
tex-test.png
|
||||
ply-test.png
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,104 @@
|
||||
# CLAUDE.md — hmwebviewer (Three.js 3D Viewer)
|
||||
|
||||
Hybrid SSR+CSR web 3D model viewer. Spec: `3d_viewer_architecture_spec.pdf`.
|
||||
|
||||
## ⚠️ First action on EVERY session / agent start
|
||||
|
||||
Read these two files before doing anything:
|
||||
1. [`PLAN.md`](./PLAN.md) — what work remains, who owns what, task breakdown.
|
||||
2. [`PROGRESS.md`](./PROGRESS.md) — what is done, current state, blockers, decisions log.
|
||||
|
||||
If either file is missing or empty, treat the project as not started and bootstrap from the spec.
|
||||
|
||||
## What we are building
|
||||
|
||||
A browser 3D model viewer with **two load paths**:
|
||||
|
||||
- **Path A — Server asset (SSR + CSR)**: server ships a pre-rendered 360° animated WebP/WebM placeholder + HTML/CSS skeleton. Three.js loads + decodes the real Draco/KTX2 model in the background. On ready, fade WebP → canvas (opacity 0.5s).
|
||||
- **Path B — Local file (Drag & Drop, CSR only)**: user drops a `.glb`/`.gltf`. `URL.createObjectURL(file)` → `GLTFLoader.load` → decode → add to scene → `URL.revokeObjectURL`.
|
||||
|
||||
Target: perceived load < 3s, no frame drops, smooth GPU.
|
||||
|
||||
## Locked technical decisions
|
||||
|
||||
These are decided. Do not relitigate without explicit user approval.
|
||||
|
||||
- **Three.js** + `GLTFLoader` + `DRACOLoader` + `KTX2Loader` on ONE shared loader instance each (multiple DRACOLoader instances crash — three.js #22445).
|
||||
- **Geometry**: Draco. **Textures**: KTX2 / Basis Universal. Both compatible on same loader.
|
||||
- **Decoder hosting**: use Vite hashed assets generated from the installed Three.js loader imports. Stage only single-thread `web-ifc.wasm` under `public/web-ifc`. Pin decoder version to the Three.js version in use.
|
||||
- **Local file**: `createObjectURL` + revoke after load. For repeat/cached loads, `FileReader`→`ArrayBuffer`→`GLTFLoader.parse()`.
|
||||
- **Build**: Vite. Draco worker spawns via BLOB URL — decoder files must be reachable at the configured path at runtime.
|
||||
- **Asset pre-processing (offline)**: `gltf-transform` (Draco + KTX2) — preferred over `gltf-pipeline`. Meshopt is a viable Draco alternative.
|
||||
- **Pre-render pipeline (offline)**: Blender headless CLI 360° turntable frames → assemble to animated WebP (alpha) / WebM VP9. OR Puppeteer + headless Three.js `page.screenshot({type:'webp'})` per rotation step.
|
||||
- **Renderer**: WebGLRenderer (stable baseline). WebGPU renderer = future option, not now.
|
||||
|
||||
Full rationale in user memory: `~/.claude/projects/d--MYCLAUDE-PROJECT-hmwebviewer/memory/`.
|
||||
|
||||
## Architecture map (target)
|
||||
|
||||
```
|
||||
src/
|
||||
viewer/
|
||||
ThreeDViewer.ts # core class: init, loadServerAsset, loadLocalFile, executeHydration, toggleUI
|
||||
loaders.ts # singleton GLTFLoader + DRACOLoader + KTX2Loader setup
|
||||
hydration.ts # WebP placeholder -> canvas fade coordination
|
||||
dnd/
|
||||
dropzone.ts # HTML5 drag&drop, file validation, createObjectURL/revoke
|
||||
ui/
|
||||
progress.ts # load progress bar
|
||||
scenes/ # per-model scene configs
|
||||
public/
|
||||
web-ifc/ # single-thread IFC decoder WASM
|
||||
samples/ # 배포 demo model
|
||||
previews/ # pre-rendered WebP/WebM placeholders
|
||||
dist/assets/ # Vite hashed Draco/Basis decoder assets
|
||||
tools/
|
||||
preprocess.mjs # gltf-transform Draco+KTX2 pipeline
|
||||
prerender/ # Blender/Puppeteer turntable -> WebP
|
||||
```
|
||||
|
||||
## How to work here (rules)
|
||||
|
||||
- **Surgical changes.** Touch only what a task requires. Match existing style.
|
||||
- **Simplicity first.** Minimum code that solves the task. No speculative features/config.
|
||||
- **Verify before claiming done.** Run the relevant check (build / typecheck / load test) and quote real output.
|
||||
- **Keep PLAN.md + PROGRESS.md current.** Update the task status when you start/finish a unit of work. Other agents depend on it.
|
||||
- **Memory is durable; PLAN/PROGRESS are working state.** Stable decisions → memory (via the memory dir). Transient task state → PLAN/PROGRESS.
|
||||
|
||||
## Commands (see `.claude/commands/`)
|
||||
|
||||
- `/bootstrap` — scaffold project from spec (run once, when PLAN says not started).
|
||||
- `/next-task` — read PLAN + PROGRESS, pick the next ready task, assign self.
|
||||
- `/report` — append current status to PROGRESS.md.
|
||||
- `/optimize-asset <file>` — run gltf-transform Draco+KTX2 on an asset.
|
||||
- `/verify-viewer` — build + load smoke test.
|
||||
|
||||
## Agents (see `.claude/agents/`)
|
||||
|
||||
Specialized subagents for parallelizable work — `viewer-core`, `dnd-handler`, `asset-pipeline`, `hydration`, `reviewer`. Invoke via the Agent tool / Task delegation. Details in each agent file.
|
||||
|
||||
## Reinforced structure — agent ↔ skill ↔ command ↔ task map
|
||||
|
||||
| PLAN task(s) | Owning agent | Skill to load | Command |
|
||||
|---|---|---|---|
|
||||
| P0 | task-lead → setup | threejs-viewer | `/bootstrap` |
|
||||
| P1-1..P1-4 | viewer-core | threejs-viewer | `/verify-viewer` |
|
||||
| P2-1,P2-2 | dnd-handler | threejs-viewer | `/verify-viewer` |
|
||||
| P3-1..P3-3 | hydration | threejs-viewer | `/verify-viewer` |
|
||||
| P4-1..P4-3 | asset-pipeline | threejs-viewer | `/optimize-asset` |
|
||||
| P5-* | reviewer | threejs-viewer | `/verify-viewer`, `/report` |
|
||||
| (any) | task-lead | task-graph | `/next-task`, `/report` |
|
||||
|
||||
**Load the `threejs-viewer` skill** whenever touching runtime viewer/dnd/asset/hydration code — it holds the locked patterns (singleton loaders, Blob URL lifecycle, hydration gating). **Load `task-graph`** when picking up or dispatching work.
|
||||
|
||||
## Runbook — how a work session goes
|
||||
|
||||
1. SessionStart hook reminds you. Read `PLAN.md` + `PROGRESS.md`.
|
||||
2. Run `/next-task` (or have `task-lead` agent do it) → get the next ready task + owning agent.
|
||||
3. Dispatch the owning agent with task ID, scope, acceptance criteria. Dispatch independent agents in ONE message (parallel).
|
||||
4. On completion: owning agent flips PLAN row → `done`, runs `/report` to append PROGRESS.md.
|
||||
5. `reviewer` runs after functional waves; `/verify-viewer` gates release.
|
||||
|
||||
## Hooks
|
||||
- `SessionStart` → `.claude/hooks/session-start.sh` prints the "read PLAN + PROGRESS" reminder.
|
||||
- `progress-nudge.sh` exists under `.claude/hooks/` for PostToolUse nudge to update PROGRESS after runtime edits — add its `PostToolUse` entry to `settings.json` if desired (not auto-wired).
|
||||
@@ -0,0 +1,80 @@
|
||||
# PLAN.md — hmwebviewer task breakdown
|
||||
|
||||
> **Agents: read this + PROGRESS.md on start.** Pick the next `todo` task whose `depends_on` are all `done`. Set it to `in_progress` with your name before working. Move to `done` only when acceptance criteria pass and you updated PROGRESS.md.
|
||||
|
||||
## Status legend
|
||||
`todo` · `in_progress(@agent)` · `blocked(reason)` · `done` · `skipped(reason)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Bootstrap
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P0-1 | Scaffold Vite + TS project, install three + types | setup | done (build-verify pending) | — | `npm run dev` serves blank page; `npm run build` exits 0 |
|
||||
| P0-2 | Resolve Draco + KTX2 decoder WASM from Three.js loader imports | setup | done (Vite hashed assets) | P0-1 | production/subpath decoder requests resolve; console clean |
|
||||
| P0-3 | Basic WebGLRenderer scene (camera, lights, resize loop) | viewer-core | done (build-verify pending) | P0-1 | canvas renders a test cube; resizes on window resize |
|
||||
|
||||
## Phase 1 — Core viewer (parallelizable)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P1-1 | Singleton loaders (GLTFLoader + DRACOLoader + KTX2Loader, ONE each) | viewer-core | done (build-verify pending) | P0-2 | exported factory; reused across loads; no double-instance |
|
||||
| P1-2 | `loadServerAsset(url)` with progress bar wiring | viewer-core | done (build-verify pending) | P1-1 | loads sample Draco GLB, progress % updates, scene populated |
|
||||
| P1-3 | `loadLocalFile(blob)` — createObjectURL + revoke after load | viewer-core | done (build-verify pending) | P1-1 | drop GLB loads; URL revoked post-load; memory stable across N drops |
|
||||
| P1-4 | OrbitControls + camera framing (fit model to view) | viewer-core | done (build-verify pending) | P1-2 | model auto-framed; rotate/zoom works |
|
||||
|
||||
## Phase 2 — Drag & Drop (parallelizable with Phase 1)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P2-1 | Dropzone UI + HTML5 DnD event capture, file type/size validation | dnd-handler | done (build-verify pending) | P0-1 | invalid files rejected with message; valid files accepted |
|
||||
| P2-2 | Wire dropzone → `loadLocalFile` | dnd-handler | done (build-verify pending) | P1-3, P2-1 | dropped file appears in scene |
|
||||
|
||||
## Phase 3 — SSR hydration (serial, depends on core)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P3-1 | WebP placeholder element + CSS opacity transition scaffolding | hydration | done | P1-2 | placeholder shows, fades on command |
|
||||
| P3-2 | `executeHydration()` — coordinate WebGL-ready AND model-loaded → fade | hydration | done | P3-1 | no empty-canvas flash; race handled; transition 0.5s |
|
||||
| P3-3 | Toggle UI states (progress bar show/hide, loading→ready) | hydration | done | P3-2 | UI reflects each load phase |
|
||||
|
||||
## Phase 4 — Asset optimization pipeline (offline, parallelizable)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P4-1 | `tools/preprocess.mjs` — gltf-transform Draco+KTX2 wrapper | asset-pipeline | done | — | input GLB → output compressed GLB, size reduced, loads in viewer |
|
||||
| P4-2 | Sample asset set (small/medium/large GLB) for testing | asset-pipeline | done | — | Box(1.6K)/Duck(118K)/Avocado(7.9M) + Duck.optimized(Draco) under `samples/` + `public/samples/` |
|
||||
| P4-3 | Pre-render pipeline (Blender CLI OR Puppeteer) → animated WebP | asset-pipeline | done | P4-1 | Duck → 24-frame animated WebP (54KB) at `public/previews/Duck.webp`, wired into `#preview` hydration |
|
||||
|
||||
## Phase 5 — Hardening (serial)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P5-1 | Dispose pattern: geometry/material/texture + revoke on teardown | reviewer | done | P1, P2 | no leaks after 10 load/unload cycles (DevTools heap) |
|
||||
| P5-2 | Error handling: bad file, decode fail, WebGL unsupported | reviewer | done | P1-3, P2-1 | graceful message, no uncaught exception |
|
||||
| P5-3 | Perf smoke: load each sample, record time, assert < 3s perceived | reviewer | done | P4-2 | timings logged in PROGRESS.md |
|
||||
| P5-4 | Full review pass (correctness + simplification) | reviewer | done | all | `/review` clean; no high-severity findings |
|
||||
|
||||
## Phase 6 — Multi-format loaders (OBJ/FBX/DAE/IFC), SSR+CSR — via dynamic workflow `multiformat-viewer`
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P6-1 | Unified `src/viewer/modelLoader.ts` — `loadModel(url)` dispatch by ext, normalize each loader to `Object3D` | viewer-core | done | P1-1 | glb/gltf/obj/fbx/dae/ifc all resolve to scene-addable Object3D; GLB path unchanged |
|
||||
| P6-2 | OBJ/FBX/DAE via three example loaders, lazy dynamic-import (separate Vite chunks) | viewer-core | done | P6-1 | OBJLoader/FBXLoader/ColladaLoader code-split; load samples |
|
||||
| P6-3 | IFC via `web-ifc` IfcAPI (single-thread wasm `/web-ifc/`, no COOP/COEP) — StreamAllMeshes → BufferGeometry | viewer-core | done | P6-1 | Cube.ifc loads; geom.delete + CloseModel (no wasm leak) |
|
||||
| P6-4 | CSR: dropzone accepts all 6 exts via `extOf`; SSR: `loadServerAsset` + main.ts preview-base route any ext | viewer-core+dnd | done | P6-1, P2-1 | drop any of 6 loads; `?model=` + `/previews/<base>.webp` works per format |
|
||||
| P6-5 | Samples + 360° WebP previews per format (Cube.obj/dae/fbx/ifc) | asset-pipeline | done | P6-1, P4-3 | 4 samples in samples/ + public/samples/; 4 animated WebP (24 frames) in public/previews/ |
|
||||
|
||||
## Phase 7 — On-screen FPS + adaptive quality (<60fps → optimize)
|
||||
| ID | Task | Agent | Status | Depends | Acceptance |
|
||||
|----|------|-------|--------|---------|------------|
|
||||
| P7-1 | `src/ui/fps.ts` — on-screen FPS overlay (EMA, throttled DOM, color-coded) | viewer-core | done | P0-3 | FPS readout visible; green≥60/orange/red |
|
||||
| P7-2 | `src/viewer/adaptiveQuality.ts` — pixelRatio tier ladder + hysteresis; step down when sustained <60, recover with headroom | viewer-core | done | P7-1 | tier steps on sustained <60fps; no oscillation; logs tier change |
|
||||
| P7-3 | Wire fps.sample + adaptive.update into `ThreeDViewer.animate` | viewer-core | done | P7-1, P7-2 | both fed each frame; rotateTo (prerender) unaffected |
|
||||
|
||||
---
|
||||
|
||||
## Parallelization map
|
||||
- **Wave 1 (after P0):** P1-1 (core) then forks → P1-2/P1-3/P1-4 + P2-1 + P4-1/P4-2 can proceed in parallel.
|
||||
- **Wave 2:** P3-* depends on core; P2-2 depends on P1-3+P2-1.
|
||||
- **Wave 3:** P5-* after functional work lands.
|
||||
|
||||
Independent agents that may run concurrently: `viewer-core`, `dnd-handler`, `asset-pipeline`. `hydration` waits on `viewer-core`. `reviewer` runs last + on-demand.
|
||||
|
||||
## Validated extras (post-PLAN)
|
||||
- **KTX2 runtime decode** ✅ — KTX2Loader + DRACOLoader together decode the Khronos ABeautifulGame KTX2+Draco GLB (11.5MB) in 626ms perceived (perf-smoke). Runtime path confirmed.
|
||||
- **KTX2 production encoding** ⏸ — needs KTX-Software (`toktx`) installed; then `gltf-transform etc1s|uastc`. Not on this box. Documented in `tools/preprocess.mjs` header + user memory.
|
||||
@@ -0,0 +1,82 @@
|
||||
# PROGRESS.md — hmwebviewer running log
|
||||
|
||||
> Append-only log of what happened. Newest at top. Pair with [PLAN.md](./PLAN.md) for what's next. Agents: add an entry whenever you start or finish a task, hit a blocker, or make a decision.
|
||||
|
||||
## Current state
|
||||
- **Phase:** ALL PLAN tasks complete (P0–P7). GLB + OBJ/FBX/DAE/IFC loaders (SSR+CSR) + on-screen FPS + adaptive quality (<60fps→pixelRatio degrade). Build verified (tsc 0, vite exit 0).
|
||||
- **Last action:** Gitea #7 runtime artifact 경량화 — Three.js hashed decoder 통합, single-thread IFC staging, production artifact budget와 Draco/Basis/IFC subpath E2E 검증.
|
||||
- **Blockers:** none. KTX2 *production* encoding still needs KTX-Software/toktx (runtime decode verified). web-ifc multi-thread는 COOP/COEP가 필요해 배포에서 제외하고 single-thread를 사용합니다.
|
||||
- **Next:** OBJ/DAE geometry-only on blob: URL (accepted); document.hidden FPS guard.
|
||||
|
||||
## Work log
|
||||
- 2026-07-29 — Gitea #7 runtime artifact 경량화: version 중복 5종은 incompatible/optional transitive dependency라 강제 override하지 않음. Three.js bundled hashed Draco/Basis asset으로 전환하고 `public/draco`, `public/basis`, `web-ifc-mt.wasm`, production source map 제거. 2D/3D artifact gate 추가. 3D dist 24,945,531→8,149,402 bytes(-67.3%), 2D dist 4,302,677 bytes, duplicate 0. 966-byte official Three.js ETC1S KTX2 test asset으로 production subpath에서 Basis transcoder 실제 실행을 검증. Vitest 8, typecheck/build, Playwright E2E 17개 통과.
|
||||
- 2026-06-19 — KTX2 runtime path validated: toktx unavailable here (no native KTX-Software), so sourced the only single-file KTX2 GLB in Khronos — ABeautifulGame.glb (glTF-Binary-KTX-ETC1S-Draco, 11.5MB, 35 KHR_texture_basisu / 33 image/ktx2 / 17 Draco). perf-smoke load via viewer: **626ms PASS** (KTX2Loader + DRACOLoader decode confirmed). Corrected tools/preprocess.mjs doc: KTX2 CLI command is `gltf-transform etc1s|uastc` (needs KTX-Software 4.3+), not `ktx`. Samples now include KTX2+Draco variant.
|
||||
- 2026-06-18 — P4-3 prerender pipeline complete: added ThreeDViewer.rotateTo(azimuth) + `window.__viewer` exposure; wrote tools/prerender.mjs (puppeteer-core orbit capture → ffmpeg animated WebP). Fixed: MSYS path mangling of `/samples` argv; ffmpeg `libwebp_anim` broken in this build (1 frame) → switched to `-c:v libwebp -vsync vfr` (correct multi-frame). Produced public/previews/Duck.webp (24 frames, 54KB). Wired into main.ts: `?model=` sets `#preview` src to matching `/previews/<name>.webp` (onerror hides), hydration gate fades it on ready. P4-2 (3-size sample set) + P4-3 → done. Result/verify: `npm run build` exits 0; ANMF=24 confirmed.
|
||||
- 2026-06-18 — Asset pipeline validated + P5-3 perf smoke: fixed tools/preprocess.mjs (textureCompress ktx2 → webp; registered draco3d.encoder dependency + EXTTextureWebP). Ran on Duck.glb → 117.7KiB→31.2KiB (73.4% reduction, Draco+webp). Added `window` `hmw:ready` event in ThreeDViewer.onLoaded. Wrote tools/perf-smoke.mjs (puppeteer-core + system Chrome headless). Ran against preview: Box 102ms, Duck 94ms, Duck.optimized(Draco) 141ms, Avocado 271ms — all < 3000ms PASS. Draco runtime decoder path confirmed. P5-3 → done.
|
||||
- 2026-06-18 — Samples: Box(1.6K)/Duck(118K)/Avocado(7.9M) + Duck.optimized(Draco,31.2K) in samples/ + public/samples/.
|
||||
- 2026-06-18 — Phase 3 hydration + Phase 5 hardening: marked P3-1/2/3 done (placeholder+gate+UI toggle already in viewer-core/hydration.ts). Reviewer agent found 0 critical, 3 🟡 (error UX), 5 nit; P5-1 clean (single loaders, full dispose, revoke both paths). Applied: index.html #status + .status CSS; progress.ts setStatus(); ThreeDViewer onError param wired to both load-error callbacks; main.ts WebGL try/catch + status surfacing for init/dnd/load failures. vite.config manualChunks split three→vendor. Result/verify: `npm run build` exits 0 (three 518KB vendor + app 132KB; chunk warning is three's inherent size). P5-1/2/4 → done.
|
||||
- 2026-06-18 — Sample assets: downloaded Box.glb (1.6K) + Duck.glb (118K) → samples/ + public/samples/ (Path A `?model=/samples/Box.glb` testable).
|
||||
- 2026-06-18 — Phase 0 viewer-core inline (classifier blocked subagent spawn): wrote src/viewer/loaders.ts (singleton getLoaders), src/viewer/hydration.ts (createHydrationGate — fades on BOTH webgl-ready + model-loaded, idempotent), src/viewer/ThreeDViewer.ts (renderer+scene+camera+OrbitControls+lights, loadServerAsset w/ progress, loadLocalFile w/ createObjectURL+revoke on both paths, frameObject auto-fit, dispose traverse), rewrote src/main.ts (wire viewer + initDropzone + ?model= Path A). Added tools/copy-decoders.mjs + package.json postinstall so `npm install` auto-copies /draco + /basis decoders. P0-1/2/3 + P1-1..P1-4 → done (build-verify pending). Result/verify: code coherent against three r169 types; `npm run build` pending install. Next: install → build → smoke load.
|
||||
- 2026-06-18 — Phase 2 dnd-handler agent (parallel): src/ui/progress.ts + src/dnd/dropzone.ts. preventDefault on dragover, /\.(glb|gltf)$/i validation, click-to-browse. Build-verify pending.
|
||||
- 2026-06-18 — Phase 4 asset-pipeline agent (parallel): tools/preprocess.mjs (gltf-transform dedup+weld+quantize+draco+KTX2, before/after sizes, graceful missing-package) + tools/README.md. Runtime pending `npm install -D @gltf-transform/*`. P4-1 done; P4-2/P4-3 blocked(needs sample asset).
|
||||
- 2026-06-18 — Web research (loaders, Draco+KTX2, DnD, Vite, prerender). Findings saved to user memory.
|
||||
- 2026-06-18 — Authored CLAUDE.md, PLAN.md, PROGRESS.md, .claude agents/commands/skills/hooks.
|
||||
- 2026-06-18 — Phase 0 viewer-core inline (classifier blocked subagent spawn): wrote src/viewer/loaders.ts (singleton getLoaders), src/viewer/hydration.ts (createHydrationGate — fades on BOTH webgl-ready + model-loaded, idempotent), src/viewer/ThreeDViewer.ts (renderer+scene+camera+OrbitControls+lights, loadServerAsset w/ progress, loadLocalFile w/ createObjectURL+revoke on both paths, frameObject auto-fit, dispose traverse), rewrote src/main.ts (wire viewer + initDropzone + ?model= Path A). Added tools/copy-decoders.mjs + package.json postinstall so `npm install` auto-copies /draco + /basis decoders. P0-1/2/3 + P1-1..P1-4 → done (build-verify pending). Result/verify: code coherent against three r169 types; `npm run build` pending install. Next: install → build → smoke load.
|
||||
- 2026-06-18 — Phase 2 dnd-handler agent (parallel): src/ui/progress.ts + src/dnd/dropzone.ts. preventDefault on dragover, /\.(glb|gltf)$/i validation, click-to-browse. Build-verify pending.
|
||||
- 2026-06-18 — Phase 4 asset-pipeline agent (parallel): tools/preprocess.mjs (gltf-transform dedup+weld+quantize+draco+KTX2, before/after sizes, graceful missing-package) + tools/README.md. Runtime pending `npm install -D @gltf-transform/*`. P4-1 done; P4-2/P4-3 blocked(needs sample asset).
|
||||
- 2026-06-18 — Web research (loaders, Draco+KTX2, DnD, Vite, prerender). Findings saved to user memory.
|
||||
- 2026-06-18 — Authored CLAUDE.md, PLAN.md, PROGRESS.md, .claude agents/commands/skills/hooks.
|
||||
|
||||
## Decision log
|
||||
- 2026-06-18 — Three.js + Vite chosen. Draco + KTX2 both, single shared loader instances. CDN decoder default. [memory: project-threejs-tech-decisions]
|
||||
- 2026-06-18 — Multi-agent split: viewer-core / dnd-handler / asset-pipeline / hydration / reviewer. PLAN.md holds task graph.
|
||||
|
||||
## Work log
|
||||
- 2026-06-18 — Web research (loaders, Draco+KTX2, DnD, Vite, prerender). Findings saved to user memory.
|
||||
- 2026-06-18 — Authored CLAUDE.md, PLAN.md, PROGRESS.md, .claude agents/commands/skills/hooks.
|
||||
## 2026-06-18 — Phase 2 dnd-handler (P2-1, P2-2)
|
||||
- Did: wrote `src/ui/progress.ts` (showProgress/hideProgress/setProgress) and `src/dnd/dropzone.ts` (initDropzone + DropzoneOpts + ViewerHandle). Dragover/dragenter preventDefault + `drag` class; dragleave/drop removes it; drop takes first file, validates `/\.(glb|gltf)$/i`, forwards valid file to `viewer.loadLocalFile(file)`, else `onError('GLB/GLTF only')`. Click-to-browse wired to `#file-input`; `change` resets input value so repeat picks fire.
|
||||
- Result/verify: signatures match the viewer-core/main.ts contract exactly (DropzoneOpts.viewer.loadLocalFile, progress el.firstChild `.bar`). File scope respected — did not touch src/viewer/* or src/main.ts. **Build-verify pending `npm install`** (blocked by permission classifier this session).
|
||||
- Next: viewer-core wires `initDropzone` from main.ts once its ThreeDViewer.loadLocalFile lands; then `/verify-viewer` smoke test drops a sample GLB.
|
||||
- Blocker: none (source-only; build verification deferred).
|
||||
## 2026-06-18 — Phase 4 asset-pipeline (P4-1 done; P4-2, P4-3 blocked)
|
||||
- Did: wrote `tools/preprocess.mjs` (Node ESM CLI) + `tools/README.md`. Pipeline = read GLB → `dedup` + `weld` + `quantize` (POSITION per `--draco-bits`, default 14; NORMAL/TEXCOORD/COLOR fixed sane) + `prune` + `draco()` + `textureCompress({targetFormat:'ktx2'})` → write `.optimized.glb`. Registers `KHRDracoMeshCompression` + `KHRTextureBasisu` on WebIO. Prints before/after bytes + reduction %. KTX2 on by default (`--no-ktx2` to disable). Dynamic import of `@gltf-transform/{core,functions,extensions,cli}` — on missing package, prints the exact `npm install -D` command and exits 1 (no silent skip). Header documents usage + install + that gltf-transform fetches the platform basis encoder on first KTX2 run.
|
||||
- Result/verify: `node --check` → SYNTAX_OK. `node tools/preprocess.mjs --help` → prints usage, exit 0. `node tools/preprocess.mjs fake.glb` with deps absent → prints missing-package message + install command, exit 1 (graceful path confirmed). **Runtime execution (actual compression) pending `npm install -D @gltf-transform/core @gltf-transform/functions @gltf-transform/extensions @gltf-transform/cli`** — blocked by the permission classifier this session.
|
||||
- Next: once deps installed + a sample asset exists, run on it, confirm size reduction + viewer load, then P4-2 (commit samples/*.optimized.glb) and P4-3 (Blender/Puppeteer pre-render → public/previews/*.webp).
|
||||
- Blocker: P4-2 blocked(needs sample asset); P4-3 blocked(needs sample asset + runtime install). PLAN.md flipped: P4-1 done, P4-2/P4-3 blocked.
|
||||
|
||||
## 2026-06-19 — FIX: CSR drag&drop regression (blob URL has no extension) — all formats
|
||||
- Bug: user dropped samples/Avocado.glb → "Failed to load file"; ALL local drops failed. Root cause: P6 refactor routed loadLocalFile through `loadModel(url)` which dispatches by `extOf(url)`; Path B passes a `blob:` URL (no `.glb` suffix) → extOf=null → switch default throws "Unsupported". Per-format smoke had only tested Path A (`?model=`), so CSR blob path was never exercised.
|
||||
- Fix (surgical): `loadModel(url, renderer, onProgress, nameHint?)` — ext = `extOf(nameHint ?? url) ?? extOf(url)`. ThreeDViewer.loadLocalFile passes `file.name` as nameHint. (modelLoader.ts + ThreeDViewer.ts, 2 lines effective.)
|
||||
- Verify: NEW tools/dnd-smoke.mjs drives the real CSR path (fetch sample → `new File` → `window.__viewer.loadLocalFile`). tsc 0, build 7.85s exit 0. **7/7 local drops PASS**: Box/Duck/Avocado.glb + Cube.obj/fbx/dae/ifc all load via drag&drop. Closes the previously-untested CSR coverage gap.
|
||||
- Blocker: none.
|
||||
|
||||
## 2026-06-19 — Per-format in-browser load smoke (all 6 formats PASS)
|
||||
- Did: built dist + `vite preview` :4173; ran tools/perf-smoke.mjs against one sample per format (Box.glb, Cube.obj, Cube.fbx, Cube.dae, Cube.ifc, Duck.optimized.glb) in real headless Chrome — measured nav→`hmw:ready`.
|
||||
- Result/verify: ALL PASS <3s — Box.glb 229ms, Cube.obj 173ms, Cube.fbx 158ms, Cube.dae 178ms, **Cube.ifc 463ms** (incl web-ifc wasm Init), Duck.optimized.glb 182ms. Closes the per-format runtime gap (SSR `?model=` path renders every format).
|
||||
- Gotcha: Git-Bash mangled `--asset /samples/..` argv → `C:/Program Files/Git/samples/..` (MSYS path conversion). Fix: prefix `MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'`.
|
||||
- Blocker: none.
|
||||
|
||||
## 2026-06-19 — P7 adaptive downstep empirically verified (tools/adaptive-smoke.mjs)
|
||||
- Did: built dist + `vite preview` :4173; ran new tools/adaptive-smoke.mjs — headless Chrome (swiftshader software GL) + CDP `Emulation.setCPUThrottlingRate` 6x, loaded heavy ABeautifulGame.ktx2.glb (11.5MB). Watched real #.fps overlay + captured genuine adaptiveQuality console logs (no logic patched).
|
||||
- Result/verify: FPS overlay rendered real values (19→4 FPS under throttle). Adaptive stepped **tier 0→1→2→3** (pr 1.5→1→0.75) on sustained <60fps; each step after ~60 slow frames per DOWN_FRAMES. Harness exit 0 PASS. The <60fps→optimize path fires on real frame measurement.
|
||||
- Note (honest): under CPU throttle the bottleneck is JS/geometry, not GPU fill, so lowering pixelRatio barely recovered fps (4→7) — the mechanism fired correctly but pixelRatio is a fill-rate knob; it pays off on GPU-bound scenes. Recovery-up not exercised (would need sustained >72fps).
|
||||
- Blocker: none.
|
||||
|
||||
## 2026-06-19 — Phase 6 + 7 via dynamic workflow `multiformat-viewer` (7 agents, 296k tok)
|
||||
- Did: extended viewer to OBJ/FBX/DAE/IFC (SSR+CSR) + on-screen FPS + adaptive quality. Run as one dynamic Workflow: Research(3 ‖ read-only: web-ifc API, adaptive-perf, three loader shapes) → Core(viewer-core) → Enhance(FPS ‖ Assets) → Verify(claude). Disjoint file sets per parallel wave (no git repo → no worktree isolation).
|
||||
- **P6** Core: NEW `src/viewer/modelLoader.ts` — `extOf`/`ACCEPT_EXT`/`loadModel(url,renderer,onProgress)` dispatch by ext, normalize each to `Object3D`. glb/gltf reuse singleton GLTFLoader (unchanged). obj/fbx/dae = lazy dynamic-import three example loaders (separate Vite chunks: OBJ 8.8K / Collada 41K / FBX 48K). ifc = `web-ifc`@0.0.77 IfcAPI, single-thread `/web-ifc/web-ifc.wasm` (SetWasmPath abs=true, Init(undefined,true), NO COOP/COEP), OpenModel(COORDINATE_TO_ORIGIN) → StreamAllMeshes → BufferGeometry per PlacedGeometry (interleaved stride-6 pos+normal, Uint32 index, color/opacity, flatTransformation), `geom.delete()` + `CloseModel` in finally (no wasm leak). ThreeDViewer.loadServerAsset(SSR) + loadLocalFile(CSR) both route through loadModel; onLoaded generalized to Object3D; Blob URL revoked in `.finally()`. dropzone via extOf (6 exts); index.html accept+text; main.ts preview-base widened; copy-decoders stages web-ifc.wasm.
|
||||
- **P7** FPS+adaptive: NEW `src/ui/fps.ts` (EMA fps, throttled 250ms DOM, color green≥60/orange/red, self-appended overlay). NEW `src/viewer/adaptiveQuality.ts` (pixelRatio ladder [min(DPR,2),1.5,1,0.75,0.5], asymmetric hysteresis: down after 60 frames <60fps, up after 180 frames >72fps, dead-band reset). Wired into ThreeDViewer.animate(now): fps.sample(now)+adaptive.update(fps()). Adaptive owns setPixelRatio (DPR cap from init). rotateTo (prerender) unaffected.
|
||||
- **Assets**: Cube.obj(793B, hand-authored)/Cube.dae(2.1K)/Cube.fbx(16.2K, three.js morph_test — assimp box.fbx failed in r0.169 FBXLoader)/Cube.ifc(2.3K, hand-authored IFC4 1-wall) in samples/ + public/samples/; 24-frame animated WebP previews per format in public/previews/. prerender.mjs base-name made format-aware.
|
||||
- Result/verify (independent main-thread re-run): `npx tsc --noEmit` → "No errors found" exit 0. `npm run build` → exit 0 (vite 5.4.21, 11.92s; chunks: app 138K, three vendor 533K, web-ifc 3.5M lazy/code-split — not on initial path). Confirmed via grep: 6 dispatch branches; dropzone EXT_RE all 6; animate wires fps+adaptive; loadServerAsset+loadLocalFile both call loadModel. web-ifc.wasm staged in public/web-ifc/.
|
||||
- Next: optional — in-browser smoke per format (.ifc/.fbx) via /verify-viewer; OBJ/DAE render geometry-only (external .mtl/textures can't resolve from blob: URL — accepted); document.hidden background-tab guard for FPS (noted, not done).
|
||||
- Blocker: none.
|
||||
|
||||
---
|
||||
<!-- Append new entries above this line. Format:
|
||||
## YYYY-MM-DD — <task ID or summary>
|
||||
- Did: ...
|
||||
- Result/verify: ...
|
||||
- Next: ...
|
||||
- Blocker (if any): ...
|
||||
-->
|
||||
@@ -0,0 +1 @@
|
||||
https://share.gemini.google/rErWUzD9BBdH 읽어라.
|
||||
@@ -0,0 +1,230 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>hmwebviewer — 아키텍처 / 구조 / 계획</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 32px; max-width: 1000px; margin: 0 auto;
|
||||
font: 15px/1.6 system-ui, "Segoe UI", "Malgun Gothic", sans-serif;
|
||||
background: #0f1115; color: #e6e9ef;
|
||||
}
|
||||
h1 { font-size: 26px; border-bottom: 2px solid #4aa3ff; padding-bottom: 10px; }
|
||||
h2 { font-size: 19px; margin-top: 36px; color: #4aa3ff; border-left: 4px solid #4aa3ff; padding-left: 10px; }
|
||||
h3 { font-size: 16px; margin-top: 24px; color: #cfe3ff; }
|
||||
code, pre { font-family: "Cascadia Code", Consolas, monospace; }
|
||||
code { background: #1c2230; padding: 1px 5px; border-radius: 4px; color: #ffd479; font-size: 13px; }
|
||||
pre { background: #161a24; border: 1px solid #262c3a; border-radius: 8px; padding: 14px; overflow-x: auto; font-size: 13px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 13.5px; }
|
||||
th, td { border: 1px solid #2a3142; padding: 7px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #1a2030; color: #9fc4ff; }
|
||||
tr:nth-child(even) td { background: #141821; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.card { background: #161a24; border: 1px solid #262c3a; border-radius: 8px; padding: 14px 16px; }
|
||||
.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; }
|
||||
.ok { background: #143a23; color: #57d98a; }
|
||||
.wip { background: #3a3014; color: #e4b35a; }
|
||||
.blk { background: #3a1414; color: #e48080; }
|
||||
.meta { color: #8b93a7; font-size: 13px; }
|
||||
.arrow { color: #4aa3ff; font-weight: 700; }
|
||||
.sep { border: 0; border-top: 1px solid #262c3a; margin: 28px 0; }
|
||||
.yes { color: #57d98a; font-weight: 700; }
|
||||
.no { color: #e48080; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>hmwebviewer — 3D 뷰어</h1>
|
||||
<p class="meta">Three.js 기반 하이브리드(SSR + CSR) 웹 3D 모델 뷰어. 설계 원본: <code>3d_viewer_architecture_spec.pdf</code></p>
|
||||
<p class="meta"><strong>지원 포맷:</strong> GLB · GLTF · OBJ · FBX · DAE(Collada) · IFC(BIM) | <strong>로드 경로:</strong> SSR + CSR | <strong>성능:</strong> 화면 FPS 표시 + 60fps 미만 시 적응형 품질 강등</p>
|
||||
<p class="meta"><strong>문서 갱신:</strong> <span id="updated">2026-06-19</span> — 매 작업 프롬프트 종료 시 이 파일을 함께 업데이트합니다.</p>
|
||||
|
||||
<h2>1. 개요 / 두 가지 로드 경로</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Path A — 서버 에셋 (SSR + CSR + Hydration)</h3>
|
||||
<p>서버가 미리 렌더링한 360° Animated WebP 자리표시자 + HTML/CSS 골조를 먼저 보냄(빠른 체감 로드). 뒤에서 <code>modelLoader.loadModel(url)</code>이 확장자로 디스패치하여 실제 모델을 비동기 로드/디코드. WebGL 준비 <strong>and</strong> 모델 로드 완료 둘 다 충족 시 WebP <span class="arrow">→</span> 캔버스로 opacity 0.5s 페이드.</p>
|
||||
<p class="meta">트리거: <code>?model=<url></code> · 프리뷰: <code>/previews/<base>.webp</code> (포맷 무관)</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Path B — 로컬 파일 (Drag & Drop, CSR)</h3>
|
||||
<p>사용자가 <code>.glb .gltf .obj .fbx .dae .ifc</code> 드롭. <code>URL.createObjectURL(file)</code> <span class="arrow">→</span> <code>loadModel</code> <span class="arrow">→</span> 디코드 <span class="arrow">→</span> 씬 추가 <span class="arrow">→</span> <code>URL.revokeObjectURL</code>(성공/에러 <code>.finally()</code> 양쪽). 검증: <code>extOf()</code> 확장자.</p>
|
||||
<p class="meta">트리거: 파일 드롭 / 클릭-탐색</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="meta">두 경로 모두 단일 디스패처 <code>src/viewer/modelLoader.ts</code>의 <code>loadModel(url, renderer, onProgress)</code>를 거쳐 <code>THREE.Object3D</code>로 정규화 → <code>ThreeDViewer.onLoaded()</code>는 원본 포맷을 알 필요 없음.</p>
|
||||
|
||||
<h2>2. 지원 파일 포맷</h2>
|
||||
<table>
|
||||
<tr><th>포맷</th><th>확장자</th><th>로더</th><th>비고</th><th>SSR</th><th>CSR</th><th>검증(체감 로드)</th></tr>
|
||||
<tr>
|
||||
<td>glTF (Binary/JSON)</td><td><code>.glb .gltf</code></td>
|
||||
<td>GLTFLoader<br>(+DRACO +KTX2 싱글톤)</td>
|
||||
<td>Draco 지오메트리 + KTX2/Basis·WebP 텍스처. 기준 경로.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 229ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Wavefront OBJ</td><td><code>.obj</code></td>
|
||||
<td>OBJLoader (지연 청크 8.8KB)</td>
|
||||
<td>지오메트리 전용. <code>blob:</code> URL에선 외부 <code>.mtl</code>/텍스처 미해결 → 기본 머티리얼.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 173ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Autodesk FBX</td><td><code>.fbx</code></td>
|
||||
<td>FBXLoader (지연 청크 47.8KB)</td>
|
||||
<td>바이너리(자체 포함). 애니메이션 가능(<code>.animations</code>) — 현재 정적 표시.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 158ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>COLLADA</td><td><code>.dae</code></td>
|
||||
<td>ColladaLoader (지연 청크 41KB)</td>
|
||||
<td>XML(DOMParser 필요). 외부 텍스처는 OBJ와 동일 제약.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 178ms</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IFC (BIM)</td><td><code>.ifc</code></td>
|
||||
<td>web-ifc <code>IfcAPI</code><br>(단일스레드 wasm, 지연 청크 3.5MB)</td>
|
||||
<td><code>StreamAllMeshes</code> <span class="arrow">→</span> <code>BufferGeometry</code>(인터리브 stride-6 pos+normal). 단일스레드라 <strong>COOP/COEP 헤더 불필요</strong>. <code>geom.delete()</code>+<code>CloseModel</code>로 wasm 메모리 해제.</td>
|
||||
<td class="yes">✔</td><td class="yes">✔</td><td><span class="badge ok">PASS 463ms</span><br><span class="meta">wasm Init 포함</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="meta">검증: 빌드 <code>dist</code> + <code>vite preview</code>에 대해 헤드리스 Chrome으로 포맷별 1개 샘플 로드, 네비게이션→<code>hmw:ready</code> 측정. 전체 <3000ms PASS (<code>tools/perf-smoke.mjs</code>).</p>
|
||||
|
||||
<h2>3. 기술 스택 (확정)</h2>
|
||||
<table>
|
||||
<tr><th>영역</th><th>선택</th><th>비고</th></tr>
|
||||
<tr><td>렌더러</td><td>three.js WebGLRenderer (r185)</td><td>WebGPU는 차후 옵션</td></tr>
|
||||
<tr><td>로더(기준)</td><td>GLTFLoader + DRACOLoader + KTX2Loader</td><td><strong>각 1개 싱글톤</strong> — 다중 인스턴스 크래시(#22445)</td></tr>
|
||||
<tr><td>로더(확장)</td><td>OBJ / FBX / Collada — three example loaders</td><td>지연 <code>import()</code> → 별도 Vite 청크(초기 번들 미증가)</td></tr>
|
||||
<tr><td>로더(IFC)</td><td>web-ifc <code>IfcAPI</code> (v0.0.77)</td><td>단일스레드 <code>web-ifc.wasm</code> 자가호스팅(<code>/web-ifc/</code>), <code>SetWasmPath(...,true)</code></td></tr>
|
||||
<tr><td>적응형 품질</td><td>FPS 측정 + pixelRatio 사다리</td><td>외부 의존성 0 — <code>ui/fps.ts</code> + <code>viewer/adaptiveQuality.ts</code></td></tr>
|
||||
<tr><td>지오메트리 압축</td><td>Draco</td><td>~73% 축소 검증됨</td></tr>
|
||||
<tr><td>텍스처 압축</td><td>WebP(런타임) / KTX2·Basis(고급)</td><td>KTX2는 별도 CLI 단계(<code>toktx</code>)</td></tr>
|
||||
<tr><td>빌드</td><td>Vite + TypeScript (strict)</td><td>three vendor + 포맷별 로더 청크 분리</td></tr>
|
||||
<tr><td>디코더 호스팅</td><td>Vite hashed Draco·Basis asset + <code>public/web-ifc</code></td><td>Three.js import로 생성, single-thread IFC WASM만 postinstall 복사</td></tr>
|
||||
<tr><td>에셋 파이프라인</td><td>gltf-transform + draco3d</td><td><code>tools/preprocess.mjs</code> (GLB 전용)</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>4. 런타임 파일 구조</h2>
|
||||
<pre>src/
|
||||
main.ts # 진입점 — ThreeDViewer + Dropzone 와이어링, ?model= Path A
|
||||
viewer/
|
||||
loaders.ts # getLoaders() 싱글톤 (GLTF+DRACO+KTX2)
|
||||
modelLoader.ts # ★ loadModel() 포맷 디스패치 (glb/gltf/obj/fbx/dae/ifc) → Object3D
|
||||
ThreeDViewer.ts # 핵심 클래스: 렌더러/씬/카메라/OrbitControls/로드/dispose + FPS·적응형 와이어링
|
||||
adaptiveQuality.ts # ★ pixelRatio 사다리 + 히스테리시스 (60fps↓ 강등)
|
||||
hydration.ts # createHydrationGate() — 양쪽 마크 시 페이드
|
||||
dnd/dropzone.ts # HTML5 DnD + 클릭탐색 + extOf 검증 → loadLocalFile
|
||||
ui/
|
||||
progress.ts # showProgress/hideProgress/setProgress/setStatus
|
||||
fps.ts # ★ 화면 FPS 오버레이 (EMA, 색상코딩)
|
||||
public/
|
||||
web-ifc/ # ★ web-ifc.wasm (단일스레드, 자동 복사)
|
||||
samples/ # 배포 demo: Box/Duck/Avocado/Duck.optimized + Cube.obj/dae/fbx/ifc
|
||||
previews/ # Duck.webp + Cube.{obj,dae,fbx,ifc}.webp (24프레임 360°)
|
||||
dist/assets/ # ★ Vite hashed Draco/Basis decoder JS/WASM
|
||||
tools/
|
||||
preprocess.mjs # gltf-transform Draco+WebP 압축 (GLB 전용)
|
||||
copy-decoders.mjs # postinstall: single-thread web-ifc WASM 복사
|
||||
prerender.mjs # 360° 턴테이블 → Animated WebP (포맷 무관)
|
||||
perf-smoke.mjs # 헤드리스 체감 로드 측정 (<3s)
|
||||
adaptive-smoke.mjs # ★ CPU 스로틀로 60fps↓ 강등 실증
|
||||
</pre>
|
||||
|
||||
<h2>5. 멀티에이전트 + 다이나믹 워크플로우</h2>
|
||||
<p>에이전트가 <code>PLAN.md</code> + <code>PROGRESS.md</code>를 매 시작 시 읽어 다음 작업을 결정. 병렬 안전(파일 스코프 분리).</p>
|
||||
<p class="card"><strong>Phase 6/7은 다이나믹 Workflow <code>multiformat-viewer</code>로 구현</strong> (7 에이전트, 296k 토큰): Research(3 병렬·읽기전용: web-ifc API / 적응형 성능 / three 로더 형태) <span class="arrow">→</span> Core(viewer-core) <span class="arrow">→</span> Enhance(FPS ‖ 에셋) <span class="arrow">→</span> Verify(빌드 green). git 미사용 환경이라 worktree 격리 대신 병렬 웨이브별 파일 스코프 분리로 충돌 방지.</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h3>에이전트</h3>
|
||||
<table>
|
||||
<tr><th>이름</th><th>역할</th></tr>
|
||||
<tr><td>task-lead</td><td>PLAN/PROGRESS 읽고 다음 작업 분배</td></tr>
|
||||
<tr><td>viewer-core</td><td>src/viewer/* 씬·로더·하이드레이션·FPS</td></tr>
|
||||
<tr><td>dnd-handler</td><td>src/dnd/* 드래그드롭·검증</td></tr>
|
||||
<tr><td>asset-pipeline</td><td>tools/* 압축·프리렌더·샘플</td></tr>
|
||||
<tr><td>hydration</td><td>SSR 자리표시자→캔버스 페이드</td></tr>
|
||||
<tr><td>reviewer</td><td>누수/에러/성능 감사(읽기 위주)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3>명령 / 스킬 / 훅</h3>
|
||||
<p><strong>명령:</strong> <code>/bootstrap</code> <code>/next-task</code> <code>/report</code> <code>/optimize-asset</code> <code>/verify-viewer</code></p>
|
||||
<p><strong>스킬:</strong> <code>threejs-viewer</code>(로드 패턴), <code>task-graph</code>(조정 프로토콜)</p>
|
||||
<p><strong>훅:</strong> <code>session-start.sh</code>(PLAN/PROGRESS 읽기 알림), <code>progress-nudge.sh</code>(src 편집 시 알림)</p>
|
||||
<p class="meta">CLAUDE.md가 두 파일 선독을 강제 + 에이전트/스킬/명령 매핑표 + 세션 런북 포함.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>6. 계획 진행 상태 (PLAN.md)</h2>
|
||||
<table>
|
||||
<tr><th>Phase</th><th>범위</th><th>상태</th></tr>
|
||||
<tr><td>P0 Bootstrap</td><td>Vite/TS 셋업, 디코더, 기본 씬</td><td><span class="badge ok">done · 빌드 검증</span></td></tr>
|
||||
<tr><td>P1 Core 뷰어</td><td>싱글톤 로더, loadServer/Local, OrbitControls, 프레이밍</td><td><span class="badge ok">done · 빌드 검증</span></td></tr>
|
||||
<tr><td>P2 Drag & Drop</td><td>드롭존 UI, 검증, loadLocalFile 와이어링</td><td><span class="badge ok">done · 빌드 검증</span></td></tr>
|
||||
<tr><td>P3 Hydration</td><td>WebP 자리표시자, 게이트, UI 토글</td><td><span class="badge ok">done</span></td></tr>
|
||||
<tr><td>P4 에셋 파이프라인</td><td>preprocess.mjs, 샘플셋, 프리렌더</td><td><span class="badge ok">P4-1/2/3 done</span></td></tr>
|
||||
<tr><td>P5 Hardening</td><td>누수/에러 감사, 퍼포먼스, 리뷰</td><td><span class="badge ok">P5-1/2/3/4 done</span> 0 critical</td></tr>
|
||||
<tr><td>P6 멀티포맷 로더</td><td>OBJ/FBX/DAE/IFC, SSR+CSR, modelLoader 디스패치, 샘플+프리뷰</td><td><span class="badge ok">P6-1..5 done</span> 6포맷 검증</td></tr>
|
||||
<tr><td>P7 FPS + 적응형 품질</td><td>화면 FPS, pixelRatio 사다리, animate 와이어링</td><td><span class="badge ok">P7-1/2/3 done</span> 강등 실증</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>7. FPS 표시 + 적응형 품질 (<60fps → 최적화)</h2>
|
||||
<div class="card">
|
||||
<ul>
|
||||
<li><strong>FPS 오버레이</strong> (<code>ui/fps.ts</code>): 좌상단, 프레임 델타 EMA 평활, ~250ms 스로틀 DOM 갱신. 색상 — <span class="yes">≥60 녹색</span> / 30–59 주황 / <span class="no"><30 빨강</span>.</li>
|
||||
<li><strong>적응형 사다리</strong> (<code>viewer/adaptiveQuality.ts</code>): pixelRatio 5단계 <code>[min(DPR,2) · 1.5 · 1 · 0.75 · 0.5]</code>. 가장 고효율·저위험 런타임 노브(드로잉 버퍼 재할당, CSS 리플로우 없음).</li>
|
||||
<li><strong>히스테리시스</strong>(진동 방지): 평균 <60fps가 <strong>60프레임 지속</strong> 시 1단계 강등 / 평균 >72fps가 <strong>180프레임 지속</strong> 시 1단계 복귀 / 60–72 데드밴드는 카운터 리셋.</li>
|
||||
<li><strong>실증</strong>(<code>tools/adaptive-smoke.mjs</code>): 헤드리스 swiftshader + CDP CPU 스로틀 6×로 ABeautifulGame(11.5MB) 로드 → 실측 FPS <strong>19→4</strong>, 적응형 <strong>tier 0→1→2→3</strong> (pr 1.5→1→0.75) 로그 캡처. 로직 미패치, 실제 프레임 측정으로 발화.</li>
|
||||
</ul>
|
||||
<p class="meta">참고: CPU 스로틀 하에선 병목이 JS/지오메트리(필레이트 아님)라 pixelRatio 강등의 FPS 회복폭이 작음(4→7) — 메커니즘은 정상 발화하나, 이 노브는 GPU 필레이트 병목 씬에서 효과가 큼.</p>
|
||||
</div>
|
||||
|
||||
<h2>8. 현재 구현 상태</h2>
|
||||
<div class="card">
|
||||
<ul>
|
||||
<li><strong>빌드:</strong> <code>tsc --noEmit</code> 클린, Vite 8 production build 통과. 3D artifact 27개, 8,149,402 bytes, 50KB 이상 중복 0개.</li>
|
||||
<li><strong>포맷별 인브라우저 로드(헤드리스 Chrome):</strong> GLB 229ms · OBJ 173ms · FBX 158ms · DAE 178ms · <strong>IFC 463ms</strong> · Duck.optimized 182ms — 전체 <3000ms PASS.</li>
|
||||
<li><strong>적응형 품질:</strong> 60fps 미만 지속 시 pixelRatio 3단계 강등 실측 확인(위 7절).</li>
|
||||
<li><strong>샘플:</strong> Box/Duck/Avocado/Duck.optimized(GLB) + Cube.obj(793B)/Cube.dae(2.1K)/Cube.fbx(16.2K)/Cube.ifc(2.3K). 각 24프레임 360° WebP 프리뷰.</li>
|
||||
<li><strong>KTX2 런타임 경로:</strong> official Three.js ETC1S KTX2 fixture를 production subpath에서 로드하고 hashed Basis transcoder WASM request와 model ready를 E2E로 확인. 인코딩은 toktx 필요(미설치).</li>
|
||||
<li><strong>하드닝:</strong> WebGL 미지원 가드, 로드 실패 메시지(<code>#status</code>), DnD 검증 피드백, IFC wasm 메모리 해제 — reviewer 0 critical.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>9. 실행 명령</h2>
|
||||
<pre># 개발 서버 (HMR)
|
||||
npm run dev # → http://127.0.0.1:3333
|
||||
|
||||
# 프로덕션 빌드 + 미리보기
|
||||
npm run build # tsc --noEmit && vite build → dist/
|
||||
npm run preview # → http://127.0.0.1:4173
|
||||
|
||||
# 사용
|
||||
# CSR(Path B): .glb .gltf .obj .fbx .dae .ifc 를 드롭존에 드롭 / 클릭 선택
|
||||
# SSR(Path A): http://127.0.0.1:4173/?model=/samples/Cube.ifc
|
||||
|
||||
# 검증/도구 (Git-Bash는 MSYS_NO_PATHCONV=1 접두 필요)
|
||||
node tools/perf-smoke.mjs # 포맷별 체감 로드 <3s
|
||||
node tools/adaptive-smoke.mjs --throttle 6 --seconds 30 # 60fps↓ 강등 실증
|
||||
node tools/preprocess.mjs <file.glb> # Draco+WebP 압축(GLB)
|
||||
node tools/prerender.mjs # 360° WebP 프리뷰 생성
|
||||
</pre>
|
||||
|
||||
<h2>10. 다음 단계 (선택)</h2>
|
||||
<ul>
|
||||
<li>✅ 전체 PLAN 완료 (P0–P7). 6포맷 로드 · SSR+CSR · 화면 FPS · 적응형 품질 전부 인브라우저 실증.</li>
|
||||
<li>OBJ/DAE 외부 텍스처: 다중 파일 드롭(.obj+.mtl+이미지) 또는 zip 수용 — 현재는 <code>blob:</code> 제약으로 지오메트리 전용.</li>
|
||||
<li>FBX/Collada 애니메이션: <code>AnimationMixer</code> 재생(현재 정적). <code>document.hidden</code> 배경탭 FPS 가드.</li>
|
||||
<li>KTX2 인코딩 파이프라인: KTX-Software(toktx) 설치 후 <code>gltf-transform etc1s|uastc</code>.</li>
|
||||
<li>실서버 SSR 연결 + 실기기 체감 로드 측정(현재 수치는 헤드리스).</li>
|
||||
</ul>
|
||||
|
||||
<hr class="sep" />
|
||||
<p class="meta">작업 로그: <code>PROGRESS.md</code> · 작업 그래프: <code>PLAN.md</code> · 지침: <code>CLAUDE.md</code></p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- SSR placeholder: pre-rendered 360 WebP fades out on hydration -->
|
||||
<img id="preview" class="preview hidden" alt="3D preview placeholder" />
|
||||
<div id="viewer" class="viewer"></div>
|
||||
<div id="controls" class="controls">
|
||||
<button id="btn-fit" type="button">Zoom Fit</button>
|
||||
<button id="btn-persp" type="button" class="active">원근뷰</button>
|
||||
<button id="btn-ortho" type="button">직교뷰</button>
|
||||
<button id="btn-outline" type="button">테두리</button>
|
||||
</div>
|
||||
<div id="progress" class="progress hidden"><div class="bar"></div></div>
|
||||
<div id="dropzone" class="dropzone">
|
||||
<p>Drop a <strong>.glb .gltf .obj .fbx .dae .ifc .ply</strong> (+ .mtl) here, or click to browse</p>
|
||||
<p id="status" class="status"></p>
|
||||
<input id="file-input" type="file" accept=".glb,.gltf,.obj,.fbx,.dae,.ifc,.ply,.mtl" multiple hidden />
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hmwebviewer/viewer-3d",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "0.185.1",
|
||||
"web-ifc": "0.0.77"
|
||||
},
|
||||
"devDependencies": {
|
||||
"puppeteer-core": "^25.1.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Cube.dae - hand-authored minimal COLLADA 1.4.1 unit cube for hmwebviewer ColladaLoader testing.
|
||||
Single <geometry> (8 verts, 12 triangles) in one <visual_scene>. Edge length 2 (range -1..1),
|
||||
centered at origin. Works for Path A (SSR ?model=) and Path B (drag & drop). No network required. -->
|
||||
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset>
|
||||
<contributor>
|
||||
<author>hmwebviewer</author>
|
||||
<authoring_tool>hand-authored</authoring_tool>
|
||||
</contributor>
|
||||
<created>2026-06-19T00:00:00Z</created>
|
||||
<modified>2026-06-19T00:00:00Z</modified>
|
||||
<unit name="meter" meter="1"/>
|
||||
<up_axis>Y_UP</up_axis>
|
||||
</asset>
|
||||
|
||||
<library_geometries>
|
||||
<geometry id="Cube-mesh" name="Cube">
|
||||
<mesh>
|
||||
<source id="Cube-positions">
|
||||
<float_array id="Cube-positions-array" count="24">
|
||||
-1 -1 -1 -1 -1 1 -1 1 -1 -1 1 1 1 -1 -1 1 -1 1 1 1 -1 1 1 1
|
||||
</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#Cube-positions-array" count="8" stride="3">
|
||||
<param name="X" type="float"/>
|
||||
<param name="Y" type="float"/>
|
||||
<param name="Z" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<vertices id="Cube-vertices">
|
||||
<input semantic="POSITION" source="#Cube-positions"/>
|
||||
</vertices>
|
||||
<triangles count="12">
|
||||
<input semantic="VERTEX" source="#Cube-vertices" offset="0"/>
|
||||
<p>
|
||||
0 1 3 0 3 2
|
||||
4 6 7 4 7 5
|
||||
0 4 5 0 5 1
|
||||
2 3 7 2 7 6
|
||||
0 2 6 0 6 4
|
||||
1 5 7 1 7 3
|
||||
</p>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</geometry>
|
||||
</library_geometries>
|
||||
|
||||
<library_visual_scenes>
|
||||
<visual_scene id="Scene" name="Scene">
|
||||
<node id="Cube" name="Cube" type="NODE">
|
||||
<matrix sid="transform">1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</matrix>
|
||||
<instance_geometry url="#Cube-mesh" name="Cube"/>
|
||||
</node>
|
||||
</visual_scene>
|
||||
</library_visual_scenes>
|
||||
|
||||
<scene>
|
||||
<instance_visual_scene url="#Scene"/>
|
||||
</scene>
|
||||
</COLLADA>
|
||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
ISO-10303-21;
|
||||
HEADER;
|
||||
/* hand-authored minimal IFC4 file for hmwebviewer web-ifc testing.
|
||||
One IfcWall (1m x 1m footprint, 1m high) as a swept-extruded solid placed in a
|
||||
site/building/storey spatial tree. Validates with web-ifc; loadable via Path A
|
||||
(SSR ?model=) and Path B (drag & drop). No network required. */
|
||||
FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');
|
||||
FILE_NAME('Cube.ifc','2026-06-19T00:00:00',(''),(''),'hmwebviewer','hand-authored','');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPERSON($,$,'',$,$,$,$,$);
|
||||
#2=IFCORGANIZATION($,'hmwebviewer',$,$,$);
|
||||
#3=IFCPERSONANDORGANIZATION(#1,#2,$);
|
||||
#4=IFCAPPLICATION(#2,'1.0','hmwebviewer','hmw');
|
||||
#5=IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);
|
||||
|
||||
#6=IFCDIRECTION((1.,0.,0.));
|
||||
#7=IFCDIRECTION((0.,0.,1.));
|
||||
#8=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#9=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
|
||||
|
||||
#11=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
|
||||
#12=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
|
||||
#13=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
|
||||
#14=IFCUNITASSIGNMENT((#11,#12,#13));
|
||||
|
||||
#15=IFCPROJECT('0Project00000000000000000',#5,'Project',$,$,$,$,(#10),#14);
|
||||
|
||||
#20=IFCLOCALPLACEMENT($,#9);
|
||||
#21=IFCSITE('0Site0000000000000000000',#5,'Site',$,$,#20,$,$,.ELEMENT.,$,$,$,$,$);
|
||||
#22=IFCLOCALPLACEMENT(#20,#9);
|
||||
#23=IFCBUILDING('0Building000000000000000',#5,'Building',$,$,#22,$,$,.ELEMENT.,$,$,$);
|
||||
#24=IFCLOCALPLACEMENT(#22,#9);
|
||||
#25=IFCBUILDINGSTOREY('0Storey00000000000000000',#5,'Storey',$,$,#24,$,$,.ELEMENT.,0.);
|
||||
|
||||
#30=IFCRELAGGREGATES('0RelAggProject0000000000',#5,$,$,#15,(#21));
|
||||
#31=IFCRELAGGREGATES('0RelAggSite000000000000 ',#5,$,$,#21,(#23));
|
||||
#32=IFCRELAGGREGATES('0RelAggBuilding00000000 ',#5,$,$,#23,(#25));
|
||||
|
||||
/* --- Wall geometry: 1m x 1m rectangle extruded 1m up --- */
|
||||
#40=IFCCARTESIANPOINT((-0.5,-0.5));
|
||||
#41=IFCCARTESIANPOINT((0.5,-0.5));
|
||||
#42=IFCCARTESIANPOINT((0.5,0.5));
|
||||
#43=IFCCARTESIANPOINT((-0.5,0.5));
|
||||
#44=IFCPOLYLINE((#40,#41,#42,#43,#40));
|
||||
#45=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#44);
|
||||
#46=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#47=IFCEXTRUDEDAREASOLID(#45,#46,#7,1.);
|
||||
#48=IFCSHAPEREPRESENTATION(#10,'Body','SweptSolid',(#47));
|
||||
#49=IFCPRODUCTDEFINITIONSHAPE($,$,(#48));
|
||||
|
||||
#50=IFCLOCALPLACEMENT(#24,#9);
|
||||
#51=IFCWALL('0Wall00000000000000000000',#5,'Wall',$,$,#50,#49,$,.SOLIDWALL.);
|
||||
|
||||
#60=IFCRELCONTAINEDINSPATIALSTRUCTURE('0RelContained0000000000 ',#5,$,$,(#51),#25);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
@@ -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
|
||||
@@ -0,0 +1,17 @@
|
||||
ply
|
||||
format ascii 1.0
|
||||
comment Three.js r185 Float64 normalization fixture
|
||||
element vertex 3
|
||||
property double x
|
||||
property double y
|
||||
property double z
|
||||
property uchar red
|
||||
property uchar green
|
||||
property uchar blue
|
||||
element face 1
|
||||
property list uchar int vertex_indices
|
||||
end_header
|
||||
0.0 0.0 0.0 255 0 0
|
||||
100.0 0.0 0.0 0 255 0
|
||||
0.0 100.0 0.0 0 0 255
|
||||
3 0 1 2
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Cube.dae - hand-authored minimal COLLADA 1.4.1 unit cube for hmwebviewer ColladaLoader testing.
|
||||
Single <geometry> (8 verts, 12 triangles) in one <visual_scene>. Edge length 2 (range -1..1),
|
||||
centered at origin. Works for Path A (SSR ?model=) and Path B (drag & drop). No network required. -->
|
||||
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset>
|
||||
<contributor>
|
||||
<author>hmwebviewer</author>
|
||||
<authoring_tool>hand-authored</authoring_tool>
|
||||
</contributor>
|
||||
<created>2026-06-19T00:00:00Z</created>
|
||||
<modified>2026-06-19T00:00:00Z</modified>
|
||||
<unit name="meter" meter="1"/>
|
||||
<up_axis>Y_UP</up_axis>
|
||||
</asset>
|
||||
|
||||
<library_geometries>
|
||||
<geometry id="Cube-mesh" name="Cube">
|
||||
<mesh>
|
||||
<source id="Cube-positions">
|
||||
<float_array id="Cube-positions-array" count="24">
|
||||
-1 -1 -1 -1 -1 1 -1 1 -1 -1 1 1 1 -1 -1 1 -1 1 1 1 -1 1 1 1
|
||||
</float_array>
|
||||
<technique_common>
|
||||
<accessor source="#Cube-positions-array" count="8" stride="3">
|
||||
<param name="X" type="float"/>
|
||||
<param name="Y" type="float"/>
|
||||
<param name="Z" type="float"/>
|
||||
</accessor>
|
||||
</technique_common>
|
||||
</source>
|
||||
<vertices id="Cube-vertices">
|
||||
<input semantic="POSITION" source="#Cube-positions"/>
|
||||
</vertices>
|
||||
<triangles count="12">
|
||||
<input semantic="VERTEX" source="#Cube-vertices" offset="0"/>
|
||||
<p>
|
||||
0 1 3 0 3 2
|
||||
4 6 7 4 7 5
|
||||
0 4 5 0 5 1
|
||||
2 3 7 2 7 6
|
||||
0 2 6 0 6 4
|
||||
1 5 7 1 7 3
|
||||
</p>
|
||||
</triangles>
|
||||
</mesh>
|
||||
</geometry>
|
||||
</library_geometries>
|
||||
|
||||
<library_visual_scenes>
|
||||
<visual_scene id="Scene" name="Scene">
|
||||
<node id="Cube" name="Cube" type="NODE">
|
||||
<matrix sid="transform">1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1</matrix>
|
||||
<instance_geometry url="#Cube-mesh" name="Cube"/>
|
||||
</node>
|
||||
</visual_scene>
|
||||
</library_visual_scenes>
|
||||
|
||||
<scene>
|
||||
<instance_visual_scene url="#Scene"/>
|
||||
</scene>
|
||||
</COLLADA>
|
||||
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
ISO-10303-21;
|
||||
HEADER;
|
||||
/* hand-authored minimal IFC4 file for hmwebviewer web-ifc testing.
|
||||
One IfcWall (1m x 1m footprint, 1m high) as a swept-extruded solid placed in a
|
||||
site/building/storey spatial tree. Validates with web-ifc; loadable via Path A
|
||||
(SSR ?model=) and Path B (drag & drop). No network required. */
|
||||
FILE_DESCRIPTION(('ViewDefinition [ReferenceView_V1.2]'),'2;1');
|
||||
FILE_NAME('Cube.ifc','2026-06-19T00:00:00',(''),(''),'hmwebviewer','hand-authored','');
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPERSON($,$,'',$,$,$,$,$);
|
||||
#2=IFCORGANIZATION($,'hmwebviewer',$,$,$);
|
||||
#3=IFCPERSONANDORGANIZATION(#1,#2,$);
|
||||
#4=IFCAPPLICATION(#2,'1.0','hmwebviewer','hmw');
|
||||
#5=IFCOWNERHISTORY(#3,#4,$,.ADDED.,$,$,$,0);
|
||||
|
||||
#6=IFCDIRECTION((1.,0.,0.));
|
||||
#7=IFCDIRECTION((0.,0.,1.));
|
||||
#8=IFCCARTESIANPOINT((0.,0.,0.));
|
||||
#9=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,#9,$);
|
||||
|
||||
#11=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
|
||||
#12=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
|
||||
#13=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
|
||||
#14=IFCUNITASSIGNMENT((#11,#12,#13));
|
||||
|
||||
#15=IFCPROJECT('0Project00000000000000000',#5,'Project',$,$,$,$,(#10),#14);
|
||||
|
||||
#20=IFCLOCALPLACEMENT($,#9);
|
||||
#21=IFCSITE('0Site0000000000000000000',#5,'Site',$,$,#20,$,$,.ELEMENT.,$,$,$,$,$);
|
||||
#22=IFCLOCALPLACEMENT(#20,#9);
|
||||
#23=IFCBUILDING('0Building000000000000000',#5,'Building',$,$,#22,$,$,.ELEMENT.,$,$,$);
|
||||
#24=IFCLOCALPLACEMENT(#22,#9);
|
||||
#25=IFCBUILDINGSTOREY('0Storey00000000000000000',#5,'Storey',$,$,#24,$,$,.ELEMENT.,0.);
|
||||
|
||||
#30=IFCRELAGGREGATES('0RelAggProject0000000000',#5,$,$,#15,(#21));
|
||||
#31=IFCRELAGGREGATES('0RelAggSite000000000000 ',#5,$,$,#21,(#23));
|
||||
#32=IFCRELAGGREGATES('0RelAggBuilding00000000 ',#5,$,$,#23,(#25));
|
||||
|
||||
/* --- Wall geometry: 1m x 1m rectangle extruded 1m up --- */
|
||||
#40=IFCCARTESIANPOINT((-0.5,-0.5));
|
||||
#41=IFCCARTESIANPOINT((0.5,-0.5));
|
||||
#42=IFCCARTESIANPOINT((0.5,0.5));
|
||||
#43=IFCCARTESIANPOINT((-0.5,0.5));
|
||||
#44=IFCPOLYLINE((#40,#41,#42,#43,#40));
|
||||
#45=IFCARBITRARYCLOSEDPROFILEDEF(.AREA.,$,#44);
|
||||
#46=IFCAXIS2PLACEMENT3D(#8,#7,#6);
|
||||
#47=IFCEXTRUDEDAREASOLID(#45,#46,#7,1.);
|
||||
#48=IFCSHAPEREPRESENTATION(#10,'Body','SweptSolid',(#47));
|
||||
#49=IFCPRODUCTDEFINITIONSHAPE($,$,(#48));
|
||||
|
||||
#50=IFCLOCALPLACEMENT(#24,#9);
|
||||
#51=IFCWALL('0Wall00000000000000000000',#5,'Wall',$,$,#50,#49,$,.SOLIDWALL.);
|
||||
|
||||
#60=IFCRELCONTAINEDINSPATIALSTRUCTURE('0RelContained0000000000 ',#5,$,$,(#51),#25);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Drag & drop local-file path (PLAN P2-1, P2-2).
|
||||
*
|
||||
* Captures HTML5 DnD events + click-to-browse on the #dropzone element,
|
||||
* validates the picked file is a supported model, and forwards valid files to
|
||||
* viewer-core's loadLocalFile. createObjectURL/revoke lifecycle is owned by
|
||||
* the viewer (per the locked Blob URL pattern in SKILL.md); this module only
|
||||
* validates + hands the File off.
|
||||
*/
|
||||
|
||||
import { extOf } from '../viewer/modelLoader';
|
||||
|
||||
/** Viewer surface this module depends on (viewer-core implements loadLocalFile). */
|
||||
export interface ViewerHandle {
|
||||
loadLocalFile: (file: File, sidecars?: File[]) => void;
|
||||
}
|
||||
|
||||
/** Options for initDropzone. */
|
||||
export interface DropzoneOpts {
|
||||
viewer: ViewerHandle;
|
||||
dropzone: HTMLElement;
|
||||
fileInput: HTMLInputElement;
|
||||
/** Optional error sink; defaults to console.warn. */
|
||||
onError?: (msg: string) => void;
|
||||
}
|
||||
|
||||
/** Accept any supported model extension by name (case-insensitive). */
|
||||
function isValidModel(file: File): boolean {
|
||||
return extOf(file.name) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up drag/drop + click-to-browse on the given dropzone.
|
||||
* Idempotent-ish: attaches listeners; caller should call once per element.
|
||||
*/
|
||||
export function initDropzone(opts: DropzoneOpts): void {
|
||||
const { dropzone, fileInput, viewer } = opts;
|
||||
const onError = opts.onError ?? ((msg: string) => console.warn(msg));
|
||||
|
||||
// Accept multiple files at once: the first supported model is the primary,
|
||||
// the rest (e.g. a .mtl beside an .obj) ride along as sidecars.
|
||||
const handleFiles = (files: File[]): void => {
|
||||
if (!files.length) return;
|
||||
const model = files.find(isValidModel);
|
||||
if (!model) {
|
||||
onError("Unsupported file (use .glb .gltf .obj .fbx .dae .ifc)");
|
||||
return;
|
||||
}
|
||||
viewer.loadLocalFile(model, files.filter((f) => f !== model));
|
||||
};
|
||||
|
||||
// --- Drag & drop ---
|
||||
// preventDefault on dragover is MANDATORY or the browser navigates to the file.
|
||||
dropzone.addEventListener("dragover", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("drag");
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragenter", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("drag");
|
||||
});
|
||||
|
||||
dropzone.addEventListener("dragleave", () => {
|
||||
dropzone.classList.remove("drag");
|
||||
});
|
||||
|
||||
dropzone.addEventListener("drop", (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
handleFiles(Array.from(e.dataTransfer?.files ?? []));
|
||||
});
|
||||
|
||||
// --- Click to browse (accessibility) ---
|
||||
dropzone.addEventListener("click", () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
handleFiles(Array.from(fileInput.files ?? []));
|
||||
// reset so picking the same file twice fires `change` again
|
||||
fileInput.value = "";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
* Drag & Drop local-file path. Path A (server asset) is also available via the
|
||||
* `?model=<url>` query param so the SSR+CSR+hydration flow can be exercised
|
||||
* without extra UI.
|
||||
*/
|
||||
const viewerEl = document.getElementById('viewer');
|
||||
const progressEl = document.getElementById('progress');
|
||||
const previewEl = document.getElementById('preview') as HTMLImageElement | null;
|
||||
const dropzoneEl = document.getElementById('dropzone');
|
||||
const fileInputEl = document.getElementById('file-input') as HTMLInputElement | null;
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
if (!viewerEl || !progressEl || !previewEl || !dropzoneEl || !fileInputEl || !statusEl) {
|
||||
throw new Error('hmwebviewer: required DOM elements missing');
|
||||
}
|
||||
|
||||
const onError = (msg: string) => setStatus(statusEl, msg);
|
||||
|
||||
let viewer: ThreeDViewer;
|
||||
try {
|
||||
viewer = new ThreeDViewer(viewerEl, progressEl, previewEl, onError);
|
||||
} catch (err) {
|
||||
console.error('[hmwebviewer] init failed', err);
|
||||
setStatus(statusEl, 'WebGL is required but unavailable in this browser.');
|
||||
throw err;
|
||||
}
|
||||
|
||||
initDropzone({
|
||||
viewer,
|
||||
dropzone: dropzoneEl,
|
||||
fileInput: fileInputEl,
|
||||
onError: (msg) => { console.warn('[hmwebviewer]', msg); setStatus(statusEl, msg); },
|
||||
});
|
||||
|
||||
// Viewer controls — Zoom Fit, projection toggle, outline overlay
|
||||
document.getElementById('btn-fit')?.addEventListener('click', () => viewer.fitView());
|
||||
|
||||
const perspBtn = document.getElementById('btn-persp');
|
||||
const orthoBtn = document.getElementById('btn-ortho');
|
||||
const setProjection = (mode: 'persp' | 'ortho') => {
|
||||
viewer.setProjection(mode);
|
||||
perspBtn?.classList.toggle('active', mode === 'persp');
|
||||
orthoBtn?.classList.toggle('active', mode === 'ortho');
|
||||
};
|
||||
perspBtn?.addEventListener('click', () => setProjection('persp'));
|
||||
orthoBtn?.addEventListener('click', () => setProjection('ortho'));
|
||||
|
||||
const outlineBtn = document.getElementById('btn-outline');
|
||||
outlineBtn?.addEventListener('click', () => {
|
||||
const on = viewer.toggleOutline();
|
||||
outlineBtn.classList.toggle('active', on);
|
||||
});
|
||||
|
||||
// Path A (server asset) — opt-in via ?model=<url>
|
||||
const modelUrl = new URLSearchParams(window.location.search).get('model');
|
||||
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 = publicAssetUrl(`previews/${base}.webp`);
|
||||
previewEl.classList.remove('hidden');
|
||||
previewEl.onerror = () => previewEl.classList.add('hidden');
|
||||
viewer.loadServerAsset(modelUrl);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { margin: 0; height: 100%; }
|
||||
#app { position: relative; background: #111; color: #eee; overflow: hidden; }
|
||||
|
||||
.viewer { position: absolute; inset: 0; }
|
||||
.viewer canvas { display: block; width: 100%; height: 100%; }
|
||||
|
||||
.preview {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: contain; background: #111; z-index: 2;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.progress {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: 4px; z-index: 3;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.progress .bar { height: 100%; width: 0%; background: #4aa3ff; transition: width 0.1s linear; }
|
||||
|
||||
.fps {
|
||||
position: absolute; top: 8px; left: 8px; z-index: 4;
|
||||
font: 12px/1.4 ui-monospace, monospace; padding: 2px 6px;
|
||||
background: rgba(0,0,0,0.5); border-radius: 4px; pointer-events: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute; top: 8px; right: 8px; z-index: 4;
|
||||
display: flex; gap: 6px;
|
||||
}
|
||||
.controls button {
|
||||
font: 12px/1.4 system-ui, sans-serif; padding: 6px 10px;
|
||||
background: rgba(0,0,0,0.55); color: #eee; border: 1px solid #888;
|
||||
border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.controls button:hover { background: rgba(0,0,0,0.75); border-color: #4aa3ff; }
|
||||
.controls button.active { background: #4aa3ff; color: #00264d; border-color: #4aa3ff; }
|
||||
|
||||
.dropzone {
|
||||
position: absolute; left: 50%; bottom: 24px; transform: translateX(-50%);
|
||||
padding: 10px 18px; border: 1px dashed #555; border-radius: 10px;
|
||||
background: rgba(0,0,0,0.4); font-size: 13px; cursor: pointer; z-index: 3;
|
||||
}
|
||||
.dropzone.drag { border-color: #4aa3ff; background: rgba(74,163,255,0.15); }
|
||||
.dropzone p { margin: 0; }
|
||||
.dropzone .status { margin-top: 6px; color: #ff7676; font-size: 12px; min-height: 0; }
|
||||
.dropzone .status:empty { display: none; }
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* On-screen FPS meter.
|
||||
*
|
||||
* Driven from the viewer's animate loop: call `sample(now)` once per frame with
|
||||
* the requestAnimationFrame timestamp. It computes an instantaneous fps from the
|
||||
* inter-frame delta, smooths it into a rolling average (EMA), and throttles DOM
|
||||
* writes to ~every 250ms so updating the overlay doesn't itself cause jank.
|
||||
* Kept dependency-free in the src/ui/progress.ts style.
|
||||
*/
|
||||
|
||||
const DOM_INTERVAL_MS = 250;
|
||||
// EMA smoothing factor: higher = more responsive, lower = smoother.
|
||||
const EMA_ALPHA = 0.1;
|
||||
|
||||
export interface FpsMeter {
|
||||
/** Overlay element; the viewer appends this to its container. */
|
||||
el: HTMLElement;
|
||||
/** Feed one frame timestamp (DOMHighResTimeStamp). */
|
||||
sample(now: number): void;
|
||||
/** Current rolling-average fps. */
|
||||
fps(): number;
|
||||
}
|
||||
|
||||
/** Create an FPS overlay element + sampler. */
|
||||
export function createFpsMeter(): FpsMeter {
|
||||
const el = document.createElement("div");
|
||||
el.className = "fps";
|
||||
el.textContent = "-- FPS";
|
||||
|
||||
let last = 0;
|
||||
let avg = 0;
|
||||
let lastDom = 0;
|
||||
|
||||
return {
|
||||
el,
|
||||
sample(now: number): void {
|
||||
if (last === 0) {
|
||||
last = now;
|
||||
return;
|
||||
}
|
||||
const dt = now - last;
|
||||
last = now;
|
||||
if (dt <= 0) return;
|
||||
const inst = 1000 / dt;
|
||||
avg = avg === 0 ? inst : avg + EMA_ALPHA * (inst - avg);
|
||||
|
||||
if (now - lastDom >= DOM_INTERVAL_MS) {
|
||||
lastDom = now;
|
||||
const v = Math.round(avg);
|
||||
el.textContent = `${v} FPS`;
|
||||
el.style.color = v >= 60 ? "#5cff5c" : v >= 30 ? "#ffb74a" : "#ff5c5c";
|
||||
}
|
||||
},
|
||||
fps(): number {
|
||||
return avg;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Minimal progress-bar helper for the load lifecycle.
|
||||
*
|
||||
* The #progress container holds a single inner .bar element whose width is
|
||||
* driven as a percentage. show/hide just toggle the `hidden` class so CSS
|
||||
* controls visibility (see src/style.css). Kept dependency-free so both the
|
||||
* dropzone wiring and ThreeDViewer.loadServerAsset can use it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reveal the progress container by removing the `hidden` class.
|
||||
* `el` is the #progress element.
|
||||
*/
|
||||
export function showProgress(el: HTMLElement): void {
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the progress container by adding the `hidden` class.
|
||||
*/
|
||||
export function hideProgress(el: HTMLElement): void {
|
||||
el.classList.add("hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the inner `.bar` width to `percent` (clamped to 0..100).
|
||||
* `el` is the #progress element; its first `.bar` descendant is resized.
|
||||
*/
|
||||
export function setProgress(el: HTMLElement, percent: number): void {
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const bar = el.querySelector<HTMLElement>(".bar");
|
||||
if (bar) bar.style.width = `${clamped}%`;
|
||||
}
|
||||
|
||||
/** Write a user-facing status/error message into the #status element. */
|
||||
export function setStatus(el: HTMLElement, msg: string): void {
|
||||
el.textContent = msg;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { getLoaders } from './loaders';
|
||||
import { loadModel, extOf } from './modelLoader';
|
||||
import { recenterObjFile } from './objRecenter';
|
||||
import { createHydrationGate } from './hydration';
|
||||
import { showProgress, hideProgress, setProgress } from '../ui/progress';
|
||||
import { createFpsMeter } from '../ui/fps';
|
||||
import { createAdaptiveQuality } from './adaptiveQuality';
|
||||
|
||||
// Formats authored Y-up; the viewer world is Z-up (structure convention), so
|
||||
// these are rotated +90° about X on load. OBJ/IFC are Z-up native (no rotate).
|
||||
const Y_UP_EXTS = new Set(['glb', 'gltf', 'fbx', 'dae']);
|
||||
function isYUp(name: string): boolean {
|
||||
const e = extOf(name);
|
||||
return e !== null && Y_UP_EXTS.has(e);
|
||||
}
|
||||
|
||||
export class ThreeDViewer {
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly perspCamera: THREE.PerspectiveCamera;
|
||||
private readonly orthoCamera: THREE.OrthographicCamera;
|
||||
private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
|
||||
private projection: 'persp' | 'ortho' = 'persp';
|
||||
private readonly fov = 50;
|
||||
private readonly controls: OrbitControls;
|
||||
private readonly gate;
|
||||
private readonly onError;
|
||||
private readonly fps = createFpsMeter();
|
||||
private readonly adaptive;
|
||||
private disposed = false;
|
||||
private raf = 0;
|
||||
private current: THREE.Object3D | null = null;
|
||||
private outline: THREE.Group | null = null;
|
||||
private outlineOn = false;
|
||||
|
||||
constructor(
|
||||
private readonly container: HTMLElement,
|
||||
private readonly progress: HTMLElement,
|
||||
preview: HTMLImageElement,
|
||||
onError?: (msg: string) => void,
|
||||
) {
|
||||
this.onError = onError ?? ((msg: string) => console.error('[hmwebviewer]', msg));
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
// Adaptive quality owns renderer.setPixelRatio: tier 0 applies a DPR cap of
|
||||
// min(devicePixelRatio, 2) immediately (uncapped DPR on 3x devices is the
|
||||
// #1 cause of <60fps). It then steps the pixel ratio down/up with hysteresis.
|
||||
this.adaptive = createAdaptiveQuality(this.renderer);
|
||||
this.container.appendChild(this.fps.el);
|
||||
|
||||
this.perspCamera = new THREE.PerspectiveCamera(this.fov, 1, 0.1, 1000);
|
||||
this.orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
||||
for (const c of [this.perspCamera, this.orthoCamera]) {
|
||||
c.up.set(0, 0, 1); // Z-up world (right-handed, structure convention)
|
||||
c.position.set(2, -3, 2);
|
||||
}
|
||||
this.camera = this.perspCamera;
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
// Pan in the ground (XY) plane, not the screen plane — keeps elevation
|
||||
// constant when panning along a road/rail alignment between turntable spins.
|
||||
this.controls.screenSpacePanning = false;
|
||||
|
||||
this.scene.background = new THREE.Color(0xcfcfcf);
|
||||
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1.2);
|
||||
hemi.position.set(0, 0, 1); // sky toward +Z
|
||||
this.scene.add(hemi);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||
dir.position.set(2, -3, 5); // from above (+Z) and front
|
||||
this.scene.add(dir);
|
||||
|
||||
// Warm the shared singleton loaders so KTX2 detectSupport(renderer) runs at
|
||||
// init; loadModel() reuses the same singletons for GLB/GLTF.
|
||||
getLoaders(this.renderer);
|
||||
this.gate = createHydrationGate(preview);
|
||||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.onResize();
|
||||
this.animate();
|
||||
this.gate.markWebGLReady();
|
||||
}
|
||||
|
||||
/** Path A — server asset (SSR placeholder already shown by host page). */
|
||||
loadServerAsset(url: string): void {
|
||||
showProgress(this.progress);
|
||||
loadModel(url, this.renderer, (loaded, total) => {
|
||||
setProgress(this.progress, (loaded / total) * 100);
|
||||
}).then(
|
||||
(obj) => this.onLoaded(obj, isYUp(url)),
|
||||
(err) => {
|
||||
hideProgress(this.progress);
|
||||
this.onError('Failed to load server asset.');
|
||||
console.error('[hmwebviewer] server asset load failed', err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path B — local file (Drag & Drop). Blob URL revoked on both paths.
|
||||
* `sidecars` carries companion files dropped alongside (e.g. a .mtl for OBJ);
|
||||
* the blob: URL can't resolve them, so their text is read and passed inline.
|
||||
*/
|
||||
loadLocalFile(file: File, sidecars: File[] = []): void {
|
||||
showProgress(this.progress);
|
||||
const mtlFile = sidecars.find((f) => /\.mtl$/i.test(f.name));
|
||||
const texFiles = sidecars.filter((f) => /\.(png|jpe?g|bmp|gif|webp|tga)$/i.test(f.name));
|
||||
let url: string | null = null;
|
||||
const isObj = extOf(file.name) === 'obj';
|
||||
// OBJLoader builds one giant string; past ~V8 max string length (~1GB of
|
||||
// text) it fails. Route large OBJ through the streaming parser instead.
|
||||
const huge = isObj && file.size > 300 * 1024 * 1024;
|
||||
const run = async (): Promise<THREE.Object3D> => {
|
||||
const mtlText = mtlFile ? await mtlFile.text() : undefined;
|
||||
// Small/medium OBJ: recenter in float64 BEFORE OBJLoader quantizes to
|
||||
// float32 (objRecenter.ts). Huge OBJ recenters inside the streaming parser.
|
||||
const source = (isObj && !huge)
|
||||
? await recenterObjFile(file, (l, t) => setProgress(this.progress, (l / t) * 100))
|
||||
: file;
|
||||
url = URL.createObjectURL(source);
|
||||
return loadModel(
|
||||
url,
|
||||
this.renderer,
|
||||
(loaded, total) => setProgress(this.progress, (loaded / total) * 100),
|
||||
file.name,
|
||||
{ mtlText, texFiles, stream: huge },
|
||||
);
|
||||
};
|
||||
run()
|
||||
.then(
|
||||
(obj) => this.onLoaded(obj, isYUp(file.name)),
|
||||
(err) => {
|
||||
hideProgress(this.progress);
|
||||
this.onError('Failed to load file (corrupt or unsupported?).');
|
||||
console.error('[hmwebviewer] local file load failed', err);
|
||||
},
|
||||
)
|
||||
.finally(() => { if (url) URL.revokeObjectURL(url); });
|
||||
}
|
||||
|
||||
private onLoaded(obj: THREE.Object3D, yUp = false): void {
|
||||
if (this.current) {
|
||||
this.scene.remove(this.current);
|
||||
this.disposeObject(this.current);
|
||||
}
|
||||
this.current = obj;
|
||||
if (yUp) obj.rotateX(Math.PI / 2); // Y-up source → Z-up world
|
||||
this.scene.add(this.current);
|
||||
this.frameObject(this.current);
|
||||
this.applyOutlineState();
|
||||
hideProgress(this.progress);
|
||||
this.gate.markModelLoaded();
|
||||
// observable hook for perf smoke tests (tools/perf-smoke.mjs)
|
||||
window.dispatchEvent(new CustomEvent('hmw:ready', { detail: performance.now() }));
|
||||
}
|
||||
|
||||
private frameObject(obj: THREE.Object3D): void {
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
const fovRad = (this.fov * Math.PI) / 180;
|
||||
const dist = (maxDim / 2) / Math.tan(fovRad / 2) * 2;
|
||||
const near = Math.max(maxDim / 1000, 0.01); // adaptive: fixed far=1000 clips large models
|
||||
const far = (dist + maxDim) * 4;
|
||||
const aspect = this.aspect;
|
||||
const dir = new THREE.Vector3(1, -1, 0.8).normalize(); // iso view, Z up
|
||||
const pos = center.clone().addScaledVector(dir, dist);
|
||||
|
||||
this.perspCamera.position.copy(pos);
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.near = near;
|
||||
this.perspCamera.far = far;
|
||||
this.perspCamera.lookAt(center);
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
|
||||
// Ortho frustum half-height matches the perspective apparent size at `dist`,
|
||||
// so toggling projection doesn't jump the model scale.
|
||||
const halfH = dist * Math.tan(fovRad / 2);
|
||||
this.orthoCamera.position.copy(pos);
|
||||
this.orthoCamera.top = halfH;
|
||||
this.orthoCamera.bottom = -halfH;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.zoom = 1;
|
||||
this.orthoCamera.near = near;
|
||||
this.orthoCamera.far = far;
|
||||
this.orthoCamera.lookAt(center);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
|
||||
this.controls.target.copy(center);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
/** Re-frame the current model to fit the view (Zoom Fit button). */
|
||||
fitView(): void {
|
||||
if (this.current) this.frameObject(this.current);
|
||||
}
|
||||
|
||||
private applyOutlineState(): void {
|
||||
this.clearOutline();
|
||||
if (this.outlineOn && this.current) this.buildOutline(this.current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feature-edge outline (EdgesGeometry, 30° threshold) — the object's
|
||||
* silhouette + sharp creases as black lines, far sparser than a wireframe.
|
||||
* Built lazily; one-time cost can be seconds on very large meshes.
|
||||
*/
|
||||
private buildOutline(obj: THREE.Object3D): void {
|
||||
obj.updateMatrixWorld(true);
|
||||
const group = new THREE.Group();
|
||||
const mat = new THREE.LineBasicMaterial({ color: 0x1a1a1a });
|
||||
obj.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (!mesh.isMesh || !mesh.geometry) return;
|
||||
const seg = new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry, 30), mat);
|
||||
mesh.matrixWorld.decompose(seg.position, seg.quaternion, seg.scale);
|
||||
group.add(seg);
|
||||
});
|
||||
this.outline = group;
|
||||
this.scene.add(group);
|
||||
}
|
||||
|
||||
private clearOutline(): void {
|
||||
if (!this.outline) return;
|
||||
this.scene.remove(this.outline);
|
||||
this.outline.traverse((node) => {
|
||||
const seg = node as THREE.LineSegments;
|
||||
if (seg.geometry) seg.geometry.dispose();
|
||||
});
|
||||
const m = (this.outline.children[0] as THREE.LineSegments | undefined)?.material;
|
||||
if (m && !Array.isArray(m)) m.dispose();
|
||||
this.outline = null;
|
||||
}
|
||||
|
||||
/** Toggle the object outline overlay (테두리 button). */
|
||||
toggleOutline(): boolean {
|
||||
this.outlineOn = !this.outlineOn;
|
||||
this.applyOutlineState();
|
||||
return this.outlineOn;
|
||||
}
|
||||
|
||||
/** Switch between perspective and orthographic projection, preserving the view. */
|
||||
setProjection(mode: 'persp' | 'ortho'): void {
|
||||
if (mode === this.projection) return;
|
||||
const target = this.controls.target;
|
||||
const from = this.camera;
|
||||
const fovRad = (this.fov * Math.PI) / 180;
|
||||
const offset = from.position.clone().sub(target);
|
||||
const dist = offset.length() || 1;
|
||||
const aspect = this.aspect;
|
||||
|
||||
if (mode === 'ortho') {
|
||||
const halfH = dist * Math.tan(fovRad / 2);
|
||||
this.orthoCamera.position.copy(from.position);
|
||||
this.orthoCamera.up.copy(from.up);
|
||||
this.orthoCamera.top = halfH;
|
||||
this.orthoCamera.bottom = -halfH;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.zoom = 1;
|
||||
this.orthoCamera.near = from.near;
|
||||
this.orthoCamera.far = from.far;
|
||||
this.orthoCamera.lookAt(target);
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
this.camera = this.orthoCamera;
|
||||
} else {
|
||||
// Place the perspective camera so apparent size matches the ortho view.
|
||||
const orthoHalfH = this.orthoCamera.top / this.orthoCamera.zoom;
|
||||
const d = orthoHalfH / Math.tan(fovRad / 2);
|
||||
this.perspCamera.position.copy(target).add(offset.setLength(d));
|
||||
this.perspCamera.up.copy(from.up);
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.near = from.near;
|
||||
this.perspCamera.far = from.far;
|
||||
this.perspCamera.lookAt(target);
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
this.camera = this.perspCamera;
|
||||
}
|
||||
this.projection = mode;
|
||||
this.controls.object = this.camera;
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
private disposeObject(obj: THREE.Object3D): void {
|
||||
const texUrls = obj.userData?.__texUrls as string[] | undefined;
|
||||
if (texUrls) texUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
obj.traverse((node) => {
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry.dispose();
|
||||
const mat = mesh.material;
|
||||
if (Array.isArray(mat)) mat.forEach((m) => this.disposeMaterial(m));
|
||||
else if (mat) this.disposeMaterial(mat);
|
||||
});
|
||||
}
|
||||
|
||||
private disposeMaterial(mat: THREE.Material): void {
|
||||
for (const v of Object.values(mat)) {
|
||||
if (v instanceof THREE.Texture) v.dispose();
|
||||
}
|
||||
mat.dispose();
|
||||
}
|
||||
|
||||
private get aspect(): number {
|
||||
const w = this.container.clientWidth || window.innerWidth;
|
||||
const h = this.container.clientHeight || window.innerHeight;
|
||||
return w / h;
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
const w = this.container.clientWidth || window.innerWidth;
|
||||
const h = this.container.clientHeight || window.innerHeight;
|
||||
this.renderer.setSize(w, h, false);
|
||||
const aspect = w / h;
|
||||
this.perspCamera.aspect = aspect;
|
||||
this.perspCamera.updateProjectionMatrix();
|
||||
const halfH = this.orthoCamera.top || 1;
|
||||
this.orthoCamera.left = -halfH * aspect;
|
||||
this.orthoCamera.right = halfH * aspect;
|
||||
this.orthoCamera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (now: number = performance.now()): void => {
|
||||
if (this.disposed) return;
|
||||
this.raf = requestAnimationFrame(this.animate);
|
||||
this.fps.sample(now);
|
||||
this.adaptive.update(this.fps.fps());
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.raf = 0;
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
this.controls.dispose();
|
||||
this.clearOutline();
|
||||
if (this.current) this.disposeObject(this.current);
|
||||
this.current = null;
|
||||
this.fps.el.remove();
|
||||
this.renderer.dispose();
|
||||
this.renderer.forceContextLoss();
|
||||
this.renderer.domElement.remove();
|
||||
this.scene.clear();
|
||||
}
|
||||
|
||||
/** Orbit the camera to azimuth (radians) around the model — used by tools/prerender.mjs. */
|
||||
rotateTo(azimuth: number): void {
|
||||
if (!this.current) return;
|
||||
const offset = new THREE.Vector3().subVectors(this.camera.position, this.controls.target);
|
||||
const radius = Math.max(offset.length(), 1e-3);
|
||||
const phi = Math.acos(THREE.MathUtils.clamp(offset.y / radius, -1, 1));
|
||||
const target = this.controls.target;
|
||||
this.camera.position.set(
|
||||
target.x + radius * Math.sin(phi) * Math.sin(azimuth),
|
||||
target.y + radius * Math.cos(phi),
|
||||
target.z + radius * Math.sin(phi) * Math.cos(azimuth),
|
||||
);
|
||||
this.camera.lookAt(target);
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
/**
|
||||
* Adaptive-quality controller.
|
||||
*
|
||||
* The single highest-leverage, lowest-risk runtime knob in this codebase is
|
||||
* renderer.setPixelRatio: it re-allocates the drawing buffer to
|
||||
* floor(clientSize * pixelRatio) without touching CSS layout (onResize already
|
||||
* uses updateStyle=false), so there is zero reflow. We walk a tiered ladder of
|
||||
* pixel-ratio steps with hysteresis (asymmetric down/up thresholds + sustained
|
||||
* windows) so the tier cannot oscillate around the 60fps line.
|
||||
*
|
||||
* Antialias is deliberately NOT touched: it is fixed at WebGLRenderer
|
||||
* construction and changing it would force a new context + KTX2 detectSupport,
|
||||
* violating the singleton-loader invariant in src/viewer/loaders.ts.
|
||||
*/
|
||||
|
||||
const TIER_MIN = 0;
|
||||
const TIER_MAX = 4;
|
||||
// Sustained-sample windows (in frames at ~60fps): ~1s down, longer to recover.
|
||||
const DOWN_FRAMES = 60;
|
||||
const UP_FRAMES = 180;
|
||||
const DOWN_FPS = 60;
|
||||
const UP_FPS = 72;
|
||||
|
||||
export interface AdaptiveQuality {
|
||||
/** Feed the current rolling-average fps; may step the tier. */
|
||||
update(avgFps: number): void;
|
||||
/** Current tier index (0 = best). */
|
||||
tier(): number;
|
||||
}
|
||||
|
||||
export interface AdaptiveQualityOptions {
|
||||
/** Override the tier-0 (best) pixel ratio. Default min(devicePixelRatio, 2). */
|
||||
baseRatio?: number;
|
||||
}
|
||||
|
||||
/** Create the controller and apply tier 0 immediately. */
|
||||
export function createAdaptiveQuality(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
opts: AdaptiveQualityOptions = {},
|
||||
): AdaptiveQuality {
|
||||
const base = opts.baseRatio ?? Math.min(window.devicePixelRatio, 2);
|
||||
// Ladder of pixel ratios, highest quality first. Tier 0 = capped DPR.
|
||||
const ratios = [base, 1.5, 1, 0.75, 0.5];
|
||||
|
||||
let tier = 0;
|
||||
let below = 0;
|
||||
let above = 0;
|
||||
|
||||
renderer.setPixelRatio(ratios[tier]);
|
||||
|
||||
function applyTier(next: number): void {
|
||||
tier = next;
|
||||
below = 0;
|
||||
above = 0;
|
||||
const pr = ratios[tier];
|
||||
renderer.setPixelRatio(pr);
|
||||
console.info(`[hmwebviewer] adaptive quality -> tier ${tier} (pr=${pr})`);
|
||||
}
|
||||
|
||||
return {
|
||||
update(avgFps: number): void {
|
||||
// Ignore warm-up / unmeasured frames.
|
||||
if (avgFps <= 0) return;
|
||||
|
||||
if (avgFps < DOWN_FPS) {
|
||||
below++;
|
||||
above = 0;
|
||||
if (below >= DOWN_FRAMES && tier < TIER_MAX) applyTier(tier + 1);
|
||||
} else if (avgFps > UP_FPS) {
|
||||
above++;
|
||||
below = 0;
|
||||
if (above >= UP_FRAMES && tier > TIER_MIN) applyTier(tier - 1);
|
||||
} else {
|
||||
// Dead band: reset both so transient blips don't accumulate.
|
||||
below = 0;
|
||||
above = 0;
|
||||
}
|
||||
},
|
||||
tier(): number {
|
||||
return tier;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SSR -> CSR hydration gate. Fades the WebP placeholder to the Three.js canvas
|
||||
// ONLY after BOTH the WebGL context is ready AND the model is loaded.
|
||||
// Firing on just one condition = empty-canvas flash (race). Idempotent.
|
||||
|
||||
export interface HydrationGate {
|
||||
markWebGLReady(): void;
|
||||
markModelLoaded(): void;
|
||||
}
|
||||
|
||||
export function createHydrationGate(preview: HTMLImageElement): HydrationGate {
|
||||
let webglReady = false;
|
||||
let modelLoaded = false;
|
||||
let fired = false;
|
||||
|
||||
function maybeFire(): void {
|
||||
if (fired || !(webglReady && modelLoaded)) return;
|
||||
fired = true;
|
||||
preview.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
preview.classList.add('hidden');
|
||||
preview.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return {
|
||||
markWebGLReady() {
|
||||
webglReady = true;
|
||||
maybeFire();
|
||||
},
|
||||
markModelLoaded() {
|
||||
modelLoaded = true;
|
||||
maybeFire();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import {
|
||||
DRACOLoader,
|
||||
DRACO_GLTF_CONFIG,
|
||||
} from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
||||
|
||||
export interface ViewerLoaders {
|
||||
gltf: GLTFLoader;
|
||||
draco: DRACOLoader;
|
||||
ktx2: KTX2Loader;
|
||||
}
|
||||
|
||||
// 여러 DRACOLoader/KTX2Loader instance를 만들면 충돌할 수 있으므로 하나를 공유합니다.
|
||||
// decoder URL은 Three.js import가 생성하는 version 일치 hashed asset을 사용합니다.
|
||||
let _cache: ViewerLoaders | null = null;
|
||||
|
||||
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
|
||||
if (_cache) return _cache;
|
||||
const draco = new DRACOLoader().setDecoderPath(DRACO_GLTF_CONFIG);
|
||||
const ktx2 = new KTX2Loader().detectSupport(renderer);
|
||||
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||
_cache = { gltf, draco, ktx2 };
|
||||
return _cache;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import * as THREE from 'three';
|
||||
import { publicAssetUrl } from '../runtimeBase';
|
||||
import { getLoaders } from './loaders';
|
||||
|
||||
/**
|
||||
* Multi-format model loader dispatch.
|
||||
*
|
||||
* GLB/GLTF go through the shared singleton loaders (loaders.ts — Draco + KTX2).
|
||||
* OBJ/FBX/Collada use three's example loaders, lazily imported as separate Vite
|
||||
* chunks so they never bloat the initial bundle. IFC uses web-ifc (single-thread
|
||||
* wasm under /web-ifc/, no COOP/COEP headers required).
|
||||
*
|
||||
* Every branch resolves to a plain THREE.Object3D ready to scene.add — the
|
||||
* caller (ThreeDViewer.onLoaded) does not need to know the source format.
|
||||
*/
|
||||
export type SupportedExt = 'glb' | 'gltf' | 'obj' | 'fbx' | 'dae' | 'ifc' | 'ply';
|
||||
|
||||
export const ACCEPT_EXT: SupportedExt[] = ['glb', 'gltf', 'obj', 'fbx', 'dae', 'ifc', 'ply'];
|
||||
|
||||
const EXT_RE = /\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i;
|
||||
|
||||
/** Extract the supported extension from a file name / URL, or null. */
|
||||
export function extOf(name: string): SupportedExt | null {
|
||||
const m = EXT_RE.exec(name);
|
||||
return m ? (m[1].toLowerCase() as SupportedExt) : null;
|
||||
}
|
||||
|
||||
type ProgressCb = (loaded: number, total: number) => void;
|
||||
|
||||
// Lazy single instances for the example loaders — avoids re-import churn on
|
||||
// repeat loads. NOT the Draco/KTX2 singletons (no shared-loader constraint here).
|
||||
type OBJLoaderT = import('three/examples/jsm/loaders/OBJLoader.js').OBJLoader;
|
||||
type FBXLoaderT = import('three/examples/jsm/loaders/FBXLoader.js').FBXLoader;
|
||||
type ColladaLoaderT = import('three/examples/jsm/loaders/ColladaLoader.js').ColladaLoader;
|
||||
type PLYLoaderT = import('three/examples/jsm/loaders/PLYLoader.js').PLYLoader;
|
||||
|
||||
let _obj: OBJLoaderT | null = null;
|
||||
let _fbx: FBXLoaderT | null = null;
|
||||
let _dae: ColladaLoaderT | null = null;
|
||||
let _ply: PLYLoaderT | null = null;
|
||||
|
||||
async function getOBJ(): Promise<OBJLoaderT> {
|
||||
if (_obj) return _obj;
|
||||
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
||||
return (_obj = new OBJLoader());
|
||||
}
|
||||
|
||||
async function getFBX(): Promise<FBXLoaderT> {
|
||||
if (_fbx) return _fbx;
|
||||
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
||||
return (_fbx = new FBXLoader());
|
||||
}
|
||||
|
||||
async function getCollada(): Promise<ColladaLoaderT> {
|
||||
if (_dae) return _dae;
|
||||
const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js');
|
||||
return (_dae = new ColladaLoader());
|
||||
}
|
||||
|
||||
async function getPLY(): Promise<PLYLoaderT> {
|
||||
if (_ply) return _ply;
|
||||
const { PLYLoader } = await import('three/examples/jsm/loaders/PLYLoader.js');
|
||||
return (_ply = new PLYLoader());
|
||||
}
|
||||
|
||||
function normalizePlyAttributes(geometry: THREE.BufferGeometry): void {
|
||||
for (const name of ['position', 'normal', 'uv', 'color']) {
|
||||
const attribute = geometry.getAttribute(name);
|
||||
if (!attribute || !(attribute.array instanceof Float64Array)) continue;
|
||||
geometry.setAttribute(
|
||||
name,
|
||||
new THREE.Float32BufferAttribute(
|
||||
new Float32Array(attribute.array),
|
||||
attribute.itemSize,
|
||||
attribute.normalized,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load any supported model URL and resolve to a scene-addable Object3D.
|
||||
* `nameHint` carries the real file name for ext detection when `url` is a
|
||||
* blob: URL (drag & drop) — blob URLs have no extension.
|
||||
*/
|
||||
export interface LoadOpts {
|
||||
/** Raw .mtl text for OBJ loads (drag & drop supplies it; blob: URLs can't
|
||||
* resolve the sibling mtllib). Color-only MTLs apply without any texture fetch. */
|
||||
mtlText?: string;
|
||||
/** Image files dropped alongside an OBJ+MTL (textures the MTL's map_* lines
|
||||
* reference). Their blob: URLs are mapped onto the requested texture names so
|
||||
* textures resolve without a server. */
|
||||
texFiles?: File[];
|
||||
/** Parse the OBJ via the streaming parser (objStream.ts) instead of OBJLoader.
|
||||
* Set for files too large for OBJLoader's single-string parse (>~300MB). */
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export async function loadModel(
|
||||
url: string,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
onProgress?: ProgressCb,
|
||||
nameHint?: string,
|
||||
opts?: LoadOpts,
|
||||
): Promise<THREE.Object3D> {
|
||||
const ext = extOf(nameHint ?? url) ?? extOf(url);
|
||||
const onXhr = (xhr: ProgressEvent): void => {
|
||||
if (onProgress && xhr.total > 0) onProgress(xhr.loaded, xhr.total);
|
||||
};
|
||||
|
||||
switch (ext) {
|
||||
case 'glb':
|
||||
case 'gltf': {
|
||||
const { gltf } = getLoaders(renderer);
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
gltf.load(url, (g) => resolve(g.scene), onXhr, (err) => reject(err));
|
||||
});
|
||||
}
|
||||
case 'obj': {
|
||||
if (opts?.stream) {
|
||||
const { loadObjStreaming } = await import('./objStream');
|
||||
return loadObjStreaming(url, opts.mtlText, onProgress);
|
||||
}
|
||||
const loader = await getOBJ();
|
||||
const texUrls: string[] = [];
|
||||
// Apply dropped .mtl (color-only) if present; otherwise clear any materials
|
||||
// left on the singleton loader from a previous load.
|
||||
if (opts?.mtlText) {
|
||||
const { MTLLoader } = await import('three/examples/jsm/loaders/MTLLoader.js');
|
||||
const manager = new THREE.LoadingManager();
|
||||
if (opts.texFiles && opts.texFiles.length) {
|
||||
// Map each dropped image's blob: URL onto the texture name the MTL
|
||||
// references (by basename), so map_Kd etc. resolve without a server.
|
||||
const byName = new Map<string, string>();
|
||||
for (const f of opts.texFiles) {
|
||||
const u = URL.createObjectURL(f);
|
||||
texUrls.push(u);
|
||||
byName.set(f.name.toLowerCase(), u);
|
||||
}
|
||||
manager.setURLModifier((u) => {
|
||||
const base = decodeURIComponent((u.split(/[\\/]/).pop() ?? '')).toLowerCase();
|
||||
return byName.get(base) ?? u;
|
||||
});
|
||||
}
|
||||
const mc = new MTLLoader(manager).parse(opts.mtlText, '');
|
||||
mc.preload();
|
||||
loader.setMaterials(mc);
|
||||
} else {
|
||||
(loader as unknown as { materials: unknown }).materials = null;
|
||||
}
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(
|
||||
url,
|
||||
(group) => {
|
||||
group.traverse((node) => {
|
||||
const mat = (node as THREE.Mesh).material;
|
||||
if (!mat) return;
|
||||
(Array.isArray(mat) ? mat : [mat]).forEach((m) => {
|
||||
// CAD OBJ faces often have inconsistent winding; backface culling
|
||||
// tears flat surfaces. Render double-sided.
|
||||
m.side = THREE.DoubleSide;
|
||||
const map = (m as THREE.MeshPhongMaterial).map;
|
||||
if (map) map.colorSpace = THREE.SRGBColorSpace; // color textures are sRGB
|
||||
});
|
||||
});
|
||||
// Texture blob: URLs live with the object; revoked on dispose.
|
||||
if (texUrls.length) group.userData.__texUrls = texUrls;
|
||||
resolve(group);
|
||||
},
|
||||
onXhr,
|
||||
(err) => reject(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'fbx': {
|
||||
const loader = await getFBX();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(url, (group) => resolve(group), onXhr, (err) => reject(err));
|
||||
});
|
||||
}
|
||||
case 'dae': {
|
||||
const loader = await getCollada();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(
|
||||
url,
|
||||
(result) => {
|
||||
if (!result) {
|
||||
reject(new Error('Collada loader returned no scene'));
|
||||
return;
|
||||
}
|
||||
resolve(result.scene);
|
||||
},
|
||||
onXhr,
|
||||
(err) => reject(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'ply': {
|
||||
const loader = await getPLY();
|
||||
return new Promise<THREE.Object3D>((resolve, reject) => {
|
||||
loader.load(
|
||||
url,
|
||||
(geometry) => {
|
||||
normalizePlyAttributes(geometry);
|
||||
const hasColor = geometry.hasAttribute('color');
|
||||
if (geometry.index) {
|
||||
// Triangle mesh
|
||||
if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xffffff,
|
||||
vertexColors: hasColor,
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
resolve(new THREE.Mesh(geometry, mat));
|
||||
} else {
|
||||
// No faces → point cloud
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: 0xffffff,
|
||||
vertexColors: hasColor,
|
||||
size: 1,
|
||||
sizeAttenuation: false,
|
||||
});
|
||||
resolve(new THREE.Points(geometry, mat));
|
||||
}
|
||||
},
|
||||
onXhr,
|
||||
(err) => reject(err),
|
||||
);
|
||||
});
|
||||
}
|
||||
case 'ifc':
|
||||
return loadIfc(url, onProgress);
|
||||
default:
|
||||
throw new Error('Unsupported model format: ' + url);
|
||||
}
|
||||
}
|
||||
|
||||
let _ifcApi: import('web-ifc').IfcAPI | null = null;
|
||||
|
||||
/** Parse an .ifc into a Group via web-ifc (single-thread wasm, no COOP/COEP). */
|
||||
async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Object3D> {
|
||||
try {
|
||||
const WebIFC = await import('web-ifc');
|
||||
if (!_ifcApi) {
|
||||
_ifcApi = new WebIFC.IfcAPI();
|
||||
_ifcApi.SetWasmPath(publicAssetUrl('web-ifc/'), true);
|
||||
await _ifcApi.Init(undefined, true);
|
||||
}
|
||||
const api = _ifcApi;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('fetch ' + res.status);
|
||||
const buf = await res.arrayBuffer();
|
||||
if (onProgress) onProgress(1, 1);
|
||||
const data = new Uint8Array(buf);
|
||||
|
||||
const modelID = api.OpenModel(data, { COORDINATE_TO_ORIGIN: true });
|
||||
if (modelID < 0) throw new Error('OpenModel returned -1');
|
||||
|
||||
const group = new THREE.Group();
|
||||
const matrix = new THREE.Matrix4();
|
||||
try {
|
||||
api.StreamAllMeshes(modelID, (mesh) => {
|
||||
const placedCount = mesh.geometries.size();
|
||||
for (let i = 0; i < placedCount; i++) {
|
||||
const placed = mesh.geometries.get(i);
|
||||
const geom = api.GetGeometry(modelID, placed.geometryExpressID);
|
||||
const verts = api.GetVertexArray(geom.GetVertexData(), geom.GetVertexDataSize());
|
||||
const indices = api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize());
|
||||
geom.delete();
|
||||
|
||||
// Interleaved [posX,posY,posZ, normX,normY,normZ] — stride 6.
|
||||
const interleaved = new THREE.InterleavedBuffer(verts, 6);
|
||||
const bg = new THREE.BufferGeometry();
|
||||
bg.setAttribute('position', new THREE.InterleavedBufferAttribute(interleaved, 3, 0));
|
||||
bg.setAttribute('normal', new THREE.InterleavedBufferAttribute(interleaved, 3, 3));
|
||||
bg.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
|
||||
const c = placed.color;
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(c.x, c.y, c.z),
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
if (c.w < 1) {
|
||||
mat.transparent = true;
|
||||
mat.opacity = c.w;
|
||||
}
|
||||
|
||||
const three = new THREE.Mesh(bg, mat);
|
||||
matrix.fromArray(placed.flatTransformation);
|
||||
three.applyMatrix4(matrix);
|
||||
group.add(three);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
api.CloseModel(modelID);
|
||||
}
|
||||
return group;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error('IFC load failed: ' + msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* In-viewer float64 recenter for OBJ files with huge absolute coordinates
|
||||
* (CAD/survey, ~1e8). Done at LOAD time, before OBJLoader quantizes to float32.
|
||||
*
|
||||
* Why here and not after parse: WebGL vertex buffers are float32; at ~1e8 the
|
||||
* float32 ulp is 16-64 units, so vertices snap to that grid, merge, and faces
|
||||
* crack. Subtracting the bbox center from the *source text* (float64) keeps
|
||||
* coords small (~±3e4) so float32 holds full detail — no preprocessed files,
|
||||
* no shader-side double emulation (RTE/two-float), which a single offset of
|
||||
* this magnitude makes unnecessary (relative precision after centering ~3e-5
|
||||
* vs float32 ~1e-7).
|
||||
*
|
||||
* Streamed in two passes (bbox, then rewrite) to keep memory bounded on the
|
||||
* 100-200MB OBJ files this targets.
|
||||
*/
|
||||
|
||||
const V = 118; // 'v'
|
||||
const SP = 32; // ' '
|
||||
const RECENTER_THRESHOLD = 1e4; // models nearer the origin than this are left as-is
|
||||
|
||||
/** Yield LF-normalized lines from a Blob's stream without buffering the whole file. */
|
||||
async function* lines(blob: Blob): AsyncGenerator<string> {
|
||||
const reader = blob.stream().pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += value;
|
||||
let i: number;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
let ln = buf.slice(0, i);
|
||||
if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR
|
||||
yield ln;
|
||||
buf = buf.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : String(Math.round(n * 1000) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an origin-centered copy of `file` as a Blob, or the original `file`
|
||||
* unchanged when it already sits near the origin (small models pay only the
|
||||
* cheap first-pass scan). The returned Blob carries the same OBJ text minus a
|
||||
* per-file integer offset on every `v` line; vn/f/usemtl/mtllib are verbatim.
|
||||
*/
|
||||
export async function recenterObjFile(
|
||||
file: File,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<Blob> {
|
||||
// Two streamed passes (bbox, then rewrite) over the file's bytes; report
|
||||
// progress across both, throttled so the DOM isn't touched per line.
|
||||
const total = file.size * 2;
|
||||
let read = 0, tick = 0;
|
||||
const report = (line: string): void => {
|
||||
read += line.length + 1;
|
||||
if (onProgress && (++tick & 0x3fff) === 0) onProgress(read, total); // every ~16k lines
|
||||
};
|
||||
|
||||
let xmin = Infinity, ymin = Infinity, zmin = Infinity;
|
||||
let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity;
|
||||
for await (const line of lines(file)) {
|
||||
report(line);
|
||||
if (line.charCodeAt(0) !== V || line.charCodeAt(1) !== SP) continue;
|
||||
const p = line.split(/\s+/);
|
||||
const x = +p[1], y = +p[2], z = +p[3];
|
||||
if (x < xmin) xmin = x; if (x > xmax) xmax = x;
|
||||
if (y < ymin) ymin = y; if (y > ymax) ymax = y;
|
||||
if (z < zmin) zmin = z; if (z > zmax) zmax = z;
|
||||
}
|
||||
if (!Number.isFinite(xmin)) return file; // no vertices — let OBJLoader handle it
|
||||
|
||||
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
|
||||
if (Math.hypot(cx, cy, cz) < RECENTER_THRESHOLD) return file;
|
||||
|
||||
const ox = Math.round(cx), oy = Math.round(cy), oz = Math.round(cz);
|
||||
const enc = new TextEncoder();
|
||||
const parts: BlobPart[] = [];
|
||||
let out = '';
|
||||
for await (const line of lines(file)) {
|
||||
report(line);
|
||||
if (line.charCodeAt(0) === V && line.charCodeAt(1) === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
out += `v ${fmt(+p[1] - ox)} ${fmt(+p[2] - oy)} ${fmt(+p[3] - oz)}\n`;
|
||||
} else {
|
||||
out += line + '\n';
|
||||
}
|
||||
if (out.length > (1 << 23)) { parts.push(enc.encode(out)); out = ''; } // flush ~8MB
|
||||
}
|
||||
if (out) parts.push(enc.encode(out));
|
||||
if (onProgress) onProgress(total, total);
|
||||
return new Blob(parts, { type: 'text/plain' });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Streaming OBJ parser for files too large for OBJLoader.
|
||||
*
|
||||
* OBJLoader builds one giant string of the whole file; past ~512MB-1GB of text
|
||||
* that exceeds the V8 max string length and fails (empty geometry). This parses
|
||||
* the file in two streamed passes (count, then fill) so it never holds the whole
|
||||
* text, building an INDEXED BufferGeometry (positions indexed by vertex, normals
|
||||
* computed). Memory stays ~O(vertices + triangles) of typed arrays.
|
||||
*
|
||||
* Trade-offs vs OBJLoader (acceptable for huge structural CAD exports):
|
||||
* - smooth (computed) normals, not per-face-corner — hard edges soften.
|
||||
* - per-vertex color from the active material's Kd (one draw call, no textures).
|
||||
* - float64 recenter baked in (huge absolute coords don't crack float32).
|
||||
*/
|
||||
|
||||
const SP = 32; // ' '
|
||||
const V = 118; // 'v'
|
||||
const F = 102; // 'f'
|
||||
const U = 117; // 'u' (usemtl)
|
||||
const RECENTER_THRESHOLD = 1e4;
|
||||
|
||||
async function* streamLines(url: string): AsyncGenerator<string> {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok || !resp.body) throw new Error('fetch failed: ' + resp.status);
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += value;
|
||||
let i: number;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
let ln = buf.slice(0, i);
|
||||
if (ln.charCodeAt(ln.length - 1) === 13) ln = ln.slice(0, -1); // strip CR
|
||||
yield ln;
|
||||
buf = buf.slice(i + 1);
|
||||
}
|
||||
}
|
||||
if (buf.length) yield buf;
|
||||
}
|
||||
|
||||
function parseMtlColors(text: string): Map<string, [number, number, number]> {
|
||||
const map = new Map<string, [number, number, number]>();
|
||||
let cur = '';
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith('newmtl ')) cur = line.slice(7).trim();
|
||||
else if (cur && line.startsWith('Kd ')) {
|
||||
const p = line.split(/\s+/);
|
||||
map.set(cur, [+p[1], +p[2], +p[3]]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function loadObjStreaming(
|
||||
url: string,
|
||||
mtlText?: string,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<THREE.Object3D> {
|
||||
// PASS 1 — count vertices + triangles, accumulate bbox in float64.
|
||||
let nV = 0, nTri = 0;
|
||||
let xmin = Infinity, ymin = Infinity, zmin = Infinity;
|
||||
let xmax = -Infinity, ymax = -Infinity, zmax = -Infinity;
|
||||
for await (const line of streamLines(url)) {
|
||||
const c0 = line.charCodeAt(0);
|
||||
if (c0 === V && line.charCodeAt(1) === SP) {
|
||||
nV++;
|
||||
const p = line.split(/\s+/);
|
||||
const x = +p[1], y = +p[2], z = +p[3];
|
||||
if (x < xmin) xmin = x; if (x > xmax) xmax = x;
|
||||
if (y < ymin) ymin = y; if (y > ymax) ymax = y;
|
||||
if (z < zmin) zmin = z; if (z > zmax) zmax = z;
|
||||
} else if (c0 === F && line.charCodeAt(1) === SP) {
|
||||
let corners = 0;
|
||||
const p = line.split(/\s+/);
|
||||
for (let k = 1; k < p.length; k++) if (p[k]) corners++;
|
||||
if (corners >= 3) nTri += corners - 2;
|
||||
}
|
||||
}
|
||||
if (nV === 0) throw new Error('OBJ has no vertices');
|
||||
|
||||
const cx = (xmin + xmax) / 2, cy = (ymin + ymax) / 2, cz = (zmin + zmax) / 2;
|
||||
const far = Math.hypot(cx, cy, cz) >= RECENTER_THRESHOLD;
|
||||
const ox = far ? Math.round(cx) : 0, oy = far ? Math.round(cy) : 0, oz = far ? Math.round(cz) : 0;
|
||||
|
||||
// PASS 2 — fill typed arrays.
|
||||
const positions = new Float32Array(nV * 3);
|
||||
const index = new Uint32Array(nTri * 3);
|
||||
const colorMap = mtlText ? parseMtlColors(mtlText) : null;
|
||||
const colors = colorMap && colorMap.size ? new Float32Array(nV * 3) : null;
|
||||
let vi = 0, ii = 0;
|
||||
let cr = 0.8, cg = 0.8, cb = 0.8;
|
||||
const total = nV + nTri; let done = 0;
|
||||
|
||||
for await (const line of streamLines(url)) {
|
||||
const c0 = line.charCodeAt(0), c1 = line.charCodeAt(1);
|
||||
if (c0 === V && c1 === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
positions[vi * 3] = +p[1] - ox;
|
||||
positions[vi * 3 + 1] = +p[2] - oy;
|
||||
positions[vi * 3 + 2] = +p[3] - oz;
|
||||
vi++;
|
||||
if ((++done & 0x3ffff) === 0) onProgress?.(done, total);
|
||||
} else if (c0 === F && c1 === SP) {
|
||||
const p = line.split(/\s+/);
|
||||
const vs: number[] = [];
|
||||
for (let k = 1; k < p.length; k++) {
|
||||
if (!p[k]) continue;
|
||||
const slash = p[k].indexOf('/');
|
||||
let idx = parseInt(slash >= 0 ? p[k].slice(0, slash) : p[k], 10);
|
||||
idx = idx < 0 ? nV + idx : idx - 1; // negative = relative; OBJ is 1-based
|
||||
vs.push(idx);
|
||||
}
|
||||
if (colors) for (const v of vs) { colors[v * 3] = cr; colors[v * 3 + 1] = cg; colors[v * 3 + 2] = cb; }
|
||||
for (let t = 1; t + 1 < vs.length; t++) { // fan triangulate
|
||||
index[ii++] = vs[0]; index[ii++] = vs[t]; index[ii++] = vs[t + 1];
|
||||
}
|
||||
if ((++done & 0x3ffff) === 0) onProgress?.(done, total);
|
||||
} else if (c0 === U && line.startsWith('usemtl ')) {
|
||||
const c = colorMap?.get(line.slice(7).trim());
|
||||
if (c) { cr = c[0]; cg = c[1]; cb = c[2]; }
|
||||
}
|
||||
}
|
||||
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
if (colors) geom.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
geom.setIndex(new THREE.BufferAttribute(index, 1));
|
||||
geom.computeVertexNormals();
|
||||
onProgress?.(total, total);
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: colors ? 0xffffff : 0xcccccc,
|
||||
vertexColors: !!colors,
|
||||
metalness: 0.0,
|
||||
roughness: 1.0,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
return new THREE.Mesh(geom, mat);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# tools/ — Offline asset pipeline
|
||||
|
||||
Offline tooling for preparing 3D assets served by the hmwebviewer. Nothing here
|
||||
ships to the browser at runtime; these produce the optimized assets and
|
||||
pre-rendered placeholders that the viewer consumes.
|
||||
|
||||
## preprocess.mjs — Draco + KTX2 compression
|
||||
|
||||
Compresses a `.glb`/`.gltf` into a single self-contained `.optimized.glb` using
|
||||
[gltf-transform](https://gltf-transform.dev/): Draco for geometry, KTX2/Basis
|
||||
Universal for textures, in one pass. Output loads directly in the viewer's
|
||||
`GLTFLoader` (with `DRACOLoader` + `KTX2Loader` wired on the shared loader).
|
||||
|
||||
### Install (once)
|
||||
|
||||
These dev dependencies are **not** in `package.json` yet. Run before first use:
|
||||
|
||||
```bash
|
||||
npm install -D @gltf-transform/core @gltf-transform/functions \
|
||||
@gltf-transform/extensions @gltf-transform/cli
|
||||
```
|
||||
|
||||
The CLI package provides the KTX2 encoder wiring. gltf-transform fetches the
|
||||
platform basis encoder automatically on first KTX2 run (network needed once).
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--ktx2|--no-ktx2]
|
||||
```
|
||||
|
||||
- Default output: `<input>.optimized.glb` (e.g. `model.glb` → `model.optimized.glb`).
|
||||
- `--draco-bits N` — position quantization bits, 8..16 (default **14**). Lower =
|
||||
smaller file, lossier geometry. Tune per asset.
|
||||
- `--ktx2` / `--no-ktx2` — texture encoding toggle (default **on**).
|
||||
|
||||
Prints before/after byte sizes and reduction %.
|
||||
|
||||
### Outputs
|
||||
|
||||
- Compressed GLBs land in [`samples/`](../samples/) (`samples/*.optimized.glb`).
|
||||
- Pre-rendered WebP placeholders land in [`public/previews/`](../public/previews/)
|
||||
(`*.webp`) — see the pre-render section below.
|
||||
|
||||
If a required package is missing at runtime, the script prints the install
|
||||
command above and exits non-zero (no silent skip).
|
||||
|
||||
## Sample assets
|
||||
|
||||
Place source `.glb` files under `samples/` (suggested split: small / medium /
|
||||
large). Compress with `preprocess.mjs` and commit the `.optimized.glb` outputs.
|
||||
Samples are not committed yet — to be added when a sample asset is available.
|
||||
|
||||
## Pre-render pipeline (360° animated WebP placeholder)
|
||||
|
||||
For Path A (server asset) the server ships a pre-rendered 360° turntable so the
|
||||
page shows motion instantly while the real model decodes in the background.
|
||||
Output: `public/previews/<asset>.webp` (animated, with alpha).
|
||||
|
||||
**Status: to be implemented when a sample asset is available.** Two options:
|
||||
|
||||
1. **Blender headless CLI (preferred).** Camera parented to an empty at the
|
||||
model origin, Z axis 0→360° keyframed over N frames. Render frames:
|
||||
```bash
|
||||
blender -b scene.blend -o //frame_### -f 1..N -F PNG
|
||||
```
|
||||
Assemble to animated WebP (alpha) or WebM VP9 via ffmpeg:
|
||||
```bash
|
||||
ffmpeg -framerate 24 -i frame_%03d.png -loop 0 -plays 0 out.webp
|
||||
```
|
||||
|
||||
2. **Puppeteer + headless three.js (fallback).** Spin a minimal three.js page,
|
||||
rotate the model, and call `page.screenshot({ type: 'webp' })` per rotation
|
||||
step. Stitch frames into an animated WebP.
|
||||
|
||||
Both options need a committed sample asset first; blocked on P4-2.
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/adaptive-smoke.mjs — empirically trigger the adaptive-quality downstep.
|
||||
*
|
||||
* Loads a heavy sample in headless Chrome (software GL = slow) + applies a CDP
|
||||
* CPU throttle so the REAL measured fps sustains < 60. Captures the genuine
|
||||
* `[hmwebviewer] adaptive quality -> tier N (pr=..)` console logs emitted by
|
||||
* src/viewer/adaptiveQuality.ts, plus the live #.fps overlay text. Proves the
|
||||
* <60fps → optimize path fires on real frame measurement (no logic patched).
|
||||
*
|
||||
* USAGE: node tools/adaptive-smoke.mjs [--url URL] [--asset PATH] [--throttle N] [--seconds S]
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = argv.slice(2);
|
||||
let url = 'http://127.0.0.1:4173', asset = '/samples/ABeautifulGame.ktx2.glb', throttle = 6, seconds = 25;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] === '--url') url = a[++i];
|
||||
else if (a[i] === '--asset') asset = a[++i];
|
||||
else if (a[i] === '--throttle') throttle = Number(a[++i]);
|
||||
else if (a[i] === '--seconds') seconds = Number(a[++i]);
|
||||
}
|
||||
return { url, asset, throttle, seconds };
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
const o = parseArgs(process.argv);
|
||||
const exe = findBrowser();
|
||||
if (!exe) { console.error('[adaptive-smoke] No Chrome/Edge found.'); process.exit(1); }
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: exe,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
const tierLogs = [];
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
if (t.includes('adaptive quality')) { tierLogs.push(t); console.log(' LOG ' + t); }
|
||||
});
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
|
||||
const target = o.url + '?model=' + o.asset;
|
||||
console.log('[adaptive-smoke] ' + target + ' throttle=' + o.throttle + 'x watch=' + o.seconds + 's browser=' + exe);
|
||||
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 60000 });
|
||||
console.log('[adaptive-smoke] model loaded; applying CPU throttle + watching fps...');
|
||||
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Emulation.setCPUThrottlingRate', { rate: o.throttle });
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < o.seconds * 1000) {
|
||||
await sleep(2000);
|
||||
const state = await page.evaluate(() => {
|
||||
const el = document.querySelector('.fps');
|
||||
return { fps: el ? el.textContent : '(no overlay)', pr: window.__viewer ? undefined : undefined };
|
||||
});
|
||||
console.log(' t+' + Math.round((Date.now() - start) / 1000) + 's overlay=' + state.fps + ' tierSteps=' + tierLogs.length);
|
||||
if (tierLogs.length >= 3) break; // enough proof
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log('\n[adaptive-smoke] tier-change log lines captured: ' + tierLogs.length);
|
||||
tierLogs.forEach((l) => console.log(' ' + l));
|
||||
if (tierLogs.length === 0) {
|
||||
console.error('[adaptive-smoke] NO downstep observed — fps stayed >=60 (raise --throttle or use a heavier asset).');
|
||||
process.exit(2);
|
||||
}
|
||||
console.log('[adaptive-smoke] PASS — adaptive quality degraded on sustained <60fps.');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('[adaptive-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });
|
||||
@@ -0,0 +1,30 @@
|
||||
// IFC single-thread WASM을 public/에 staging합니다.
|
||||
// `npm install`의 postinstall에서 자동 실행되며 반복 실행해도 같은 결과를 만듭니다.
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
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, '..');
|
||||
|
||||
// 현재 IFC 경로는 COOP/COEP가 필요 없는 single-thread WASM만 사용합니다.
|
||||
const ifcEntry = fileURLToPath(import.meta.resolve('web-ifc'));
|
||||
const ifcSrcDir = dirname(ifcEntry);
|
||||
const ifcDest = resolve(root, 'public/web-ifc');
|
||||
if (existsSync(ifcSrcDir)) {
|
||||
rmSync(ifcDest, { force: true, recursive: true });
|
||||
mkdirSync(ifcDest, { recursive: true });
|
||||
const src = resolve(ifcSrcDir, 'web-ifc.wasm');
|
||||
const dest = resolve(ifcDest, 'web-ifc.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.');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/dnd-smoke.mjs — CSR (Path B / drag&drop) load smoke.
|
||||
*
|
||||
* The per-format perf-smoke only exercised Path A (?model=). This drives the
|
||||
* REAL local-file path: fetch each sample into a File, hand it to
|
||||
* window.__viewer.loadLocalFile(file) (exactly what the dropzone does), and wait
|
||||
* for hmw:ready. Catches the blob:-URL-has-no-extension class of bug.
|
||||
*
|
||||
* USAGE: node tools/dnd-smoke.mjs [--url URL] [--asset /samples/x ...]
|
||||
* (run `npm run build && npm run preview` first)
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
function parseArgs(argv) {
|
||||
const a = argv.slice(2); let url = 'http://127.0.0.1:4173'; const assets = [];
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] === '--url') url = a[++i]; else if (a[i] === '--asset') assets.push(a[++i]);
|
||||
}
|
||||
if (!assets.length) assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Avocado.glb', '/samples/Cube.obj', '/samples/Cube.fbx', '/samples/Cube.dae', '/samples/Cube.ifc');
|
||||
return { url, assets };
|
||||
}
|
||||
|
||||
async function dropOne(page, base, asset) {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__viewer != null', { timeout: 15000 });
|
||||
try {
|
||||
const res = await page.evaluate(async (assetPath) => {
|
||||
const name = assetPath.split('/').pop();
|
||||
const resp = await fetch(assetPath);
|
||||
if (!resp.ok) return { ok: false, err: 'fetch ' + resp.status };
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
|
||||
const ready = new Promise((resolve) => {
|
||||
const onErr = (e) => resolve({ ok: false, err: 'viewer error: ' + (e.detail || 'unknown') });
|
||||
window.addEventListener('hmw:ready', () => resolve({ ok: true }), { once: true });
|
||||
// surface our own onError via console; also time out below
|
||||
window.__dndTimeout = setTimeout(() => resolve({ ok: false, err: 'timeout (no hmw:ready)' }), 20000);
|
||||
});
|
||||
window.__viewer.loadLocalFile(file);
|
||||
return ready;
|
||||
}, asset);
|
||||
return { asset, ...res };
|
||||
} catch (e) {
|
||||
return { asset, ok: false, err: (e && e.message) || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const o = parseArgs(process.argv);
|
||||
const exe = findBrowser();
|
||||
if (!exe) { console.error('[dnd-smoke] No Chrome/Edge found.'); process.exit(1); }
|
||||
const browser = await puppeteer.launch({ executablePath: exe, headless: 'new', args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
|
||||
const page = await browser.newPage();
|
||||
const errs = [];
|
||||
page.on('console', (m) => { const t = m.text(); if (t.includes('load failed') || t.includes('Failed to load')) errs.push(t); });
|
||||
|
||||
console.log('[dnd-smoke] CSR drag&drop path, base=' + o.url);
|
||||
const results = [];
|
||||
for (const asset of o.assets) {
|
||||
process.stdout.write(' drop ' + asset + ' ... ');
|
||||
const r = await dropOne(page, o.url, asset);
|
||||
results.push(r);
|
||||
console.log(r.ok ? 'OK' : 'FAIL (' + r.err + ')');
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log('\n[dnd-smoke] ' + (results.length - failed.length) + '/' + results.length + ' passed');
|
||||
if (failed.length) { console.error('[dnd-smoke] FAILURES: ' + failed.map((f) => f.asset).join(', ')); process.exit(1); }
|
||||
console.log('[dnd-smoke] all local drops load.');
|
||||
}
|
||||
main().catch((e) => { console.error('[dnd-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Diagnostic: load a huge OBJ (+mtl) through the REAL drag&drop path in headless
|
||||
* Chrome, capture console ([recenter]/[hmw] bbox) and a screenshot. Confirms
|
||||
* whether the in-viewer recenter ran and whether faces crack.
|
||||
*
|
||||
* USAGE: node tools/girder-smoke.mjs [--url http://localhost:3333] [obj] [mtl]
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const CHROME = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find((p) => existsSync(p));
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let url = 'http://localhost:3333';
|
||||
const files = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--url') url = args[++i];
|
||||
else files.push(args[i]);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
files.push('samples/GirderObjs/part01.obj', 'samples/GirderObjs/part01.mtl');
|
||||
}
|
||||
const paths = files.map((f) => resolve(f));
|
||||
for (const p of paths) if (!existsSync(p)) { console.error('missing', p); process.exit(1); }
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist',
|
||||
'--disable-dev-shm-usage', '--window-size=1400,900'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1400, height: 900 });
|
||||
page.on('console', (m) => console.log(' [browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
|
||||
console.log('goto', url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('#file-input', { timeout: 10000 });
|
||||
|
||||
const input = await page.$('#file-input');
|
||||
console.log('uploading', paths.join(', '));
|
||||
await input.uploadFile(...paths);
|
||||
await page.evaluate(() => document.getElementById('file-input')
|
||||
.dispatchEvent(new Event('change', { bubbles: true })));
|
||||
|
||||
console.log('waiting for hmw:ready (up to 240s)...');
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 240000, polling: 1000 });
|
||||
console.log('READY in', ((Date.now() - t0) / 1000).toFixed(1) + 's');
|
||||
} catch {
|
||||
console.log('TIMEOUT after', ((Date.now() - t0) / 1000).toFixed(1) + 's — capturing anyway');
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await page.screenshot({ path: resolve('girder-smoke.png') });
|
||||
console.log('screenshot -> girder-smoke.png (full)');
|
||||
|
||||
// Medium zoom (reproduce user's close inspection) via OrbitControls wheel.
|
||||
await page.mouse.move(700, 450);
|
||||
for (let i = 0; i < 14; i++) { await page.mouse.wheel({ deltaY: -200 }); await new Promise((r) => setTimeout(r, 20)); }
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await page.screenshot({ path: resolve('girder-zoom.png') });
|
||||
console.log('screenshot -> girder-zoom.png (zoomed in)');
|
||||
|
||||
await browser.close();
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/perf-smoke.mjs — Perceived-load smoke test for hmwebviewer (PLAN P5-3).
|
||||
*
|
||||
* Launches headless Chrome via puppeteer-core (uses the system Chrome; no
|
||||
* Chromium download). For each sample asset it loads `?model=<asset>` and
|
||||
* measures wall time from navigation-start to the viewer's `hmw:ready` event
|
||||
* (dispatched in ThreeDViewer.onLoaded). Asserts < 3000ms perceived.
|
||||
*
|
||||
* USAGE
|
||||
* node tools/perf-smoke.mjs [--url http://127.0.0.1:4173] [--asset /samples/Box.glb ...]
|
||||
*
|
||||
* Defaults: url = http://127.0.0.1:4173 (vite preview); assets = the sample set.
|
||||
* Run `npm run build && npm run preview` first (or `npm run dev` on its port).
|
||||
*
|
||||
* Browser auto-detected: Chrome, then Edge, then PUPPETEER_EXECUTABLE_PATH.
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
|
||||
return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
}
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
let url = 'http://127.0.0.1:4173';
|
||||
const assets = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--url') url = args[++i];
|
||||
else if (args[i] === '--asset') assets.push(args[++i]);
|
||||
else if (args[i] === '-h' || args[i] === '--help') return { help: true };
|
||||
}
|
||||
if (assets.length === 0) {
|
||||
assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Duck.optimized.glb', '/samples/Avocado.glb');
|
||||
}
|
||||
return { url, assets };
|
||||
}
|
||||
|
||||
const THRESHOLD_MS = 3000;
|
||||
|
||||
async function measureAsset(browser, baseUrl, asset) {
|
||||
const page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
const target = baseUrl + '?model=' + asset;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 30000 });
|
||||
const data = await page.evaluate(() => ({ readyPerf: window.__hmwReady, origin: performance.timeOrigin }));
|
||||
const ms = (data.origin + data.readyPerf) - t0;
|
||||
return { asset, ms: Math.round(ms), ok: ms < THRESHOLD_MS };
|
||||
} catch (e) {
|
||||
return { asset, ms: null, ok: false, error: (e && e.message) ? e.message : String(e) };
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
console.error('Usage: node tools/perf-smoke.mjs [--url URL] [--asset PATH ...]');
|
||||
process.exit(0);
|
||||
}
|
||||
const exe = findBrowser();
|
||||
if (!exe) {
|
||||
console.error('[perf-smoke] No Chrome/Edge found. Set PUPPETEER_EXECUTABLE_PATH.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: exe,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
|
||||
});
|
||||
|
||||
console.log('[perf-smoke] base=' + opts.url + ' browser=' + exe);
|
||||
console.log('[perf-smoke] threshold=' + THRESHOLD_MS + 'ms perceived');
|
||||
const results = [];
|
||||
for (const asset of opts.assets) {
|
||||
process.stdout.write(' ' + asset + ' ... ');
|
||||
const r = await measureAsset(browser, opts.url, asset);
|
||||
results.push(r);
|
||||
console.log(r.ms === null ? 'FAIL (' + (r.error || 'timeout') + ')' : (r.ms + 'ms ' + (r.ok ? 'OK' : 'OVER')));
|
||||
}
|
||||
await browser.close();
|
||||
|
||||
console.log('\n[perf-smoke] summary');
|
||||
for (const r of results) {
|
||||
console.log(' ' + r.asset.padEnd(34) + (r.ms === null ? 'FAIL' : (r.ms + 'ms').padEnd(8)) + (r.ok ? 'PASS' : 'FAIL'));
|
||||
}
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
if (failed.length) {
|
||||
console.error('[perf-smoke] ' + failed.length + ' asset(s) over threshold/failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[perf-smoke] all within ' + THRESHOLD_MS + 'ms.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[perf-smoke] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/** Generate an ASCII PLY cube (vertex colors) and load it via the drag&drop path
|
||||
* in headless Chrome; screenshot to verify PLY support. */
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const PLY = `ply
|
||||
format ascii 1.0
|
||||
element vertex 8
|
||||
property float x
|
||||
property float y
|
||||
property float z
|
||||
property uchar red
|
||||
property uchar green
|
||||
property uchar blue
|
||||
element face 12
|
||||
property list uchar int vertex_indices
|
||||
end_header
|
||||
-1 -1 -1 255 0 0
|
||||
1 -1 -1 0 255 0
|
||||
1 1 -1 0 0 255
|
||||
-1 1 -1 255 255 0
|
||||
-1 -1 1 255 0 255
|
||||
1 -1 1 0 255 255
|
||||
1 1 1 255 255 255
|
||||
-1 1 1 90 90 90
|
||||
3 0 1 2
|
||||
3 0 2 3
|
||||
3 4 5 6
|
||||
3 4 6 7
|
||||
3 0 4 7
|
||||
3 0 7 3
|
||||
3 1 5 6
|
||||
3 1 6 2
|
||||
3 3 2 6
|
||||
3 3 6 7
|
||||
3 0 1 5
|
||||
3 0 5 4
|
||||
`;
|
||||
const dir = resolve('samples/plytest');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolve(dir, 'cube.ply'), PLY);
|
||||
console.log('asset ->', resolve(dir, 'cube.ply'));
|
||||
|
||||
const url = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://localhost:3333';
|
||||
const CHROME = ['C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
|
||||
const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--window-size=900,700'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 900, height: 700 });
|
||||
page.on('console', (m) => console.log(' [browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
await page.evaluateOnNewDocument(() => { window.__r = null; addEventListener('hmw:ready', (e) => { window.__r = e.detail; }); });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('#file-input');
|
||||
await (await page.$('#file-input')).uploadFile(resolve(dir, 'cube.ply'));
|
||||
await page.evaluate(() => document.getElementById('file-input').dispatchEvent(new Event('change', { bubbles: true })));
|
||||
try { await page.waitForFunction('window.__r != null', { timeout: 30000, polling: 500 }); console.log('READY'); }
|
||||
catch { console.log('TIMEOUT'); }
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
await page.screenshot({ path: resolve('ply-test.png') });
|
||||
console.log('screenshot -> ply-test.png');
|
||||
await browser.close();
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/preprocess.mjs — Offline GLB optimization for hmwebviewer.
|
||||
*
|
||||
* SCOPE: GLB/glTF ONLY (gltf-transform pipeline). Other formats shipped as
|
||||
* samples (OBJ/DAE/FBX/IFC) are NOT processed here — they are consumed raw by
|
||||
* their respective three.js loaders; no offline Draco/KTX2 step applies.
|
||||
*
|
||||
* Compresses a glTF/GLB with Draco (geometry) + WebP texture compression in one
|
||||
* pass using gltf-transform. Output is a single self-contained .optimized.glb
|
||||
* ready for the viewer's GLTFLoader (+ DRACOLoader).
|
||||
*
|
||||
* KTX2/Basis texture encoding is NOT done here — it needs the `toktx` platform
|
||||
* encoder (KTX-Software 4.3+). gltf-transform exposes it via the CLI `etc1s`
|
||||
* (lossy, smaller) or `uastc` (higher quality) commands, e.g.:
|
||||
* gltf-transform etc1s <out.optimized.glb> <out.ktx2.glb>
|
||||
* Install KTX-Software from https://github.com/KhronosGroup/KTX-Software first.
|
||||
* Runtime: the viewer's KTX2Loader decodes these at load (verified with the
|
||||
* Khronos ABeautifulGame KTX2+Draco sample — 626ms perceived load).
|
||||
*
|
||||
* USAGE
|
||||
* node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture FMT]
|
||||
*
|
||||
* --draco-bits N Position quantization bits (default 14). 8..16; lower=smaller/lossier.
|
||||
* --texture FMT Texture re-encode: webp (default) | jpeg | png | none.
|
||||
*
|
||||
* OUTPUT
|
||||
* Default output = input with `.optimized.glb` suffix. Prints before/after
|
||||
* byte sizes + reduction %.
|
||||
*
|
||||
* INSTALL (these are devDependencies now; if absent the script prints the
|
||||
* install command and exits non-zero — no silent skip):
|
||||
* npm install -D @gltf-transform/core @gltf-transform/functions \
|
||||
* @gltf-transform/extensions @gltf-transform/cli
|
||||
*/
|
||||
|
||||
async function loadDeps() {
|
||||
let core, fns, ext;
|
||||
try {
|
||||
core = await import('@gltf-transform/core');
|
||||
fns = await import('@gltf-transform/functions');
|
||||
ext = await import('@gltf-transform/extensions');
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
console.error('[preprocess] Missing required package: ' + msg);
|
||||
console.error('[preprocess] Install the toolchain with:');
|
||||
console.error(
|
||||
' npm install -D @gltf-transform/core @gltf-transform/functions ' +
|
||||
'@gltf-transform/extensions @gltf-transform/cli'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { core, fns, ext };
|
||||
}
|
||||
|
||||
const TEXTURE_FORMATS = ['webp', 'jpeg', 'png', 'none'];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
|
||||
return { help: true };
|
||||
}
|
||||
const positional = [];
|
||||
let dracoBits = 14;
|
||||
let texture = 'webp';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--draco-bits') {
|
||||
const v = Number(args[++i]);
|
||||
if (!Number.isInteger(v) || v < 8 || v > 16) {
|
||||
throw new Error('--draco-bits must be an integer 8..16');
|
||||
}
|
||||
dracoBits = v;
|
||||
} else if (a === '--texture') {
|
||||
const v = String(args[++i]).toLowerCase();
|
||||
if (!TEXTURE_FORMATS.includes(v)) {
|
||||
throw new Error('--texture must be one of: ' + TEXTURE_FORMATS.join(', '));
|
||||
}
|
||||
texture = v;
|
||||
} else if (a.startsWith('--')) {
|
||||
throw new Error('Unknown option: ' + a);
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
if (positional.length === 0) {
|
||||
throw new Error('Missing <input.glb>');
|
||||
}
|
||||
const input = positional[0];
|
||||
let output = positional[1];
|
||||
if (!output) {
|
||||
output = input.replace(/\.glb$/i, '') + '.optimized.glb';
|
||||
}
|
||||
return { input, output, dracoBits, texture };
|
||||
}
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
'Usage: node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture webp|jpeg|png|none]'
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
usage();
|
||||
return;
|
||||
}
|
||||
|
||||
const { core, fns, ext } = await loadDeps();
|
||||
const { WebIO } = core;
|
||||
const { quantize, dedup, weld, draco, textureCompress, prune } = fns;
|
||||
const { KHRDracoMeshCompression, EXTTextureWebP } = ext;
|
||||
|
||||
const { promises: fs } = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
|
||||
const inputPath = path.resolve(opts.input);
|
||||
const outputPath = path.resolve(opts.output);
|
||||
|
||||
let inputBytes;
|
||||
try {
|
||||
inputBytes = await fs.readFile(inputPath);
|
||||
} catch (e) {
|
||||
console.error('[preprocess] Cannot read input "' + inputPath + '": ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const beforeSize = inputBytes.byteLength;
|
||||
|
||||
// Draco encoder must be handed to the extension as a registered dependency.
|
||||
const draco3d = await import('draco3d');
|
||||
const dracoEncoder = await draco3d.createEncoderModule();
|
||||
const io = new WebIO()
|
||||
.registerExtensions([KHRDracoMeshCompression, EXTTextureWebP])
|
||||
.registerDependencies({ 'draco3d.encoder': dracoEncoder });
|
||||
|
||||
let doc;
|
||||
try {
|
||||
doc = await io.readBinary(inputBytes);
|
||||
} catch (e) {
|
||||
console.error('[preprocess] Failed to parse glTF: ' + e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const transforms = [];
|
||||
transforms.push(dedup());
|
||||
transforms.push(weld({ tolerance: 1e-4 }));
|
||||
transforms.push(
|
||||
quantize({
|
||||
quantizePosition: opts.dracoBits,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
})
|
||||
);
|
||||
transforms.push(prune());
|
||||
transforms.push(
|
||||
draco({
|
||||
encodeSpeed: 5,
|
||||
decodeSpeed: 5,
|
||||
quantizePosition: opts.dracoBits,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
})
|
||||
);
|
||||
if (opts.texture !== 'none') {
|
||||
transforms.push(textureCompress({ targetFormat: opts.texture, quality: 8 }));
|
||||
}
|
||||
|
||||
await doc.transform(...transforms);
|
||||
|
||||
const outGLB = await io.writeBinary(doc);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, outGLB);
|
||||
const afterSize = outGLB.byteLength;
|
||||
|
||||
const reduction = beforeSize > 0 ? ((1 - afterSize / beforeSize) * 100) : 0;
|
||||
const fmt = (n) => (n / 1024).toFixed(1) + ' KiB';
|
||||
console.log('[preprocess] ' + path.basename(inputPath) + ' -> ' + path.basename(outputPath));
|
||||
console.log('[preprocess] before: ' + beforeSize + ' bytes (' + fmt(beforeSize) + ')');
|
||||
console.log('[preprocess] after: ' + afterSize + ' bytes (' + fmt(afterSize) + ')');
|
||||
console.log(
|
||||
'[preprocess] reduction: ' + reduction.toFixed(1) + '% ' +
|
||||
'(draco-bits=' + opts.dracoBits + ', texture=' + opts.texture + ')'
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[preprocess] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* tools/prerender.mjs — 360° turntable pre-render → animated WebP placeholder (PLAN P4-3).
|
||||
*
|
||||
* Loads the running viewer (?model=<asset>) in headless Chrome, orbits the
|
||||
* camera one full turn in N steps, screenshots each frame, then assembles the
|
||||
* PNG sequence into an animated WebP via ffmpeg. The result is an SSR
|
||||
* placeholder image that hydrates into the live canvas on load.
|
||||
*
|
||||
* USAGE
|
||||
* node tools/prerender.mjs --asset /samples/Duck.glb [--frames 36] [--size 512] \
|
||||
* [--framerate 20] [--out public/previews/Duck.webp] \
|
||||
* [--url http://127.0.0.1:4173]
|
||||
*
|
||||
* Requires: puppeteer-core (installed) + ffmpeg on PATH + the viewer built & served
|
||||
* (`npm run build && npm run preview`, or `npm run dev`).
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
function findBrowser() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
|
||||
return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
}
|
||||
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = argv.slice(2);
|
||||
const o = { asset: null, frames: 36, size: 512, framerate: 20, out: null, url: 'http://127.0.0.1:4173' };
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const k = a[i], v = a[i + 1];
|
||||
if (k === '--asset') { o.asset = v; i++; }
|
||||
else if (k === '--frames') { o.frames = parseInt(v, 10); i++; }
|
||||
else if (k === '--size') { o.size = parseInt(v, 10); i++; }
|
||||
else if (k === '--framerate') { o.framerate = parseInt(v, 10); i++; }
|
||||
else if (k === '--out') { o.out = v; i++; }
|
||||
else if (k === '--url') { o.url = v; i++; }
|
||||
else if (k === '-h' || k === '--help') return { help: true };
|
||||
}
|
||||
if (!o.asset) return { help: true };
|
||||
if (!o.out) {
|
||||
const base = o.asset.split('/').pop().replace(/\.(glb|gltf|obj|fbx|dae|ifc)$/i, '');
|
||||
o.out = 'public/previews/' + base + '.webp';
|
||||
}
|
||||
// Undo MSYS/Git-Bash mangling of leading-slash args (/samples/x.glb -> C:/.../Git/samples/x.glb)
|
||||
o.asset = o.asset.replace(/^.*\/Program Files\/Git\//i, '/');
|
||||
if (!o.asset.startsWith('/')) o.asset = '/' + o.asset;
|
||||
return o;
|
||||
}
|
||||
|
||||
function ffmpegAvailable() {
|
||||
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv);
|
||||
if (opts.help) {
|
||||
console.error('Usage: node tools/prerender.mjs --asset /samples/Duck.glb [--frames 36] [--size 512] [--framerate 20] [--out public/previews/Duck.webp] [--url URL]');
|
||||
process.exit(opts.asset ? 0 : 1);
|
||||
}
|
||||
|
||||
const exe = findBrowser();
|
||||
if (!exe) { console.error('[prerender] No Chrome/Edge found.'); process.exit(1); }
|
||||
const haveFfmpeg = ffmpegAvailable();
|
||||
|
||||
const baseName = opts.asset.split('/').pop().replace(/\.(glb|gltf|obj|fbx|dae|ifc)$/i, '');
|
||||
const frameDir = resolve(root, '.prerender-frames', baseName);
|
||||
rmSync(frameDir, { recursive: true, force: true });
|
||||
mkdirSync(frameDir, { recursive: true });
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: exe,
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
|
||||
defaultViewport: { width: opts.size, height: opts.size },
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
page.on('console', (m) => { if (m.type() === 'error') console.error('[page-console]', m.text()); });
|
||||
page.on('pageerror', (e) => console.error('[page-error]', e.message));
|
||||
page.on('requestfailed', (r) => console.error('[req-failed]', r.url(), r.failure()?.errorText));
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
window.__hmwReady = null;
|
||||
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
|
||||
});
|
||||
await page.goto(opts.url + '?model=' + opts.asset, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction('window.__hmwReady != null', { timeout: 30000 });
|
||||
|
||||
for (let i = 0; i < opts.frames; i++) {
|
||||
const azimuth = (i / opts.frames) * Math.PI * 2;
|
||||
await page.evaluate((az) => window.__viewer && window.__viewer.rotateTo(az), azimuth);
|
||||
const file = resolve(frameDir, 'frame_' + String(i).padStart(3, '0') + '.png');
|
||||
await page.screenshot({ path: file, omitBackground: false });
|
||||
}
|
||||
await browser.close();
|
||||
const { readdirSync } = await import('node:fs');
|
||||
const frameCount = readdirSync(frameDir).filter((f) => f.endsWith('.png')).length;
|
||||
console.log('[prerender] captured ' + frameCount + '/' + opts.frames + ' frames -> ' + frameDir);
|
||||
|
||||
const outAbs = resolve(root, opts.out);
|
||||
mkdirSync(dirname(outAbs), { recursive: true });
|
||||
|
||||
if (!haveFfmpeg) {
|
||||
console.warn('[prerender] ffmpeg not found. Frames left in ' + frameDir);
|
||||
console.warn('[prerender] assemble manually:');
|
||||
console.warn(' ffmpeg -framerate ' + opts.framerate + ' -i ' + frameDir + '/frame_%03d.png -loop 0 ' + outAbs);
|
||||
return;
|
||||
}
|
||||
// Assemble animated WebP. NOTE: libwebp_anim is broken in this ffmpeg build
|
||||
// (outputs 1 frame); -c:v libwebp + -vsync vfr produces correct multi-frame WebP.
|
||||
execFileSync('ffmpeg', [
|
||||
'-y', '-framerate', String(opts.framerate), '-vsync', 'vfr',
|
||||
'-i', resolve(frameDir, 'frame_%03d.png'),
|
||||
'-vf', 'scale=' + opts.size + ':' + opts.size + ':flags=lanczos',
|
||||
'-c:v', 'libwebp', '-loop', '0', '-lossless', '0', '-q:v', '70',
|
||||
outAbs,
|
||||
], { stdio: 'inherit' });
|
||||
console.log('[prerender] animated WebP -> ' + opts.out);
|
||||
rmSync(frameDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[prerender] Fatal: ' + (e && e.stack ? e.stack : e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate a textured cube (cube.obj + cube.mtl + checker.png), then load it
|
||||
* through the real drag&drop path (uploadFile of all 3) in headless Chrome and
|
||||
* screenshot — verifies OBJ texture support (map_Kd → dropped image blob URL).
|
||||
*
|
||||
* USAGE: node tools/tex-test.mjs [--url http://localhost:3333]
|
||||
*/
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// ---- minimal PNG encoder (truecolor RGB, filter 0) ----
|
||||
const CRC = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
|
||||
return (buf) => { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
||||
})();
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(CRC(td), 0);
|
||||
return Buffer.concat([len, td, crc]);
|
||||
}
|
||||
function checkerPng(size = 64, cell = 8) {
|
||||
const raw = Buffer.alloc((size * 3 + 1) * size);
|
||||
for (let y = 0; y < size; y++) {
|
||||
let o = y * (size * 3 + 1); raw[o++] = 0; // filter byte
|
||||
for (let x = 0; x < size; x++) {
|
||||
const on = ((x / cell | 0) + (y / cell | 0)) & 1;
|
||||
raw[o++] = on ? 255 : 0; raw[o++] = on ? 0 : 255; raw[o++] = 255; // magenta / cyan
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 2; // bitdepth 8, colortype 2 (RGB)
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
const OBJ = `mtllib cube.mtl
|
||||
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
|
||||
vt 0 0
|
||||
vt 1 0
|
||||
vt 1 1
|
||||
vt 0 1
|
||||
usemtl checker
|
||||
f 1/1 2/2 3/3
|
||||
f 1/1 3/3 4/4
|
||||
f 5/1 6/2 7/3
|
||||
f 5/1 7/3 8/4
|
||||
f 1/1 5/2 8/3
|
||||
f 1/1 8/3 4/4
|
||||
f 2/1 6/2 7/3
|
||||
f 2/1 7/3 3/4
|
||||
f 4/1 3/2 7/3
|
||||
f 4/1 7/3 8/4
|
||||
f 1/1 2/2 6/3
|
||||
f 1/1 6/3 5/4
|
||||
`;
|
||||
const MTL = `newmtl checker
|
||||
Kd 1 1 1
|
||||
map_Kd checker.png
|
||||
`;
|
||||
|
||||
const dir = resolve('samples/textest');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolve(dir, 'cube.obj'), OBJ);
|
||||
writeFileSync(resolve(dir, 'cube.mtl'), MTL);
|
||||
writeFileSync(resolve(dir, 'checker.png'), checkerPng());
|
||||
console.log('assets ->', dir);
|
||||
|
||||
const url = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://localhost:3333';
|
||||
const CHROME = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find((p) => existsSync(p));
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME, headless: 'new',
|
||||
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--window-size=900,700'],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 900, height: 700 });
|
||||
page.on('console', (m) => console.log(' [browser]', m.text()));
|
||||
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
|
||||
await page.evaluateOnNewDocument(() => { window.__r = null; addEventListener('hmw:ready', (e) => { window.__r = e.detail; }); });
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('#file-input');
|
||||
const input = await page.$('#file-input');
|
||||
await input.uploadFile(resolve(dir, 'cube.obj'), resolve(dir, 'cube.mtl'), resolve(dir, 'checker.png'));
|
||||
await page.evaluate(() => document.getElementById('file-input').dispatchEvent(new Event('change', { bubbles: true })));
|
||||
try { await page.waitForFunction('window.__r != null', { timeout: 30000, polling: 500 }); console.log('READY'); }
|
||||
catch { console.log('TIMEOUT'); }
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
await page.screenshot({ path: resolve('tex-test.png') });
|
||||
console.log('screenshot -> tex-test.png');
|
||||
await browser.close();
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["three", "vite/client"]
|
||||
},
|
||||
"include": ["src", "tools"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: { host: '127.0.0.1', port: 3333, open: false, strictPort: true },
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('/node_modules/three/')) return 'three';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
function A(t){const e=x(t,s.__wbindgen_malloc),n=w,r=s.parse_dwg(e,n);if(r[2])throw h(r[1]);return h(r[0])}function W(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(g(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),i=T(r,s.__wbindgen_malloc,s.__wbindgen_realloc),d=w;m().setInt32(e+4,d,!0),m().setInt32(e+0,i,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(g(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return g(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=s.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==s.memory.buffer)&&(a=new DataView(s.memory.buffer)),a}function g(t,e){return R(t>>>0,e)}let u=null;function _(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(s.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return _().set(t,n/1),w=t.length,n}function T(t,e,n){if(n===void 0){const o=b.encode(t),f=e(o.length,1)>>>0;return _().subarray(f,f+o.length).set(o),w=o.length,f}let r=t.length,i=e(r,1)>>>0;const d=_();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;d[i+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),i=n(i,r,r=c+t.length*3,1)>>>0;const o=_().subarray(i+c,i+r),f=b.encodeInto(t,o);c+=f.written,i=n(i,r,c,1)>>>0}return w=c,i}function h(t){const e=s.__wbindgen_externrefs.get(t);return s.__externref_table_dealloc(t),e}let l=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});l.decode();const E=2146435072;let y=0;function R(t,e){return y+=e,y>=E&&(l=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),l.decode(),y=e),l.decode(_().subarray(t,t+e))}const b=new TextEncoder;"encodeInto"in b||(b.encodeInto=function(t,e){const n=b.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,s;function M(t,e){return s=t.exports,a=null,u=null,s.__wbindgen_start(),s}async function O(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(i){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",i);else throw i}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function D(t){if(s!==void 0)return s;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-Bw7hOWaW.wasm",import.meta.url));const e=W();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await O(await t,e);return M(n)}const S="/assets/acadrust_dwg_bg-Bw7hOWaW.wasm";let p=null;function I(){return p||(p=D({module_or_path:S}).then(()=>{})),p}function B(t){return A(t)}export{I as initAcadrustParser,B as parseDwgAcadrust};
|
||||
@@ -1 +0,0 @@
|
||||
function A(t){let e,n;try{const f=x(t,i.__wbindgen_malloc),c=w,o=i.parse_dwg_json(f,c);var r=o[0],s=o[1];if(o[3])throw r=0,s=0,T(o[2]);return e=r,n=s,l(r,s)}finally{i.__wbindgen_free(e,n,1)}}function h(){return{__proto__:null,"./acadrust_dwg_bg.js":{__proto__:null,__wbg_Error_92b29b0548f8b746:function(e,n){return Error(l(e,n))},__wbg_String_8564e559799eccda:function(e,n){const r=String(n),s=S(r,i.__wbindgen_malloc,i.__wbindgen_realloc),f=w;m().setInt32(e+4,f,!0),m().setInt32(e+0,s,!0)},__wbg___wbindgen_is_string_ea5e6cc2e4141dfe:function(e){return typeof e=="string"},__wbg___wbindgen_throw_344f42d3211c4765:function(e,n){throw new Error(l(e,n))},__wbg_new_32b398fb48b6d94a:function(){return new Array},__wbg_new_7796ffc7ed656783:function(){return new Map},__wbg_new_da52cf8fe3429cb2:function(){return new Object},__wbg_set_575dd786d51585f8:function(e,n,r){return e.set(n,r)},__wbg_set_6be42768c690e380:function(e,n,r){e[n]=r},__wbg_set_8a16b38e4805b298:function(e,n,r){e[n>>>0]=r},__wbindgen_cast_0000000000000001:function(e){return e},__wbindgen_cast_0000000000000002:function(e){return e},__wbindgen_cast_0000000000000003:function(e,n){return l(e,n)},__wbindgen_cast_0000000000000004:function(e){return BigInt.asUintN(64,e)},__wbindgen_init_externref_table:function(){const e=i.__wbindgen_externrefs,n=e.grow(4);e.set(0,void 0),e.set(n+0,void 0),e.set(n+1,null),e.set(n+2,!0),e.set(n+3,!1)}}}}let a=null;function m(){return(a===null||a.buffer.detached===!0||a.buffer.detached===void 0&&a.buffer!==i.memory.buffer)&&(a=new DataView(i.memory.buffer)),a}function l(t,e){return E(t>>>0,e)}let u=null;function b(){return(u===null||u.byteLength===0)&&(u=new Uint8Array(i.memory.buffer)),u}function x(t,e){const n=e(t.length*1,1)>>>0;return b().set(t,n/1),w=t.length,n}function S(t,e,n){if(n===void 0){const o=d.encode(t),_=e(o.length,1)>>>0;return b().subarray(_,_+o.length).set(o),w=o.length,_}let r=t.length,s=e(r,1)>>>0;const f=b();let c=0;for(;c<r;c++){const o=t.charCodeAt(c);if(o>127)break;f[s+c]=o}if(c!==r){c!==0&&(t=t.slice(c)),s=n(s,r,r=c+t.length*3,1)>>>0;const o=b().subarray(s+c,s+r),_=d.encodeInto(t,o);c+=_.written,s=n(s,r,c,1)>>>0}return w=c,s}function T(t){const e=i.__wbindgen_externrefs.get(t);return i.__externref_table_dealloc(t),e}let g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0});g.decode();const D=2146435072;let y=0;function E(t,e){return y+=e,y>=D&&(g=new TextDecoder("utf-8",{ignoreBOM:!0,fatal:!0}),g.decode(),y=e),g.decode(b().subarray(t,t+e))}const d=new TextEncoder;"encodeInto"in d||(d.encodeInto=function(t,e){const n=d.encode(t);return e.set(n),{read:t.length,written:n.length}});let w=0,i;function R(t,e){return i=t.exports,a=null,u=null,i.__wbindgen_start(),i}async function W(t,e){if(typeof Response=="function"&&t instanceof Response){if(typeof WebAssembly.instantiateStreaming=="function")try{return await WebAssembly.instantiateStreaming(t,e)}catch(s){if(t.ok&&n(t.type)&&t.headers.get("Content-Type")!=="application/wasm")console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n",s);else throw s}const r=await t.arrayBuffer();return await WebAssembly.instantiate(r,e)}else{const r=await WebAssembly.instantiate(t,e);return r instanceof WebAssembly.Instance?{instance:r,module:t}:r}function n(r){switch(r){case"basic":case"cors":case"default":return!0}return!1}}async function M(t){if(i!==void 0)return i;t!==void 0&&(Object.getPrototypeOf(t)===Object.prototype?{module_or_path:t}=t:console.warn("using deprecated parameters for the initialization function; pass a single object instead")),t===void 0&&(t=new URL("/assets/acadrust_dwg_bg-Dt2ifJSA.wasm",import.meta.url));const e=h();(typeof t=="string"||typeof Request=="function"&&t instanceof Request||typeof URL=="function"&&t instanceof URL)&&(t=fetch(t));const{instance:n,module:r}=await W(await t,e);return R(n)}const O="/assets/acadrust_dwg_bg-Dt2ifJSA.wasm";let p=null;function v(){return p||(p=M({module_or_path:O}).then(()=>{})),p}function I(t){return JSON.parse(A(t))}export{v as initAcadrustParser,I as parseDwgAcadrust};
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user