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/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
dist-subpath/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.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,113 +1,541 @@
|
|||||||
# dwg-dxf-viewer-sample
|
# 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` …)를 **직접 조합**해 동작하는 독립 프로젝트입니다.
|
좌표, 단위, 원점, 형상 허용오차, topology와 CAD/BIM 의미 정보의 보존을
|
||||||
`hmwebviewer` 저장소 안에 의존하지 않습니다.
|
보장하는 운영용 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
|
```bash
|
||||||
cd D:\MYCLAUDE_PROJECT\dwg-dxf-viewer-sample
|
npm ci
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
# → http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| UI | 모듈 경로 |
|
`npm ci`는 workspace dependency를 설치한 뒤 single-thread IFC WASM을 staging합니다.
|
||||||
|----|-----------|
|
Draco·Basis decoder는 production build가 설치된 Three.js에서 hashed asset으로
|
||||||
| **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)
|
```bash
|
||||||
조합 코드: [`src/main.ts`](./src/main.ts)
|
npm run dev:2d
|
||||||
|
# http://127.0.0.1:5173
|
||||||
|
|
||||||
---
|
npm run dev:3d
|
||||||
|
# http://127.0.0.1:3333
|
||||||
## 레이아웃
|
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
URL load 예시:
|
||||||
|
|
||||||
## 조합 파이프라인
|
```text
|
||||||
|
http://127.0.0.1:5173/?model=/samples/simple.dxf
|
||||||
```
|
http://127.0.0.1:3333/?model=/samples/Box.glb
|
||||||
File/URL
|
|
||||||
→ ext2d (isDwg / isDxf)
|
|
||||||
→ parseDwgBuffer | parseDxfBuffer
|
|
||||||
│ │
|
|
||||||
└ acadrust-dwg ├ decodeDxf
|
|
||||||
├ dxf-parser + dxfHatchHandler
|
|
||||||
└ dxfAdapter
|
|
||||||
→ CadParseResult
|
|
||||||
→ Viewer2D.load() (+ three, slugText/font)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
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/` 에 포함되어 있으며
|
```bash
|
||||||
Vite `?url` import 로 로드됩니다.
|
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/` 폴더 복사
|
2026-07-29 실제 검증 결과:
|
||||||
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` 가 업데이트되면 해당 파일을
|
- workspace typecheck 통과
|
||||||
이 프로젝트 `src/viewer2d` 로 다시 복사하면 됩니다.
|
- 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
|
||||||
|
|
||||||
| 조각 | 라이선스 |
|
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
|
||||||
|------|----------|
|
engineering 정확도와 외부 배포 compliance를 승인하는 test는 아닙니다.
|
||||||
| Viewer2D 포트 · 샘플 글루 | MIT (hmwebviewer 계열) |
|
|
||||||
| acadrust (WASM 내부) | MPL-2.0 |
|
## 문서
|
||||||
| dxf-parser · three | MIT |
|
|
||||||
| NanumGothic | OFL |
|
- [Third-party notices](./THIRD_PARTY_NOTICES.md)
|
||||||
|
- [Source와 fixture provenance](./docs/PROVENANCE.md)
|
||||||
|
- [DWG/DXF module map](./docs/modules-dwg-dxf.html)
|
||||||
|
- [2D sample 안내](./apps/viewer-2d-sample/README.md)
|
||||||
|
- [3D architecture](./apps/viewer-3d/docs/architecture.html)
|
||||||
|
- [3D 구현 계획](./apps/viewer-3d/PLAN.md)
|
||||||
|
- [3D 검증 기록](./apps/viewer-3d/PROGRESS.md)
|
||||||
|
- [3D asset 도구](./apps/viewer-3d/tools/README.md)
|
||||||
|
- [초기 3D 설계서](./apps/viewer-3d/3d_viewer_architecture_spec.pdf)
|
||||||
|
|
||||||
|
하위 2D README에는 통합 전 standalone layout이, 3D tools README에는 과거 KTX2
|
||||||
|
계획이 남아 있습니다. 현재 workspace의 canonical 개요와 배포 판단은 이 문서를
|
||||||
|
기준으로 하며, 하위 문서는 후속 정합성 정리가 필요합니다.
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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>
|
<title>DWG/DXF · 모듈 조합 샘플</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
</ol>
|
</ol>
|
||||||
<p style="margin:10px 0 0">
|
<p style="margin:10px 0 0">
|
||||||
코드: <code>src/main.ts</code> ·
|
코드: <code>src/main.ts</code> ·
|
||||||
지도: <a href="/docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
|
지도: <a href="./docs/modules-dwg-dxf.html" target="_blank">docs/modules-dwg-dxf.html</a>
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// ── ① 모듈 지도의 각 카드 (src/viewer2d/*) ────────────────────────
|
// ── ① 모듈 지도의 각 카드 (src/viewer2d/*) ────────────────────────
|
||||||
import { Viewer2D } from './viewer2d/Viewer2D.js';
|
import {
|
||||||
import { parseDwgBuffer, type CadParseResult } from './viewer2d/dwgParser';
|
createViewer2D,
|
||||||
import { parseDxfBuffer } from './viewer2d/parseDxf';
|
isCad2DFile,
|
||||||
import { is2D, isDxf, isDwg } from './viewer2d/ext2d';
|
loadCad,
|
||||||
import { setDefaultFontUrl } from './viewer2d/slugText';
|
type CadLoadSummary,
|
||||||
|
} from '@hmwebviewer/viewer2d';
|
||||||
|
import {
|
||||||
|
assertCadByteLength,
|
||||||
|
fetchCadBuffer,
|
||||||
|
resolveCadUrl,
|
||||||
|
} from './cadInputPolicy';
|
||||||
|
|
||||||
// parseDxfBuffer 내부:
|
// parseDxfBuffer 내부:
|
||||||
// dxf-parser → HatchHandler → dxfAdapter
|
// dxf-parser → HatchHandler → dxfAdapter
|
||||||
@@ -32,7 +38,8 @@ import { setDefaultFontUrl } from './viewer2d/slugText';
|
|||||||
// acadrustParser → acadrust-dwg WASM
|
// acadrustParser → acadrust-dwg WASM
|
||||||
|
|
||||||
// ── ② 호스트 정적 자산 ──────────────────────────────────────────
|
// ── ② 호스트 정적 자산 ──────────────────────────────────────────
|
||||||
setDefaultFontUrl('/fonts/NanumGothic-Regular.ttf');
|
const appBase = new URL(import.meta.env.BASE_URL, window.location.origin);
|
||||||
|
const publicUrl = (path: string) => new URL(path, appBase).href;
|
||||||
|
|
||||||
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
|
// ── ③ Viewer2D 마운트 ───────────────────────────────────────────
|
||||||
const app = document.getElementById('app')!;
|
const app = document.getElementById('app')!;
|
||||||
@@ -45,7 +52,15 @@ function setStatus(msg: string, err = false) {
|
|||||||
statusEl.classList.toggle('err', err);
|
statusEl.classList.toggle('err', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewer = new Viewer2D(app);
|
const viewer = createViewer2D(app, {
|
||||||
|
fontUrl: publicUrl('fonts/NanumGothic-Regular.ttf'),
|
||||||
|
});
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__viewer2d?: typeof viewer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.__viewer2d = viewer;
|
||||||
viewer.onSelect((entity: unknown) => {
|
viewer.onSelect((entity: unknown) => {
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
setStatus('deselected');
|
setStatus('deselected');
|
||||||
@@ -56,32 +71,19 @@ viewer.onSelect((entity: unknown) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── ④ 파서 조합 ─────────────────────────────────────────────────
|
// ── ④ 파서 조합 ─────────────────────────────────────────────────
|
||||||
async function parseCad(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadLoadSummary> {
|
||||||
if (isDxf(name)) return parseDxfBuffer(buf);
|
return loadCad(viewer, { data: buf, name });
|
||||||
if (isDwg(name)) return parseDwgBuffer(new Uint8Array(buf));
|
|
||||||
const head = new TextDecoder('latin1').decode(
|
|
||||||
new Uint8Array(buf, 0, Math.min(6, buf.byteLength)),
|
|
||||||
);
|
|
||||||
if (head.startsWith('AC')) return parseDwgBuffer(new Uint8Array(buf));
|
|
||||||
return parseDxfBuffer(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseResult> {
|
|
||||||
const result = await parseCad(buf, name);
|
|
||||||
viewer.load(result);
|
|
||||||
viewer.resize();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUrl(url: string, nameHint?: string) {
|
async function loadUrl(url: string, nameHint?: string) {
|
||||||
const name = nameHint ?? url.split('?')[0]!.split('/').pop() ?? url;
|
const resolved = resolveCadUrl(url, new URL(window.location.href));
|
||||||
const res = await fetch(url);
|
const name = nameHint ?? resolved.pathname.split('/').pop() ?? resolved.href;
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
return loadAndRender(await fetchCadBuffer(resolved), name);
|
||||||
return loadAndRender(await res.arrayBuffer(), name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(file: File) {
|
async function loadFile(file: File) {
|
||||||
if (!is2D(file.name)) throw new Error(`not .dwg/.dxf: ${file.name}`);
|
if (!isCad2DFile(file.name)) throw new Error(`not .dwg/.dxf: ${file.name}`);
|
||||||
|
assertCadByteLength(file.size);
|
||||||
return loadAndRender(await file.arrayBuffer(), file.name);
|
return loadAndRender(await file.arrayBuffer(), file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +114,13 @@ function fillLayers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function afterLoad(name: string, result: CadParseResult) {
|
async function afterLoad(name: string, result: CadLoadSummary) {
|
||||||
setStatus(`✓ ${name} · ${result.entities?.length ?? 0} entities`);
|
setStatus(`✓ ${name} · ${result.entityCount} entities`);
|
||||||
fillLayers();
|
fillLayers();
|
||||||
viewer.fit();
|
viewer.fit();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(label: string, job: () => Promise<CadParseResult>) {
|
async function run(label: string, job: () => Promise<CadLoadSummary>) {
|
||||||
setStatus(`loading ${label}…`);
|
setStatus(`loading ${label}…`);
|
||||||
try {
|
try {
|
||||||
await afterLoad(label, await job());
|
await afterLoad(label, await job());
|
||||||
@@ -129,10 +131,12 @@ async function run(label: string, job: () => Promise<CadParseResult>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('sample')!.addEventListener('click', () => {
|
document.getElementById('sample')!.addEventListener('click', () => {
|
||||||
void run('BasicSample.dwg', () => loadUrl('/samples/BasicSample.dwg', 'BasicSample.dwg'));
|
void run('BasicSample.dwg', () =>
|
||||||
|
loadUrl(publicUrl('samples/BasicSample.dwg'), 'BasicSample.dwg'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
document.getElementById('sampleDxf')!.addEventListener('click', () => {
|
document.getElementById('sampleDxf')!.addEventListener('click', () => {
|
||||||
void run('simple.dxf', () => loadUrl('/samples/simple.dxf', 'simple.dxf'));
|
void run('simple.dxf', () => loadUrl(publicUrl('samples/simple.dxf'), 'simple.dxf'));
|
||||||
});
|
});
|
||||||
document.getElementById('file')!.addEventListener('change', (ev) => {
|
document.getElementById('file')!.addEventListener('change', (ev) => {
|
||||||
const file = (ev.target as HTMLInputElement).files?.[0];
|
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||||
@@ -158,7 +162,22 @@ window.addEventListener('drop', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const model = new URLSearchParams(location.search).get('model');
|
const model = new URLSearchParams(location.search).get('model');
|
||||||
if (model) void run(model, () => loadUrl(model));
|
if (model) {
|
||||||
else setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
|
try {
|
||||||
|
const resolved = resolveCadUrl(model, new URL(window.location.href));
|
||||||
|
void run(model, () => loadUrl(resolved.href));
|
||||||
|
} catch (error) {
|
||||||
|
setStatus((error as Error).message, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStatus('ready · Sample DWG / DXF 또는 파일 드롭');
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => viewer.resize());
|
window.addEventListener(
|
||||||
|
'pagehide',
|
||||||
|
() => {
|
||||||
|
viewer.dispose();
|
||||||
|
delete window.__viewer2d;
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
@@ -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';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 소스 및 fixture provenance
|
||||||
|
|
||||||
|
## Repository history
|
||||||
|
|
||||||
|
- 2D standalone source: `cyhan/dwg-dxf-viewer-sample`
|
||||||
|
- upstream fork source: `kimminsung/dwg-dxf-viewer-sample`
|
||||||
|
- exact commit: `95fa6a452b50394ff11fba60db482dd038fe2355`
|
||||||
|
- 두 Gitea remote가 동일 commit을 가리키는 것을 확인했습니다.
|
||||||
|
- 3D source: `cyhan/hmwebviewer`
|
||||||
|
- upstream fork source: `kimminsung/hmwebviewer`
|
||||||
|
- exact commit: `a71790070dd479abc2707a5dd63b0e8c872db412`
|
||||||
|
- 두 Gitea remote가 동일 commit을 가리키는 것을 확인했습니다.
|
||||||
|
|
||||||
|
3D source는 non-squash subtree commit으로 전체 tree와 commit ancestry를
|
||||||
|
보존해 가져왔습니다. 2D standalone rollback tag와 3D import provenance tag를
|
||||||
|
로컬 Git에 생성했습니다.
|
||||||
|
|
||||||
|
## 2D fixture
|
||||||
|
|
||||||
|
| Fixture | SHA-256 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BasicSample.dwg` | `ef9f1a225931df62cd25a7897771d0d4fcebf0f79d312e3b7888bba9834b7429` | standalone initial commit에서 이관. 외부 재배포 원출처 확인 필요 |
|
||||||
|
| `simple.dxf` | `ce8fc0735b78e912e1ddd26c1535e3e53c429332a7495ae50b749687892bd336` | standalone initial commit의 최소 ASCII DXF test fixture |
|
||||||
|
|
||||||
|
원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에
|
||||||
|
소유권이 확인된 fixture로 교체하거나 명시적 재배포 승인을 기록해야 합니다.
|
||||||
|
|
||||||
|
## 3D test fixture
|
||||||
|
|
||||||
|
| Fixture | SHA-256 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BasisTexture.ktx2` | `e56ddcc757fc73ff06bb0dac2a3533ce79c1e196ad895a3ff7dcc4d9de6b9d5d` | Three.js commit `42f66a1620be72e497a0bb3bdc7663d21d43169a`의 `examples/textures/ktx2/2d_etc1s.ktx2`, MIT. E2E에서만 제공하며 production artifact에는 미포함 |
|
||||||
|
| `BasisTexture.gltf` | repository text fixture | 위 KTX2를 적용한 최소 triangle을 이 저장소에서 작성. E2E 전용 |
|
||||||
|
|
||||||
|
## Policy
|
||||||
|
|
||||||
|
- source와 binary는 exact commit 또는 SHA-256으로 고정합니다.
|
||||||
|
- 출처가 불명확한 binary와 fixture를 새 외부 package나 공개 demo에 추가하지 않습니다.
|
||||||
|
- Draco·Basis decoder는 설치된 exact Three.js dependency에서 production build가
|
||||||
|
hashed asset으로 생성합니다. single-thread `web-ifc.wasm`만 clean install의
|
||||||
|
postinstall에서 다시 staging합니다.
|
||||||
|
- package 전체의 공개 배포 자동화와 SBOM은 Gitea issue #3의 범위입니다.
|
||||||
Generated
+1824
-677
File diff suppressed because it is too large
Load Diff
+25
-15
@@ -1,23 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "dwg-dxf-viewer-sample",
|
"name": "hmwebviewer-workspace",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Standalone sample: compose acadrust-dwg + dxf-parser + Viewer2D (sibling of hmwebviewer)",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5173 --strictPort",
|
"dev:2d": "npm --workspace @hmwebviewer/viewer-2d-sample run dev",
|
||||||
"build": "tsc --noEmit && vite build",
|
"dev:3d": "npm --workspace @hmwebviewer/viewer-3d run dev",
|
||||||
"preview": "vite preview",
|
"build": "npm run build --workspaces --if-present",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
},
|
"test": "npm run test:unit",
|
||||||
"dependencies": {
|
"test:unit": "vitest run",
|
||||||
"dxf-parser": "^1.1.2",
|
"test:artifact": "node tools/check-production-artifacts.mjs",
|
||||||
"opentype.js": "^2.0.0",
|
"test:e2e": "node tools/run-e2e.mjs",
|
||||||
"three": "^0.185.1"
|
"verify": "npm run typecheck && npm run test:unit && npm run build && npm run test:artifact && npm run test:e2e",
|
||||||
|
"postinstall": "npm --workspace @hmwebviewer/viewer-3d run stage:decoders"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/three": "^0.185.1",
|
"@playwright/test": "1.62.0",
|
||||||
"typescript": "^5.6.0",
|
"@types/three": "0.185.1",
|
||||||
"vite": "^5.4.0"
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.1.5",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0",
|
||||||
|
"npm": ">=11.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@hmwebviewer/viewer2d",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"dxf-parser": "1.1.2",
|
||||||
|
"opentype.js": "2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": "0.185.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
|
// 포팅된 JavaScript 2D 엔진의 공개 interface입니다.
|
||||||
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
|
|
||||||
export class Viewer2D {
|
export class Viewer2D {
|
||||||
constructor(container: HTMLElement);
|
constructor(container: HTMLElement);
|
||||||
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
|
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
|
||||||
@@ -9,12 +8,19 @@ export class Viewer2D {
|
|||||||
/** Resync renderer/camera to the container size (call after un-hiding). */
|
/** Resync renderer/camera to the container size (call after un-hiding). */
|
||||||
resize(): void;
|
resize(): void;
|
||||||
onSelect(cb: (entity: unknown) => void): void;
|
onSelect(cb: (entity: unknown) => void): void;
|
||||||
|
setZoomSpeed(speed: number): void;
|
||||||
|
getZoomSpeed(): number;
|
||||||
setTheme(dark: boolean): void;
|
setTheme(dark: boolean): void;
|
||||||
setGrid(visible: boolean): void;
|
setGrid(visible: boolean): void;
|
||||||
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
|
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
|
||||||
setHiddenLayers(nameSet: Set<string>): void;
|
setHiddenLayers(nameSet: Set<string>): void;
|
||||||
getZoomPercent(): number;
|
getZoomPercent(): number;
|
||||||
onViewChange(cb: () => void): void;
|
onViewChange(cb: () => void): () => void;
|
||||||
|
getStats(): { entities: number; blocks: number };
|
||||||
|
snapshotWebPBlob(): Promise<Blob | null>;
|
||||||
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
|
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
|
||||||
stopMeasure(): void;
|
stopMeasure(): void;
|
||||||
|
setAccent(hex: string): void;
|
||||||
|
/** RAF, DOM listener, WebGL 자원과 canvas를 멱등으로 해제합니다. */
|
||||||
|
dispose(): void;
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,25 @@ function stripMText(s) {
|
|||||||
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
|
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disposeMaterial(material) {
|
||||||
|
for (const item of (Array.isArray(material) ? material : [material])) {
|
||||||
|
item?.map?.dispose();
|
||||||
|
item?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject(root) {
|
||||||
|
root?.traverse?.((object) => {
|
||||||
|
object.geometry?.dispose();
|
||||||
|
if (object.material) disposeMaterial(object.material);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class Viewer2D {
|
export class Viewer2D {
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
|
this._disposed = false;
|
||||||
|
this._rafId = null;
|
||||||
|
this._viewChangeCallbacks = new Set();
|
||||||
this._container = container;
|
this._container = container;
|
||||||
this._entityMeta = [];
|
this._entityMeta = [];
|
||||||
this._layerByHandle = new Map();
|
this._layerByHandle = new Map();
|
||||||
@@ -74,25 +91,42 @@ export class Viewer2D {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); });
|
this._handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'f' || e.key === 'F') this.fit();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', this._handleKeyDown);
|
||||||
|
|
||||||
this._group = new THREE.Group();
|
this._group = new THREE.Group();
|
||||||
this._scene.add(this._group);
|
this._scene.add(this._group);
|
||||||
|
|
||||||
window.addEventListener('resize', () => this._onResize());
|
this._handleResize = () => this._onResize();
|
||||||
|
window.addEventListener('resize', this._handleResize);
|
||||||
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
|
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
|
||||||
this._renderer.domElement.addEventListener('pointerdown', (e) => {
|
this._handlePointerDown = (e) => {
|
||||||
if (e.button === 1) e.preventDefault();
|
if (e.button === 1) e.preventDefault();
|
||||||
});
|
};
|
||||||
this._renderer.domElement.addEventListener('mousedown', (e) => {
|
this._handleMouseDown = (e) => {
|
||||||
this._downXY = [e.clientX, e.clientY];
|
this._downXY = [e.clientX, e.clientY];
|
||||||
if (e.button === 1) e.preventDefault();
|
if (e.button === 1) e.preventDefault();
|
||||||
});
|
};
|
||||||
this._renderer.domElement.addEventListener('click', (e) => this._onClick(e));
|
this._handleClick = (e) => this._onClick(e);
|
||||||
|
this._renderer.domElement.addEventListener('pointerdown', this._handlePointerDown);
|
||||||
|
this._renderer.domElement.addEventListener('mousedown', this._handleMouseDown);
|
||||||
|
this._renderer.domElement.addEventListener('click', this._handleClick);
|
||||||
this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 };
|
this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 };
|
||||||
this._gridVisible = false;
|
this._gridVisible = false;
|
||||||
this._gridMesh = null;
|
this._gridMesh = null;
|
||||||
this._controls.addEventListener('change', () => { if (this._gridVisible) this._rebuildGrid(); });
|
this._handleControlsChange = () => {
|
||||||
|
if (this._gridVisible) this._rebuildGrid();
|
||||||
|
for (const callback of this._viewChangeCallbacks) callback();
|
||||||
|
};
|
||||||
|
this._controls.addEventListener('change', this._handleControlsChange);
|
||||||
|
this._onAnimationFrame = () => {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._controls.update();
|
||||||
|
this._renderer.render(this._scene, this._camera);
|
||||||
|
this._rafId = requestAnimationFrame(this._onAnimationFrame);
|
||||||
|
};
|
||||||
this._animate();
|
this._animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +717,11 @@ export class Viewer2D {
|
|||||||
getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); }
|
getZoomPercent() { return Math.round((this._camera?.zoom ?? 1) * 100); }
|
||||||
|
|
||||||
/** Subscribe to pan/zoom changes (for live zoom readout). */
|
/** Subscribe to pan/zoom changes (for live zoom readout). */
|
||||||
onViewChange(cb) { this._controls?.addEventListener('change', cb); }
|
onViewChange(cb) {
|
||||||
|
if (this._disposed) return () => {};
|
||||||
|
this._viewChangeCallbacks.add(cb);
|
||||||
|
return () => this._viewChangeCallbacks.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
/** Hide entities on the given layer names (Set<string>); re-renders keeping the view. */
|
/** Hide entities on the given layer names (Set<string>); re-renders keeping the view. */
|
||||||
setHiddenLayers(nameSet) {
|
setHiddenLayers(nameSet) {
|
||||||
@@ -2349,11 +2387,53 @@ export class Viewer2D {
|
|||||||
this._controls.update();
|
this._controls.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._disposed = true;
|
||||||
|
|
||||||
|
if (this._rafId !== null) {
|
||||||
|
cancelAnimationFrame(this._rafId);
|
||||||
|
this._rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('keydown', this._handleKeyDown);
|
||||||
|
window.removeEventListener('resize', this._handleResize);
|
||||||
|
const canvas = this._renderer.domElement;
|
||||||
|
canvas.removeEventListener('pointerdown', this._handlePointerDown);
|
||||||
|
canvas.removeEventListener('mousedown', this._handleMouseDown);
|
||||||
|
canvas.removeEventListener('click', this._handleClick);
|
||||||
|
this._controls.removeEventListener('change', this._handleControlsChange);
|
||||||
|
this._viewChangeCallbacks.clear();
|
||||||
|
|
||||||
|
this.stopMeasure();
|
||||||
|
this.setGrid(false);
|
||||||
|
this._clear();
|
||||||
|
this._scene.remove(this._group);
|
||||||
|
this._controls.dispose();
|
||||||
|
this._renderer.dispose();
|
||||||
|
this._renderer.forceContextLoss();
|
||||||
|
canvas.remove();
|
||||||
|
this._scene.clear();
|
||||||
|
|
||||||
|
this._lastResult = null;
|
||||||
|
this._onSelectCb = null;
|
||||||
|
this._measureCb = null;
|
||||||
|
this._entityMeta = [];
|
||||||
|
this._pickSegs = [];
|
||||||
|
this._pickSegMeta = [];
|
||||||
|
this._pickFills = [];
|
||||||
|
this._layerByHandle.clear();
|
||||||
|
this._layerByName.clear();
|
||||||
|
this._layerNameByHandle?.clear();
|
||||||
|
this._linetypeByName?.clear();
|
||||||
|
this._hiddenLayers?.clear();
|
||||||
|
this._texCache?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
_clear() {
|
_clear() {
|
||||||
for (let i = this._group.children.length - 1; i >= 0; i--) {
|
for (let i = this._group.children.length - 1; i >= 0; i--) {
|
||||||
const o = this._group.children[i];
|
const o = this._group.children[i];
|
||||||
o.geometry?.dispose();
|
disposeObject(o);
|
||||||
if (o.material) { o.material.map?.dispose(); o.material.dispose(); }
|
|
||||||
this._group.remove(o);
|
this._group.remove(o);
|
||||||
}
|
}
|
||||||
// Shared text textures were disposed via the sprites above (re-dispose is a
|
// Shared text textures were disposed via the sprites above (re-dispose is a
|
||||||
@@ -2363,6 +2443,7 @@ export class Viewer2D {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_onResize() {
|
_onResize() {
|
||||||
|
if (this._disposed) return;
|
||||||
const w = this._container.clientWidth, h = this._container.clientHeight;
|
const w = this._container.clientWidth, h = this._container.clientHeight;
|
||||||
if (!w || !h) return;
|
if (!w || !h) return;
|
||||||
this._renderer.setSize(w, h);
|
this._renderer.setSize(w, h);
|
||||||
@@ -2376,8 +2457,7 @@ export class Viewer2D {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_animate() {
|
_animate() {
|
||||||
requestAnimationFrame(() => this._animate());
|
if (this._disposed || this._rafId !== null) return;
|
||||||
this._controls.update();
|
this._rafId = requestAnimationFrame(this._onAnimationFrame);
|
||||||
this._renderer.render(this._scene, this._camera);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
|
"description": "WASM wrapper around acadrust (MPL-2.0, used unmodified) emitting the hmwebviewer parseResult shape",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MPL-2.0",
|
||||||
"files": [
|
"files": [
|
||||||
"acadrust_dwg_bg.wasm",
|
"acadrust_dwg_bg.wasm",
|
||||||
"acadrust_dwg.js",
|
"acadrust_dwg.js",
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user