fix: resolve workspace integration blockers (#5)
This commit is contained in:
@@ -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,21 +1,415 @@
|
|||||||
# hmwebviewer workspace
|
# dwg-dxf-viewer-sample
|
||||||
|
|
||||||
Three.js 기반 3D viewer와 DWG/DXF 2D viewer sample을 한 저장소에서 관리합니다.
|
DWG/DXF 2D viewer module과 다중 포맷 3D viewer sample을 함께 관리하는
|
||||||
|
Three.js 기반 npm workspace입니다.
|
||||||
|
|
||||||
이 저장소의 구조 통합과 검증 계획은 Gitea issue #5를 기준으로 진행합니다.
|
현재 구현은 파일을 브라우저에서 파싱하고 화면에 표시하는 기술 검증용입니다.
|
||||||
|
좌표, 단위, 원점, 형상 허용오차, topology와 CAD/BIM 의미 정보의 보존을
|
||||||
|
보장하는 운영용 engineering viewer로는 아직 검증되지 않았습니다.
|
||||||
|
|
||||||
## Workspace
|
## 검토 기준
|
||||||
|
|
||||||
- `@hmwebviewer/viewer-3d`: 기존 3D viewer application
|
| 항목 | 내용 |
|
||||||
- `@hmwebviewer/viewer-2d-sample`: DWG/DXF sample application
|
|---|---|
|
||||||
- `@hmwebviewer/viewer2d`: 재사용 가능한 DWG/DXF parser와 renderer module
|
| 기준일 | 2026-07-29 |
|
||||||
|
| 기준 commit | `3279ac099ec30bb52363710c73fbd4fe174b707b` |
|
||||||
|
| 기준 tree | 위 commit과 2026-07-29 작업 tree의 미커밋 변경 포함 |
|
||||||
|
| 대상 | `cyhan/dwg-dxf-viewer-sample` |
|
||||||
|
| 참조 | 형제 저장소 `hmwebviewer/README.md`의 분석 구조와 3D asset provenance |
|
||||||
|
| 검토 범위 | workspace 구조, 2D/3D 기능, source·test·build 설정, 입력 경계, npm dependency, WASM·font·decoder·fixture license |
|
||||||
|
| 기능 판정 | **내부 개발·기능 검증 조건부 GO** |
|
||||||
|
| 외부 배포 판정 | **NO-GO — license·source 제공·asset provenance blocker 존재** |
|
||||||
|
|
||||||
## 실행
|
외부 배포 판정은 기술적인 compliance 준비 상태를 뜻합니다. 법률 자문이 아니며,
|
||||||
|
실제 계약·특허·상표와 배포 승인은 조직의 법무·오픈소스 정책으로 확정해야 합니다.
|
||||||
|
|
||||||
|
## Workspace 구성
|
||||||
|
|
||||||
|
| Workspace | 역할 | 주요 runtime |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/viewer-2d-sample` | DWG/DXF sample UI와 입력 정책 | `@hmwebviewer/viewer2d`, Three.js |
|
||||||
|
| `packages/viewer2d` | 재사용 가능한 2D parser·renderer module | `dxf-parser`, `opentype.js`, Three.js peer, acadrust WASM |
|
||||||
|
| `apps/viewer-3d` | 다중 포맷 3D static web application | Three.js, `web-ifc` |
|
||||||
|
|
||||||
|
```text
|
||||||
|
dwg-dxf-viewer-sample/
|
||||||
|
├─ apps/
|
||||||
|
│ ├─ viewer-2d-sample/ # 2D sample application
|
||||||
|
│ └─ viewer-3d/ # 3D sample application과 offline 도구
|
||||||
|
├─ packages/
|
||||||
|
│ └─ viewer2d/ # 2D public module
|
||||||
|
├─ tests/ # Vitest와 Playwright
|
||||||
|
├─ LICENSES/ # 현재 MPL-2.0, OFL-1.1 전문
|
||||||
|
├─ THIRD_PARTY_NOTICES.md
|
||||||
|
└─ docs/PROVENANCE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
세 workspace는 `"private": true`이며 npm registry 배포용 package가 아닙니다.
|
||||||
|
`@hmwebviewer/viewer2d`의 export도 현재 TypeScript source를 직접 가리키므로,
|
||||||
|
독립 배포 package가 아니라 이 workspace 안에서 재사용하는 module입니다.
|
||||||
|
|
||||||
|
## 현재 제공 기능
|
||||||
|
|
||||||
|
### DWG/DXF 2D viewer
|
||||||
|
|
||||||
|
- `.dwg`, `.dxf` URL과 로컬 파일 load
|
||||||
|
- 파일 선택, Drag & Drop, `?model=<url>` 진입 경로
|
||||||
|
- DWG는 acadrust WebAssembly parser, DXF는 `dxf-parser`와 adapter 사용
|
||||||
|
- Three.js orthographic renderer와 pan·zoom
|
||||||
|
- Zoom Fit, dark/light theme, layer별 표시·숨김
|
||||||
|
- entity click 선택과 type·layer·handle 표시
|
||||||
|
- TEXT/MTEXT glyph용 NanumGothic TTF load
|
||||||
|
- module API의 grid, zoom speed, view-change 구독, entity/block 통계,
|
||||||
|
WebP snapshot, 2점 거리 측정, accent color
|
||||||
|
- page 종료 시 RAF, listener, WebGL resource와 canvas dispose
|
||||||
|
|
||||||
|
renderer에는 LINE, CIRCLE, ARC, ELLIPSE, POINT, POLYLINE/LWPOLYLINE,
|
||||||
|
SPLINE, TEXT/MTEXT/ATTRIB, INSERT, HATCH, DIMENSION, LEADER, XLINE/RAY 등
|
||||||
|
여러 entity 경로가 있습니다. 모든 DWG/DXF version과 entity 조합의 정확도를
|
||||||
|
보장한다는 의미는 아닙니다.
|
||||||
|
|
||||||
|
2D 입력 경계는 application layer에서 다음과 같이 제한합니다.
|
||||||
|
|
||||||
|
- URL은 HTTP(S)와 viewer 동일 origin만 허용
|
||||||
|
- `Content-Length`와 streaming 누적 크기를 모두 확인
|
||||||
|
- 로컬·URL 입력 최대 50 MiB
|
||||||
|
- URL fetch timeout 15초
|
||||||
|
|
||||||
|
### 다중 포맷 3D viewer
|
||||||
|
|
||||||
|
- `?model=<url>` server asset load와 로컬 Drag & Drop
|
||||||
|
- GLB, GLTF, OBJ, FBX, DAE, IFC, PLY load
|
||||||
|
- OBJ와 함께 드롭한 MTL 및 PNG/JPEG/BMP/GIF/WebP/TGA texture 연결
|
||||||
|
- 300 MiB 초과 OBJ의 2-pass streaming parser
|
||||||
|
- 큰 절대 좌표를 가진 OBJ의 float64 source 단계 재중심화
|
||||||
|
- Draco geometry와 KTX2/Basis texture runtime decode
|
||||||
|
- IFC single-thread WebAssembly parse와 geometry 변환
|
||||||
|
- PLY mesh·point cloud 처리 및 Float64 attribute의 Float32 정규화
|
||||||
|
- OrbitControls, Zoom Fit, 원근·직교 projection, outline 표시
|
||||||
|
- FPS overlay와 지속적인 저 FPS에서 pixel ratio를 낮추는 adaptive quality
|
||||||
|
- model URL과 이름이 같은 360° WebP preview를 먼저 표시한 뒤 canvas로 전환
|
||||||
|
- Vite `BASE_URL`을 따르는 decoder·preview 경로와 subpath 배포
|
||||||
|
- 교체 model의 geometry·material·texture와 Blob URL 정리
|
||||||
|
|
||||||
|
WebP preview는 미리 생성된 정적 파일입니다. Vite가 HTML shell을 제공한 뒤
|
||||||
|
브라우저에서 preview와 model을 load하므로 server-side 3D rendering 또는 SSR
|
||||||
|
구현은 없습니다.
|
||||||
|
|
||||||
|
## 실행 구조
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["2D File / same-origin URL"] --> B["cadInputPolicy"]
|
||||||
|
B --> C["parseCad"]
|
||||||
|
C --> D["acadrust WASM<br/>DWG"]
|
||||||
|
C --> E["dxf-parser + adapter<br/>DXF"]
|
||||||
|
D --> F["CadParseResult"]
|
||||||
|
E --> F
|
||||||
|
F --> G["Viewer2D / Three.js"]
|
||||||
|
|
||||||
|
H["3D File / ?model URL"] --> I["ThreeDViewer"]
|
||||||
|
I --> J["modelLoader"]
|
||||||
|
J --> K["Three.js format loaders"]
|
||||||
|
J --> L["web-ifc JS + WASM"]
|
||||||
|
K --> M["THREE.Object3D"]
|
||||||
|
L --> M
|
||||||
|
M --> N["WebGLRenderer"]
|
||||||
|
```
|
||||||
|
|
||||||
|
2D application은 `packages/viewer2d` 내부 파일을 직접 import하지 않고
|
||||||
|
`@hmwebviewer/viewer2d` public interface를 사용합니다. 3D `modelLoader`는
|
||||||
|
확장자로 loader를 선택하고 결과를 `THREE.Object3D`로 정규화합니다.
|
||||||
|
|
||||||
|
`npm ci`의 root `postinstall`은 Three.js의 Draco·Basis 파일과 `web-ifc` WASM을
|
||||||
|
`apps/viewer-3d/public` 아래에 staging합니다. runtime에서는 설치된 Three.js와
|
||||||
|
동일한 decoder를 사용합니다. `web-ifc-mt.wasm`도 복사되지만 현재 code path는
|
||||||
|
single-thread `web-ifc.wasm`만 사용합니다.
|
||||||
|
|
||||||
|
## 지원 범위와 알려진 제한
|
||||||
|
|
||||||
|
### 2D
|
||||||
|
|
||||||
|
| 포맷 | URL | 로컬 파일 | 현재 검증과 제한 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| DWG | 동일 origin 지원 | 지원 | `BasicSample.dwg` 1개 fixture 중심. acadrust WASM wrapper의 재현 build source가 없음 |
|
||||||
|
| DXF | 동일 origin 지원 | 지원 | `simple.dxf`와 public parser contract 검증. binary DXF 및 전체 entity fidelity 미검증 |
|
||||||
|
|
||||||
|
### 3D
|
||||||
|
|
||||||
|
| 포맷 | `?model` URL | 로컬 파일 | 알려진 제한 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| GLB | 지원 | 지원 | Draco/KTX2 지원. 손실 압축 허용오차와 원본 대비 fidelity 기준 없음 |
|
||||||
|
| GLTF | 지원 | 제한적 | 로컬 `.bin`·texture sidecar를 함께 해석하는 file-set session 없음 |
|
||||||
|
| OBJ | 지원 | 지원 | URL 경로의 MTL 자동 load 없음. 로컬만 MTL·texture sidecar 지원 |
|
||||||
|
| OBJ > 300 MiB | 지원 | 지원 | streaming path는 smooth normal과 MTL `Kd` color만 사용하고 texture·hard edge fidelity를 포기 |
|
||||||
|
| FBX | 지원 | 지원 | 축·단위·animation·외부 texture 보존 계약 없음 |
|
||||||
|
| DAE | 지원 | 제한적 | 로컬 외부 texture sidecar 미지원 |
|
||||||
|
| IFC | 지원 | 지원 | geometry와 color만 Three.js object로 변환. property/semantic API와 원점 이동 기록 미보존 |
|
||||||
|
| PLY | 지원 | 지원 | mesh 또는 point cloud. double attribute는 GPU용 Float32로 변환되어 정밀도 손실 가능 |
|
||||||
|
|
||||||
|
여기서 “지원”은 sample을 파싱해 renderable object를 만들 수 있다는 뜻입니다.
|
||||||
|
CAD/BIM 원본과의 좌표·단위·원점·topology·재질·속성 일치를 의미하지 않습니다.
|
||||||
|
|
||||||
|
## 신뢰 경계와 운영 위험
|
||||||
|
|
||||||
|
2D 경로는 same-origin, 50 MiB, 15초 제한을 적용하지만 magic bytes, MIME,
|
||||||
|
압축 해제 후 entity 수와 geometry 복잡도 quota는 없습니다.
|
||||||
|
|
||||||
|
3D `?model`은 URL scheme·origin·크기·timeout을 제한하지 않습니다. browser CORS가
|
||||||
|
허용하면 외부 origin도 요청할 수 있고, GLTF/DAE/FBX/OBJ가 참조하는 URI가 추가
|
||||||
|
network request를 만들 수 있습니다. 로컬 3D 파일에도 최대 크기와 decode 후
|
||||||
|
vertex·texture·memory quota가 없습니다.
|
||||||
|
|
||||||
|
두 viewer 모두 parser와 상당한 geometry 처리를 browser main thread에서 수행합니다.
|
||||||
|
운영 배포에는 format별 Worker 격리, URL allowlist, CSP, MIME·signature 검사,
|
||||||
|
streaming byte limit, decode complexity quota, timeout과 cancellation이 필요합니다.
|
||||||
|
|
||||||
|
동시에 여러 load를 시작했을 때 이전 요청을 취소하거나 latest-wins를 보장하는
|
||||||
|
session 규약도 없습니다. 느린 이전 요청이 나중 요청 뒤에 완료되어 화면을
|
||||||
|
덮을 수 있습니다.
|
||||||
|
|
||||||
|
현재 E2E는 sample render, finite vertex, canvas lifecycle과 subpath decoder URL을
|
||||||
|
검사합니다. 공식 validator, golden geometry, 좌표·단위 왕복, topology,
|
||||||
|
material·IFC property contract와 장시간 memory soak는 release gate에 없습니다.
|
||||||
|
|
||||||
|
## 외부 의존성·라이선스 감사
|
||||||
|
|
||||||
|
> **배포 준비 판정: 외부 demo·고객 전달·public CDN·commercial SaaS `NO-GO`,
|
||||||
|
> 조직 내부 개발·검증 조건부 `GO`**
|
||||||
|
>
|
||||||
|
> dependency 자체는 대부분 상업 사용 가능한 license이지만, 프로젝트 소유 코드의
|
||||||
|
> license, MPL source 제공, Apache/MIT 고지와 sample provenance가 완결되지 않았습니다.
|
||||||
|
|
||||||
|
감사 범위는 `package-lock.json`의 `node_modules/*` entry, 실제 browser bundle,
|
||||||
|
`public` 정적 파일, bundled WASM·font, sample과 파생 preview, repository가
|
||||||
|
호출하거나 언급하는 system tool입니다.
|
||||||
|
|
||||||
|
lockfile에는 142개 `node_modules/*` entry가 있습니다. 이 중 3개는 이 저장소의
|
||||||
|
workspace link이고 제3자 package는 139개입니다. 제3자 entry의 license metadata
|
||||||
|
누락은 0개입니다. runtime entry는 5개, build/dev entry는 134개이며 그중 57개는
|
||||||
|
platform별 optional package입니다.
|
||||||
|
|
||||||
|
### Browser runtime과 직접 배포되는 구성요소
|
||||||
|
|
||||||
|
| 구성요소 | 버전·출처 | 역할·배포 위치 | License | 현재 compliance 상태 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Three.js | `0.185.1` | 두 viewer의 JS bundle, loader·controls | MIT | package license는 확인. 배포 artifact에 통합 MIT notice 없음 |
|
||||||
|
| dxf-parser | `1.1.2` | DXF parser JS bundle | MIT | package license는 확인. 통합 notice 없음 |
|
||||||
|
| loglevel | `1.9.2`, `dxf-parser` transitive | DXF runtime logging | MIT | lockfile·package license 확인 |
|
||||||
|
| opentype.js | `2.0.0` | TTF glyph 처리 JS bundle | MIT | package license는 확인. 통합 notice 없음 |
|
||||||
|
| acadrust | WASM 내부 `0.4.1` | `acadrust_dwg_bg.wasm`, DWG parse | MPL-2.0 | MPL 전문과 checksum은 있음. wrapper source·toolchain과 재현 build가 없음 |
|
||||||
|
| NanumGothic | Google Fonts 원본 checksum 일치 | `NanumGothic-Regular.ttf` | OFL-1.1 | 전문과 checksum은 있음. root license 파일이 app dist에는 자동 포함되지 않음 |
|
||||||
|
| web-ifc | `0.0.77` | dynamic JS bundle, `/web-ifc/*.wasm` | MPL-2.0 | package 전문은 확인. exact covered source 취득 안내가 배포물에 없음 |
|
||||||
|
| Draco decoder | Three.js r185 배포본 | `/draco/*.js`, `/draco/*.wasm` | Apache-2.0 | source와 byte-for-byte staging test 있음. Apache 전문·NOTICE가 app dist에 없음 |
|
||||||
|
| Basis Universal transcoder | Three.js r185 배포본 | `/basis/*.js`, `/basis/*.wasm` | Apache-2.0 | `basis/README.md`에 upstream link만 있음. Apache 전문·NOTICE가 app dist에 없음 |
|
||||||
|
|
||||||
|
저장소의 [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md)는 acadrust,
|
||||||
|
NanumGothic과 주요 runtime library를 기록하고, [`LICENSES/`](./LICENSES/)에는
|
||||||
|
MPL-2.0과 OFL-1.1 전문이 있습니다. 그러나 이 파일들은 두 Vite application의
|
||||||
|
`public` 아래에 있지 않아 현재 `npm run build` 결과에는 포함되지 않습니다.
|
||||||
|
|
||||||
|
### 전체 npm license 집계
|
||||||
|
|
||||||
|
| License | 제3자 package entry 수 | 주 사용 범위 | 상업 사용 | source 공개 영향 |
|
||||||
|
|---|---:|---|---:|---|
|
||||||
|
| MIT | 86 | runtime 4개와 build/dev | 가능 | 프로젝트 source 공개 의무 없음. copyright·license notice 보존 |
|
||||||
|
| Apache-2.0 | 31 | TypeScript, Playwright, Puppeteer와 platform package | 가능 | 프로젝트 source 공개 의무 없음. license·변경·NOTICE·patent 조건 적용 |
|
||||||
|
| MPL-2.0 | 13 | `web-ifc` runtime 1개, Lightning CSS 계열 dev 12개 | 가능 | 외부 executable 배포 시 covered source와 취득 안내 필요 |
|
||||||
|
| ISC | 6 | build/dev | 가능 | copyright·permission notice 보존 |
|
||||||
|
| BSD-3-Clause | 2 | build/dev | 가능 | notice·면책 보존, 이름을 홍보에 사용하지 않음 |
|
||||||
|
| 0BSD | 1 | optional build/dev | 가능 | source 공개와 attribution 의무 없음 |
|
||||||
|
|
||||||
|
위 집계에는 npm 밖의 acadrust WASM, NanumGothic, Draco, Basis와 sample asset
|
||||||
|
license가 포함되지 않습니다. `UNKNOWN` 3개처럼 보이는 lockfile entry는
|
||||||
|
`@hmwebviewer/*` first-party workspace link이며 제3자 license 누락이 아닙니다.
|
||||||
|
|
||||||
|
### Build·test·offline 도구
|
||||||
|
|
||||||
|
| 구성요소 | lockfile 기준 | 역할 | License·배포 판단 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| Vite | `8.1.5` | dev server와 production build | MIT. build output에는 runtime third-party notice를 별도로 넣어야 함 |
|
||||||
|
| TypeScript | `7.0.2` | typecheck | Apache-2.0. compiler를 고객에게 함께 전달하지 않으면 제품 source 공개 영향 없음 |
|
||||||
|
| Vitest | `4.1.10` | unit test | MIT, 제품 runtime 아님 |
|
||||||
|
| Playwright | `1.62.0` | browser E2E | Apache-2.0, 제품 runtime 아님 |
|
||||||
|
| Puppeteer Core | `25.4.0` resolved | 3D smoke·preview capture | Apache-2.0. Chrome binary를 포함하지 않음 |
|
||||||
|
| Lightning CSS | `1.33.0` 계열 12 entry | Vite build transitive | MPL-2.0. build host에서만 사용하며 browser runtime에는 포함되지 않음 |
|
||||||
|
| Node.js·npm | Node >=24, npm >=11 요구 | install, build, tool runtime | 함께 재배포하면 각 runtime과 bundled dependency license를 별도 감사 |
|
||||||
|
| Chrome | system executable | Playwright/Puppeteer 실행 | repository가 bundle하지 않음. CI image·설치형 배포에 포함하면 vendor 조건 별도 검토 |
|
||||||
|
| ffmpeg | optional, lockfile 밖 | PNG frame을 animated WebP로 결합 | binary를 bundle할 때 실제 build option의 LGPL/GPL/nonfree 조건 확인 |
|
||||||
|
| KTX-Software `toktx` | 도입 안내만 존재 | 선택적 KTX2 encoding | 현재 dependency가 아님. 도입 시 exact binary BOM과 license 재검토 |
|
||||||
|
|
||||||
|
`apps/viewer-3d/tools/preprocess.mjs`는 `@gltf-transform/core`,
|
||||||
|
`@gltf-transform/functions`, `@gltf-transform/extensions`, `draco3d`를 import하지만
|
||||||
|
현재 `package.json`과 lockfile에는 이 package들이 없습니다. 따라서 clean install의
|
||||||
|
정식 toolchain으로 재현되지 않습니다.
|
||||||
|
|
||||||
|
현재 script가 구현한 texture output은 WebP/JPEG/PNG이며 KTX2 encoding은 하지
|
||||||
|
않습니다. KTX2는 별도 glTF Transform CLI와 `toktx`가 필요합니다.
|
||||||
|
하위 `tools/README.md`의 “KTX2 default” 설명은 현재 code와 일치하지 않습니다.
|
||||||
|
|
||||||
|
### MPL·Apache·OFL 적용 범위
|
||||||
|
|
||||||
|
MPL-2.0은 file-level copyleft입니다. 별도 파일로 결합한 이 application 전체를
|
||||||
|
MPL로 공개할 필요는 없지만, 조직 밖으로 browser JS/WASM executable을 전달하면
|
||||||
|
수령자가 해당 MPL covered source와 수정분을 합리적인 방법으로 얻을 수 있게
|
||||||
|
알려야 합니다. browser로 전송되는 client code도 배포에 해당합니다.
|
||||||
|
|
||||||
|
- `web-ifc@0.0.77`: exact source archive 또는 고정 commit/tag URL과 수정 여부,
|
||||||
|
build 가능한 covered source 취득 안내가 필요
|
||||||
|
- acadrust DWG WASM: crate source만으로는 현재 binary wrapper를 재현할 수 없으므로
|
||||||
|
wrapper source·Cargo/wasm-bindgen 설정·toolchain을 복구하기 전 외부 배포 보류
|
||||||
|
- Lightning CSS: `node_modules`나 build container를 외부에 전달하지 않고 build
|
||||||
|
host에서만 실행하면 application source 공개 범위를 만들지 않음
|
||||||
|
|
||||||
|
Apache-2.0의 Draco와 Basis는 source 공개 의무는 없지만 license 사본, 기존
|
||||||
|
attribution과 upstream NOTICE, 수정 시 변경 고지를 보존해야 합니다. OFL font는
|
||||||
|
software와 bundle할 수 있지만 copyright와 OFL 전문을 함께 제공해야 하며,
|
||||||
|
Reserved Font Name 조건을 지켜야 합니다.
|
||||||
|
|
||||||
|
참조:
|
||||||
|
|
||||||
|
- [Mozilla MPL 2.0 FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)
|
||||||
|
- [acadrust upstream](https://github.com/hakanaktt/acadrust)
|
||||||
|
- [web-ifc upstream](https://github.com/ThatOpen/engine_web-ifc)
|
||||||
|
- [Three.js r185 MIT license](https://github.com/mrdoob/three.js/blob/r185/LICENSE)
|
||||||
|
- [Google Draco Apache-2.0 license](https://github.com/google/draco/blob/main/LICENSE)
|
||||||
|
- [Basis Universal](https://github.com/BinomialLLC/basis_universal)
|
||||||
|
- [NanumGothic OFL](https://github.com/google/fonts/blob/main/ofl/nanumgothic/OFL.txt)
|
||||||
|
|
||||||
|
### Sample asset과 파생 preview
|
||||||
|
|
||||||
|
Sample과 preview는 software dependency license와 별개의 저작물입니다.
|
||||||
|
optimized model과 회전 preview도 원본 asset의 license·attribution·변경 표시를
|
||||||
|
계승해야 합니다.
|
||||||
|
|
||||||
|
| 자산 | 확인된 출처·license | 현재 판단 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BasicSample.dwg` | 2D standalone initial commit에서 이관, 원출처 불명 | 내부 회귀 test 전용. 외부 demo 배포 보류 |
|
||||||
|
| `simple.dxf` | 2D standalone initial commit의 최소 fixture | 권리자·license 명시가 없어 외부 재배포 승인 필요 |
|
||||||
|
| `Box.glb` | Khronos sample, Cesium 제공, CC BY 4.0 | 상업 사용 가능하나 Cesium attribution, license link와 변경 여부 고지가 현재 배포물에 없음 |
|
||||||
|
| `Duck.glb` | Copyright 2006 Sony Computer Entertainment Inc., SCEA Shared Source License 1.0 | 비표준 license. 내부 법무 승인 전 외부 배포 보류 |
|
||||||
|
| `Duck.optimized.glb`, `Duck.webp` | Duck의 Draco/WebP·preview 파생물 | 원본 SCEA 승인과 derivative 변경 표시에 종속 |
|
||||||
|
| `Cube.obj`, `Cube.dae`, `Cube.ifc`, `DoublePrecision.ply` | 파일·commit에 hand-authored/project fixture 정황 | 프로젝트 권리자와 first-party license가 선언되지 않아 외부 배포 보류 |
|
||||||
|
| `Cube.fbx`, `sample.mtl` | history에는 Three.js `morph_test`와 Blender 정황만 있고 exact 원본 URL·asset license 없음 | provenance 복구 또는 제거 전 외부 배포 보류 |
|
||||||
|
| `Cube.*.webp` | 각 Cube model에서 생성한 24-frame preview | 원본 model의 권리·license와 변경 표시에 종속 |
|
||||||
|
|
||||||
|
Box와 Duck의 upstream license:
|
||||||
|
|
||||||
|
- [Khronos Box — CC BY 4.0](https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Box/README.md)
|
||||||
|
- [Khronos Duck — SCEA Shared Source License 1.0](https://github.com/KhronosGroup/glTF-Sample-Models/blob/main/2.0/Duck/README.md)
|
||||||
|
|
||||||
|
fixture checksum과 source history는 [`docs/PROVENANCE.md`](./docs/PROVENANCE.md),
|
||||||
|
acadrust·font checksum은 [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md)를
|
||||||
|
기준으로 관리합니다. 현재 provenance 문서는 2D fixture 중심이며 3D asset과
|
||||||
|
preview derivative chain은 아직 통합되지 않았습니다.
|
||||||
|
|
||||||
|
### 프로젝트 license와 외부 release gate
|
||||||
|
|
||||||
|
루트에는 프로젝트 소유 코드에 적용되는 `LICENSE`와 copyright holder 선언이
|
||||||
|
없습니다. `package.json`의 `"private": true`는 npm publish 방지 설정일 뿐
|
||||||
|
외부 이용·수정·재배포 권한을 부여하지 않습니다. 하위 과거 README의
|
||||||
|
“Viewer2D 포트·샘플 글루 MIT” 문구도 현재 repository의 license grant로 볼 수
|
||||||
|
없습니다.
|
||||||
|
|
||||||
|
다음 조건을 완료하기 전에는 외부 demo, 고객 전달, 설치형 package, public CDN과
|
||||||
|
commercial SaaS 배포를 승인하지 않습니다.
|
||||||
|
|
||||||
|
- 프로젝트 소유 코드의 권리자와 proprietary/open-source license 결정
|
||||||
|
- 2D·3D build artifact에 runtime `THIRD_PARTY_NOTICES`와 필요한 license 전문 포함
|
||||||
|
- acadrust wrapper source·toolchain 복구와 exact WASM 재현 build
|
||||||
|
- acadrust 및 `web-ifc@0.0.77` covered source의 고정 archive/URL과 취득 안내 제공
|
||||||
|
- Draco·Basis Apache license와 upstream NOTICE 보존
|
||||||
|
- Box attribution과 sample별 원본 URL·권리자·license·checksum·derivative chain 기록
|
||||||
|
- Duck의 내부 법무 승인 또는 승인된 CC0/CC BY asset으로 교체
|
||||||
|
- `BasicSample.dwg`, `Cube.fbx`, `sample.mtl` 등 출처 미완료 fixture의 승인 또는 제거
|
||||||
|
- 미사용 `web-ifc-mt.wasm`과 중복 decoder artifact의 배포 필요성 결정
|
||||||
|
- runtime/build/sample을 구분한 SBOM과 CI의 unknown-license/provenance gate
|
||||||
|
|
||||||
|
## 설치와 실행
|
||||||
|
|
||||||
|
요구 환경은 Node.js 24 이상, npm 11 이상과 WebGL을 지원하는 browser입니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
npm run dev:2d
|
|
||||||
npm run dev:3d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
전체 검증은 `npm run verify`로 실행합니다.
|
`npm ci`는 workspace dependency를 설치한 뒤 3D decoder와 WASM을 staging합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:2d
|
||||||
|
# http://127.0.0.1:5173
|
||||||
|
|
||||||
|
npm run dev:3d
|
||||||
|
# http://127.0.0.1:3333
|
||||||
|
```
|
||||||
|
|
||||||
|
URL load 예시:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5173/?model=/samples/simple.dxf
|
||||||
|
http://127.0.0.1:3333/?model=/samples/Box.glb
|
||||||
|
```
|
||||||
|
|
||||||
|
Production build와 preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm --workspace @hmwebviewer/viewer-2d-sample run preview
|
||||||
|
npm --workspace @hmwebviewer/viewer-3d run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
전체 순차 검증:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run verify
|
||||||
|
```
|
||||||
|
|
||||||
|
현재 test가 확인하는 범위:
|
||||||
|
|
||||||
|
- 2D public package interface의 DXF parse
|
||||||
|
- 2D same-origin·50 MiB 입력 정책
|
||||||
|
- canonical module map 문서 drift
|
||||||
|
- decoder WASM의 installed dependency 대비 checksum과 실행 권한
|
||||||
|
- Vite subpath의 public asset URL
|
||||||
|
- 2D DWG/DXF sample과 local file load
|
||||||
|
- 3D GLB, Draco GLB, OBJ, FBX, DAE, IFC, PLY sample render
|
||||||
|
- 2D/3D canvas dispose와 반복 page transition
|
||||||
|
- production subpath의 Draco·IFC decoder request
|
||||||
|
|
||||||
|
이 검증은 sample이 화면에 나타나고 runtime error가 없다는 smoke/contract 수준입니다.
|
||||||
|
engineering 정확도와 외부 배포 compliance를 승인하는 test는 아닙니다.
|
||||||
|
|
||||||
|
## 문서
|
||||||
|
|
||||||
|
- [Third-party notices](./THIRD_PARTY_NOTICES.md)
|
||||||
|
- [Source와 fixture provenance](./docs/PROVENANCE.md)
|
||||||
|
- [DWG/DXF module map](./docs/modules-dwg-dxf.html)
|
||||||
|
- [2D sample 안내](./apps/viewer-2d-sample/README.md)
|
||||||
|
- [3D architecture](./apps/viewer-3d/docs/architecture.html)
|
||||||
|
- [3D 구현 계획](./apps/viewer-3d/PLAN.md)
|
||||||
|
- [3D 검증 기록](./apps/viewer-3d/PROGRESS.md)
|
||||||
|
- [3D asset 도구](./apps/viewer-3d/tools/README.md)
|
||||||
|
- [초기 3D 설계서](./apps/viewer-3d/3d_viewer_architecture_spec.pdf)
|
||||||
|
|
||||||
|
하위 2D README에는 통합 전 standalone layout이, 3D tools README에는 과거 KTX2
|
||||||
|
계획이 남아 있습니다. 현재 workspace의 canonical 개요와 배포 판단은 이 문서를
|
||||||
|
기준으로 하며, 하위 문서는 후속 정합성 정리가 필요합니다.
|
||||||
|
|
||||||
|
## 현재 판정
|
||||||
|
|
||||||
|
2D와 3D sample의 주요 load path, lifecycle, decoder staging과 subpath 배포를
|
||||||
|
검증하는 자동화가 있습니다. 내부 기능 검증용 workspace로는 사용할 수 있습니다.
|
||||||
|
|
||||||
|
운영 release에는 format별 정확도 contract, 3D 입력 제한·cancellation·Worker 격리,
|
||||||
|
프로젝트 license, MPL source 제공, third-party 고지와 sample provenance blocker가
|
||||||
|
남아 있습니다. 특히 “상업 사용 가능한 dependency”와 “현재 artifact를 조건 없이
|
||||||
|
상업 배포할 수 있음”은 같은 뜻이 아닙니다.
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
이 문서는 저장소에 직접 포함되어 배포되는 주요 third-party 자산의 출처와
|
||||||
|
라이선스를 기록합니다. package manager가 설치하는 전체 dependency 목록은
|
||||||
|
lockfile을 기준으로 별도 감사합니다.
|
||||||
|
|
||||||
|
## acadrust 0.4.1 / DWG WASM
|
||||||
|
|
||||||
|
- 역할: DWG binary parsing
|
||||||
|
- upstream: <https://github.com/hakanaktt/acadrust>
|
||||||
|
- crate source: <https://crates.io/crates/acadrust/0.4.1>
|
||||||
|
- license: Mozilla Public License 2.0
|
||||||
|
- license text: [`LICENSES/MPL-2.0.txt`](./LICENSES/MPL-2.0.txt)
|
||||||
|
- shipped WASM SHA-256:
|
||||||
|
`aeb211f98e7e4aacfeb2d8430a1ca0fb14b507885f17af13137bd24ed0eca7a3`
|
||||||
|
|
||||||
|
WASM binary의 embedded source path에서 `acadrust-0.4.1` 사용을 확인했습니다.
|
||||||
|
현재 binary를 최초 생성한 Rust wrapper source와 toolchain은 기존 두 upstream
|
||||||
|
repository에 남아 있지 않습니다. 이 저장소는 해당 binary를 standalone commit
|
||||||
|
`95fa6a452b50394ff11fba60db482dd038fe2355`에서 checksum을 유지한 채 이관했습니다.
|
||||||
|
재현 build와 외부 package 배포는 Gitea issue #3에서 별도로 관리합니다.
|
||||||
|
|
||||||
|
## NanumGothic-Regular.ttf
|
||||||
|
|
||||||
|
- 역할: DWG/DXF TEXT와 MTEXT glyph rendering
|
||||||
|
- upstream: <https://github.com/google/fonts/tree/main/ofl/nanumgothic>
|
||||||
|
- copyright: Copyright 2010 The Nanum Project Authors
|
||||||
|
- license: SIL Open Font License 1.1
|
||||||
|
- license text: [`LICENSES/OFL-1.1.txt`](./LICENSES/OFL-1.1.txt)
|
||||||
|
- shipped font SHA-256:
|
||||||
|
`76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31`
|
||||||
|
|
||||||
|
위 checksum은 Google Fonts의 현재 `NanumGothic-Regular.ttf`와 동일함을
|
||||||
|
2026-07-29에 확인했습니다.
|
||||||
|
|
||||||
|
## Runtime libraries
|
||||||
|
|
||||||
|
- Three.js — MIT, <https://github.com/mrdoob/three.js>
|
||||||
|
- dxf-parser — MIT, <https://github.com/gdsestimating/dxf-parser>
|
||||||
|
- opentype.js — MIT, <https://github.com/opentypejs/opentype.js>
|
||||||
|
- web-ifc — MPL-2.0, <https://github.com/ThatOpen/engine_web-ifc>
|
||||||
|
|
||||||
|
각 library의 license text는 clean install 후 해당 package에 포함된 license
|
||||||
|
파일에서도 확인할 수 있습니다.
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5173 --strictPort",
|
"dev": "vite --host 127.0.0.1 --port 5173 --strictPort",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export const MAX_CAD_BYTES = 50 * 1024 * 1024;
|
||||||
|
export const CAD_FETCH_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
export function assertCadByteLength(byteLength: number): void {
|
||||||
|
if (!Number.isFinite(byteLength) || byteLength < 0) {
|
||||||
|
throw new Error('CAD 파일 크기를 확인할 수 없습니다.');
|
||||||
|
}
|
||||||
|
if (byteLength > MAX_CAD_BYTES) {
|
||||||
|
throw new Error('CAD 파일은 50 MiB 이하여야 합니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCadUrl(input: string, base: URL): URL {
|
||||||
|
const url = new URL(input, base);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
throw new Error('CAD URL은 HTTP(S)만 허용합니다.');
|
||||||
|
}
|
||||||
|
if (url.origin !== base.origin) {
|
||||||
|
throw new Error('CAD URL은 viewer와 같은 origin만 허용합니다.');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCadBuffer(url: URL): Promise<ArrayBuffer> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), CAD_FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const declaredLength = Number(response.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > 0) {
|
||||||
|
assertCadByteLength(declaredLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
assertCadByteLength(buffer.byteLength);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
assertCadByteLength(total);
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
data.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return data.buffer;
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
throw new Error('CAD 파일 요청 시간이 초과되었습니다.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
parseCad,
|
parseCad,
|
||||||
type CadParseResult,
|
type CadParseResult,
|
||||||
} from '@hmwebviewer/viewer2d';
|
} from '@hmwebviewer/viewer2d';
|
||||||
|
import {
|
||||||
|
assertCadByteLength,
|
||||||
|
fetchCadBuffer,
|
||||||
|
resolveCadUrl,
|
||||||
|
} from './cadInputPolicy';
|
||||||
|
|
||||||
// parseDxfBuffer 내부:
|
// parseDxfBuffer 내부:
|
||||||
// dxf-parser → HatchHandler → dxfAdapter
|
// dxf-parser → HatchHandler → dxfAdapter
|
||||||
@@ -50,6 +55,12 @@ function setStatus(msg: string, err = false) {
|
|||||||
const viewer = createViewer2D(app, {
|
const viewer = createViewer2D(app, {
|
||||||
fontUrl: publicUrl('fonts/NanumGothic-Regular.ttf'),
|
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');
|
||||||
@@ -68,14 +79,14 @@ async function loadAndRender(buf: ArrayBuffer, name: string): Promise<CadParseRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 (!isCad2DFile(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +165,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 },
|
||||||
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>hmwebviewer — 3D Viewer</title>
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"build:subpath:e2e": "vite build --base=/viewer-3d/ --outDir=dist-subpath",
|
||||||
"preview": "vite preview",
|
"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",
|
"typecheck": "tsc --noEmit",
|
||||||
"stage:decoders": "node tools/copy-decoders.mjs"
|
"stage:decoders": "node tools/copy-decoders.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Hand-authored cube fixture
|
||||||
|
o Cube
|
||||||
|
v -1 -1 -1
|
||||||
|
v 1 -1 -1
|
||||||
|
v 1 1 -1
|
||||||
|
v -1 1 -1
|
||||||
|
v -1 -1 1
|
||||||
|
v 1 -1 1
|
||||||
|
v 1 1 1
|
||||||
|
v -1 1 1
|
||||||
|
f 1 4 3 2
|
||||||
|
f 5 6 7 8
|
||||||
|
f 1 2 6 5
|
||||||
|
f 2 3 7 6
|
||||||
|
f 3 4 8 7
|
||||||
|
f 4 1 5 8
|
||||||
Executable → Regular
Executable → Regular
@@ -1,6 +1,7 @@
|
|||||||
import { ThreeDViewer } from './viewer/ThreeDViewer';
|
import { ThreeDViewer } from './viewer/ThreeDViewer';
|
||||||
import { initDropzone } from './dnd/dropzone';
|
import { initDropzone } from './dnd/dropzone';
|
||||||
import { setStatus } from './ui/progress';
|
import { setStatus } from './ui/progress';
|
||||||
|
import { publicAssetUrl } from './runtimeBase';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point. Wires the ThreeDViewer (renderer + loaders + hydration) and the
|
* Entry point. Wires the ThreeDViewer (renderer + loaders + hydration) and the
|
||||||
@@ -62,11 +63,21 @@ if (modelUrl) {
|
|||||||
// SSR placeholder: a pre-rendered 360° animated WebP (tools/prerender.mjs)
|
// SSR placeholder: a pre-rendered 360° animated WebP (tools/prerender.mjs)
|
||||||
// shows instantly, then the hydration gate fades it out once WebGL + model ready.
|
// 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, '');
|
const base = modelUrl.split('/').pop()!.replace(/\.(glb|gltf|obj|fbx|dae|ifc|ply)$/i, '');
|
||||||
previewEl.src = '/previews/' + base + '.webp';
|
previewEl.src = publicAssetUrl(`previews/${base}.webp`);
|
||||||
previewEl.classList.remove('hidden');
|
previewEl.classList.remove('hidden');
|
||||||
previewEl.onerror = () => previewEl.classList.add('hidden');
|
previewEl.onerror = () => previewEl.classList.add('hidden');
|
||||||
viewer.loadServerAsset(modelUrl);
|
viewer.loadServerAsset(modelUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exposed for tooling (tools/prerender.mjs captures rotating frames).
|
// tools/prerender.mjs가 회전 frame을 캡처할 때 사용하는 공개 reference입니다.
|
||||||
(window as unknown as { __viewer?: ThreeDViewer }).__viewer = viewer;
|
const viewerWindow = window as Window & { __viewer?: ThreeDViewer };
|
||||||
|
viewerWindow.__viewer = viewer;
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'pagehide',
|
||||||
|
() => {
|
||||||
|
viewer.dispose();
|
||||||
|
delete viewerWindow.__viewer;
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Vite의 배포 base 아래에 있는 public asset의 절대 URL을 반환합니다.
|
||||||
|
*/
|
||||||
|
export function publicAssetUrl(
|
||||||
|
path: string,
|
||||||
|
base = import.meta.env.BASE_URL,
|
||||||
|
origin = window.location.origin,
|
||||||
|
): string {
|
||||||
|
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||||
|
const normalizedPath = path.replace(/^\/+/, '');
|
||||||
|
return new URL(normalizedPath, new URL(normalizedBase, `${origin}/`)).href;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
||||||
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
|
||||||
|
import { publicAssetUrl } from '../runtimeBase';
|
||||||
|
|
||||||
export interface ViewerLoaders {
|
export interface ViewerLoaders {
|
||||||
gltf: GLTFLoader;
|
gltf: GLTFLoader;
|
||||||
@@ -16,8 +17,10 @@ let _cache: ViewerLoaders | null = null;
|
|||||||
|
|
||||||
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
|
export function getLoaders(renderer: THREE.WebGLRenderer): ViewerLoaders {
|
||||||
if (_cache) return _cache;
|
if (_cache) return _cache;
|
||||||
const draco = new DRACOLoader().setDecoderPath('/draco/');
|
const draco = new DRACOLoader().setDecoderPath(publicAssetUrl('draco/'));
|
||||||
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
|
const ktx2 = new KTX2Loader()
|
||||||
|
.setTranscoderPath(publicAssetUrl('basis/'))
|
||||||
|
.detectSupport(renderer);
|
||||||
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
const gltf = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
|
||||||
_cache = { gltf, draco, ktx2 };
|
_cache = { gltf, draco, ktx2 };
|
||||||
return _cache;
|
return _cache;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { publicAssetUrl } from '../runtimeBase';
|
||||||
import { getLoaders } from './loaders';
|
import { getLoaders } from './loaders';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,7 +245,7 @@ async function loadIfc(url: string, onProgress?: ProgressCb): Promise<THREE.Obje
|
|||||||
const WebIFC = await import('web-ifc');
|
const WebIFC = await import('web-ifc');
|
||||||
if (!_ifcApi) {
|
if (!_ifcApi) {
|
||||||
_ifcApi = new WebIFC.IfcAPI();
|
_ifcApi = new WebIFC.IfcAPI();
|
||||||
_ifcApi.SetWasmPath('/web-ifc/', true);
|
_ifcApi.SetWasmPath(publicAssetUrl('web-ifc/'), true);
|
||||||
await _ifcApi.Init(undefined, true);
|
await _ifcApi.Init(undefined, true);
|
||||||
}
|
}
|
||||||
const api = _ifcApi;
|
const api = _ifcApi;
|
||||||
|
|||||||
@@ -2,13 +2,30 @@
|
|||||||
// runtime paths /draco/ and /basis/ resolve (version-matched to installed three).
|
// runtime paths /draco/ and /basis/ resolve (version-matched to installed three).
|
||||||
// Also copies the web-ifc single-thread WASM to public/web-ifc/ for the IFC path.
|
// Also copies the web-ifc single-thread WASM to public/web-ifc/ for the IFC path.
|
||||||
// Runs automatically via `npm install` (postinstall). Safe to re-run.
|
// Runs automatically via `npm install` (postinstall). Safe to re-run.
|
||||||
import { cpSync, copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
|
import {
|
||||||
|
chmodSync,
|
||||||
|
cpSync,
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const root = resolve(__dirname, '..');
|
const root = resolve(__dirname, '..');
|
||||||
|
|
||||||
|
function normalizePublicModes(directory) {
|
||||||
|
chmodSync(directory, 0o755);
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const path = resolve(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) normalizePublicModes(path);
|
||||||
|
else if (entry.isFile()) chmodSync(path, 0o644);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const threeEntry = fileURLToPath(import.meta.resolve('three'));
|
const threeEntry = fileURLToPath(import.meta.resolve('three'));
|
||||||
const threeRoot = resolve(dirname(threeEntry), '..');
|
const threeRoot = resolve(dirname(threeEntry), '..');
|
||||||
const libs = resolve(threeRoot, 'examples/jsm/libs');
|
const libs = resolve(threeRoot, 'examples/jsm/libs');
|
||||||
@@ -30,6 +47,7 @@ for (const [src, dest] of targets) {
|
|||||||
rmSync(dest, { force: true, recursive: true });
|
rmSync(dest, { force: true, recursive: true });
|
||||||
mkdirSync(dest, { recursive: true });
|
mkdirSync(dest, { recursive: true });
|
||||||
cpSync(src, dest, { recursive: true });
|
cpSync(src, dest, { recursive: true });
|
||||||
|
normalizePublicModes(dest);
|
||||||
console.log(`[copy-decoders] ${src} -> ${dest}`);
|
console.log(`[copy-decoders] ${src} -> ${dest}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +60,10 @@ if (existsSync(ifcSrcDir)) {
|
|||||||
for (const wasm of ['web-ifc.wasm', 'web-ifc-mt.wasm']) {
|
for (const wasm of ['web-ifc.wasm', 'web-ifc-mt.wasm']) {
|
||||||
const src = resolve(ifcSrcDir, wasm);
|
const src = resolve(ifcSrcDir, wasm);
|
||||||
if (!existsSync(src)) continue;
|
if (!existsSync(src)) continue;
|
||||||
copyFileSync(src, resolve(ifcDest, wasm));
|
const dest = resolve(ifcDest, wasm);
|
||||||
console.log(`[copy-decoders] ${src} -> ${resolve(ifcDest, wasm)}`);
|
copyFileSync(src, dest);
|
||||||
|
chmodSync(dest, 0o644);
|
||||||
|
console.log(`[copy-decoders] ${src} -> ${dest}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('[copy-decoders] web-ifc not installed yet — skipping IFC wasm.');
|
console.warn('[copy-decoders] web-ifc not installed yet — skipping IFC wasm.');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"types": ["three"]
|
"types": ["three", "vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src", "tools"]
|
"include": ["src", "tools"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 소스 및 fixture provenance
|
||||||
|
|
||||||
|
## Repository history
|
||||||
|
|
||||||
|
- 2D standalone source: `cyhan/dwg-dxf-viewer-sample`
|
||||||
|
- upstream fork source: `kimminsung/dwg-dxf-viewer-sample`
|
||||||
|
- exact commit: `95fa6a452b50394ff11fba60db482dd038fe2355`
|
||||||
|
- 두 Gitea remote가 동일 commit을 가리키는 것을 확인했습니다.
|
||||||
|
- 3D source: `cyhan/hmwebviewer`
|
||||||
|
- upstream fork source: `kimminsung/hmwebviewer`
|
||||||
|
- exact commit: `a71790070dd479abc2707a5dd63b0e8c872db412`
|
||||||
|
- 두 Gitea remote가 동일 commit을 가리키는 것을 확인했습니다.
|
||||||
|
|
||||||
|
3D source는 non-squash subtree commit으로 전체 tree와 commit ancestry를
|
||||||
|
보존해 가져왔습니다. 2D standalone rollback tag와 3D import provenance tag를
|
||||||
|
로컬 Git에 생성했습니다.
|
||||||
|
|
||||||
|
## 2D fixture
|
||||||
|
|
||||||
|
| Fixture | SHA-256 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BasicSample.dwg` | `ef9f1a225931df62cd25a7897771d0d4fcebf0f79d312e3b7888bba9834b7429` | standalone initial commit에서 이관. 외부 재배포 원출처 확인 필요 |
|
||||||
|
| `simple.dxf` | `ce8fc0735b78e912e1ddd26c1535e3e53c429332a7495ae50b749687892bd336` | standalone initial commit의 최소 ASCII DXF test fixture |
|
||||||
|
|
||||||
|
원출처가 확인되지 않은 fixture는 내부 회귀 test에만 사용합니다. 공개 배포 전에
|
||||||
|
소유권이 확인된 fixture로 교체하거나 명시적 재배포 승인을 기록해야 합니다.
|
||||||
|
|
||||||
|
## Policy
|
||||||
|
|
||||||
|
- source와 binary는 exact commit 또는 SHA-256으로 고정합니다.
|
||||||
|
- 출처가 불명확한 binary와 fixture를 새 외부 package나 공개 demo에 추가하지 않습니다.
|
||||||
|
- generated decoder는 설치된 exact dependency에서 매 clean install마다 다시 staging합니다.
|
||||||
|
- package 전체의 공개 배포 자동화와 SBOM은 Gitea issue #3의 범위입니다.
|
||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||||
"test": "npm run test:unit",
|
"test": "npm run test:unit",
|
||||||
"test:unit": "vitest run",
|
"test:unit": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "node tools/run-e2e.mjs",
|
||||||
"verify": "npm run typecheck && npm run test:unit && npm run build && npm run test:e2e",
|
"verify": "npm run typecheck && npm run test:unit && npm run build && npm run test:e2e",
|
||||||
"postinstall": "npm --workspace @hmwebviewer/viewer-3d run stage:decoders"
|
"postinstall": "npm --workspace @hmwebviewer/viewer-3d run stage:decoders"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+9
-3
@@ -1,5 +1,4 @@
|
|||||||
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
|
// 포팅된 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ARC_SEGS = 64;
|
|||||||
const ELLIPSE_SEGS = 72;
|
const ELLIPSE_SEGS = 72;
|
||||||
const CLICK_THRESHOLD_PX = 14;
|
const CLICK_THRESHOLD_PX = 14;
|
||||||
|
|
||||||
function stripMText(s) {
|
function stripMText(s) {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
return s
|
return s
|
||||||
.replace(/\\A\d+;/g, '') // \A1; vertical alignment
|
.replace(/\\A\d+;/g, '') // \A1; vertical alignment
|
||||||
@@ -30,11 +30,28 @@ function stripMText(s) {
|
|||||||
.replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n)
|
.replace(/^[ \t]+|[ \t]+$/gm, '') // trim each line's edges (keeps interior \n)
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
|
.replace(/^\n+|\n+$/g, ''); // drop leading/trailing blank lines
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Viewer2D {
|
function disposeMaterial(material) {
|
||||||
constructor(container) {
|
for (const item of (Array.isArray(material) ? material : [material])) {
|
||||||
this._container = container;
|
item?.map?.dispose();
|
||||||
|
item?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeObject(root) {
|
||||||
|
root?.traverse?.((object) => {
|
||||||
|
object.geometry?.dispose();
|
||||||
|
if (object.material) disposeMaterial(object.material);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Viewer2D {
|
||||||
|
constructor(container) {
|
||||||
|
this._disposed = false;
|
||||||
|
this._rafId = null;
|
||||||
|
this._viewChangeCallbacks = new Set();
|
||||||
|
this._container = container;
|
||||||
this._entityMeta = [];
|
this._entityMeta = [];
|
||||||
this._layerByHandle = new Map();
|
this._layerByHandle = new Map();
|
||||||
this._layerByName = new Map();
|
this._layerByName = new Map();
|
||||||
@@ -74,26 +91,43 @@ export class Viewer2D {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', (e) => { if (e.key === 'f' || e.key === 'F') this.fit(); });
|
this._handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'f' || e.key === 'F') this.fit();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', this._handleKeyDown);
|
||||||
|
|
||||||
this._group = new THREE.Group();
|
this._group = new THREE.Group();
|
||||||
this._scene.add(this._group);
|
this._scene.add(this._group);
|
||||||
|
|
||||||
window.addEventListener('resize', () => this._onResize());
|
this._handleResize = () => this._onResize();
|
||||||
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
|
window.addEventListener('resize', this._handleResize);
|
||||||
this._renderer.domElement.addEventListener('pointerdown', (e) => {
|
// Prevent browser auto-scroll popup when middle-button is pressed on the WebGL canvas
|
||||||
if (e.button === 1) e.preventDefault();
|
this._handlePointerDown = (e) => {
|
||||||
});
|
if (e.button === 1) e.preventDefault();
|
||||||
this._renderer.domElement.addEventListener('mousedown', (e) => {
|
};
|
||||||
this._downXY = [e.clientX, e.clientY];
|
this._handleMouseDown = (e) => {
|
||||||
if (e.button === 1) e.preventDefault();
|
this._downXY = [e.clientX, e.clientY];
|
||||||
});
|
if (e.button === 1) e.preventDefault();
|
||||||
this._renderer.domElement.addEventListener('click', (e) => this._onClick(e));
|
};
|
||||||
|
this._handleClick = (e) => this._onClick(e);
|
||||||
|
this._renderer.domElement.addEventListener('pointerdown', this._handlePointerDown);
|
||||||
|
this._renderer.domElement.addEventListener('mousedown', this._handleMouseDown);
|
||||||
|
this._renderer.domElement.addEventListener('click', this._handleClick);
|
||||||
this._selColor = { r: 0xd8 / 255, g: 0x3a / 255, b: 0x2f / 255 };
|
this._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 = () => {
|
||||||
this._animate();
|
if (this._gridVisible) this._rebuildGrid();
|
||||||
|
for (const callback of this._viewChangeCallbacks) callback();
|
||||||
|
};
|
||||||
|
this._controls.addEventListener('change', this._handleControlsChange);
|
||||||
|
this._onAnimationFrame = () => {
|
||||||
|
if (this._disposed) return;
|
||||||
|
this._controls.update();
|
||||||
|
this._renderer.render(this._scene, this._camera);
|
||||||
|
this._rafId = requestAnimationFrame(this._onAnimationFrame);
|
||||||
|
};
|
||||||
|
this._animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelect(cb) { this._onSelectCb = cb; }
|
onSelect(cb) { this._onSelectCb = cb; }
|
||||||
@@ -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) {
|
||||||
@@ -2328,7 +2366,7 @@ export class Viewer2D {
|
|||||||
return Infinity;
|
return Infinity;
|
||||||
}
|
}
|
||||||
|
|
||||||
_fit(box) {
|
_fit(box) {
|
||||||
const c = box.getCenter(new THREE.Vector3());
|
const c = box.getCenter(new THREE.Vector3());
|
||||||
const size = box.getSize(new THREE.Vector3());
|
const size = box.getSize(new THREE.Vector3());
|
||||||
const w = this._container.clientWidth || 800;
|
const w = this._container.clientWidth || 800;
|
||||||
@@ -2346,15 +2384,57 @@ export class Viewer2D {
|
|||||||
this._camera.up.set(-Math.sin(a), Math.cos(a), 0);
|
this._camera.up.set(-Math.sin(a), Math.cos(a), 0);
|
||||||
this._camera.updateProjectionMatrix();
|
this._camera.updateProjectionMatrix();
|
||||||
this._controls.target.set(c.x, c.y, 0);
|
this._controls.target.set(c.x, c.y, 0);
|
||||||
this._controls.update();
|
this._controls.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
_clear() {
|
dispose() {
|
||||||
for (let i = this._group.children.length - 1; i >= 0; i--) {
|
if (this._disposed) return;
|
||||||
const o = this._group.children[i];
|
this._disposed = true;
|
||||||
o.geometry?.dispose();
|
|
||||||
if (o.material) { o.material.map?.dispose(); o.material.dispose(); }
|
if (this._rafId !== null) {
|
||||||
this._group.remove(o);
|
cancelAnimationFrame(this._rafId);
|
||||||
|
this._rafId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('keydown', this._handleKeyDown);
|
||||||
|
window.removeEventListener('resize', this._handleResize);
|
||||||
|
const canvas = this._renderer.domElement;
|
||||||
|
canvas.removeEventListener('pointerdown', this._handlePointerDown);
|
||||||
|
canvas.removeEventListener('mousedown', this._handleMouseDown);
|
||||||
|
canvas.removeEventListener('click', this._handleClick);
|
||||||
|
this._controls.removeEventListener('change', this._handleControlsChange);
|
||||||
|
this._viewChangeCallbacks.clear();
|
||||||
|
|
||||||
|
this.stopMeasure();
|
||||||
|
this.setGrid(false);
|
||||||
|
this._clear();
|
||||||
|
this._scene.remove(this._group);
|
||||||
|
this._controls.dispose();
|
||||||
|
this._renderer.dispose();
|
||||||
|
this._renderer.forceContextLoss();
|
||||||
|
canvas.remove();
|
||||||
|
this._scene.clear();
|
||||||
|
|
||||||
|
this._lastResult = null;
|
||||||
|
this._onSelectCb = null;
|
||||||
|
this._measureCb = null;
|
||||||
|
this._entityMeta = [];
|
||||||
|
this._pickSegs = [];
|
||||||
|
this._pickSegMeta = [];
|
||||||
|
this._pickFills = [];
|
||||||
|
this._layerByHandle.clear();
|
||||||
|
this._layerByName.clear();
|
||||||
|
this._layerNameByHandle?.clear();
|
||||||
|
this._linetypeByName?.clear();
|
||||||
|
this._hiddenLayers?.clear();
|
||||||
|
this._texCache?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
for (let i = this._group.children.length - 1; i >= 0; i--) {
|
||||||
|
const o = this._group.children[i];
|
||||||
|
disposeObject(o);
|
||||||
|
this._group.remove(o);
|
||||||
}
|
}
|
||||||
// Shared text textures were disposed via the sprites above (re-dispose is a
|
// Shared text textures were disposed via the sprites above (re-dispose is a
|
||||||
// no-op); drop the cache so the next load rasterizes fresh.
|
// no-op); drop the cache so the next load rasterizes fresh.
|
||||||
@@ -2362,8 +2442,9 @@ export class Viewer2D {
|
|||||||
this._textGen++; // invalidate any in-flight async text flush
|
this._textGen++; // invalidate any in-flight async text flush
|
||||||
}
|
}
|
||||||
|
|
||||||
_onResize() {
|
_onResize() {
|
||||||
const w = this._container.clientWidth, h = this._container.clientHeight;
|
if (this._disposed) return;
|
||||||
|
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);
|
||||||
// Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords.
|
// Frustum planes are CAMERA-LOCAL (relative to camera.position), not world coords.
|
||||||
@@ -2375,9 +2456,8 @@ export class Viewer2D {
|
|||||||
this._camera.updateProjectionMatrix();
|
this._camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
_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);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
timeout: 30_000,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:5173',
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
launchOptions: {
|
||||||
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? '/usr/bin/google-chrome',
|
||||||
|
args: ['--no-sandbox'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webServer: process.env.E2E_SERVERS_READY
|
||||||
|
? undefined
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
command: 'npm run dev:2d',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'npm run dev:3d',
|
||||||
|
port: 3333,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'npm --workspace @hmwebviewer/viewer-3d run serve:subpath:e2e',
|
||||||
|
port: 43174,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
assertCadByteLength,
|
||||||
|
resolveCadUrl,
|
||||||
|
} from '../apps/viewer-2d-sample/src/cadInputPolicy';
|
||||||
|
|
||||||
|
describe('CAD input policy', () => {
|
||||||
|
it('accepts same-origin URLs and rejects cross-origin model URLs', () => {
|
||||||
|
expect(resolveCadUrl('/samples/simple.dxf', new URL('https://viewer.example/app/')).href).toBe(
|
||||||
|
'https://viewer.example/samples/simple.dxf',
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
resolveCadUrl('https://attacker.example/oversized.dxf', new URL('https://viewer.example/')),
|
||||||
|
).toThrow('같은 origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects CAD input larger than 50 MiB', () => {
|
||||||
|
expect(() => assertCadByteLength(50 * 1024 * 1024)).not.toThrow();
|
||||||
|
expect(() => assertCadByteLength(50 * 1024 * 1024 + 1)).toThrow('50 MiB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function sha256(path: string): string {
|
||||||
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decoder staging', () => {
|
||||||
|
it('copies decoder binaries byte-for-byte without executable permission', () => {
|
||||||
|
const threeRoot = resolve(
|
||||||
|
dirname(fileURLToPath(import.meta.resolve('three'))),
|
||||||
|
'..',
|
||||||
|
);
|
||||||
|
const ifcRoot = dirname(fileURLToPath(import.meta.resolve('web-ifc')));
|
||||||
|
const pairs = [
|
||||||
|
[
|
||||||
|
resolve(threeRoot, 'examples/jsm/libs/draco/gltf/draco_decoder.wasm'),
|
||||||
|
resolve(projectRoot, 'apps/viewer-3d/public/draco/draco_decoder.wasm'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
resolve(threeRoot, 'examples/jsm/libs/basis/basis_transcoder.wasm'),
|
||||||
|
resolve(projectRoot, 'apps/viewer-3d/public/basis/basis_transcoder.wasm'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
resolve(ifcRoot, 'web-ifc.wasm'),
|
||||||
|
resolve(projectRoot, 'apps/viewer-3d/public/web-ifc/web-ifc.wasm'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [source, staged] of pairs) {
|
||||||
|
expect(sha256(staged)).toBe(sha256(source));
|
||||||
|
expect(statSync(staged).mode & 0o111).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('canonical documentation', () => {
|
||||||
|
it('keeps the served 2D module map identical to docs source', () => {
|
||||||
|
const canonical = readFileSync(resolve('docs/modules-dwg-dxf.html'));
|
||||||
|
const served = readFileSync(
|
||||||
|
resolve('apps/viewer-2d-sample/public/docs/modules-dwg-dxf.html'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(served.equals(canonical)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const evidenceDirectory = resolve('test-results/evidence');
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
mkdirSync(evidenceDirectory, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2D DWG와 DXF 구동 증거를 캡처합니다', async ({ page }) => {
|
||||||
|
await page.goto('http://127.0.0.1:5173/');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sample DWG' }).click();
|
||||||
|
await expect(page.locator('#status')).toContainText('7309 entities');
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(evidenceDirectory, 'viewer2d-dwg.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sample DXF' }).click();
|
||||||
|
await expect(page.locator('#status')).toContainText('6 entities');
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(evidenceDirectory, 'viewer2d-dxf.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, fixture, output] of [
|
||||||
|
['GLB', 'Box.glb', 'viewer3d-three-r185-glb.png'],
|
||||||
|
['PLY', 'DoublePrecision.ply', 'viewer3d-three-r185-ply.png'],
|
||||||
|
] as const) {
|
||||||
|
test(`Three.js r185 ${label} 구동 증거를 캡처합니다`, async ({ page }) => {
|
||||||
|
await page.goto(`http://127.0.0.1:3333/?model=/samples/${fixture}`);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer?: { current?: object };
|
||||||
|
}
|
||||||
|
).__viewer?.current,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
await expect(page.locator('canvas')).toHaveCount(1);
|
||||||
|
await page.screenshot({
|
||||||
|
path: resolve(evidenceDirectory, output),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('viewer2d loads DWG, DXF, and an uploaded file', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sample DWG' }).click();
|
||||||
|
await expect(page.locator('#status')).toContainText('✓ BasicSample.dwg · 7309 entities');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Layers' }).click();
|
||||||
|
await expect(page.locator('#layerList label').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Sample DXF' }).click();
|
||||||
|
await expect(page.locator('#status')).toContainText('✓ simple.dxf · 6 entities');
|
||||||
|
|
||||||
|
await page.locator('#file').setInputFiles(
|
||||||
|
path.resolve('apps/viewer-2d-sample/public/samples/simple.dxf'),
|
||||||
|
);
|
||||||
|
await expect(page.locator('#status')).toContainText('✓ simple.dxf · 6 entities');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer2d rejects cross-origin and oversized CAD input', async ({ page }) => {
|
||||||
|
await page.goto('/?model=https://attacker.example/oversized.dxf');
|
||||||
|
await expect(page.locator('#status')).toContainText('같은 origin');
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const file = new File([new Uint8Array(50 * 1024 * 1024 + 1)], 'oversized.dxf');
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(file);
|
||||||
|
window.dispatchEvent(new DragEvent('drop', { dataTransfer: transfer }));
|
||||||
|
});
|
||||||
|
await expect(page.locator('#status')).toContainText('50 MiB');
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('viewer2d releases its canvas when disposed', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'Sample DXF' }).click();
|
||||||
|
await expect(page.locator('#status')).toContainText('✓ simple.dxf');
|
||||||
|
await expect(page.locator('canvas')).toHaveCount(1);
|
||||||
|
|
||||||
|
const lifecycle = await page.evaluate(() => {
|
||||||
|
const viewer = (
|
||||||
|
window as Window & {
|
||||||
|
__viewer2d?: { dispose?: () => void };
|
||||||
|
}
|
||||||
|
).__viewer2d;
|
||||||
|
return {
|
||||||
|
exposed: Boolean(viewer),
|
||||||
|
disposable: typeof viewer?.dispose === 'function',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lifecycle).toEqual({ exposed: true, disposable: true });
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer2d?: { dispose?: () => void };
|
||||||
|
}
|
||||||
|
).__viewer2d?.dispose?.();
|
||||||
|
});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer2d?: { dispose?: () => void };
|
||||||
|
}
|
||||||
|
).__viewer2d?.dispose?.();
|
||||||
|
});
|
||||||
|
await expect(page.locator('canvas')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer3d releases its canvas on pagehide and remains disposable', async ({ page }) => {
|
||||||
|
await page.goto('http://127.0.0.1:3333/?model=/samples/Box.glb');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer?: { current?: object };
|
||||||
|
}
|
||||||
|
).__viewer?.current,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
window.dispatchEvent(new PageTransitionEvent('pagehide'));
|
||||||
|
const exposed = (window as Window & { __viewer?: { dispose: () => void } }).__viewer;
|
||||||
|
exposed?.dispose();
|
||||||
|
return {
|
||||||
|
canvases: document.querySelectorAll('canvas').length,
|
||||||
|
exposed: Boolean(exposed),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ canvases: 0, exposed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer2d and viewer3d survive ten mount transitions without duplicate canvases', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
page.on('pageerror', (error) => runtimeErrors.push(error.message));
|
||||||
|
|
||||||
|
for (let transition = 0; transition < 10; transition += 1) {
|
||||||
|
await page.goto('http://127.0.0.1:5173/');
|
||||||
|
await page.getByRole('button', { name: 'Sample DWG' }).click();
|
||||||
|
await expect(page.locator('#status')).toContainText('7309 entities', { timeout: 20_000 });
|
||||||
|
await expect(page.locator('canvas')).toHaveCount(1);
|
||||||
|
|
||||||
|
await page.goto('http://127.0.0.1:3333/?model=/samples/Box.glb');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer?: { current?: object };
|
||||||
|
}
|
||||||
|
).__viewer?.current,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
await expect(page.locator('canvas')).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const fixtures = [
|
||||||
|
['GLB', 'Box.glb'],
|
||||||
|
['Draco GLB', 'Duck.optimized.glb'],
|
||||||
|
['OBJ', 'Cube.obj'],
|
||||||
|
['FBX', 'Cube.fbx'],
|
||||||
|
['DAE', 'Cube.dae'],
|
||||||
|
['IFC', 'Cube.ifc'],
|
||||||
|
['double PLY', 'DoublePrecision.ply'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [label, fixture] of fixtures) {
|
||||||
|
test(`viewer3d renders ${label} with Three.js r185`, async ({ page }) => {
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
page.on('pageerror', (error) => runtimeErrors.push(error.message));
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') runtimeErrors.push(message.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`http://127.0.0.1:3333/?model=/samples/${fixture}`);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer?: { current?: object };
|
||||||
|
}
|
||||||
|
).__viewer?.current,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const scene = await page.evaluate(() => {
|
||||||
|
const current = (
|
||||||
|
window as Window & {
|
||||||
|
__viewer?: {
|
||||||
|
current?: {
|
||||||
|
traverse: (visitor: (node: unknown) => void) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).__viewer?.current;
|
||||||
|
let renderables = 0;
|
||||||
|
let vertices = 0;
|
||||||
|
let invalidValues = 0;
|
||||||
|
let float64Attributes = 0;
|
||||||
|
|
||||||
|
current?.traverse((node: unknown) => {
|
||||||
|
const geometry = (
|
||||||
|
node as {
|
||||||
|
geometry?: {
|
||||||
|
attributes?: {
|
||||||
|
position?: {
|
||||||
|
array: ArrayLike<number>;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).geometry;
|
||||||
|
const position = geometry?.attributes?.position;
|
||||||
|
if (!position) return;
|
||||||
|
renderables += 1;
|
||||||
|
vertices += position.count;
|
||||||
|
if (position.array instanceof Float64Array) float64Attributes += 1;
|
||||||
|
for (let index = 0; index < position.array.length; index += 1) {
|
||||||
|
if (!Number.isFinite(position.array[index])) invalidValues += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvases: document.querySelectorAll('canvas').length,
|
||||||
|
float64Attributes,
|
||||||
|
invalidValues,
|
||||||
|
renderables,
|
||||||
|
vertices,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scene.canvases).toBe(1);
|
||||||
|
expect(scene.renderables).toBeGreaterThan(0);
|
||||||
|
expect(scene.vertices).toBeGreaterThan(0);
|
||||||
|
expect(scene.invalidValues).toBe(0);
|
||||||
|
expect(scene.float64Attributes).toBe(0);
|
||||||
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('viewer3d resolves Draco and IFC decoders under a production subpath', async ({ page }) => {
|
||||||
|
const failedRequests: string[] = [];
|
||||||
|
const responseUrls: string[] = [];
|
||||||
|
page.on('requestfailed', (request) => failedRequests.push(request.url()));
|
||||||
|
page.on('response', (response) => responseUrls.push(response.url()));
|
||||||
|
|
||||||
|
for (const fixture of ['Duck.optimized.glb', 'Cube.ifc']) {
|
||||||
|
await page.goto(
|
||||||
|
`http://127.0.0.1:43174/viewer-3d/?model=/viewer-3d/samples/${fixture}`,
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
Boolean(
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__viewer?: { current?: object };
|
||||||
|
}
|
||||||
|
).__viewer?.current,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(responseUrls.some((url) => url.includes('/viewer-3d/draco/'))).toBe(true);
|
||||||
|
expect(responseUrls.some((url) => url.endsWith('/viewer-3d/web-ifc/web-ifc.wasm'))).toBe(true);
|
||||||
|
expect(failedRequests).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { publicAssetUrl } from '../apps/viewer-3d/src/runtimeBase';
|
||||||
|
|
||||||
|
describe('3D public asset URL', () => {
|
||||||
|
it('keeps decoder and preview assets under the configured deployment subpath', () => {
|
||||||
|
expect(publicAssetUrl('draco/', '/viewer-3d/', 'https://viewer.example')).toBe(
|
||||||
|
'https://viewer.example/viewer-3d/draco/',
|
||||||
|
);
|
||||||
|
expect(publicAssetUrl('/previews/Box.webp', '/viewer-3d/', 'https://viewer.example')).toBe(
|
||||||
|
'https://viewer.example/viewer-3d/previews/Box.webp',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const serverSpecs = [
|
||||||
|
{ name: 'viewer-2d', args: ['run', 'dev:2d'] },
|
||||||
|
{ name: 'viewer-3d', args: ['run', 'dev:3d'] },
|
||||||
|
{
|
||||||
|
name: 'viewer-3d-subpath',
|
||||||
|
args: ['--workspace', '@hmwebviewer/viewer-3d', 'run', 'serve:subpath:e2e'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const servers = [];
|
||||||
|
let stopping = false;
|
||||||
|
|
||||||
|
function stopServers() {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
for (const server of servers) {
|
||||||
|
if (!server.pid) continue;
|
||||||
|
try {
|
||||||
|
// npm과 그 아래 Vite process를 같은 process group 단위로 종료합니다.
|
||||||
|
process.kill(-server.pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
// 이미 종료된 server는 별도 처리하지 않습니다.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer({ name, args }) {
|
||||||
|
return new Promise((resolveReady, rejectReady) => {
|
||||||
|
const child = spawn('npm', args, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
detached: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NO_PROXY: '127.0.0.1,localhost',
|
||||||
|
no_proxy: '127.0.0.1,localhost',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
servers.push(child);
|
||||||
|
|
||||||
|
let ready = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
rejectReady(new Error(`${name} server가 90초 안에 준비되지 않았습니다.`));
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
|
const inspect = (chunk) => {
|
||||||
|
const output = chunk.toString();
|
||||||
|
process.stderr.write(`[${name}] ${output}`);
|
||||||
|
if (!ready && output.includes('Local:')) {
|
||||||
|
ready = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolveReady();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
child.stdout.on('data', inspect);
|
||||||
|
child.stderr.on('data', inspect);
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!ready) {
|
||||||
|
rejectReady(
|
||||||
|
new Error(`${name} server가 준비 전에 종료됐습니다. code=${code} signal=${signal}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once('SIGINT', () => {
|
||||||
|
stopServers();
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
process.once('SIGTERM', () => {
|
||||||
|
stopServers();
|
||||||
|
process.exit(143);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(serverSpecs.map(startServer));
|
||||||
|
|
||||||
|
const playwright = spawn(
|
||||||
|
resolve(projectRoot, 'node_modules/.bin/playwright'),
|
||||||
|
['test', ...process.argv.slice(2)],
|
||||||
|
{
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
E2E_SERVERS_READY: '1',
|
||||||
|
NO_PROXY: '127.0.0.1,localhost',
|
||||||
|
no_proxy: '127.0.0.1,localhost',
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const exitCode = await new Promise((resolveExit) => {
|
||||||
|
playwright.once('exit', (code, signal) => {
|
||||||
|
if (signal) resolveExit(1);
|
||||||
|
else resolveExit(code ?? 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
process.exitCode = exitCode;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
stopServers();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user