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
|
# Build frontend + backend
|
||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
WORKDIR /app
|
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 package.json package-lock.json* ./
|
||||||
COPY packages/backend/package.json packages/backend/
|
COPY packages/backend/package.json packages/backend/
|
||||||
|
|||||||
Generated
+96
-14
@@ -2866,6 +2866,16 @@
|
|||||||
"resolved": "packages/frontend",
|
"resolved": "packages/frontend",
|
||||||
"link": true
|
"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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -4107,6 +4117,17 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -4785,6 +4806,12 @@
|
|||||||
"url": "https://dotenvx.com"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -5237,6 +5264,14 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"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": "^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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -6022,12 +6063,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -6040,6 +6075,29 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/open-color": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz",
|
||||||
@@ -6422,6 +6480,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/robust-predicates": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
@@ -7755,12 +7819,6 @@
|
|||||||
"typescript": ">=5.0.0"
|
"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": {
|
"node_modules/webworkify": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz",
|
||||||
@@ -7869,17 +7927,21 @@
|
|||||||
"@mlightcad/dxf-json-converter": "^1.11.2",
|
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||||
"@rhwp/editor": "^0.7.18",
|
"@rhwp/editor": "^0.7.18",
|
||||||
"@vueuse/core": "^11.3.0",
|
"@vueuse/core": "^11.3.0",
|
||||||
|
"draco3d": "^1.5.7",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mermaid": "^11.16.0",
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"rhino3dm": "^8.17.0",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^11.4.7",
|
"vue-i18n": "^11.4.7",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"web-ifc": "^0.0.77"
|
"web-ifc": "^0.0.68"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
||||||
@@ -7892,6 +7954,26 @@
|
|||||||
"vite": "^6.2.2",
|
"vite": "^6.2.2",
|
||||||
"vue-tsc": "^2.2.8"
|
"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",
|
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||||
"@rhwp/editor": "^0.7.18",
|
"@rhwp/editor": "^0.7.18",
|
||||||
"@vueuse/core": "^11.3.0",
|
"@vueuse/core": "^11.3.0",
|
||||||
|
"draco3d": "^1.5.7",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"mermaid": "^11.16.0",
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"rhino3dm": "^8.17.0",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^11.4.7",
|
"vue-i18n": "^11.4.7",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"web-ifc": "^0.0.77"
|
"web-ifc": "^0.0.68"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/noto-sans-kr": "^5.3.0",
|
"@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 path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -7,78 +7,169 @@ const frontendDir = path.resolve(
|
|||||||
'..',
|
'..',
|
||||||
);
|
);
|
||||||
const rootDir = path.resolve(frontendDir, '../..');
|
const rootDir = path.resolve(frontendDir, '../..');
|
||||||
const nodeModules = path.join(rootDir, 'node_modules');
|
|
||||||
const publicDir = path.join(frontendDir, 'public');
|
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',
|
'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',
|
'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',
|
'assets/mtext-renderer-worker.js',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'occt-import-js/dist/occt-import-js.wasm',
|
resolvePackage('@noto-pdf-ts/fonts-kr/dist/fonts/NotoSansKR-VF.ttf'),
|
||||||
'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',
|
|
||||||
'viewer-assets/cad-resources/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',
|
'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(
|
await Promise.all(
|
||||||
assets.map(async ([source, destination]) => {
|
[...cadAssets, ...o3dvAssets].map(([sources, destination]) =>
|
||||||
const output = path.join(publicDir, destination);
|
copyFirst(sources, destination),
|
||||||
await mkdir(path.dirname(output), { recursive: true });
|
),
|
||||||
await cp(path.join(nodeModules, source), output);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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');
|
const resources = path.join(publicDir, 'viewer-assets', 'cad-resources');
|
||||||
await mkdir(path.join(resources, 'fonts'), { recursive: true });
|
await mkdir(path.join(resources, 'fonts'), { recursive: true });
|
||||||
await mkdir(path.join(resources, 'templates'), { recursive: true });
|
await mkdir(path.join(resources, 'templates'), { recursive: true });
|
||||||
const fontManifest = `${JSON.stringify(
|
const fontManifest = `${JSON.stringify(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: [
|
name: [
|
||||||
'Noto Sans KR',
|
'Noto Sans KR',
|
||||||
'arial',
|
'arial',
|
||||||
'batang',
|
'batang',
|
||||||
'dotum',
|
'dotum',
|
||||||
'ghs',
|
'ghs',
|
||||||
'gulim',
|
'gulim',
|
||||||
'hztxt',
|
'hztxt',
|
||||||
'isocpeur',
|
'isocpeur',
|
||||||
'malgun',
|
'malgun',
|
||||||
'malgun gothic',
|
'malgun gothic',
|
||||||
'pmingliu',
|
'pmingliu',
|
||||||
'romans',
|
'romans',
|
||||||
'sanss___',
|
'sanss___',
|
||||||
'simplex',
|
'simplex',
|
||||||
'swisscbo',
|
'swisscbo',
|
||||||
'whgtxt',
|
'whgtxt',
|
||||||
'whs',
|
'whs',
|
||||||
],
|
],
|
||||||
file: 'NotoSansKR-VF.ttf',
|
file: 'NotoSansKR-VF.ttf',
|
||||||
type: 'mesh',
|
type: 'mesh',
|
||||||
description: 'Open-source Korean CAD fallback (SIL OFL 1.1)',
|
description: 'Open-source Korean CAD fallback (SIL OFL 1.1)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
)}\n`;
|
)}\n`;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(path.join(resources, 'fonts.json'), fontManifest),
|
writeFile(path.join(resources, 'fonts.json'), fontManifest),
|
||||||
writeFile(path.join(resources, 'fonts', 'fonts.json'), fontManifest),
|
writeFile(path.join(resources, 'fonts', 'fonts.json'), fontManifest),
|
||||||
@@ -92,3 +183,58 @@ await writeFile(
|
|||||||
'',
|
'',
|
||||||
].join('\n'),
|
].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">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
Box3,
|
BufferGeometry,
|
||||||
Clock,
|
Float32BufferAttribute,
|
||||||
Color,
|
Line,
|
||||||
DirectionalLight,
|
LineBasicMaterial,
|
||||||
GridHelper,
|
|
||||||
Group,
|
|
||||||
HemisphereLight,
|
|
||||||
MathUtils,
|
|
||||||
Object3D,
|
|
||||||
PerspectiveCamera,
|
|
||||||
Scene,
|
|
||||||
Vector3,
|
|
||||||
WebGLRenderer,
|
|
||||||
AnimationMixer,
|
|
||||||
AxesHelper,
|
|
||||||
Mesh,
|
Mesh,
|
||||||
Material,
|
MeshBasicMaterial,
|
||||||
Texture,
|
Object3D,
|
||||||
|
SphereGeometry,
|
||||||
|
Vector3,
|
||||||
} from 'three';
|
} from 'three';
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
||||||
import {
|
import {
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onActivated,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onMounted,
|
onMounted,
|
||||||
ref,
|
ref,
|
||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} 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 {
|
import type {
|
||||||
|
ModelPropertyGroup,
|
||||||
ModelStats,
|
ModelStats,
|
||||||
ModelTreeNode,
|
ModelTreeNode,
|
||||||
} from '@/integrations/model3d/types';
|
} from '@/integrations/model3d/types';
|
||||||
import type { SystemTheme } from '@/composables/useSystemTheme';
|
import {
|
||||||
|
meshInstancePropertyGroups,
|
||||||
|
modelPropertyGroups,
|
||||||
|
} from '@/integrations/model3d/o3dv';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
files: readonly File[];
|
files: readonly File[];
|
||||||
theme: SystemTheme;
|
theme: SystemTheme;
|
||||||
|
edges: boolean;
|
||||||
|
projection: 'perspective' | 'orthographic';
|
||||||
|
upAxis: 'y' | 'z';
|
||||||
|
measureMode: boolean;
|
||||||
|
hiddenKeys: ReadonlySet<string>;
|
||||||
|
hiddenVersion: number;
|
||||||
|
selectedKey: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
progress: [value: number, label: string];
|
progress: [value: number, label: string];
|
||||||
loaded: [stats: ModelStats, tree: ModelTreeNode];
|
loaded: [
|
||||||
|
stats: ModelStats,
|
||||||
|
tree: ModelTreeNode,
|
||||||
|
modelGroups: ModelPropertyGroup[],
|
||||||
|
missingFiles: string[],
|
||||||
|
];
|
||||||
error: [message: 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 host = ref<HTMLElement | null>(null);
|
||||||
const scene = new Scene();
|
|
||||||
const camera = new PerspectiveCamera(45, 1, 0.01, 100000);
|
let viewer: OV.Viewer | null = null;
|
||||||
const content = new Group();
|
let loader: OV.ThreeModelLoader | null = null;
|
||||||
const clock = new Clock();
|
let model: OV.Model | null = null;
|
||||||
let renderer: WebGLRenderer | null = null;
|
let canvas: HTMLCanvasElement | null = null;
|
||||||
let controls: OrbitControls | null = null;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let animationFrame = 0;
|
|
||||||
let mixer: AnimationMixer | null = null;
|
|
||||||
let abortController: AbortController | null = null;
|
|
||||||
let loadSequence = 0;
|
let loadSequence = 0;
|
||||||
|
let measurePoints: Vector3[] = [];
|
||||||
scene.add(content);
|
let measureObjects: Object3D[] = [];
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
if (!renderer || !host.value) return;
|
if (!viewer || !host.value) return;
|
||||||
const width = Math.max(1, host.value.clientWidth);
|
viewer.Resize(
|
||||||
const height = Math.max(1, host.value.clientHeight);
|
Math.max(1, host.value.clientWidth),
|
||||||
renderer.setSize(width, height, false);
|
Math.max(1, host.value.clientHeight),
|
||||||
camera.aspect = width / height;
|
);
|
||||||
camera.updateProjectionMatrix();
|
}
|
||||||
|
|
||||||
|
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() {
|
function fitCamera() {
|
||||||
if (!controls || !content.children.length) return;
|
if (!viewer || !model) return;
|
||||||
const box = new Box3().setFromObject(content);
|
const sphere = viewer.GetBoundingSphere(() => true);
|
||||||
if (box.isEmpty()) return;
|
if (!sphere) return;
|
||||||
const center = box.getCenter(new Vector3());
|
viewer.AdjustClippingPlanesToSphere(sphere);
|
||||||
const size = box.getSize(new Vector3());
|
viewer.FitSphereToWindow(sphere, true);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLoop() {
|
function clearMeasure() {
|
||||||
animationFrame = requestAnimationFrame(renderLoop);
|
measurePoints = [];
|
||||||
mixer?.update(clock.getDelta());
|
measureObjects = [];
|
||||||
controls?.update();
|
viewer?.ClearExtra();
|
||||||
if (renderer) renderer.render(scene, camera);
|
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[]) {
|
async function load(files: readonly File[]) {
|
||||||
const sequence = ++loadSequence;
|
const sequence = ++loadSequence;
|
||||||
abortController?.abort();
|
if (!viewer || !loader) return;
|
||||||
abortController = new AbortController();
|
clearMeasure();
|
||||||
clearContent();
|
model = null;
|
||||||
|
viewer.Clear();
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
try {
|
const inputFiles = OV.InputFilesFromFileObjects([...files]);
|
||||||
const loaded = await loadModelFiles(
|
const settings = new OV.ImportSettings();
|
||||||
files,
|
|
||||||
(value, label) => emit('progress', value, label),
|
loader.LoadModel(inputFiles, settings, {
|
||||||
abortController.signal,
|
onLoadStart: () => emit('progress', 0.05, '모델 준비 중'),
|
||||||
);
|
onFileListProgress: (current: number, total: number) => {
|
||||||
if (sequence !== loadSequence) {
|
emit('progress', total ? current / total * 0.35 : 0.2, `파일 ${current}/${total}`);
|
||||||
disposeObject(loaded.object);
|
},
|
||||||
return;
|
onFileLoadProgress: (current: number, total: number) => {
|
||||||
}
|
emit(
|
||||||
content.add(loaded.object);
|
'progress',
|
||||||
if (loaded.animations.length) {
|
0.35 + (total ? (current / total) * 0.25 : 0.1),
|
||||||
mixer = new AnimationMixer(loaded.object);
|
`읽는 중 ${current}/${total}`,
|
||||||
loaded.animations.forEach((clip) => mixer?.clipAction(clip).play());
|
);
|
||||||
}
|
},
|
||||||
await nextTick();
|
onImportStart: () => emit('progress', 0.65, '가져오는 중'),
|
||||||
fitCamera();
|
onSelectMainFile: (fileNames: string[], selectFile: (index: number) => void) => {
|
||||||
emit('loaded', loaded.stats, loaded.tree);
|
emit('selectMainFile', fileNames, selectFile);
|
||||||
} catch (reason) {
|
},
|
||||||
if (reason instanceof DOMException && reason.name === 'AbortError') return;
|
onVisualizationStart: () => emit('progress', 0.8, '시각화 중'),
|
||||||
emit(
|
onTextureLoaded: () => viewer?.Render(),
|
||||||
'error',
|
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
|
||||||
reason instanceof Error
|
if (sequence !== loadSequence) return;
|
||||||
? reason.message
|
viewer?.SetMainObject(threeObject);
|
||||||
: '3D 모델을 열지 못했습니다.',
|
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(
|
watch(
|
||||||
@@ -173,43 +312,89 @@ watch(
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.theme,
|
() => props.theme,
|
||||||
(theme) => {
|
(theme) => applyTheme(theme),
|
||||||
scene.background = new Color(theme === 'dark' ? 0x161a1d : 0xf2f2f2);
|
);
|
||||||
|
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
if (!host.value) return;
|
if (!host.value) return;
|
||||||
renderer = new WebGLRenderer({ antialias: true, alpha: false });
|
canvas = document.createElement('canvas');
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
canvas.style.display = 'block';
|
||||||
renderer.outputColorSpace = 'srgb';
|
canvas.style.width = '100%';
|
||||||
host.value.append(renderer.domElement);
|
canvas.style.height = '100%';
|
||||||
controls = new OrbitControls(camera, renderer.domElement);
|
host.value.append(canvas);
|
||||||
controls.enableDamping = true;
|
|
||||||
controls.dampingFactor = 0.08;
|
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 = new ResizeObserver(resize);
|
||||||
resizeObserver.observe(host.value);
|
resizeObserver.observe(host.value);
|
||||||
resize();
|
resize();
|
||||||
renderLoop();
|
|
||||||
void load(props.files);
|
void load(props.files);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
resize();
|
||||||
|
viewer?.Render();
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
loadSequence += 1;
|
loadSequence += 1;
|
||||||
abortController?.abort();
|
|
||||||
cancelAnimationFrame(animationFrame);
|
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
clearContent();
|
clearMeasure();
|
||||||
controls?.dispose();
|
loader?.Destroy();
|
||||||
renderer?.dispose();
|
viewer?.Destroy();
|
||||||
renderer?.forceContextLoss();
|
canvas?.remove();
|
||||||
renderer?.domElement.remove();
|
loader = null;
|
||||||
renderer = null;
|
viewer = null;
|
||||||
controls = null;
|
canvas = null;
|
||||||
|
model = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ fitCamera });
|
defineExpose({
|
||||||
|
fitCamera,
|
||||||
|
screenshot,
|
||||||
|
getModel,
|
||||||
|
clearMeasure,
|
||||||
|
selectByKey,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -3,25 +3,62 @@ import type { ModelTreeNode } from '@/integrations/model3d/types';
|
|||||||
|
|
||||||
defineOptions({ name: 'ModelTreeItem' });
|
defineOptions({ name: 'ModelTreeItem' });
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
node: ModelTreeNode;
|
node: ModelTreeNode;
|
||||||
depth?: number;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<li class="tree-item">
|
<li class="tree-item">
|
||||||
<details :open="(depth ?? 0) < 1 && node.children.length > 0">
|
<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>
|
<span>{{ node.name }}</span>
|
||||||
<small>{{ node.type }}</small>
|
<small>{{ node.type }}</small>
|
||||||
</summary>
|
</summary>
|
||||||
<ul v-if="node.children.length">
|
<ul v-if="node.children.length">
|
||||||
<ModelTreeItem
|
<ModelTreeItem
|
||||||
v-for="(child, index) in node.children"
|
v-for="child in node.children"
|
||||||
:key="`${child.name}-${index}`"
|
:key="child.id"
|
||||||
:node="child"
|
:node="child"
|
||||||
:depth="(depth ?? 0) + 1"
|
:depth="(depth ?? 0) + 1"
|
||||||
|
:selected-key="selectedKey"
|
||||||
|
:hidden-keys="hiddenKeys"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@toggle-visible="emit('toggleVisible', $event)"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
@@ -63,8 +100,26 @@ summary small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree-leaf {
|
.tree-leaf {
|
||||||
padding-left: 0.9rem;
|
padding-left: 0.15rem;
|
||||||
cursor: default;
|
}
|
||||||
list-style: none;
|
|
||||||
|
.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>
|
</style>
|
||||||
@@ -16,15 +16,39 @@ const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
|||||||
xml: "drawio",
|
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> = {
|
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
||||||
dxf: "cad",
|
dxf: "cad",
|
||||||
dwg: "cad",
|
dwg: "cad",
|
||||||
glb: "model3d",
|
...Object.fromEntries(
|
||||||
gltf: "model3d",
|
MODEL3D_EXTENSIONS.map((ext) => [ext, "model3d" as const]),
|
||||||
obj: "model3d",
|
),
|
||||||
step: "model3d",
|
|
||||||
stp: "model3d",
|
|
||||||
ifc: "model3d",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** HWP/HWPX — client-side rhwp */
|
/** HWP/HWPX — client-side rhwp */
|
||||||
|
|||||||
@@ -82,12 +82,29 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
xml: 'diagram',
|
xml: 'diagram',
|
||||||
dxf: 'cad',
|
dxf: 'cad',
|
||||||
dwg: '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',
|
glb: 'model3d',
|
||||||
gltf: 'model3d',
|
gltf: 'model3d',
|
||||||
obj: 'model3d',
|
|
||||||
step: 'model3d',
|
|
||||||
stp: 'model3d',
|
|
||||||
ifc: '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',
|
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 {
|
export interface ModelStats {
|
||||||
objects: number;
|
nodes: number;
|
||||||
meshes: number;
|
meshes: number;
|
||||||
|
meshInstances: number;
|
||||||
vertices: number;
|
vertices: number;
|
||||||
triangles: number;
|
triangles: number;
|
||||||
animations: number;
|
materials: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelPropertyRow {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelPropertyGroup {
|
||||||
|
name: string;
|
||||||
|
properties: ModelPropertyRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelTreeNode {
|
export interface ModelTreeNode {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
meshInstanceKey?: string;
|
||||||
children: ModelTreeNode[];
|
children: ModelTreeNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadedModel {
|
export type ExportFormatId =
|
||||||
object: Object3D;
|
| 'glb'
|
||||||
animations: AnimationClip[];
|
| 'gltf'
|
||||||
stats: ModelStats;
|
| 'obj'
|
||||||
tree: ModelTreeNode;
|
| '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 {
|
export const EXPORT_OPTIONS: ExportOption[] = [
|
||||||
name: string;
|
{ id: 'glb', label: 'glTF Binary (.glb)', extension: 'glb', format: 'binary' },
|
||||||
positions: Float32Array;
|
{ id: 'gltf', label: 'glTF (.gltf)', extension: 'gltf', format: 'text' },
|
||||||
normals?: Float32Array;
|
{ id: 'obj', label: 'Wavefront OBJ (.obj)', extension: 'obj', format: 'text' },
|
||||||
indices: Uint32Array;
|
{
|
||||||
color?: [number, number, number, number?];
|
id: 'stl-binary',
|
||||||
matrix?: number[];
|
label: 'STL Binary (.stl)',
|
||||||
}
|
extension: 'stl',
|
||||||
|
format: 'binary',
|
||||||
export interface WorkerModelResult {
|
},
|
||||||
meshes: WorkerMesh[];
|
{ 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',
|
route: 'model3d-viewer',
|
||||||
extension: 'glb',
|
extension: 'glb',
|
||||||
title: '3D 모델',
|
title: '3D 모델',
|
||||||
subtitle: 'GLB · glTF · OBJ · STEP · IFC',
|
subtitle: 'glTF · OBJ · STEP · IFC · STL · 3DM 등',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ function onOpenTool(route: string) {
|
|||||||
<input
|
<input
|
||||||
class="drop__input"
|
class="drop__input"
|
||||||
type="file"
|
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
|
multiple
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@change="onInput"
|
@change="onInput"
|
||||||
@@ -116,7 +116,7 @@ function onOpenTool(route: string) {
|
|||||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="drop__hint">
|
<span class="drop__hint">
|
||||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델
|
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델(ZIP 포함)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -172,8 +172,8 @@ function onOpenTool(route: string) {
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section__title">CAD·3D 모델</h2>
|
<h2 class="section__title">CAD·3D 모델</h2>
|
||||||
<p class="section__lead">
|
<p class="section__lead">
|
||||||
서버 업로드 없이 도면과 모델을 조회합니다. glTF·OBJ의 보조 파일은
|
서버 업로드 없이 도면과 모델을 조회합니다. 텍스처·MTL·BIN 등 보조
|
||||||
원본과 함께 선택하세요.
|
파일이나 ZIP은 원본과 함께 선택하세요.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const thirdParty = [
|
|||||||
url: 'https://github.com/mermaid-js/mermaid',
|
url: 'https://github.com/mermaid-js/mermaid',
|
||||||
},
|
},
|
||||||
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
|
{ 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',
|
name: 'occt-import-js',
|
||||||
license: 'LGPL-2.1',
|
license: 'LGPL-2.1',
|
||||||
@@ -31,6 +36,21 @@ const thirdParty = [
|
|||||||
license: 'MPL-2.0',
|
license: 'MPL-2.0',
|
||||||
url: 'https://github.com/ThatOpen/engine_web-ifc',
|
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',
|
name: 'Noto Sans KR',
|
||||||
license: 'SIL OFL 1.1',
|
license: 'SIL OFL 1.1',
|
||||||
@@ -110,12 +130,18 @@ const thirdParty = [
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>3D 모델 파서</h2>
|
<h2>Online 3D Viewer</h2>
|
||||||
<p>
|
<p>
|
||||||
glTF·OBJ 렌더링은 Three.js(MIT), STEP 변환은
|
3D 뷰어 엔진은
|
||||||
occt-import-js(LGPL-2.1)와 OpenCascade, IFC 변환은
|
<a
|
||||||
web-ifc(MPL-2.0)를 사용합니다. 각 WASM 파일은 앱과 함께 자체
|
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>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
CAD·3D 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
|
CAD·3D 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
onActivated,
|
onActivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@@ -10,25 +11,40 @@ import AppShell from '@/components/AppShell.vue';
|
|||||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
|
import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
|
||||||
import ModelTreeItem from '@/components/model3d/ModelTreeItem.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 { consumeViewerFiles } from '@/composables/viewerSession';
|
||||||
import { usePageTitle } from '@/composables/usePageTitle';
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||||
|
import {
|
||||||
|
downloadExportedFiles,
|
||||||
|
exportModel,
|
||||||
|
exportOptionById,
|
||||||
|
findPrimaryModelFile,
|
||||||
|
} from '@/integrations/model3d/o3dv';
|
||||||
import type {
|
import type {
|
||||||
|
ExportFormatId,
|
||||||
|
ModelPropertyGroup,
|
||||||
ModelStats,
|
ModelStats,
|
||||||
ModelTreeNode,
|
ModelTreeNode,
|
||||||
} from '@/integrations/model3d/types';
|
} from '@/integrations/model3d/types';
|
||||||
|
import { EXPORT_OPTIONS } from '@/integrations/model3d/types';
|
||||||
|
import { downloadBlob } from '@/utils/downloadBlob';
|
||||||
|
|
||||||
defineOptions({ name: 'Model3DViewerView' });
|
defineOptions({ name: 'Model3DViewerView' });
|
||||||
|
|
||||||
const PRIMARY_EXTENSIONS = new Set([
|
const ACCEPT = [
|
||||||
'glb',
|
...MODEL3D_EXTENSIONS.map((ext) => `.${ext}`),
|
||||||
'gltf',
|
'.bin',
|
||||||
'obj',
|
'.mtl',
|
||||||
'step',
|
'.png',
|
||||||
'stp',
|
'.jpg',
|
||||||
'ifc',
|
'.jpeg',
|
||||||
]);
|
'.webp',
|
||||||
|
'.gif',
|
||||||
|
'.bmp',
|
||||||
|
'.tif',
|
||||||
|
'.tiff',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
@@ -42,21 +58,39 @@ const error = ref('');
|
|||||||
const notice = ref('');
|
const notice = ref('');
|
||||||
const stats = ref<ModelStats | null>(null);
|
const stats = ref<ModelStats | null>(null);
|
||||||
const tree = ref<ModelTreeNode | 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 theme = useSystemTheme();
|
||||||
|
|
||||||
const progressPercent = computed(() =>
|
const progressPercent = computed(() =>
|
||||||
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
|
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const propertyGroups = computed(() =>
|
||||||
|
selectedKey.value ? selectionGroups.value : modelGroups.value,
|
||||||
|
);
|
||||||
|
|
||||||
usePageTitle(filename);
|
usePageTitle(filename);
|
||||||
|
|
||||||
function applyFiles(nextFiles: File[]) {
|
function applyFiles(nextFiles: File[]) {
|
||||||
const primary = nextFiles.find((file) =>
|
const primary = findPrimaryModelFile(nextFiles);
|
||||||
PRIMARY_EXTENSIONS.has(extensionOf(file.name)),
|
|
||||||
);
|
|
||||||
if (!primary) {
|
if (!primary) {
|
||||||
error.value =
|
error.value =
|
||||||
'GLB, glTF, OBJ, STEP 또는 IFC 모델 파일을 선택해 주세요.';
|
'지원하는 3D 모델 파일(또는 ZIP)을 선택해 주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
files.value = [...nextFiles];
|
files.value = [...nextFiles];
|
||||||
@@ -67,6 +101,14 @@ function applyFiles(nextFiles: File[]) {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
stats.value = null;
|
stats.value = null;
|
||||||
tree.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);
|
const totalBytes = nextFiles.reduce((sum, file) => sum + file.size, 0);
|
||||||
notice.value =
|
notice.value =
|
||||||
totalBytes > 400 * 1024 * 1024
|
totalBytes > 400 * 1024 * 1024
|
||||||
@@ -99,11 +141,20 @@ function onProgress(value: number, label: string) {
|
|||||||
progressLabel.value = label;
|
progressLabel.value = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLoaded(nextStats: ModelStats, nextTree: ModelTreeNode) {
|
function onLoaded(
|
||||||
|
nextStats: ModelStats,
|
||||||
|
nextTree: ModelTreeNode,
|
||||||
|
groups: ModelPropertyGroup[],
|
||||||
|
missingFiles: string[],
|
||||||
|
) {
|
||||||
stats.value = nextStats;
|
stats.value = nextStats;
|
||||||
tree.value = nextTree;
|
tree.value = nextTree;
|
||||||
|
modelGroups.value = groups;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
progress.value = 1;
|
progress.value = 1;
|
||||||
|
if (missingFiles.length) {
|
||||||
|
notice.value = `누락된 보조 파일: ${missingFiles.join(', ')}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onError(message: string) {
|
function onError(message: string) {
|
||||||
@@ -111,6 +162,75 @@ function onError(message: string) {
|
|||||||
loading.value = false;
|
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() {
|
function onClose() {
|
||||||
void router.push({ name: 'home' });
|
void router.push({ name: 'home' });
|
||||||
}
|
}
|
||||||
@@ -121,16 +241,18 @@ function onClose() {
|
|||||||
<div class="model-page">
|
<div class="model-page">
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
:filename="filename"
|
:filename="filename"
|
||||||
:show-save="false"
|
save-label="내보내기"
|
||||||
:busy="loading"
|
:show-save="Boolean(stats)"
|
||||||
|
:busy="loading || exportBusy"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
@save="exportOpen = !exportOpen"
|
||||||
@close="onClose"
|
@close="onClose"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".glb,.gltf,.obj,.mtl,.step,.stp,.ifc,.bin,.png,.jpg,.jpeg,.webp"
|
:accept="ACCEPT"
|
||||||
multiple
|
multiple
|
||||||
@change="onFilesSelected"
|
@change="onFilesSelected"
|
||||||
/>
|
/>
|
||||||
@@ -140,6 +262,34 @@ function onClose() {
|
|||||||
</p>
|
</p>
|
||||||
<p v-else-if="notice" class="viewer-message">{{ notice }}</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">
|
<div class="viewer-layout">
|
||||||
<main class="viewport">
|
<main class="viewport">
|
||||||
<Model3DCanvas
|
<Model3DCanvas
|
||||||
@@ -147,25 +297,73 @@ function onClose() {
|
|||||||
ref="canvas"
|
ref="canvas"
|
||||||
:files="files"
|
:files="files"
|
||||||
:theme="theme"
|
:theme="theme"
|
||||||
|
:edges="edges"
|
||||||
|
:projection="projection"
|
||||||
|
:up-axis="upAxis"
|
||||||
|
:measure-mode="measureMode"
|
||||||
|
:hidden-keys="hiddenKeys"
|
||||||
|
:hidden-version="hiddenVersion"
|
||||||
|
:selected-key="selectedKey"
|
||||||
@progress="onProgress"
|
@progress="onProgress"
|
||||||
@loaded="onLoaded"
|
@loaded="onLoaded"
|
||||||
@error="onError"
|
@error="onError"
|
||||||
|
@select-main-file="onSelectMainFile"
|
||||||
|
@select="onSelect"
|
||||||
|
@measure="onMeasure"
|
||||||
/>
|
/>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<strong>3D 모델을 선택해 주세요.</strong>
|
<strong>3D 모델을 선택해 주세요.</strong>
|
||||||
<span>
|
<span>
|
||||||
glTF의 BIN·텍스처나 OBJ의 MTL·텍스처도 함께 선택할 수 있습니다.
|
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열 수
|
||||||
|
있습니다.
|
||||||
</span>
|
</span>
|
||||||
<button type="button" @click="onOpen">파일 열기</button>
|
<button type="button" @click="onOpen">파일 열기</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
v-if="files.length"
|
<div v-if="files.length" class="viewport-tools">
|
||||||
type="button"
|
<button type="button" @click="canvas?.fitCamera()">뷰 초기화</button>
|
||||||
class="reset-view"
|
<button
|
||||||
@click="canvas?.fitCamera()"
|
type="button"
|
||||||
>
|
:aria-pressed="projection === 'orthographic'"
|
||||||
뷰 초기화
|
@click="
|
||||||
</button>
|
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">
|
<div v-if="loading" class="progress" aria-live="polite">
|
||||||
<span>{{ progressLabel }}</span>
|
<span>{{ progressLabel }}</span>
|
||||||
<progress :value="progressPercent" max="100" />
|
<progress :value="progressPercent" max="100" />
|
||||||
@@ -177,21 +375,67 @@ function onClose() {
|
|||||||
<section>
|
<section>
|
||||||
<h2>모델 통계</h2>
|
<h2>모델 통계</h2>
|
||||||
<dl v-if="stats">
|
<dl v-if="stats">
|
||||||
<div><dt>객체</dt><dd>{{ stats.objects.toLocaleString() }}</dd></div>
|
<div>
|
||||||
<div><dt>메시</dt><dd>{{ stats.meshes.toLocaleString() }}</dd></div>
|
<dt>노드</dt>
|
||||||
<div><dt>정점</dt><dd>{{ stats.vertices.toLocaleString() }}</dd></div>
|
<dd>{{ stats.nodes.toLocaleString() }}</dd>
|
||||||
<div><dt>삼각형</dt><dd>{{ Math.round(stats.triangles).toLocaleString() }}</dd></div>
|
</div>
|
||||||
<div><dt>애니메이션</dt><dd>{{ stats.animations }}</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>
|
</dl>
|
||||||
<p v-else>모델을 열면 통계를 표시합니다.</p>
|
<p v-else>모델을 열면 통계를 표시합니다.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="structure">
|
<section class="structure">
|
||||||
<h2>구조</h2>
|
<h2>구조</h2>
|
||||||
<ul v-if="tree">
|
<ul v-if="tree">
|
||||||
<ModelTreeItem :node="tree" />
|
<ModelTreeItem
|
||||||
|
:node="tree"
|
||||||
|
:selected-key="selectedKey"
|
||||||
|
:hidden-keys="hiddenKeys"
|
||||||
|
@select="onTreeSelect"
|
||||||
|
@toggle-visible="onToggleVisible"
|
||||||
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else>모델 구조가 없습니다.</p>
|
<p v-else>모델 구조가 없습니다.</p>
|
||||||
</section>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,6 +444,7 @@ function onClose() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.model-page {
|
.model-page {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -210,7 +455,7 @@ function onClose() {
|
|||||||
|
|
||||||
.viewer-layout {
|
.viewer-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 230px;
|
grid-template-columns: minmax(0, 1fr) 260px;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -233,13 +478,19 @@ function onClose() {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspector h2 {
|
.inspector h2,
|
||||||
|
.inspector h3 {
|
||||||
margin: 0 0 0.55rem;
|
margin: 0 0 0.55rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inspector h3 {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.inspector p {
|
.inspector p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -266,6 +517,8 @@ function onClose() {
|
|||||||
.inspector dd {
|
.inspector dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.structure {
|
.structure {
|
||||||
@@ -277,6 +530,10 @@ function onClose() {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prop-group + .prop-group {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -295,23 +552,54 @@ function onClose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state button,
|
.empty-state button,
|
||||||
.reset-view {
|
.viewport-tools button,
|
||||||
border: 1px solid var(--accent);
|
.export-bar button,
|
||||||
|
.main-file-dialog button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.42rem 0.75rem;
|
padding: 0.42rem 0.75rem;
|
||||||
background: var(--accent);
|
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||||
color: white;
|
color: var(--ink);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-view {
|
.empty-state button {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-tools {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.75rem;
|
top: 0.75rem;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
z-index: 2;
|
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);
|
color: var(--ink);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +632,49 @@ function onClose() {
|
|||||||
text-align: right;
|
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 {
|
.viewer-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.45rem 0.75rem;
|
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