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
+1375 -803

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",
+162 -16
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,45 +7,136 @@ 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',
], ],
[ [
resolvePackage(
'@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js', '@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js',
),
'assets/libredwg-parser-worker.js', 'assets/libredwg-parser-worker.js',
], ],
[ [
resolvePackage(
'@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js', '@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 });
@@ -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) => {
}
content.add(loaded.object);
if (loaded.animations.length) {
mixer = new AnimationMixer(loaded.object);
loaded.animations.forEach((clip) => mixer?.clipAction(clip).play());
}
await nextTick();
fitCamera();
emit('loaded', loaded.stats, loaded.tree);
} catch (reason) {
if (reason instanceof DOMException && reason.name === 'AbortError') return;
emit( emit(
'error', 'progress',
reason instanceof Error 0.35 + (total ? (current / total) * 0.25 : 0.1),
? reason.message `읽는 중 ${current}/${total}`,
: '3D 모델을 열지 못했습니다.', );
},
onImportStart: () => emit('progress', 0.65, '가져오는 중'),
onSelectMainFile: (fileNames: string[], selectFile: (index: number) => void) => {
emit('selectMainFile', fileNames, selectFile);
},
onVisualizationStart: () => emit('progress', 0.8, '시각화 중'),
onTextureLoaded: () => viewer?.Render(),
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
if (sequence !== loadSequence) return;
viewer?.SetMainObject(threeObject);
model = importResult.model;
applyVisibility();
applyHighlight();
applyEdges(props.edges);
applyProjection(props.projection);
applyUp(props.upAxis);
void nextTick(() => {
fitCamera();
emit(
'loaded',
summarizeModel(importResult.model),
treeFromModel(importResult.model),
modelPropertyGroups(importResult.model),
importResult.missingFiles ?? [],
);
emit('progress', 1, '모델 준비 완료');
selectByKey(null);
});
},
onLoadError: (importError: OV.ImportError) => {
if (sequence !== loadSequence) return;
emit('error', importErrorMessage(importError));
},
});
}
function screenshot() {
if (!viewer || !host.value) return null;
return viewer.GetImageAsDataUrl(
Math.max(1, host.value.clientWidth * 2),
Math.max(1, host.value.clientHeight * 2),
false,
); );
} }
function getModel() {
return model;
} }
watch( 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 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
+367 -36
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>
<div v-if="files.length" class="viewport-tools">
<button type="button" @click="canvas?.fitCamera()">뷰 초기화</button>
<button <button
v-if="files.length"
type="button" type="button"
class="reset-view" :aria-pressed="projection === 'orthographic'"
@click="canvas?.fitCamera()" @click="
projection =
projection === 'perspective' ? 'orthographic' : 'perspective'
"
> >
뷰 초기화 {{ projection === 'perspective' ? '원근' : '직교' }}
</button> </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 {};