3d viewer change
This commit is contained in:
1 parent
cbf05a9ee1
commit
1dbc73312b
17 files changed
+1413
-841
No files matched your search
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
packages/backend/public
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
.cursor
|
||||
.tmp
|
||||
tmp
|
||||
@@ -1,6 +1,9 @@
|
||||
# Build frontend + backend
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
# CAD viewer + Online3DViewer + Mermaid/Excalidraw make Vite chunking
|
||||
# exceed the default ~2GB heap inside slim Docker builds.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY packages/backend/package.json packages/backend/
|
||||
|
||||
Generated
+96
-14
@@ -2866,6 +2866,16 @@
|
||||
"resolved": "packages/frontend",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@simonwep/pickr": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.9.0.tgz",
|
||||
"integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.32.2",
|
||||
"nanopop": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -4107,6 +4117,17 @@
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.32.2",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.2.tgz",
|
||||
"integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -4785,6 +4806,12 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -5237,6 +5264,14 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glm": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glm/-/glm-1.0.0.tgz",
|
||||
"integrity": "sha512-9/ubsXH/3w4pWunc1b3iVY9O/avtwt0kiEM07lydjASWI6CC4oFJHPURlpn8KuluDRlSHwNH6FlHFc5P1SBiJQ==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -5977,6 +6012,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nanopop": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.3.0.tgz",
|
||||
"integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -6022,12 +6063,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/occt-import-js": {
|
||||
"version": "0.0.23",
|
||||
"resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz",
|
||||
"integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==",
|
||||
"license": "LGPL-2.1"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -6040,6 +6075,29 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/online-3d-viewer": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/online-3d-viewer/-/online-3d-viewer-0.18.0.tgz",
|
||||
"integrity": "sha512-y7ZlV/zkakNUyjqcXz6XecA7vXgLEUnaAey9tyx8o6/wcdV64RfjXAQOjGXGY2JOZoDi4Cg1ic9icSWMWAvRQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simonwep/pickr": "1.9.0",
|
||||
"fflate": "0.8.2",
|
||||
"three": "0.176.0"
|
||||
}
|
||||
},
|
||||
"node_modules/online-3d-viewer/node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/online-3d-viewer/node_modules/three": {
|
||||
"version": "0.176.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz",
|
||||
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/open-color": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz",
|
||||
@@ -6422,6 +6480,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/rhino3dm": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/rhino3dm/-/rhino3dm-8.17.0.tgz",
|
||||
"integrity": "sha512-6m3nE/DUwu8vgc4Y7ffmLvV+WvUgjGYAf68q5WAn/CJZvboAPRlU6wTM7PpPU+wxiKfyfkOKKEkXVyPGIQyGeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||
@@ -7755,12 +7819,6 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-ifc": {
|
||||
"version": "0.0.77",
|
||||
"resolved": "https://registry.npmjs.org/web-ifc/-/web-ifc-0.0.77.tgz",
|
||||
"integrity": "sha512-VzQ0W/Iiqbidxn1ECUvz6qJ6p2sXBVNcOsUOBCETzy77psAH6yFLKQm74aXabkx3JH4OvFVHe8k1qS6+Z2zl1w==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/webworkify": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz",
|
||||
@@ -7869,17 +7927,21 @@
|
||||
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||
"@rhwp/editor": "^0.7.18",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"draco3d": "^1.5.7",
|
||||
"element-plus": "^2.14.3",
|
||||
"fflate": "^0.8.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mermaid": "^11.16.0",
|
||||
"occt-import-js": "^0.0.23",
|
||||
"occt-import-js": "^0.0.22",
|
||||
"online-3d-viewer": "^0.18.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"rhino3dm": "^8.17.0",
|
||||
"three": "^0.172.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.7",
|
||||
"vue-router": "^4.5.0",
|
||||
"web-ifc": "^0.0.77"
|
||||
"web-ifc": "^0.0.68"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
||||
@@ -7892,6 +7954,26 @@
|
||||
"vite": "^6.2.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
},
|
||||
"packages/frontend/node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/frontend/node_modules/occt-import-js": {
|
||||
"version": "0.0.22",
|
||||
"resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.22.tgz",
|
||||
"integrity": "sha512-pqvP0hJGMgAoFKji+nL+2zIHJ2NJeFTpI2oEEJrVQtvLErDApd2TeAJNhPO9oU+aWUKIUSWoxSfIrF0KisrPtw==",
|
||||
"license": "LGPL-2.1"
|
||||
},
|
||||
"packages/frontend/node_modules/web-ifc": {
|
||||
"version": "0.0.68",
|
||||
"resolved": "https://registry.npmjs.org/web-ifc/-/web-ifc-0.0.68.tgz",
|
||||
"integrity": "sha512-SqAmmwpultii8lQZoFvn2vnusW4tRVaed8GJNGuh8EenFMPe4x0g6NIXGu+5eBrXsiV+AaOdwjmOKTHEGXM4Gg==",
|
||||
"dependencies": {
|
||||
"glm": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,21 @@
|
||||
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||
"@rhwp/editor": "^0.7.18",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"draco3d": "^1.5.7",
|
||||
"element-plus": "^2.14.3",
|
||||
"fflate": "^0.8.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mermaid": "^11.16.0",
|
||||
"occt-import-js": "^0.0.23",
|
||||
"occt-import-js": "^0.0.22",
|
||||
"online-3d-viewer": "^0.18.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"rhino3dm": "^8.17.0",
|
||||
"three": "^0.172.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.7",
|
||||
"vue-router": "^4.5.0",
|
||||
"web-ifc": "^0.0.77"
|
||||
"web-ifc": "^0.0.68"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -7,78 +7,169 @@ const frontendDir = path.resolve(
|
||||
'..',
|
||||
);
|
||||
const rootDir = path.resolve(frontendDir, '../..');
|
||||
const nodeModules = path.join(rootDir, 'node_modules');
|
||||
const publicDir = path.join(frontendDir, 'public');
|
||||
|
||||
const assets = [
|
||||
function resolvePackage(...parts) {
|
||||
const candidates = [
|
||||
path.join(frontendDir, 'node_modules', ...parts),
|
||||
path.join(rootDir, 'node_modules', ...parts),
|
||||
];
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function copyFirst(sources, destination) {
|
||||
const output = path.join(publicDir, destination);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
let lastError;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
await cp(source, output);
|
||||
return output;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error(`Missing asset for ${destination}`);
|
||||
}
|
||||
|
||||
const cadAssets = [
|
||||
[
|
||||
'@mlightcad/cad-simple-viewer/dist/dxf-parser-worker.js',
|
||||
resolvePackage('@mlightcad/cad-simple-viewer/dist/dxf-parser-worker.js'),
|
||||
'assets/dxf-parser-worker.js',
|
||||
],
|
||||
[
|
||||
'@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js',
|
||||
resolvePackage(
|
||||
'@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js',
|
||||
),
|
||||
'assets/libredwg-parser-worker.js',
|
||||
],
|
||||
[
|
||||
'@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js',
|
||||
resolvePackage(
|
||||
'@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js',
|
||||
),
|
||||
'assets/mtext-renderer-worker.js',
|
||||
],
|
||||
[
|
||||
'occt-import-js/dist/occt-import-js.wasm',
|
||||
'viewer-assets/wasm/occt-import-js.wasm',
|
||||
],
|
||||
['web-ifc/web-ifc.wasm', 'viewer-assets/wasm/web-ifc.wasm'],
|
||||
[
|
||||
'@noto-pdf-ts/fonts-kr/dist/fonts/NotoSansKR-VF.ttf',
|
||||
resolvePackage('@noto-pdf-ts/fonts-kr/dist/fonts/NotoSansKR-VF.ttf'),
|
||||
'viewer-assets/cad-resources/fonts/NotoSansKR-VF.ttf',
|
||||
],
|
||||
[
|
||||
'@fontsource-variable/noto-sans-kr/LICENSE',
|
||||
resolvePackage('@fontsource-variable/noto-sans-kr/LICENSE'),
|
||||
'viewer-assets/cad-resources/fonts/OFL-1.1.txt',
|
||||
],
|
||||
];
|
||||
|
||||
const o3dvAssets = [
|
||||
[
|
||||
resolvePackage('rhino3dm/rhino3dm.min.js'),
|
||||
'viewer-assets/o3dv/rhino3dm.min.js',
|
||||
],
|
||||
[
|
||||
resolvePackage('rhino3dm/rhino3dm.wasm'),
|
||||
'viewer-assets/o3dv/rhino3dm.wasm',
|
||||
],
|
||||
[
|
||||
resolvePackage('web-ifc/web-ifc-api-iife.js'),
|
||||
'viewer-assets/o3dv/web-ifc-api-iife.js',
|
||||
],
|
||||
[resolvePackage('web-ifc/web-ifc.wasm'), 'viewer-assets/o3dv/web-ifc.wasm'],
|
||||
[
|
||||
resolvePackage('draco3d/draco_decoder_nodejs.js'),
|
||||
'viewer-assets/o3dv/draco_decoder_nodejs.min.js',
|
||||
],
|
||||
[
|
||||
resolvePackage('occt-import-js/dist/occt-import-js.js'),
|
||||
'viewer-assets/o3dv/occt-import-js.js',
|
||||
],
|
||||
[
|
||||
resolvePackage('occt-import-js/dist/occt-import-js-worker.js'),
|
||||
'viewer-assets/o3dv/occt-import-js-worker.js',
|
||||
],
|
||||
[
|
||||
resolvePackage('occt-import-js/dist/occt-import-js.wasm'),
|
||||
'viewer-assets/o3dv/occt-import-js.wasm',
|
||||
],
|
||||
[
|
||||
resolvePackage('occt-import-js/dist/license.occt-import-js.txt'),
|
||||
'viewer-assets/o3dv/licenses/license.occt-import-js.txt',
|
||||
],
|
||||
[
|
||||
resolvePackage('occt-import-js/dist/license.occt.txt'),
|
||||
'viewer-assets/o3dv/licenses/license.occt.txt',
|
||||
],
|
||||
[
|
||||
resolvePackage('online-3d-viewer/LICENSE.md'),
|
||||
'viewer-assets/o3dv/licenses/online-3d-viewer.LICENSE.md',
|
||||
],
|
||||
[
|
||||
resolvePackage('rhino3dm/README.md'),
|
||||
'viewer-assets/o3dv/licenses/rhino3dm.README.md',
|
||||
],
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
assets.map(async ([source, destination]) => {
|
||||
const output = path.join(publicDir, destination);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
await cp(path.join(nodeModules, source), output);
|
||||
}),
|
||||
[...cadAssets, ...o3dvAssets].map(([sources, destination]) =>
|
||||
copyFirst(sources, destination),
|
||||
),
|
||||
);
|
||||
|
||||
const envmapCandidates = resolvePackage(
|
||||
'online-3d-viewer/website/assets/envmaps/fishermans_bastion',
|
||||
);
|
||||
const envmapDest = path.join(
|
||||
publicDir,
|
||||
'viewer-assets/o3dv/envmaps/fishermans_bastion',
|
||||
);
|
||||
await mkdir(path.dirname(envmapDest), { recursive: true });
|
||||
{
|
||||
let copied = false;
|
||||
for (const candidate of envmapCandidates) {
|
||||
try {
|
||||
await cp(candidate, envmapDest, { recursive: true });
|
||||
copied = true;
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
if (!copied) {
|
||||
throw new Error('Missing Online3DViewer envmap assets');
|
||||
}
|
||||
}
|
||||
|
||||
const resources = path.join(publicDir, 'viewer-assets', 'cad-resources');
|
||||
await mkdir(path.join(resources, 'fonts'), { recursive: true });
|
||||
await mkdir(path.join(resources, 'templates'), { recursive: true });
|
||||
const fontManifest = `${JSON.stringify(
|
||||
[
|
||||
{
|
||||
name: [
|
||||
'Noto Sans KR',
|
||||
'arial',
|
||||
'batang',
|
||||
'dotum',
|
||||
'ghs',
|
||||
'gulim',
|
||||
'hztxt',
|
||||
'isocpeur',
|
||||
'malgun',
|
||||
'malgun gothic',
|
||||
'pmingliu',
|
||||
'romans',
|
||||
'sanss___',
|
||||
'simplex',
|
||||
'swisscbo',
|
||||
'whgtxt',
|
||||
'whs',
|
||||
],
|
||||
file: 'NotoSansKR-VF.ttf',
|
||||
type: 'mesh',
|
||||
description: 'Open-source Korean CAD fallback (SIL OFL 1.1)',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
[
|
||||
{
|
||||
name: [
|
||||
'Noto Sans KR',
|
||||
'arial',
|
||||
'batang',
|
||||
'dotum',
|
||||
'ghs',
|
||||
'gulim',
|
||||
'hztxt',
|
||||
'isocpeur',
|
||||
'malgun',
|
||||
'malgun gothic',
|
||||
'pmingliu',
|
||||
'romans',
|
||||
'sanss___',
|
||||
'simplex',
|
||||
'swisscbo',
|
||||
'whgtxt',
|
||||
'whs',
|
||||
],
|
||||
file: 'NotoSansKR-VF.ttf',
|
||||
type: 'mesh',
|
||||
description: 'Open-source Korean CAD fallback (SIL OFL 1.1)',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
await Promise.all([
|
||||
writeFile(path.join(resources, 'fonts.json'), fontManifest),
|
||||
writeFile(path.join(resources, 'fonts', 'fonts.json'), fontManifest),
|
||||
@@ -92,3 +183,58 @@ await writeFile(
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
path.join(publicDir, 'viewer-assets/o3dv/README.txt'),
|
||||
[
|
||||
'Self-hosted Online3DViewer external libraries.',
|
||||
'CDN URLs in online-3d-viewer are rewritten to /viewer-assets/o3dv/.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const O3DV_BASE = '/viewer-assets/o3dv/';
|
||||
const replacements = [
|
||||
[
|
||||
'https://cdn.jsdelivr.net/npm/occt-import-js@0.0.22/dist/',
|
||||
O3DV_BASE,
|
||||
],
|
||||
[
|
||||
'https://cdn.jsdelivr.net/npm/rhino3dm@8.17.0/rhino3dm.min.js',
|
||||
`${O3DV_BASE}rhino3dm.min.js`,
|
||||
],
|
||||
[
|
||||
'https://cdn.jsdelivr.net/npm/web-ifc@0.0.68/web-ifc-api-iife.js',
|
||||
`${O3DV_BASE}web-ifc-api-iife.js`,
|
||||
],
|
||||
[
|
||||
'https://cdn.jsdelivr.net/npm/draco3d@1.5.7/draco_decoder_nodejs.min.js',
|
||||
`${O3DV_BASE}draco_decoder_nodejs.min.js`,
|
||||
],
|
||||
];
|
||||
|
||||
async function patchO3dvFile(relativePath) {
|
||||
for (const candidate of resolvePackage(relativePath)) {
|
||||
try {
|
||||
let source = await readFile(candidate, 'utf8');
|
||||
let changed = false;
|
||||
for (const [from, to] of replacements) {
|
||||
if (source.includes(from)) {
|
||||
source = source.split(from).join(to);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await writeFile(candidate, source);
|
||||
console.log(`patched ${candidate}`);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// try next path
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not patch ${relativePath}`);
|
||||
}
|
||||
|
||||
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.module.js');
|
||||
await patchO3dvFile('online-3d-viewer/build/engine/o3dv.min.js');
|
||||
@@ -1,167 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Box3,
|
||||
Clock,
|
||||
Color,
|
||||
DirectionalLight,
|
||||
GridHelper,
|
||||
Group,
|
||||
HemisphereLight,
|
||||
MathUtils,
|
||||
Object3D,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
AnimationMixer,
|
||||
AxesHelper,
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
Line,
|
||||
LineBasicMaterial,
|
||||
Mesh,
|
||||
Material,
|
||||
Texture,
|
||||
MeshBasicMaterial,
|
||||
Object3D,
|
||||
SphereGeometry,
|
||||
Vector3,
|
||||
} from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import {
|
||||
nextTick,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { loadModelFiles } from '@/integrations/model3d/loadModel';
|
||||
import type { SystemTheme } from '@/composables/useSystemTheme';
|
||||
import {
|
||||
importErrorMessage,
|
||||
O3DV_ENV_MAP,
|
||||
OV,
|
||||
summarizeModel,
|
||||
treeFromModel,
|
||||
} from '@/integrations/model3d/o3dv';
|
||||
import type {
|
||||
ModelPropertyGroup,
|
||||
ModelStats,
|
||||
ModelTreeNode,
|
||||
} from '@/integrations/model3d/types';
|
||||
import type { SystemTheme } from '@/composables/useSystemTheme';
|
||||
import {
|
||||
meshInstancePropertyGroups,
|
||||
modelPropertyGroups,
|
||||
} from '@/integrations/model3d/o3dv';
|
||||
|
||||
const props = defineProps<{
|
||||
files: readonly File[];
|
||||
theme: SystemTheme;
|
||||
edges: boolean;
|
||||
projection: 'perspective' | 'orthographic';
|
||||
upAxis: 'y' | 'z';
|
||||
measureMode: boolean;
|
||||
hiddenKeys: ReadonlySet<string>;
|
||||
hiddenVersion: number;
|
||||
selectedKey: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
progress: [value: number, label: string];
|
||||
loaded: [stats: ModelStats, tree: ModelTreeNode];
|
||||
loaded: [
|
||||
stats: ModelStats,
|
||||
tree: ModelTreeNode,
|
||||
modelGroups: ModelPropertyGroup[],
|
||||
missingFiles: string[],
|
||||
];
|
||||
error: [message: string];
|
||||
selectMainFile: [fileNames: string[], select: (index: number) => void];
|
||||
select: [key: string | null, groups: ModelPropertyGroup[]];
|
||||
measure: [distance: number | null];
|
||||
}>();
|
||||
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera(45, 1, 0.01, 100000);
|
||||
const content = new Group();
|
||||
const clock = new Clock();
|
||||
let renderer: WebGLRenderer | null = null;
|
||||
let controls: OrbitControls | null = null;
|
||||
|
||||
let viewer: OV.Viewer | null = null;
|
||||
let loader: OV.ThreeModelLoader | null = null;
|
||||
let model: OV.Model | null = null;
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let animationFrame = 0;
|
||||
let mixer: AnimationMixer | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
let loadSequence = 0;
|
||||
|
||||
scene.add(content);
|
||||
scene.add(new HemisphereLight(0xffffff, 0x334155, 2.1));
|
||||
const keyLight = new DirectionalLight(0xffffff, 2.4);
|
||||
keyLight.position.set(6, 10, 8);
|
||||
scene.add(keyLight);
|
||||
scene.add(new GridHelper(20, 20, 0x7a8794, 0xb8c0c8));
|
||||
scene.add(new AxesHelper(2));
|
||||
|
||||
function disposeMaterial(material: Material) {
|
||||
for (const value of Object.values(material)) {
|
||||
if (value instanceof Texture) value.dispose();
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObject(object: Object3D) {
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof Mesh)) return;
|
||||
child.geometry.dispose();
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
materials.forEach(disposeMaterial);
|
||||
});
|
||||
}
|
||||
|
||||
function clearContent() {
|
||||
mixer?.stopAllAction();
|
||||
mixer = null;
|
||||
for (const child of [...content.children]) {
|
||||
content.remove(child);
|
||||
disposeObject(child);
|
||||
}
|
||||
}
|
||||
let measurePoints: Vector3[] = [];
|
||||
let measureObjects: Object3D[] = [];
|
||||
|
||||
function resize() {
|
||||
if (!renderer || !host.value) return;
|
||||
const width = Math.max(1, host.value.clientWidth);
|
||||
const height = Math.max(1, host.value.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
if (!viewer || !host.value) return;
|
||||
viewer.Resize(
|
||||
Math.max(1, host.value.clientWidth),
|
||||
Math.max(1, host.value.clientHeight),
|
||||
);
|
||||
}
|
||||
|
||||
function applyTheme(theme: SystemTheme) {
|
||||
if (!viewer) return;
|
||||
if (theme === 'dark') {
|
||||
viewer.SetBackgroundColor(new OV.RGBAColor(22, 26, 29, 255));
|
||||
} else {
|
||||
viewer.SetBackgroundColor(new OV.RGBAColor(242, 242, 242, 255));
|
||||
}
|
||||
}
|
||||
|
||||
function applyEnvironment() {
|
||||
if (!viewer) return;
|
||||
viewer.SetEnvironmentMapSettings(
|
||||
new OV.EnvironmentSettings([...O3DV_ENV_MAP], false),
|
||||
);
|
||||
}
|
||||
|
||||
function applyEdges(enabled: boolean) {
|
||||
if (!viewer) return;
|
||||
viewer.SetEdgeSettings(
|
||||
new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15),
|
||||
);
|
||||
}
|
||||
|
||||
function applyProjection(mode: 'perspective' | 'orthographic') {
|
||||
if (!viewer) return;
|
||||
viewer.SetProjectionMode(
|
||||
mode === 'orthographic'
|
||||
? OV.ProjectionMode.Orthographic
|
||||
: OV.ProjectionMode.Perspective,
|
||||
);
|
||||
}
|
||||
|
||||
function applyUp(axis: 'y' | 'z') {
|
||||
if (!viewer) return;
|
||||
viewer.SetUpVector(axis === 'z' ? OV.Direction.Z : OV.Direction.Y, true);
|
||||
}
|
||||
|
||||
function fitCamera() {
|
||||
if (!controls || !content.children.length) return;
|
||||
const box = new Box3().setFromObject(content);
|
||||
if (box.isEmpty()) return;
|
||||
const center = box.getCenter(new Vector3());
|
||||
const size = box.getSize(new Vector3());
|
||||
const maxSize = Math.max(size.x, size.y, size.z, 0.01);
|
||||
const distance =
|
||||
maxSize /
|
||||
(2 * Math.tan(MathUtils.degToRad(camera.fov * 0.5)));
|
||||
const direction = new Vector3(1, 0.75, 1).normalize();
|
||||
camera.position.copy(center).addScaledVector(direction, distance * 1.65);
|
||||
camera.near = Math.max(maxSize / 10000, 0.001);
|
||||
camera.far = Math.max(maxSize * 1000, 1000);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.copy(center);
|
||||
controls.update();
|
||||
if (!viewer || !model) return;
|
||||
const sphere = viewer.GetBoundingSphere(() => true);
|
||||
if (!sphere) return;
|
||||
viewer.AdjustClippingPlanesToSphere(sphere);
|
||||
viewer.FitSphereToWindow(sphere, true);
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
animationFrame = requestAnimationFrame(renderLoop);
|
||||
mixer?.update(clock.getDelta());
|
||||
controls?.update();
|
||||
if (renderer) renderer.render(scene, camera);
|
||||
function clearMeasure() {
|
||||
measurePoints = [];
|
||||
measureObjects = [];
|
||||
viewer?.ClearExtra();
|
||||
emit('measure', null);
|
||||
}
|
||||
|
||||
function addMeasureMarker(point: Vector3, color: number) {
|
||||
if (!viewer) return;
|
||||
const geometry = new SphereGeometry(Math.max(point.length() * 0.008, 0.01), 16, 16);
|
||||
const material = new MeshBasicMaterial({ color });
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.position.copy(point);
|
||||
viewer.AddExtraObject(mesh);
|
||||
measureObjects.push(mesh);
|
||||
}
|
||||
|
||||
function addMeasureLine(a: Vector3, b: Vector3) {
|
||||
if (!viewer) return;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new Float32BufferAttribute([a.x, a.y, a.z, b.x, b.y, b.z], 3),
|
||||
);
|
||||
const line = new Line(
|
||||
geometry,
|
||||
new LineBasicMaterial({ color: 0xe53935 }),
|
||||
);
|
||||
viewer.AddExtraObject(line);
|
||||
measureObjects.push(line);
|
||||
}
|
||||
|
||||
function applyVisibility() {
|
||||
if (!viewer) return;
|
||||
viewer.SetMeshesVisibility((userData: {
|
||||
originalMeshInstance?: OV.MeshInstance;
|
||||
}) => {
|
||||
const key = userData.originalMeshInstance?.GetId()?.GetKey();
|
||||
if (!key) return true;
|
||||
return !props.hiddenKeys.has(key);
|
||||
});
|
||||
}
|
||||
|
||||
function applyHighlight() {
|
||||
if (!viewer) return;
|
||||
viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: {
|
||||
originalMeshInstance?: OV.MeshInstance;
|
||||
}) => {
|
||||
const key = userData.originalMeshInstance?.GetId()?.GetKey();
|
||||
return Boolean(key && key === props.selectedKey);
|
||||
});
|
||||
}
|
||||
|
||||
function selectByKey(key: string | null) {
|
||||
if (!model) {
|
||||
emit('select', null, []);
|
||||
return;
|
||||
}
|
||||
const groups = key
|
||||
? meshInstancePropertyGroups(model, key)
|
||||
: modelPropertyGroups(model);
|
||||
emit('select', key, groups);
|
||||
}
|
||||
|
||||
function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
||||
if (!viewer || !model) return;
|
||||
|
||||
if (props.measureMode) {
|
||||
const hit = viewer.GetMeshIntersectionUnderMouse(
|
||||
OV.IntersectionMode.MeshOnly,
|
||||
mouseCoords,
|
||||
);
|
||||
if (!hit?.point) return;
|
||||
const point = new Vector3(hit.point.x, hit.point.y, hit.point.z);
|
||||
if (measurePoints.length >= 2) clearMeasure();
|
||||
measurePoints.push(point);
|
||||
addMeasureMarker(point, measurePoints.length === 1 ? 0x1e88e5 : 0xe53935);
|
||||
if (measurePoints.length === 2) {
|
||||
addMeasureLine(measurePoints[0], measurePoints[1]);
|
||||
emit(
|
||||
'measure',
|
||||
OV.CoordDistance3D(
|
||||
new OV.Coord3D(
|
||||
measurePoints[0].x,
|
||||
measurePoints[0].y,
|
||||
measurePoints[0].z,
|
||||
),
|
||||
new OV.Coord3D(
|
||||
measurePoints[1].x,
|
||||
measurePoints[1].y,
|
||||
measurePoints[1].z,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = viewer.GetMeshUserDataUnderMouse(
|
||||
OV.IntersectionMode.MeshOnly,
|
||||
mouseCoords,
|
||||
);
|
||||
const key = userData?.originalMeshInstance?.GetId()?.GetKey() ?? null;
|
||||
selectByKey(key);
|
||||
}
|
||||
|
||||
async function load(files: readonly File[]) {
|
||||
const sequence = ++loadSequence;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
clearContent();
|
||||
if (!viewer || !loader) return;
|
||||
clearMeasure();
|
||||
model = null;
|
||||
viewer.Clear();
|
||||
if (!files.length) return;
|
||||
|
||||
try {
|
||||
const loaded = await loadModelFiles(
|
||||
files,
|
||||
(value, label) => emit('progress', value, label),
|
||||
abortController.signal,
|
||||
);
|
||||
if (sequence !== loadSequence) {
|
||||
disposeObject(loaded.object);
|
||||
return;
|
||||
}
|
||||
content.add(loaded.object);
|
||||
if (loaded.animations.length) {
|
||||
mixer = new AnimationMixer(loaded.object);
|
||||
loaded.animations.forEach((clip) => mixer?.clipAction(clip).play());
|
||||
}
|
||||
await nextTick();
|
||||
fitCamera();
|
||||
emit('loaded', loaded.stats, loaded.tree);
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === 'AbortError') return;
|
||||
emit(
|
||||
'error',
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '3D 모델을 열지 못했습니다.',
|
||||
);
|
||||
}
|
||||
const inputFiles = OV.InputFilesFromFileObjects([...files]);
|
||||
const settings = new OV.ImportSettings();
|
||||
|
||||
loader.LoadModel(inputFiles, settings, {
|
||||
onLoadStart: () => emit('progress', 0.05, '모델 준비 중'),
|
||||
onFileListProgress: (current: number, total: number) => {
|
||||
emit('progress', total ? current / total * 0.35 : 0.2, `파일 ${current}/${total}`);
|
||||
},
|
||||
onFileLoadProgress: (current: number, total: number) => {
|
||||
emit(
|
||||
'progress',
|
||||
0.35 + (total ? (current / total) * 0.25 : 0.1),
|
||||
`읽는 중 ${current}/${total}`,
|
||||
);
|
||||
},
|
||||
onImportStart: () => emit('progress', 0.65, '가져오는 중'),
|
||||
onSelectMainFile: (fileNames: string[], selectFile: (index: number) => void) => {
|
||||
emit('selectMainFile', fileNames, selectFile);
|
||||
},
|
||||
onVisualizationStart: () => emit('progress', 0.8, '시각화 중'),
|
||||
onTextureLoaded: () => viewer?.Render(),
|
||||
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
|
||||
if (sequence !== loadSequence) return;
|
||||
viewer?.SetMainObject(threeObject);
|
||||
model = importResult.model;
|
||||
applyVisibility();
|
||||
applyHighlight();
|
||||
applyEdges(props.edges);
|
||||
applyProjection(props.projection);
|
||||
applyUp(props.upAxis);
|
||||
void nextTick(() => {
|
||||
fitCamera();
|
||||
emit(
|
||||
'loaded',
|
||||
summarizeModel(importResult.model),
|
||||
treeFromModel(importResult.model),
|
||||
modelPropertyGroups(importResult.model),
|
||||
importResult.missingFiles ?? [],
|
||||
);
|
||||
emit('progress', 1, '모델 준비 완료');
|
||||
selectByKey(null);
|
||||
});
|
||||
},
|
||||
onLoadError: (importError: OV.ImportError) => {
|
||||
if (sequence !== loadSequence) return;
|
||||
emit('error', importErrorMessage(importError));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function screenshot() {
|
||||
if (!viewer || !host.value) return null;
|
||||
return viewer.GetImageAsDataUrl(
|
||||
Math.max(1, host.value.clientWidth * 2),
|
||||
Math.max(1, host.value.clientHeight * 2),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -173,43 +312,89 @@ watch(
|
||||
|
||||
watch(
|
||||
() => props.theme,
|
||||
(theme) => {
|
||||
scene.background = new Color(theme === 'dark' ? 0x161a1d : 0xf2f2f2);
|
||||
(theme) => applyTheme(theme),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.edges,
|
||||
(value) => applyEdges(value),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.projection,
|
||||
(value) => applyProjection(value),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.upAxis,
|
||||
(value) => applyUp(value),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.measureMode,
|
||||
(enabled) => {
|
||||
if (!enabled) clearMeasure();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.hiddenVersion,
|
||||
() => applyVisibility(),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selectedKey,
|
||||
() => applyHighlight(),
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value) return;
|
||||
renderer = new WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = 'srgb';
|
||||
host.value.append(renderer.domElement);
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
host.value.append(canvas);
|
||||
|
||||
viewer = new OV.Viewer();
|
||||
viewer.Init(canvas);
|
||||
loader = new OV.ThreeModelLoader();
|
||||
applyTheme(props.theme);
|
||||
applyEnvironment();
|
||||
applyEdges(props.edges);
|
||||
applyProjection(props.projection);
|
||||
viewer.SetMouseClickHandler(onMouseClick);
|
||||
|
||||
resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(host.value);
|
||||
resize();
|
||||
renderLoop();
|
||||
void load(props.files);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
resize();
|
||||
viewer?.Render();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
abortController?.abort();
|
||||
cancelAnimationFrame(animationFrame);
|
||||
resizeObserver?.disconnect();
|
||||
clearContent();
|
||||
controls?.dispose();
|
||||
renderer?.dispose();
|
||||
renderer?.forceContextLoss();
|
||||
renderer?.domElement.remove();
|
||||
renderer = null;
|
||||
controls = null;
|
||||
clearMeasure();
|
||||
loader?.Destroy();
|
||||
viewer?.Destroy();
|
||||
canvas?.remove();
|
||||
loader = null;
|
||||
viewer = null;
|
||||
canvas = null;
|
||||
model = null;
|
||||
});
|
||||
|
||||
defineExpose({ fitCamera });
|
||||
defineExpose({
|
||||
fitCamera,
|
||||
screenshot,
|
||||
getModel,
|
||||
clearMeasure,
|
||||
selectByKey,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,25 +3,62 @@ import type { ModelTreeNode } from '@/integrations/model3d/types';
|
||||
|
||||
defineOptions({ name: 'ModelTreeItem' });
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
node: ModelTreeNode;
|
||||
depth?: number;
|
||||
selectedKey?: string | null;
|
||||
hiddenKeys?: ReadonlySet<string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [key: string | null];
|
||||
toggleVisible: [key: string];
|
||||
}>();
|
||||
|
||||
function isHidden(node: ModelTreeNode) {
|
||||
if (!node.meshInstanceKey || !props.hiddenKeys) return false;
|
||||
return props.hiddenKeys.has(node.meshInstanceKey);
|
||||
}
|
||||
|
||||
function onSelect(node: ModelTreeNode) {
|
||||
if (node.meshInstanceKey) emit('select', node.meshInstanceKey);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="tree-item">
|
||||
<details :open="(depth ?? 0) < 1 && node.children.length > 0">
|
||||
<summary :class="{ 'tree-leaf': !node.children.length }">
|
||||
<summary
|
||||
:class="{
|
||||
'tree-leaf': !node.children.length,
|
||||
'tree-selected':
|
||||
node.meshInstanceKey && node.meshInstanceKey === selectedKey,
|
||||
'tree-hidden': isHidden(node),
|
||||
}"
|
||||
@click.stop="onSelect(node)"
|
||||
>
|
||||
<button
|
||||
v-if="node.meshInstanceKey"
|
||||
type="button"
|
||||
class="vis"
|
||||
:title="isHidden(node) ? '표시' : '숨기기'"
|
||||
@click.stop="emit('toggleVisible', node.meshInstanceKey!)"
|
||||
>
|
||||
{{ isHidden(node) ? '○' : '●' }}
|
||||
</button>
|
||||
<span>{{ node.name }}</span>
|
||||
<small>{{ node.type }}</small>
|
||||
</summary>
|
||||
<ul v-if="node.children.length">
|
||||
<ModelTreeItem
|
||||
v-for="(child, index) in node.children"
|
||||
:key="`${child.name}-${index}`"
|
||||
v-for="child in node.children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:depth="(depth ?? 0) + 1"
|
||||
:selected-key="selectedKey"
|
||||
:hidden-keys="hiddenKeys"
|
||||
@select="emit('select', $event)"
|
||||
@toggle-visible="emit('toggleVisible', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</details>
|
||||
@@ -63,8 +100,26 @@ summary small {
|
||||
}
|
||||
|
||||
.tree-leaf {
|
||||
padding-left: 0.9rem;
|
||||
cursor: default;
|
||||
list-style: none;
|
||||
padding-left: 0.15rem;
|
||||
}
|
||||
|
||||
.tree-selected span {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-hidden span {
|
||||
opacity: 0.45;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.vis {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -16,15 +16,39 @@ const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||
xml: "drawio",
|
||||
};
|
||||
|
||||
/** Online3DViewer import formats (+ zip container). */
|
||||
export const MODEL3D_EXTENSIONS = [
|
||||
"3dm",
|
||||
"3ds",
|
||||
"3mf",
|
||||
"amf",
|
||||
"bim",
|
||||
"brp",
|
||||
"brep",
|
||||
"dae",
|
||||
"fbx",
|
||||
"fcstd",
|
||||
"gltf",
|
||||
"glb",
|
||||
"ifc",
|
||||
"igs",
|
||||
"iges",
|
||||
"stp",
|
||||
"step",
|
||||
"stl",
|
||||
"obj",
|
||||
"off",
|
||||
"ply",
|
||||
"wrl",
|
||||
"zip",
|
||||
] as const;
|
||||
|
||||
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
||||
dxf: "cad",
|
||||
dwg: "cad",
|
||||
glb: "model3d",
|
||||
gltf: "model3d",
|
||||
obj: "model3d",
|
||||
step: "model3d",
|
||||
stp: "model3d",
|
||||
ifc: "model3d",
|
||||
...Object.fromEntries(
|
||||
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
||||
),
|
||||
};
|
||||
|
||||
/** HWP/HWPX — client-side rhwp */
|
||||
|
||||
@@ -82,12 +82,29 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
||||
xml: 'diagram',
|
||||
dxf: 'cad',
|
||||
dwg: 'cad',
|
||||
'3dm': 'model3d',
|
||||
'3ds': 'model3d',
|
||||
'3mf': 'model3d',
|
||||
amf: 'model3d',
|
||||
bim: 'model3d',
|
||||
brp: 'model3d',
|
||||
brep: 'model3d',
|
||||
dae: 'model3d',
|
||||
fbx: 'model3d',
|
||||
fcstd: 'model3d',
|
||||
glb: 'model3d',
|
||||
gltf: 'model3d',
|
||||
obj: 'model3d',
|
||||
step: 'model3d',
|
||||
stp: 'model3d',
|
||||
ifc: 'model3d',
|
||||
igs: 'model3d',
|
||||
iges: 'model3d',
|
||||
stp: 'model3d',
|
||||
step: 'model3d',
|
||||
stl: 'model3d',
|
||||
obj: 'model3d',
|
||||
off: 'model3d',
|
||||
ply: 'model3d',
|
||||
wrl: 'model3d',
|
||||
zip: 'model3d',
|
||||
txt: 'text',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
import {
|
||||
AnimationClip,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Color,
|
||||
Group,
|
||||
LoadingManager,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
} from 'three';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js';
|
||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';
|
||||
import { extensionOf } from '@/config/app';
|
||||
import type {
|
||||
LoadedModel,
|
||||
ModelStats,
|
||||
ModelTreeNode,
|
||||
WorkerModelResult,
|
||||
} from './types';
|
||||
|
||||
export type ModelProgress = (value: number, label: string) => void;
|
||||
|
||||
const MODEL_EXTENSIONS = new Set([
|
||||
'glb',
|
||||
'gltf',
|
||||
'obj',
|
||||
'step',
|
||||
'stp',
|
||||
'ifc',
|
||||
]);
|
||||
|
||||
function normalizePath(value: string) {
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch {
|
||||
// Keep the original URL when it contains malformed percent escapes.
|
||||
}
|
||||
return value
|
||||
.replace(/[?#].*$/, '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\.?\//, '');
|
||||
}
|
||||
|
||||
function basename(value: string) {
|
||||
return normalizePath(value).split('/').pop() ?? value;
|
||||
}
|
||||
|
||||
class BrowserFileResolver {
|
||||
private readonly exact = new Map<string, File>();
|
||||
private readonly byName = new Map<string, File>();
|
||||
private readonly urls = new Map<File, string>();
|
||||
|
||||
constructor(files: readonly File[]) {
|
||||
for (const file of files) {
|
||||
const relative = normalizePath(file.webkitRelativePath || file.name);
|
||||
this.exact.set(relative.toLowerCase(), file);
|
||||
this.byName.set(file.name.toLowerCase(), file);
|
||||
}
|
||||
}
|
||||
|
||||
find(path: string) {
|
||||
const normalized = normalizePath(path);
|
||||
return (
|
||||
this.exact.get(normalized.toLowerCase()) ??
|
||||
this.byName.get(basename(normalized).toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
resolve = (url: string) => {
|
||||
if (/^(?:blob:|data:|https?:)/i.test(url)) return url;
|
||||
const file = this.find(url);
|
||||
if (!file) return url;
|
||||
let objectUrl = this.urls.get(file);
|
||||
if (!objectUrl) {
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
this.urls.set(file, objectUrl);
|
||||
}
|
||||
return objectUrl;
|
||||
};
|
||||
|
||||
dispose() {
|
||||
for (const url of this.urls.values()) URL.revokeObjectURL(url);
|
||||
this.urls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function collectUris(value: unknown, result = new Set<string>()) {
|
||||
if (!value || typeof value !== 'object') return result;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => collectUris(item, result));
|
||||
return result;
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === 'uri' && typeof item === 'string' && !item.startsWith('data:')) {
|
||||
result.add(item);
|
||||
} else {
|
||||
collectUris(item, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertResources(
|
||||
references: Iterable<string>,
|
||||
resolver: BrowserFileResolver,
|
||||
) {
|
||||
const missing = [...references].filter((uri) => !resolver.find(uri));
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`보조 파일이 누락되었습니다: ${missing.map(basename).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGltf(
|
||||
primary: File,
|
||||
manager: LoadingManager,
|
||||
resolver: BrowserFileResolver,
|
||||
) {
|
||||
const loader = new GLTFLoader(manager);
|
||||
const extension = extensionOf(primary.name);
|
||||
let data: string | ArrayBuffer;
|
||||
if (extension === 'gltf') {
|
||||
const text = await primary.text();
|
||||
let manifest: unknown;
|
||||
try {
|
||||
manifest = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('glTF JSON 형식이 올바르지 않습니다.');
|
||||
}
|
||||
assertResources(collectUris(manifest), resolver);
|
||||
data = text;
|
||||
} else {
|
||||
data = await primary.arrayBuffer();
|
||||
}
|
||||
const gltf = await loader.parseAsync(data, '');
|
||||
return {
|
||||
object: gltf.scene,
|
||||
animations: gltf.animations,
|
||||
};
|
||||
}
|
||||
|
||||
function mtlTextureReferences(text: string) {
|
||||
const result = new Set<string>();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const match =
|
||||
/^\s*(?:map_(?:ka|kd|ks|ke|ns|d|bump|disp|decal)|bump|norm)\s+(.+?)\s*$/i.exec(
|
||||
line,
|
||||
);
|
||||
if (!match) continue;
|
||||
const tokens = match[1].trim().split(/\s+/);
|
||||
const candidate = tokens[tokens.length - 1];
|
||||
if (candidate) result.add(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadObj(
|
||||
primary: File,
|
||||
files: readonly File[],
|
||||
manager: LoadingManager,
|
||||
resolver: BrowserFileResolver,
|
||||
) {
|
||||
const source = await primary.text();
|
||||
const declaredMtl = [
|
||||
...source.matchAll(/^\s*mtllib\s+(.+?)\s*$/gim),
|
||||
].map((match) => match[1]);
|
||||
if (declaredMtl.length) assertResources(declaredMtl, resolver);
|
||||
|
||||
const mtl =
|
||||
declaredMtl.map((name) => resolver.find(name)).find(Boolean) ??
|
||||
files.find((file) => extensionOf(file.name) === 'mtl');
|
||||
const loader = new OBJLoader(manager);
|
||||
if (mtl) {
|
||||
const materialSource = await mtl.text();
|
||||
assertResources(mtlTextureReferences(materialSource), resolver);
|
||||
const materials = new MTLLoader(manager).parse(materialSource, '');
|
||||
materials.preload();
|
||||
loader.setMaterials(materials);
|
||||
}
|
||||
return {
|
||||
object: loader.parse(source),
|
||||
animations: [] as AnimationClip[],
|
||||
};
|
||||
}
|
||||
|
||||
function parseInWorker(
|
||||
primary: File,
|
||||
format: 'step' | 'ifc',
|
||||
onProgress: ModelProgress,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return new Promise<WorkerModelResult>(async (resolve, reject) => {
|
||||
const worker = new Worker(
|
||||
new URL('../../workers/modelParser.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
const id = Date.now();
|
||||
const abort = () => {
|
||||
worker.terminate();
|
||||
reject(new DOMException('모델 열기가 취소되었습니다.', 'AbortError'));
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
worker.onerror = (event) => {
|
||||
worker.terminate();
|
||||
reject(new Error(event.message || '모델 Worker 실행에 실패했습니다.'));
|
||||
};
|
||||
worker.onmessage = (event) => {
|
||||
if (event.data.id !== id) return;
|
||||
if (event.data.type === 'progress') {
|
||||
onProgress(event.data.value, event.data.label);
|
||||
return;
|
||||
}
|
||||
worker.terminate();
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (event.data.type === 'error') {
|
||||
reject(new Error(event.data.message));
|
||||
} else {
|
||||
resolve(event.data.result as WorkerModelResult);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const buffer = await primary.arrayBuffer();
|
||||
worker.postMessage({ id, format, buffer }, [buffer]);
|
||||
} catch (reason) {
|
||||
worker.terminate();
|
||||
reject(reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function objectFromWorker(result: WorkerModelResult) {
|
||||
const group = new Group();
|
||||
for (const source of result.meshes) {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute('position', new BufferAttribute(source.positions, 3));
|
||||
if (source.normals) {
|
||||
geometry.setAttribute('normal', new BufferAttribute(source.normals, 3));
|
||||
} else {
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
geometry.setIndex(new BufferAttribute(source.indices, 1));
|
||||
geometry.computeBoundingBox();
|
||||
const color = source.color ?? [0.72, 0.75, 0.8, 1];
|
||||
const material = new MeshStandardMaterial({
|
||||
color: new Color(color[0], color[1], color[2]),
|
||||
opacity: color[3] ?? 1,
|
||||
transparent: (color[3] ?? 1) < 1,
|
||||
metalness: 0,
|
||||
roughness: 0.75,
|
||||
});
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.name = source.name;
|
||||
if (source.matrix?.length === 16) {
|
||||
mesh.applyMatrix4(new Matrix4().fromArray(source.matrix));
|
||||
}
|
||||
group.add(mesh);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function summarize(object: Object3D, animations: AnimationClip[]) {
|
||||
const stats: ModelStats = {
|
||||
objects: 0,
|
||||
meshes: 0,
|
||||
vertices: 0,
|
||||
triangles: 0,
|
||||
animations: animations.length,
|
||||
};
|
||||
object.traverse((child) => {
|
||||
stats.objects += 1;
|
||||
if (!(child instanceof Mesh)) return;
|
||||
stats.meshes += 1;
|
||||
const position = child.geometry.getAttribute('position');
|
||||
stats.vertices += position?.count ?? 0;
|
||||
stats.triangles += child.geometry.index
|
||||
? child.geometry.index.count / 3
|
||||
: (position?.count ?? 0) / 3;
|
||||
});
|
||||
return stats;
|
||||
}
|
||||
|
||||
function treeFrom(object: Object3D): ModelTreeNode {
|
||||
return {
|
||||
name: object.name || object.type,
|
||||
type: object.type,
|
||||
children: object.children.map(treeFrom),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadModelFiles(
|
||||
files: readonly File[],
|
||||
onProgress: ModelProgress,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LoadedModel> {
|
||||
const primary = files.find((file) =>
|
||||
MODEL_EXTENSIONS.has(extensionOf(file.name)),
|
||||
);
|
||||
if (!primary) {
|
||||
throw new Error('지원하는 3D 모델 파일을 선택해 주세요.');
|
||||
}
|
||||
|
||||
const resolver = new BrowserFileResolver(files);
|
||||
const manager = new LoadingManager();
|
||||
manager.setURLModifier(resolver.resolve);
|
||||
manager.onProgress = (_url, loaded, total) => {
|
||||
onProgress(total ? loaded / total : 0.5, `리소스 ${loaded}/${total}`);
|
||||
};
|
||||
|
||||
try {
|
||||
onProgress(0.05, `${primary.name} 읽는 중`);
|
||||
const extension = extensionOf(primary.name);
|
||||
let loaded: { object: Object3D; animations: AnimationClip[] };
|
||||
if (extension === 'glb' || extension === 'gltf') {
|
||||
loaded = await loadGltf(primary, manager, resolver);
|
||||
} else if (extension === 'obj') {
|
||||
loaded = await loadObj(primary, files, manager, resolver);
|
||||
} else {
|
||||
const parsed = await parseInWorker(
|
||||
primary,
|
||||
extension === 'ifc' ? 'ifc' : 'step',
|
||||
onProgress,
|
||||
signal,
|
||||
);
|
||||
loaded = { object: objectFromWorker(parsed), animations: [] };
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
onProgress(1, '모델 준비 완료');
|
||||
return {
|
||||
...loaded,
|
||||
stats: summarize(loaded.object, loaded.animations),
|
||||
tree: treeFrom(loaded.object),
|
||||
};
|
||||
} finally {
|
||||
resolver.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { zipSync } from 'fflate';
|
||||
import * as OV from 'online-3d-viewer';
|
||||
import { MODEL3D_EXTENSIONS } from '@/config/app';
|
||||
import { downloadBlob } from '@/utils/downloadBlob';
|
||||
import type {
|
||||
ExportFormatId,
|
||||
ExportOption,
|
||||
ModelPropertyGroup,
|
||||
ModelStats,
|
||||
ModelTreeNode,
|
||||
} from './types';
|
||||
import { EXPORT_OPTIONS } from './types';
|
||||
|
||||
export const O3DV_ENV_MAP = [
|
||||
'/viewer-assets/o3dv/envmaps/fishermans_bastion/posx.jpg',
|
||||
'/viewer-assets/o3dv/envmaps/fishermans_bastion/negx.jpg',
|
||||
'/viewer-assets/o3dv/envmaps/fishermans_bastion/posy.jpg',
|
||||
'/viewer-assets/o3dv/envmaps/fishermans_bastion/negy.jpg',
|
||||
'/viewer-assets/o3dv/envmaps/fishermans_bastion/posz.jpg',
|
||||
'/viewer-assets/o3dv/envmaps/fishermans_bastion/negz.jpg',
|
||||
] as const;
|
||||
|
||||
const PRIMARY_EXTENSIONS = new Set<string>(
|
||||
MODEL3D_EXTENSIONS.filter((ext) => ext !== 'zip'),
|
||||
);
|
||||
|
||||
export function isPrimaryModelFile(name: string) {
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
return PRIMARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function findPrimaryModelFile(files: readonly File[]) {
|
||||
return (
|
||||
files.find((file) => isPrimaryModelFile(file.name)) ??
|
||||
files.find((file) => file.name.toLowerCase().endsWith('.zip')) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeModel(model: OV.Model): ModelStats {
|
||||
return {
|
||||
nodes: model.NodeCount(),
|
||||
meshes: model.MeshCount(),
|
||||
meshInstances: model.MeshInstanceCount(),
|
||||
vertices: model.VertexCount(),
|
||||
triangles: model.TriangleCount(),
|
||||
materials: model.MaterialCount(),
|
||||
};
|
||||
}
|
||||
|
||||
function collectPropertyGroups(object: {
|
||||
PropertyGroupCount(): number;
|
||||
GetPropertyGroup(index: number): OV.PropertyGroup;
|
||||
}): ModelPropertyGroup[] {
|
||||
const groups: ModelPropertyGroup[] = [];
|
||||
for (let i = 0; i < object.PropertyGroupCount(); i += 1) {
|
||||
const group = object.GetPropertyGroup(i);
|
||||
const properties = [];
|
||||
for (let j = 0; j < group.PropertyCount(); j += 1) {
|
||||
const property = group.GetProperty(j);
|
||||
properties.push({
|
||||
name: String(property.name ?? ''),
|
||||
value: String(OV.PropertyToString(property) ?? ''),
|
||||
});
|
||||
}
|
||||
groups.push({
|
||||
name: String(group.name || `속성 ${i + 1}`),
|
||||
properties,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function modelPropertyGroups(model: OV.Model): ModelPropertyGroup[] {
|
||||
return collectPropertyGroups(model);
|
||||
}
|
||||
|
||||
export function meshInstancePropertyGroups(
|
||||
model: OV.Model,
|
||||
key: string,
|
||||
): ModelPropertyGroup[] {
|
||||
const [nodeIdText, meshIndexText] = key.split(':');
|
||||
const nodeId = Number(nodeIdText);
|
||||
const meshIndex = Number(meshIndexText);
|
||||
if (!Number.isFinite(nodeId) || !Number.isFinite(meshIndex)) return [];
|
||||
const instance = model.GetMeshInstance(
|
||||
new OV.MeshInstanceId(nodeId, meshIndex),
|
||||
);
|
||||
if (!instance) return [];
|
||||
const mesh = instance.GetMesh();
|
||||
return [
|
||||
...collectPropertyGroups(instance),
|
||||
...collectPropertyGroups(mesh),
|
||||
];
|
||||
}
|
||||
|
||||
export function treeFromModel(model: OV.Model): ModelTreeNode {
|
||||
function fromNode(node: OV.Node): ModelTreeNode {
|
||||
const children: ModelTreeNode[] = node
|
||||
.GetChildNodes()
|
||||
.map((child: OV.Node) => fromNode(child));
|
||||
|
||||
for (let i = 0; i < node.MeshIndexCount(); i += 1) {
|
||||
const meshIndex = node.GetMeshIndex(i);
|
||||
const mesh = model.GetMesh(meshIndex);
|
||||
const key = new OV.MeshInstanceId(node.GetId(), meshIndex).GetKey();
|
||||
children.push({
|
||||
id: key,
|
||||
name: mesh?.GetName?.() || `Mesh ${meshIndex}`,
|
||||
type: 'Mesh',
|
||||
meshInstanceKey: key,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: `node-${node.GetId()}`,
|
||||
name: node.GetName() || `Node ${node.GetId()}`,
|
||||
type: 'Node',
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
return fromNode(model.GetRootNode());
|
||||
}
|
||||
|
||||
export function importErrorMessage(error: OV.ImportError) {
|
||||
if (error.code === OV.ImportErrorCode.NoImportableFile) {
|
||||
return '가져올 수 있는 3D 모델 파일이 없습니다.';
|
||||
}
|
||||
if (error.code === OV.ImportErrorCode.FailedToLoadFile) {
|
||||
return '모델 파일을 읽지 못했습니다.';
|
||||
}
|
||||
if (error.code === OV.ImportErrorCode.ImportFailed) {
|
||||
return error.message
|
||||
? `모델을 가져오지 못했습니다: ${error.message}`
|
||||
: '모델을 가져오지 못했습니다.';
|
||||
}
|
||||
return error.message || '알 수 없는 오류로 모델을 열지 못했습니다.';
|
||||
}
|
||||
|
||||
export function exportOptionById(id: ExportFormatId): ExportOption {
|
||||
const option = EXPORT_OPTIONS.find((item) => item.id === id);
|
||||
if (!option) throw new Error(`알 수 없는 내보내기 형식: ${id}`);
|
||||
return option;
|
||||
}
|
||||
|
||||
export function exportModel(
|
||||
model: OV.Model,
|
||||
option: ExportOption,
|
||||
visibleKeys: ReadonlySet<string> | null,
|
||||
): Promise<OV.ExportedFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const exporter = new OV.Exporter();
|
||||
const settings = new OV.ExporterSettings({});
|
||||
if (visibleKeys) {
|
||||
settings.isMeshVisible = (id: OV.MeshInstanceId) =>
|
||||
visibleKeys.has(id.GetKey());
|
||||
}
|
||||
exporter.Export(
|
||||
model,
|
||||
settings,
|
||||
option.format === 'text' ? OV.FileFormat.Text : OV.FileFormat.Binary,
|
||||
option.extension,
|
||||
{
|
||||
onSuccess: (files: OV.ExportedFile[]) => resolve(files),
|
||||
onError: () => reject(new Error('모델 내보내기에 실패했습니다.')),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadExportedFiles(
|
||||
files: OV.ExportedFile[],
|
||||
baseName: string,
|
||||
option: ExportOption,
|
||||
) {
|
||||
if (files.length === 1) {
|
||||
const file = files[0];
|
||||
const name = file.GetName() || `${baseName}.${option.extension}`;
|
||||
const buffer = file.GetBufferContent() as ArrayBuffer;
|
||||
downloadBlob(
|
||||
new Blob([buffer], {
|
||||
type: 'application/octet-stream',
|
||||
}),
|
||||
name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const archive: Record<string, Uint8Array> = {};
|
||||
for (const file of files) {
|
||||
archive[file.GetName()] = new Uint8Array(file.GetBufferContent());
|
||||
}
|
||||
const zipped = zipSync(archive);
|
||||
downloadBlob(
|
||||
new Blob([Uint8Array.from(zipped)], { type: 'application/zip' }),
|
||||
`${baseName}.${option.extension}.zip`,
|
||||
);
|
||||
}
|
||||
|
||||
export { OV, EXPORT_OPTIONS };
|
||||
@@ -1,35 +1,68 @@
|
||||
import type { AnimationClip, Object3D } from 'three';
|
||||
|
||||
export interface ModelStats {
|
||||
objects: number;
|
||||
nodes: number;
|
||||
meshes: number;
|
||||
meshInstances: number;
|
||||
vertices: number;
|
||||
triangles: number;
|
||||
animations: number;
|
||||
materials: number;
|
||||
}
|
||||
|
||||
export interface ModelPropertyRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ModelPropertyGroup {
|
||||
name: string;
|
||||
properties: ModelPropertyRow[];
|
||||
}
|
||||
|
||||
export interface ModelTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
meshInstanceKey?: string;
|
||||
children: ModelTreeNode[];
|
||||
}
|
||||
|
||||
export interface LoadedModel {
|
||||
object: Object3D;
|
||||
animations: AnimationClip[];
|
||||
stats: ModelStats;
|
||||
tree: ModelTreeNode;
|
||||
export type ExportFormatId =
|
||||
| 'glb'
|
||||
| 'gltf'
|
||||
| 'obj'
|
||||
| 'stl-binary'
|
||||
| 'stl-text'
|
||||
| 'ply-binary'
|
||||
| 'ply-text'
|
||||
| 'off'
|
||||
| '3dm'
|
||||
| 'bim';
|
||||
|
||||
export interface ExportOption {
|
||||
id: ExportFormatId;
|
||||
label: string;
|
||||
extension: string;
|
||||
format: 'text' | 'binary';
|
||||
}
|
||||
|
||||
export interface WorkerMesh {
|
||||
name: string;
|
||||
positions: Float32Array;
|
||||
normals?: Float32Array;
|
||||
indices: Uint32Array;
|
||||
color?: [number, number, number, number?];
|
||||
matrix?: number[];
|
||||
}
|
||||
|
||||
export interface WorkerModelResult {
|
||||
meshes: WorkerMesh[];
|
||||
}
|
||||
export const EXPORT_OPTIONS: ExportOption[] = [
|
||||
{ id: 'glb', label: 'glTF Binary (.glb)', extension: 'glb', format: 'binary' },
|
||||
{ id: 'gltf', label: 'glTF (.gltf)', extension: 'gltf', format: 'text' },
|
||||
{ id: 'obj', label: 'Wavefront OBJ (.obj)', extension: 'obj', format: 'text' },
|
||||
{
|
||||
id: 'stl-binary',
|
||||
label: 'STL Binary (.stl)',
|
||||
extension: 'stl',
|
||||
format: 'binary',
|
||||
},
|
||||
{ id: 'stl-text', label: 'STL Text (.stl)', extension: 'stl', format: 'text' },
|
||||
{
|
||||
id: 'ply-binary',
|
||||
label: 'PLY Binary (.ply)',
|
||||
extension: 'ply',
|
||||
format: 'binary',
|
||||
},
|
||||
{ id: 'ply-text', label: 'PLY Text (.ply)', extension: 'ply', format: 'text' },
|
||||
{ id: 'off', label: 'OFF (.off)', extension: 'off', format: 'text' },
|
||||
{ id: '3dm', label: 'Rhino (.3dm)', extension: '3dm', format: 'binary' },
|
||||
{ id: 'bim', label: 'Dotbim (.bim)', extension: 'bim', format: 'text' },
|
||||
];
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
declare module 'occt-import-js' {
|
||||
interface OcctAttribute {
|
||||
array: number[];
|
||||
}
|
||||
|
||||
interface OcctMesh {
|
||||
name: string;
|
||||
color?: [number, number, number];
|
||||
attributes: {
|
||||
position: OcctAttribute;
|
||||
normal?: OcctAttribute;
|
||||
};
|
||||
index: OcctAttribute;
|
||||
}
|
||||
|
||||
interface OcctResult {
|
||||
success: boolean;
|
||||
meshes: OcctMesh[];
|
||||
}
|
||||
|
||||
interface OcctApi {
|
||||
ReadStepFile(content: Uint8Array, params: unknown): OcctResult;
|
||||
}
|
||||
|
||||
interface OcctOptions {
|
||||
locateFile?: (path: string) => string;
|
||||
}
|
||||
|
||||
export default function createOcct(options?: OcctOptions): Promise<OcctApi>;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ const viewerTypes = [
|
||||
route: 'model3d-viewer',
|
||||
extension: 'glb',
|
||||
title: '3D 모델',
|
||||
subtitle: 'GLB · glTF · OBJ · STEP · IFC',
|
||||
subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -107,7 +107,7 @@ function onOpenTool(route: string) {
|
||||
<input
|
||||
class="drop__input"
|
||||
type="file"
|
||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.glb,.gltf,.obj,.step,.stp,.ifc,.bin,.mtl,.png,.jpg,.jpeg,.webp"
|
||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp"
|
||||
multiple
|
||||
:disabled="busy"
|
||||
@change="onInput"
|
||||
@@ -116,7 +116,7 @@ function onOpenTool(route: string) {
|
||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||
</span>
|
||||
<span class="drop__hint">
|
||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델
|
||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델(ZIP 포함)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -172,8 +172,8 @@ function onOpenTool(route: string) {
|
||||
<section class="section">
|
||||
<h2 class="section__title">CAD·3D 모델</h2>
|
||||
<p class="section__lead">
|
||||
서버 업로드 없이 도면과 모델을 조회합니다. glTF·OBJ의 보조 파일은
|
||||
원본과 함께 선택하세요.
|
||||
서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조
|
||||
파일이나 ZIP은 원본과 함께 선택하세요.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
@@ -21,6 +21,11 @@ const thirdParty = [
|
||||
url: 'https://github.com/mermaid-js/mermaid',
|
||||
},
|
||||
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
|
||||
{
|
||||
name: 'Online 3D Viewer',
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/kovacsv/Online3DViewer',
|
||||
},
|
||||
{
|
||||
name: 'occt-import-js',
|
||||
license: 'LGPL-2.1',
|
||||
@@ -31,6 +36,21 @@ const thirdParty = [
|
||||
license: 'MPL-2.0',
|
||||
url: 'https://github.com/ThatOpen/engine_web-ifc',
|
||||
},
|
||||
{
|
||||
name: 'rhino3dm',
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/mcneel/rhino3dm',
|
||||
},
|
||||
{
|
||||
name: 'Draco',
|
||||
license: 'Apache-2.0',
|
||||
url: 'https://github.com/google/draco',
|
||||
},
|
||||
{
|
||||
name: 'fflate',
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/101arrowz/fflate',
|
||||
},
|
||||
{
|
||||
name: 'Noto Sans KR',
|
||||
license: 'SIL OFL 1.1',
|
||||
@@ -110,12 +130,18 @@ const thirdParty = [
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>3D 모델 파서</h2>
|
||||
<h2>Online 3D Viewer</h2>
|
||||
<p>
|
||||
glTF·OBJ 렌더링은 Three.js(MIT), STEP 변환은
|
||||
occt-import-js(LGPL-2.1)와 OpenCascade, IFC 변환은
|
||||
web-ifc(MPL-2.0)를 사용합니다. 각 WASM 파일은 앱과 함께 자체
|
||||
호스팅됩니다.
|
||||
3D 뷰어 엔진은
|
||||
<a
|
||||
href="https://github.com/kovacsv/Online3DViewer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Online 3D Viewer</a
|
||||
>
|
||||
(MIT)를 사용합니다. Three.js·fflate·Pickr(MIT), Draco(Apache-2.0),
|
||||
rhino3dm(MIT), web-ifc(MPL-2.0), occt-import-js(LGPL-2.1) 로더는
|
||||
CDN 없이 <code>/viewer-assets/o3dv/</code>에 자체 호스팅됩니다.
|
||||
</p>
|
||||
<p class="muted">
|
||||
CAD·3D 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
computed,
|
||||
onActivated,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -10,25 +11,40 @@ import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
|
||||
import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue';
|
||||
import { extensionOf } from '@/config/app';
|
||||
import { MODEL3D_EXTENSIONS } from '@/config/app';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||
import {
|
||||
downloadExportedFiles,
|
||||
exportModel,
|
||||
exportOptionById,
|
||||
findPrimaryModelFile,
|
||||
} from '@/integrations/model3d/o3dv';
|
||||
import type {
|
||||
ExportFormatId,
|
||||
ModelPropertyGroup,
|
||||
ModelStats,
|
||||
ModelTreeNode,
|
||||
} from '@/integrations/model3d/types';
|
||||
import { EXPORT_OPTIONS } from '@/integrations/model3d/types';
|
||||
import { downloadBlob } from '@/utils/downloadBlob';
|
||||
|
||||
defineOptions({ name: 'Model3DViewerView' });
|
||||
|
||||
const PRIMARY_EXTENSIONS = new Set([
|
||||
'glb',
|
||||
'gltf',
|
||||
'obj',
|
||||
'step',
|
||||
'stp',
|
||||
'ifc',
|
||||
]);
|
||||
const ACCEPT = [
|
||||
...MODEL3D_EXTENSIONS.map((ext) => `.${ext}`),
|
||||
'.bin',
|
||||
'.mtl',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.tif',
|
||||
'.tiff',
|
||||
].join(',');
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -42,21 +58,39 @@ const error = ref('');
|
||||
const notice = ref('');
|
||||
const stats = ref<ModelStats | null>(null);
|
||||
const tree = ref<ModelTreeNode | null>(null);
|
||||
const modelGroups = ref<ModelPropertyGroup[]>([]);
|
||||
const selectionGroups = ref<ModelPropertyGroup[]>([]);
|
||||
const selectedKey = ref<string | null>(null);
|
||||
const hiddenKeys = reactive(new Set<string>());
|
||||
const hiddenVersion = ref(0);
|
||||
const edges = ref(false);
|
||||
const projection = ref<'perspective' | 'orthographic'>('perspective');
|
||||
const upAxis = ref<'y' | 'z'>('y');
|
||||
const measureMode = ref(false);
|
||||
const measureDistance = ref<number | null>(null);
|
||||
const exportOpen = ref(false);
|
||||
const exportBusy = ref(false);
|
||||
const mainFilePrompt = ref<{
|
||||
names: string[];
|
||||
select: (index: number) => void;
|
||||
} | null>(null);
|
||||
const theme = useSystemTheme();
|
||||
|
||||
const progressPercent = computed(() =>
|
||||
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
|
||||
);
|
||||
|
||||
const propertyGroups = computed(() =>
|
||||
selectedKey.value ? selectionGroups.value : modelGroups.value,
|
||||
);
|
||||
|
||||
usePageTitle(filename);
|
||||
|
||||
function applyFiles(nextFiles: File[]) {
|
||||
const primary = nextFiles.find((file) =>
|
||||
PRIMARY_EXTENSIONS.has(extensionOf(file.name)),
|
||||
);
|
||||
const primary = findPrimaryModelFile(nextFiles);
|
||||
if (!primary) {
|
||||
error.value =
|
||||
'GLB, glTF, OBJ, STEP 또는 IFC 모델 파일을 선택해 주세요.';
|
||||
'지원하는 3D 모델 파일(또는 ZIP)을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
files.value = [...nextFiles];
|
||||
@@ -67,6 +101,14 @@ function applyFiles(nextFiles: File[]) {
|
||||
error.value = '';
|
||||
stats.value = null;
|
||||
tree.value = null;
|
||||
modelGroups.value = [];
|
||||
selectionGroups.value = [];
|
||||
selectedKey.value = null;
|
||||
hiddenKeys.clear();
|
||||
hiddenVersion.value = 0;
|
||||
measureMode.value = false;
|
||||
measureDistance.value = null;
|
||||
exportOpen.value = false;
|
||||
const totalBytes = nextFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
notice.value =
|
||||
totalBytes > 400 * 1024 * 1024
|
||||
@@ -99,11 +141,20 @@ function onProgress(value: number, label: string) {
|
||||
progressLabel.value = label;
|
||||
}
|
||||
|
||||
function onLoaded(nextStats: ModelStats, nextTree: ModelTreeNode) {
|
||||
function onLoaded(
|
||||
nextStats: ModelStats,
|
||||
nextTree: ModelTreeNode,
|
||||
groups: ModelPropertyGroup[],
|
||||
missingFiles: string[],
|
||||
) {
|
||||
stats.value = nextStats;
|
||||
tree.value = nextTree;
|
||||
modelGroups.value = groups;
|
||||
loading.value = false;
|
||||
progress.value = 1;
|
||||
if (missingFiles.length) {
|
||||
notice.value = `누락된 보조 파일: ${missingFiles.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function onError(message: string) {
|
||||
@@ -111,6 +162,75 @@ function onError(message: string) {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function onSelectMainFile(names: string[], select: (index: number) => void) {
|
||||
mainFilePrompt.value = { names, select };
|
||||
}
|
||||
|
||||
function chooseMainFile(index: number) {
|
||||
mainFilePrompt.value?.select(index);
|
||||
mainFilePrompt.value = null;
|
||||
}
|
||||
|
||||
function onSelect(key: string | null, groups: ModelPropertyGroup[]) {
|
||||
selectedKey.value = key;
|
||||
selectionGroups.value = groups;
|
||||
}
|
||||
|
||||
function onTreeSelect(key: string | null) {
|
||||
canvas.value?.selectByKey(key);
|
||||
}
|
||||
|
||||
function onToggleVisible(key: string) {
|
||||
if (hiddenKeys.has(key)) hiddenKeys.delete(key);
|
||||
else hiddenKeys.add(key);
|
||||
hiddenVersion.value += 1;
|
||||
}
|
||||
|
||||
function onMeasure(distance: number | null) {
|
||||
measureDistance.value = distance;
|
||||
}
|
||||
|
||||
function toggleMeasure() {
|
||||
measureMode.value = !measureMode.value;
|
||||
if (!measureMode.value) {
|
||||
canvas.value?.clearMeasure();
|
||||
measureDistance.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onScreenshot() {
|
||||
const dataUrl = canvas.value?.screenshot();
|
||||
if (!dataUrl) return;
|
||||
const response = await fetch(dataUrl);
|
||||
downloadBlob(await response.blob(), `${baseName()}.png`);
|
||||
}
|
||||
|
||||
function baseName() {
|
||||
return filename.value.replace(/\.[^.]+$/, '') || 'model';
|
||||
}
|
||||
|
||||
async function onExport(id: ExportFormatId) {
|
||||
const model = canvas.value?.getModel();
|
||||
if (!model) return;
|
||||
exportBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const option = exportOptionById(id);
|
||||
const exported = await exportModel(
|
||||
model,
|
||||
option,
|
||||
hiddenKeys.size ? hiddenKeys : null,
|
||||
);
|
||||
await downloadExportedFiles(exported, baseName(), option);
|
||||
exportOpen.value = false;
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error ? reason.message : '내보내기에 실패했습니다.';
|
||||
} finally {
|
||||
exportBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
@@ -121,16 +241,18 @@ function onClose() {
|
||||
<div class="model-page">
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="false"
|
||||
:busy="loading"
|
||||
save-label="내보내기"
|
||||
:show-save="Boolean(stats)"
|
||||
:busy="loading || exportBusy"
|
||||
@open="onOpen"
|
||||
@save="exportOpen = !exportOpen"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".glb,.gltf,.obj,.mtl,.step,.stp,.ifc,.bin,.png,.jpg,.jpeg,.webp"
|
||||
:accept="ACCEPT"
|
||||
multiple
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
@@ -140,6 +262,34 @@ function onClose() {
|
||||
</p>
|
||||
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
|
||||
|
||||
<div v-if="exportOpen" class="export-bar">
|
||||
<span>내보내기 형식</span>
|
||||
<button
|
||||
v-for="option in EXPORT_OPTIONS"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
:disabled="exportBusy"
|
||||
@click="onExport(option.id)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mainFilePrompt" class="main-file-dialog" role="dialog">
|
||||
<div class="main-file-dialog__panel">
|
||||
<h2>주 파일 선택</h2>
|
||||
<p>여러 모델 파일이 있습니다. 열 파일을 고르세요.</p>
|
||||
<button
|
||||
v-for="(name, index) in mainFilePrompt.names"
|
||||
:key="name"
|
||||
type="button"
|
||||
@click="chooseMainFile(index)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-layout">
|
||||
<main class="viewport">
|
||||
<Model3DCanvas
|
||||
@@ -147,25 +297,73 @@ function onClose() {
|
||||
ref="canvas"
|
||||
:files="files"
|
||||
:theme="theme"
|
||||
:edges="edges"
|
||||
:projection="projection"
|
||||
:up-axis="upAxis"
|
||||
:measure-mode="measureMode"
|
||||
:hidden-keys="hiddenKeys"
|
||||
:hidden-version="hiddenVersion"
|
||||
:selected-key="selectedKey"
|
||||
@progress="onProgress"
|
||||
@loaded="onLoaded"
|
||||
@error="onError"
|
||||
@select-main-file="onSelectMainFile"
|
||||
@select="onSelect"
|
||||
@measure="onMeasure"
|
||||
/>
|
||||
<div v-else class="empty-state">
|
||||
<strong>3D 모델을 선택해 주세요.</strong>
|
||||
<span>
|
||||
glTF의 BIN·텍스처나 OBJ의 MTL·텍스처도 함께 선택할 수 있습니다.
|
||||
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열 수
|
||||
있습니다.
|
||||
</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="files.length"
|
||||
type="button"
|
||||
class="reset-view"
|
||||
@click="canvas?.fitCamera()"
|
||||
>
|
||||
뷰 초기화
|
||||
</button>
|
||||
|
||||
<div v-if="files.length" class="viewport-tools">
|
||||
<button type="button" @click="canvas?.fitCamera()">뷰 초기화</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="projection === 'orthographic'"
|
||||
@click="
|
||||
projection =
|
||||
projection === 'perspective' ? 'orthographic' : 'perspective'
|
||||
"
|
||||
>
|
||||
{{ projection === 'perspective' ? '원근' : '직교' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="upAxis = upAxis === 'y' ? 'z' : 'y'"
|
||||
>
|
||||
Up {{ upAxis.toUpperCase() }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="edges"
|
||||
@click="edges = !edges"
|
||||
>
|
||||
에지 {{ edges ? 'ON' : 'OFF' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="measureMode"
|
||||
@click="toggleMeasure"
|
||||
>
|
||||
측정 {{ measureMode ? 'ON' : 'OFF' }}
|
||||
</button>
|
||||
<button type="button" :disabled="!stats" @click="onScreenshot">
|
||||
스크린샷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="measureMode" class="measure-hint">
|
||||
메시 위 두 점을 클릭하세요
|
||||
<template v-if="measureDistance !== null">
|
||||
· 거리 {{ measureDistance.toFixed(4) }}
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="progress" aria-live="polite">
|
||||
<span>{{ progressLabel }}</span>
|
||||
<progress :value="progressPercent" max="100" />
|
||||
@@ -177,21 +375,67 @@ function onClose() {
|
||||
<section>
|
||||
<h2>모델 통계</h2>
|
||||
<dl v-if="stats">
|
||||
<div><dt>객체</dt><dd>{{ stats.objects.toLocaleString() }}</dd></div>
|
||||
<div><dt>메시</dt><dd>{{ stats.meshes.toLocaleString() }}</dd></div>
|
||||
<div><dt>정점</dt><dd>{{ stats.vertices.toLocaleString() }}</dd></div>
|
||||
<div><dt>삼각형</dt><dd>{{ Math.round(stats.triangles).toLocaleString() }}</dd></div>
|
||||
<div><dt>애니메이션</dt><dd>{{ stats.animations }}</dd></div>
|
||||
<div>
|
||||
<dt>노드</dt>
|
||||
<dd>{{ stats.nodes.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>메시</dt>
|
||||
<dd>{{ stats.meshes.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>인스턴스</dt>
|
||||
<dd>{{ stats.meshInstances.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>정점</dt>
|
||||
<dd>{{ stats.vertices.toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>삼각형</dt>
|
||||
<dd>{{ Math.round(stats.triangles).toLocaleString() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>재질</dt>
|
||||
<dd>{{ stats.materials.toLocaleString() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else>모델을 열면 통계를 표시합니다.</p>
|
||||
</section>
|
||||
|
||||
<section class="structure">
|
||||
<h2>구조</h2>
|
||||
<ul v-if="tree">
|
||||
<ModelTreeItem :node="tree" />
|
||||
<ModelTreeItem
|
||||
:node="tree"
|
||||
:selected-key="selectedKey"
|
||||
:hidden-keys="hiddenKeys"
|
||||
@select="onTreeSelect"
|
||||
@toggle-visible="onToggleVisible"
|
||||
/>
|
||||
</ul>
|
||||
<p v-else>모델 구조가 없습니다.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{{ selectedKey ? '선택 속성' : '모델 속성' }}</h2>
|
||||
<template v-if="propertyGroups.length">
|
||||
<div
|
||||
v-for="group in propertyGroups"
|
||||
:key="group.name"
|
||||
class="prop-group"
|
||||
>
|
||||
<h3>{{ group.name }}</h3>
|
||||
<dl>
|
||||
<div v-for="row in group.properties" :key="row.name">
|
||||
<dt>{{ row.name }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>속성 정보가 없습니다.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,6 +444,7 @@ function onClose() {
|
||||
|
||||
<style scoped>
|
||||
.model-page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -210,7 +455,7 @@ function onClose() {
|
||||
|
||||
.viewer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 230px;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -233,13 +478,19 @@ function onClose() {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.inspector h2 {
|
||||
.inspector h2,
|
||||
.inspector h3 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.inspector h3 {
|
||||
margin-top: 0.55rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inspector p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
@@ -266,6 +517,8 @@ function onClose() {
|
||||
.inspector dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.structure {
|
||||
@@ -277,6 +530,10 @@ function onClose() {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prop-group + .prop-group {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
@@ -295,23 +552,54 @@ function onClose() {
|
||||
}
|
||||
|
||||
.empty-state button,
|
||||
.reset-view {
|
||||
border: 1px solid var(--accent);
|
||||
.viewport-tools button,
|
||||
.export-bar button,
|
||||
.main-file-dialog button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.42rem 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.reset-view {
|
||||
.empty-state button {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.viewport-tools {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 2;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
}
|
||||
|
||||
.viewport-tools button[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.measure-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0.75rem;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
color: var(--ink);
|
||||
font-size: 0.75rem;
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@@ -344,6 +632,49 @@ function onClose() {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.export-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.main-file-dialog {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, black 35%, transparent);
|
||||
}
|
||||
|
||||
.main-file-dialog__panel {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
width: min(24rem, calc(100% - 2rem));
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.main-file-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.main-file-dialog p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.75rem;
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import createOcct from 'occt-import-js';
|
||||
import { IfcAPI } from 'web-ifc';
|
||||
import type {
|
||||
WorkerMesh,
|
||||
WorkerModelResult,
|
||||
} from '@/integrations/model3d/types';
|
||||
|
||||
interface ParseRequest {
|
||||
id: number;
|
||||
format: 'step' | 'ifc';
|
||||
buffer: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface ProgressMessage {
|
||||
id: number;
|
||||
type: 'progress';
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ResultMessage {
|
||||
id: number;
|
||||
type: 'result';
|
||||
result: WorkerModelResult;
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
id: number;
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
function progress(
|
||||
id: number,
|
||||
value: number,
|
||||
label: string,
|
||||
): ProgressMessage {
|
||||
return { id, type: 'progress', value, label };
|
||||
}
|
||||
|
||||
async function parseStep(id: number, buffer: ArrayBuffer) {
|
||||
self.postMessage(progress(id, 0.15, 'OpenCascade WASM 초기화 중'));
|
||||
const occt = await createOcct({
|
||||
locateFile: () => '/viewer-assets/wasm/occt-import-js.wasm',
|
||||
});
|
||||
self.postMessage(progress(id, 0.35, 'STEP 형상 분석 중'));
|
||||
const imported = occt.ReadStepFile(new Uint8Array(buffer), {
|
||||
linearUnit: 'meter',
|
||||
});
|
||||
if (!imported.success) {
|
||||
throw new Error('STEP 형상을 해석하지 못했습니다.');
|
||||
}
|
||||
|
||||
const meshes: WorkerMesh[] = imported.meshes.map((mesh, index) => ({
|
||||
name: mesh.name || `STEP Mesh ${index + 1}`,
|
||||
positions: new Float32Array(mesh.attributes.position.array),
|
||||
normals: mesh.attributes.normal
|
||||
? new Float32Array(mesh.attributes.normal.array)
|
||||
: undefined,
|
||||
indices: new Uint32Array(mesh.index.array),
|
||||
color: mesh.color ? [...mesh.color, 1] : undefined,
|
||||
}));
|
||||
self.postMessage(progress(id, 0.9, 'STEP 메시 전송 중'));
|
||||
return meshes;
|
||||
}
|
||||
|
||||
async function parseIfc(id: number, buffer: ArrayBuffer) {
|
||||
self.postMessage(progress(id, 0.1, 'web-ifc WASM 초기화 중'));
|
||||
const api = new IfcAPI();
|
||||
api.SetWasmPath('/viewer-assets/wasm/', true);
|
||||
await api.Init();
|
||||
|
||||
self.postMessage(progress(id, 0.25, 'IFC 모델 분석 중'));
|
||||
const modelId = api.OpenModel(new Uint8Array(buffer), {
|
||||
COORDINATE_TO_ORIGIN: true,
|
||||
CIRCLE_SEGMENTS: 16,
|
||||
MEMORY_LIMIT: 1024 * 1024 * 1024,
|
||||
});
|
||||
if (modelId < 0) {
|
||||
throw new Error('IFC 모델을 열지 못했습니다.');
|
||||
}
|
||||
|
||||
const meshes: WorkerMesh[] = [];
|
||||
try {
|
||||
api.StreamAllMeshes(modelId, (flatMesh, index, total) => {
|
||||
const placed = flatMesh.geometries;
|
||||
for (let i = 0; i < placed.size(); i += 1) {
|
||||
const placement = placed.get(i);
|
||||
const geometry = api.GetGeometry(
|
||||
modelId,
|
||||
placement.geometryExpressID,
|
||||
);
|
||||
const source = api.GetVertexArray(
|
||||
geometry.GetVertexData(),
|
||||
geometry.GetVertexDataSize(),
|
||||
);
|
||||
const positions = new Float32Array((source.length / 6) * 3);
|
||||
const normals = new Float32Array((source.length / 6) * 3);
|
||||
for (let sourceIndex = 0, targetIndex = 0; sourceIndex < source.length; sourceIndex += 6) {
|
||||
positions[targetIndex] = source[sourceIndex];
|
||||
normals[targetIndex] = source[sourceIndex + 3];
|
||||
positions[targetIndex + 1] = source[sourceIndex + 1];
|
||||
normals[targetIndex + 1] = source[sourceIndex + 4];
|
||||
positions[targetIndex + 2] = source[sourceIndex + 2];
|
||||
normals[targetIndex + 2] = source[sourceIndex + 5];
|
||||
targetIndex += 3;
|
||||
}
|
||||
const indices = new Uint32Array(
|
||||
api.GetIndexArray(
|
||||
geometry.GetIndexData(),
|
||||
geometry.GetIndexDataSize(),
|
||||
),
|
||||
);
|
||||
geometry.delete();
|
||||
meshes.push({
|
||||
name: `IFC #${flatMesh.expressID}`,
|
||||
positions,
|
||||
normals,
|
||||
indices,
|
||||
color: [
|
||||
placement.color.x,
|
||||
placement.color.y,
|
||||
placement.color.z,
|
||||
placement.color.w,
|
||||
],
|
||||
matrix: Array.from(placement.flatTransformation),
|
||||
});
|
||||
}
|
||||
if (index % 20 === 0 || index + 1 === total) {
|
||||
self.postMessage(
|
||||
progress(
|
||||
id,
|
||||
0.25 + (0.65 * (index + 1)) / Math.max(1, total),
|
||||
`IFC 형상 ${index + 1}/${total}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
api.CloseModel(modelId);
|
||||
}
|
||||
return meshes;
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ParseRequest>) => {
|
||||
const { id, format, buffer } = event.data;
|
||||
try {
|
||||
const meshes =
|
||||
format === 'step'
|
||||
? await parseStep(id, buffer)
|
||||
: await parseIfc(id, buffer);
|
||||
const result: ResultMessage = {
|
||||
id,
|
||||
type: 'result',
|
||||
result: { meshes },
|
||||
};
|
||||
const transfer = meshes.flatMap((mesh) => [
|
||||
mesh.positions.buffer,
|
||||
...(mesh.normals ? [mesh.normals.buffer] : []),
|
||||
mesh.indices.buffer,
|
||||
]);
|
||||
self.postMessage(result, { transfer });
|
||||
} catch (reason) {
|
||||
const response: ErrorMessage = {
|
||||
id,
|
||||
type: 'error',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '모델 파싱 중 알 수 없는 오류가 발생했습니다.',
|
||||
};
|
||||
self.postMessage(response);
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user