3d viewer change

This commit is contained in:
ysj7705 committed 2026-07-22 10:04:49 +09:00
1 parent cbf05a9ee1
commit 1dbc73312b
17 files changed
+1413 -841

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
node_modules
**/node_modules
**/dist
packages/backend/public
.git
.env
.env.*
*.log
.DS_Store
.cursor
.tmp
tmp
+3
View File
@@ -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/
+96 -14
View File
@@ -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"
}
} }
} }
} }
+6 -2
View File
@@ -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",
+193 -47
View File
@@ -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>
+30 -6
View File
@@ -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 */
+20 -3
View File
@@ -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
View File
@@ -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>;
}
+5 -5
View File
@@ -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">
+31 -5
View File
@@ -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 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
+371 -40
View File
@@ -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 {};