diff --git a/packages/frontend/src/views/LicensesView.vue b/packages/frontend/src/views/LicensesView.vue
index 6512aaa..d06a77e 100644
--- a/packages/frontend/src/views/LicensesView.vue
+++ b/packages/frontend/src/views/LicensesView.vue
@@ -21,6 +21,11 @@ const thirdParty = [
url: 'https://github.com/mermaid-js/mermaid',
},
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
+ {
+ name: 'Online 3D Viewer',
+ license: 'MIT',
+ url: 'https://github.com/kovacsv/Online3DViewer',
+ },
{
name: 'occt-import-js',
license: 'LGPL-2.1',
@@ -31,6 +36,21 @@ const thirdParty = [
license: 'MPL-2.0',
url: 'https://github.com/ThatOpen/engine_web-ifc',
},
+ {
+ name: 'rhino3dm',
+ license: 'MIT',
+ url: 'https://github.com/mcneel/rhino3dm',
+ },
+ {
+ name: 'Draco',
+ license: 'Apache-2.0',
+ url: 'https://github.com/google/draco',
+ },
+ {
+ name: 'fflate',
+ license: 'MIT',
+ url: 'https://github.com/101arrowz/fflate',
+ },
{
name: 'Noto Sans KR',
license: 'SIL OFL 1.1',
@@ -110,12 +130,18 @@ const thirdParty = [
- 3D 모델 파서
+ Online 3D Viewer
- glTF·OBJ 렌더링은 Three.js(MIT), STEP 변환은
- occt-import-js(LGPL-2.1)와 OpenCascade, IFC 변환은
- web-ifc(MPL-2.0)를 사용합니다. 각 WASM 파일은 앱과 함께 자체
- 호스팅됩니다.
+ 3D 뷰어 엔진은
+ Online 3D Viewer
+ (MIT)를 사용합니다. Three.js·fflate·Pickr(MIT), Draco(Apache-2.0),
+ rhino3dm(MIT), web-ifc(MPL-2.0), occt-import-js(LGPL-2.1) 로더는
+ CDN 없이 /viewer-assets/o3dv/에 자체 호스팅됩니다.
CAD·3D 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
diff --git a/packages/frontend/src/views/Model3DViewerView.vue b/packages/frontend/src/views/Model3DViewerView.vue
index e35f900..f319e5c 100644
--- a/packages/frontend/src/views/Model3DViewerView.vue
+++ b/packages/frontend/src/views/Model3DViewerView.vue
@@ -3,6 +3,7 @@ import {
computed,
onActivated,
onMounted,
+ reactive,
ref,
} from 'vue';
import { useRouter } from 'vue-router';
@@ -10,25 +11,40 @@ import AppShell from '@/components/AppShell.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import Model3DCanvas from '@/components/model3d/Model3DCanvas.vue';
import ModelTreeItem from '@/components/model3d/ModelTreeItem.vue';
-import { extensionOf } from '@/config/app';
+import { MODEL3D_EXTENSIONS } from '@/config/app';
import { consumeViewerFiles } from '@/composables/viewerSession';
import { usePageTitle } from '@/composables/usePageTitle';
import { useSystemTheme } from '@/composables/useSystemTheme';
+import {
+ downloadExportedFiles,
+ exportModel,
+ exportOptionById,
+ findPrimaryModelFile,
+} from '@/integrations/model3d/o3dv';
import type {
+ ExportFormatId,
+ ModelPropertyGroup,
ModelStats,
ModelTreeNode,
} from '@/integrations/model3d/types';
+import { EXPORT_OPTIONS } from '@/integrations/model3d/types';
+import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'Model3DViewerView' });
-const PRIMARY_EXTENSIONS = new Set([
- 'glb',
- 'gltf',
- 'obj',
- 'step',
- 'stp',
- 'ifc',
-]);
+const ACCEPT = [
+ ...MODEL3D_EXTENSIONS.map((ext) => `.${ext}`),
+ '.bin',
+ '.mtl',
+ '.png',
+ '.jpg',
+ '.jpeg',
+ '.webp',
+ '.gif',
+ '.bmp',
+ '.tif',
+ '.tiff',
+].join(',');
const router = useRouter();
const fileInput = ref(null);
@@ -42,21 +58,39 @@ const error = ref('');
const notice = ref('');
const stats = ref(null);
const tree = ref(null);
+const modelGroups = ref([]);
+const selectionGroups = ref([]);
+const selectedKey = ref(null);
+const hiddenKeys = reactive(new Set());
+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(null);
+const exportOpen = ref(false);
+const exportBusy = ref(false);
+const mainFilePrompt = ref<{
+ names: string[];
+ select: (index: number) => void;
+} | null>(null);
const theme = useSystemTheme();
const progressPercent = computed(() =>
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
);
+const propertyGroups = computed(() =>
+ selectedKey.value ? selectionGroups.value : modelGroups.value,
+);
+
usePageTitle(filename);
function applyFiles(nextFiles: File[]) {
- const primary = nextFiles.find((file) =>
- PRIMARY_EXTENSIONS.has(extensionOf(file.name)),
- );
+ const primary = findPrimaryModelFile(nextFiles);
if (!primary) {
error.value =
- 'GLB, glTF, OBJ, STEP 또는 IFC 모델 파일을 선택해 주세요.';
+ '지원하는 3D 모델 파일(또는 ZIP)을 선택해 주세요.';
return;
}
files.value = [...nextFiles];
@@ -67,6 +101,14 @@ function applyFiles(nextFiles: File[]) {
error.value = '';
stats.value = null;
tree.value = null;
+ modelGroups.value = [];
+ selectionGroups.value = [];
+ selectedKey.value = null;
+ hiddenKeys.clear();
+ hiddenVersion.value = 0;
+ measureMode.value = false;
+ measureDistance.value = null;
+ exportOpen.value = false;
const totalBytes = nextFiles.reduce((sum, file) => sum + file.size, 0);
notice.value =
totalBytes > 400 * 1024 * 1024
@@ -99,11 +141,20 @@ function onProgress(value: number, label: string) {
progressLabel.value = label;
}
-function onLoaded(nextStats: ModelStats, nextTree: ModelTreeNode) {
+function onLoaded(
+ nextStats: ModelStats,
+ nextTree: ModelTreeNode,
+ groups: ModelPropertyGroup[],
+ missingFiles: string[],
+) {
stats.value = nextStats;
tree.value = nextTree;
+ modelGroups.value = groups;
loading.value = false;
progress.value = 1;
+ if (missingFiles.length) {
+ notice.value = `누락된 보조 파일: ${missingFiles.join(', ')}`;
+ }
}
function onError(message: string) {
@@ -111,6 +162,75 @@ function onError(message: string) {
loading.value = false;
}
+function onSelectMainFile(names: string[], select: (index: number) => void) {
+ mainFilePrompt.value = { names, select };
+}
+
+function chooseMainFile(index: number) {
+ mainFilePrompt.value?.select(index);
+ mainFilePrompt.value = null;
+}
+
+function onSelect(key: string | null, groups: ModelPropertyGroup[]) {
+ selectedKey.value = key;
+ selectionGroups.value = groups;
+}
+
+function onTreeSelect(key: string | null) {
+ canvas.value?.selectByKey(key);
+}
+
+function onToggleVisible(key: string) {
+ if (hiddenKeys.has(key)) hiddenKeys.delete(key);
+ else hiddenKeys.add(key);
+ hiddenVersion.value += 1;
+}
+
+function onMeasure(distance: number | null) {
+ measureDistance.value = distance;
+}
+
+function toggleMeasure() {
+ measureMode.value = !measureMode.value;
+ if (!measureMode.value) {
+ canvas.value?.clearMeasure();
+ measureDistance.value = null;
+ }
+}
+
+async function onScreenshot() {
+ const dataUrl = canvas.value?.screenshot();
+ if (!dataUrl) return;
+ const response = await fetch(dataUrl);
+ downloadBlob(await response.blob(), `${baseName()}.png`);
+}
+
+function baseName() {
+ return filename.value.replace(/\.[^.]+$/, '') || 'model';
+}
+
+async function onExport(id: ExportFormatId) {
+ const model = canvas.value?.getModel();
+ if (!model) return;
+ exportBusy.value = true;
+ error.value = '';
+ try {
+ const option = exportOptionById(id);
+ const exported = await exportModel(
+ model,
+ option,
+ hiddenKeys.size ? hiddenKeys : null,
+ );
+ await downloadExportedFiles(exported, baseName(), option);
+ exportOpen.value = false;
+ } catch (reason) {
+ error.value =
+ reason instanceof Error ? reason.message : '내보내기에 실패했습니다.';
+ } finally {
+ exportBusy.value = false;
+ }
+}
+
function onClose() {
void router.push({ name: 'home' });
}
@@ -121,16 +241,18 @@ function onClose() {
@@ -140,6 +262,34 @@ function onClose() {
{{ notice }}
+
+ 내보내기 형식
+
+
+
+
+
+
주 파일 선택
+
여러 모델 파일이 있습니다. 열 파일을 고르세요.
+
+
+
+
3D 모델을 선택해 주세요.
- glTF의 BIN·텍스처나 OBJ의 MTL·텍스처도 함께 선택할 수 있습니다.
+ Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열 수
+ 있습니다.
-
+
+
+
+
+
+
+
+
+
+
+
+ 메시 위 두 점을 클릭하세요
+
+ · 거리 {{ measureDistance.toFixed(4) }}
+
+
+
{{ progressLabel }}
@@ -177,21 +375,67 @@ function onClose() {
모델 통계
- - 객체
- {{ stats.objects.toLocaleString() }}
- - 메시
- {{ stats.meshes.toLocaleString() }}
- - 정점
- {{ stats.vertices.toLocaleString() }}
- - 삼각형
- {{ Math.round(stats.triangles).toLocaleString() }}
- - 애니메이션
- {{ stats.animations }}
+
+
- 노드
+ - {{ stats.nodes.toLocaleString() }}
+
+
+
- 메시
+ - {{ stats.meshes.toLocaleString() }}
+
+
+
- 인스턴스
+ - {{ stats.meshInstances.toLocaleString() }}
+
+
+
- 정점
+ - {{ stats.vertices.toLocaleString() }}
+
+
+
- 삼각형
+ - {{ Math.round(stats.triangles).toLocaleString() }}
+
+
+
- 재질
+ - {{ stats.materials.toLocaleString() }}
+
모델을 열면 통계를 표시합니다.
+
+
+
+ {{ selectedKey ? '선택 속성' : '모델 속성' }}
+
+
+
{{ group.name }}
+
+
+
- {{ row.name }}
+ - {{ row.value }}
+
+
+
+
+ 속성 정보가 없습니다.
+
@@ -200,6 +444,7 @@ function onClose() {