캐드 지원 추가
This commit is contained in:
1 parent
c1ceec3fb1
commit
e18eab7a64
27 files changed
+2787
-89
No files matched your search
@@ -2,6 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sejong Office</title>
|
||||
<link
|
||||
|
||||
@@ -2,24 +2,45 @@
|
||||
"name": "@sejong-office/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"assets:viewers": "node scripts/copy-viewer-assets.mjs",
|
||||
"predev": "npm run assets:viewers",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run assets:viewers",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@excalidraw/excalidraw": "^0.18.1",
|
||||
"@mlightcad/cad-agent-plugin": "^1.5.8",
|
||||
"@mlightcad/cad-html-plugin": "^1.5.8",
|
||||
"@mlightcad/cad-pdf-plugin": "^1.5.8",
|
||||
"@mlightcad/cad-simple-viewer": "^1.5.8",
|
||||
"@mlightcad/cad-svg-plugin": "^1.5.8",
|
||||
"@mlightcad/cad-viewer": "^1.5.8",
|
||||
"@mlightcad/data-model": "^1.11.2",
|
||||
"@mlightcad/dxf-json-converter": "^1.11.2",
|
||||
"@rhwp/editor": "^0.7.18",
|
||||
"@vueuse/core": "^11.3.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mermaid": "^11.16.0",
|
||||
"occt-import-js": "^0.0.23",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.172.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
"vue-i18n": "^11.4.7",
|
||||
"vue-router": "^4.5.0",
|
||||
"web-ifc": "^0.0.77"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.2",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cp, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const frontendDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const rootDir = path.resolve(frontendDir, '../..');
|
||||
const nodeModules = path.join(rootDir, 'node_modules');
|
||||
const publicDir = path.join(frontendDir, 'public');
|
||||
|
||||
const assets = [
|
||||
[
|
||||
'@mlightcad/cad-simple-viewer/dist/dxf-parser-worker.js',
|
||||
'assets/dxf-parser-worker.js',
|
||||
],
|
||||
[
|
||||
'@mlightcad/cad-simple-viewer/dist/libredwg-parser-worker.js',
|
||||
'assets/libredwg-parser-worker.js',
|
||||
],
|
||||
[
|
||||
'@mlightcad/cad-simple-viewer/dist/mtext-renderer-worker.js',
|
||||
'assets/mtext-renderer-worker.js',
|
||||
],
|
||||
[
|
||||
'occt-import-js/dist/occt-import-js.wasm',
|
||||
'viewer-assets/wasm/occt-import-js.wasm',
|
||||
],
|
||||
['web-ifc/web-ifc.wasm', 'viewer-assets/wasm/web-ifc.wasm'],
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
assets.map(async ([source, destination]) => {
|
||||
const output = path.join(publicDir, destination);
|
||||
await mkdir(path.dirname(output), { recursive: true });
|
||||
await cp(path.join(nodeModules, source), output);
|
||||
}),
|
||||
);
|
||||
|
||||
const resources = path.join(publicDir, 'viewer-assets', 'cad-resources');
|
||||
await mkdir(path.join(resources, 'fonts'), { recursive: true });
|
||||
await mkdir(path.join(resources, 'templates'), { recursive: true });
|
||||
await writeFile(path.join(resources, 'fonts.json'), '[]\n');
|
||||
await writeFile(
|
||||
path.join(resources, 'README.txt'),
|
||||
[
|
||||
'Optional CAD fonts and templates can be placed here.',
|
||||
'Only files with redistribution rights should be added.',
|
||||
'The read-only viewer does not require a drawing template.',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
@@ -18,7 +18,9 @@ const viewKey = computed(() => {
|
||||
if (
|
||||
route.name === 'excalidraw-editor' ||
|
||||
route.name === 'mermaid-editor' ||
|
||||
route.name === 'drawio-editor'
|
||||
route.name === 'drawio-editor' ||
|
||||
route.name === 'cad-viewer' ||
|
||||
route.name === 'model3d-viewer'
|
||||
) {
|
||||
return String(route.name);
|
||||
}
|
||||
@@ -35,8 +37,10 @@ const viewKey = computed(() => {
|
||||
'ExcalidrawEditorView',
|
||||
'MermaidEditorView',
|
||||
'DrawioEditorView',
|
||||
'CadViewerView',
|
||||
'Model3DViewerView',
|
||||
]"
|
||||
:max="8"
|
||||
:max="10"
|
||||
>
|
||||
<component :is="Component" :key="viewKey" />
|
||||
</KeepAlive>
|
||||
|
||||
@@ -3,12 +3,14 @@ withDefaults(
|
||||
defineProps<{
|
||||
filename: string;
|
||||
saveLabel?: string;
|
||||
showSave?: boolean;
|
||||
showExportActions?: boolean;
|
||||
showFilename?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
saveLabel: '저장',
|
||||
showSave: true,
|
||||
showExportActions: false,
|
||||
showFilename: true,
|
||||
busy: false,
|
||||
@@ -36,6 +38,7 @@ defineEmits<{
|
||||
열기
|
||||
</button>
|
||||
<button
|
||||
v-if="showSave"
|
||||
type="button"
|
||||
class="toolbar__btn"
|
||||
:disabled="busy"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Box3,
|
||||
Clock,
|
||||
Color,
|
||||
DirectionalLight,
|
||||
GridHelper,
|
||||
Group,
|
||||
HemisphereLight,
|
||||
MathUtils,
|
||||
Object3D,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
AnimationMixer,
|
||||
AxesHelper,
|
||||
Mesh,
|
||||
Material,
|
||||
Texture,
|
||||
} from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import {
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { loadModelFiles } from '@/integrations/model3d/loadModel';
|
||||
import type {
|
||||
ModelStats,
|
||||
ModelTreeNode,
|
||||
} from '@/integrations/model3d/types';
|
||||
import type { SystemTheme } from '@/composables/useSystemTheme';
|
||||
|
||||
const props = defineProps<{
|
||||
files: readonly File[];
|
||||
theme: SystemTheme;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
progress: [value: number, label: string];
|
||||
loaded: [stats: ModelStats, tree: ModelTreeNode];
|
||||
error: [message: string];
|
||||
}>();
|
||||
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera(45, 1, 0.01, 100000);
|
||||
const content = new Group();
|
||||
const clock = new Clock();
|
||||
let renderer: WebGLRenderer | null = null;
|
||||
let controls: OrbitControls | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let animationFrame = 0;
|
||||
let mixer: AnimationMixer | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
let loadSequence = 0;
|
||||
|
||||
scene.add(content);
|
||||
scene.add(new HemisphereLight(0xffffff, 0x334155, 2.1));
|
||||
const keyLight = new DirectionalLight(0xffffff, 2.4);
|
||||
keyLight.position.set(6, 10, 8);
|
||||
scene.add(keyLight);
|
||||
scene.add(new GridHelper(20, 20, 0x7a8794, 0xb8c0c8));
|
||||
scene.add(new AxesHelper(2));
|
||||
|
||||
function disposeMaterial(material: Material) {
|
||||
for (const value of Object.values(material)) {
|
||||
if (value instanceof Texture) value.dispose();
|
||||
}
|
||||
material.dispose();
|
||||
}
|
||||
|
||||
function disposeObject(object: Object3D) {
|
||||
object.traverse((child) => {
|
||||
if (!(child instanceof Mesh)) return;
|
||||
child.geometry.dispose();
|
||||
const materials = Array.isArray(child.material)
|
||||
? child.material
|
||||
: [child.material];
|
||||
materials.forEach(disposeMaterial);
|
||||
});
|
||||
}
|
||||
|
||||
function clearContent() {
|
||||
mixer?.stopAllAction();
|
||||
mixer = null;
|
||||
for (const child of [...content.children]) {
|
||||
content.remove(child);
|
||||
disposeObject(child);
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (!renderer || !host.value) return;
|
||||
const width = Math.max(1, host.value.clientWidth);
|
||||
const height = Math.max(1, host.value.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function fitCamera() {
|
||||
if (!controls || !content.children.length) return;
|
||||
const box = new Box3().setFromObject(content);
|
||||
if (box.isEmpty()) return;
|
||||
const center = box.getCenter(new Vector3());
|
||||
const size = box.getSize(new Vector3());
|
||||
const maxSize = Math.max(size.x, size.y, size.z, 0.01);
|
||||
const distance =
|
||||
maxSize /
|
||||
(2 * Math.tan(MathUtils.degToRad(camera.fov * 0.5)));
|
||||
const direction = new Vector3(1, 0.75, 1).normalize();
|
||||
camera.position.copy(center).addScaledVector(direction, distance * 1.65);
|
||||
camera.near = Math.max(maxSize / 10000, 0.001);
|
||||
camera.far = Math.max(maxSize * 1000, 1000);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.copy(center);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
animationFrame = requestAnimationFrame(renderLoop);
|
||||
mixer?.update(clock.getDelta());
|
||||
controls?.update();
|
||||
if (renderer) renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
async function load(files: readonly File[]) {
|
||||
const sequence = ++loadSequence;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
clearContent();
|
||||
if (!files.length) return;
|
||||
|
||||
try {
|
||||
const loaded = await loadModelFiles(
|
||||
files,
|
||||
(value, label) => emit('progress', value, label),
|
||||
abortController.signal,
|
||||
);
|
||||
if (sequence !== loadSequence) {
|
||||
disposeObject(loaded.object);
|
||||
return;
|
||||
}
|
||||
content.add(loaded.object);
|
||||
if (loaded.animations.length) {
|
||||
mixer = new AnimationMixer(loaded.object);
|
||||
loaded.animations.forEach((clip) => mixer?.clipAction(clip).play());
|
||||
}
|
||||
await nextTick();
|
||||
fitCamera();
|
||||
emit('loaded', loaded.stats, loaded.tree);
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === 'AbortError') return;
|
||||
emit(
|
||||
'error',
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '3D 모델을 열지 못했습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.files,
|
||||
(files) => {
|
||||
void load(files);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.theme,
|
||||
(theme) => {
|
||||
scene.background = new Color(theme === 'dark' ? 0x161a1d : 0xf2f2f2);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value) return;
|
||||
renderer = new WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = 'srgb';
|
||||
host.value.append(renderer.domElement);
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(host.value);
|
||||
resize();
|
||||
renderLoop();
|
||||
void load(props.files);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadSequence += 1;
|
||||
abortController?.abort();
|
||||
cancelAnimationFrame(animationFrame);
|
||||
resizeObserver?.disconnect();
|
||||
clearContent();
|
||||
controls?.dispose();
|
||||
renderer?.dispose();
|
||||
renderer?.forceContextLoss();
|
||||
renderer?.domElement.remove();
|
||||
renderer = null;
|
||||
controls = null;
|
||||
});
|
||||
|
||||
defineExpose({ fitCamera });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="model-canvas" aria-label="3D 모델 뷰어" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-canvas :deep(canvas) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelTreeNode } from '@/integrations/model3d/types';
|
||||
|
||||
defineOptions({ name: 'ModelTreeItem' });
|
||||
|
||||
defineProps<{
|
||||
node: ModelTreeNode;
|
||||
depth?: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="tree-item">
|
||||
<details :open="(depth ?? 0) < 1 && node.children.length > 0">
|
||||
<summary :class="{ 'tree-leaf': !node.children.length }">
|
||||
<span>{{ node.name }}</span>
|
||||
<small>{{ node.type }}</small>
|
||||
</summary>
|
||||
<ul v-if="node.children.length">
|
||||
<ModelTreeItem
|
||||
v-for="(child, index) in node.children"
|
||||
:key="`${child.name}-${index}`"
|
||||
:node="child"
|
||||
:depth="(depth ?? 0) + 1"
|
||||
/>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-item,
|
||||
.tree-item ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tree-item ul {
|
||||
padding-left: 0.8rem;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
summary {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
summary span {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
summary small {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.tree-leaf {
|
||||
padding-left: 0.9rem;
|
||||
cursor: default;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,11 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { diagramKindFor, isHwp, isOffice } from '@/config/app';
|
||||
import {
|
||||
diagramKindFor,
|
||||
isHwp,
|
||||
isOffice,
|
||||
viewerKindFor,
|
||||
} from '@/config/app';
|
||||
import {
|
||||
createBlankDocument,
|
||||
createSession,
|
||||
@@ -13,18 +18,41 @@ import {
|
||||
storeHwpFile,
|
||||
} from '@/composables/useSessionLifecycle';
|
||||
import { stageDiagramFile } from '@/composables/diagramSession';
|
||||
import { stageViewerFiles } from '@/composables/viewerSession';
|
||||
|
||||
export function useOpenDocument() {
|
||||
const router = useRouter();
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function openDocument(file: File | undefined): Promise<boolean> {
|
||||
if (!file || busy.value) return false;
|
||||
async function openDocument(
|
||||
input: File | FileList | readonly File[] | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!input || busy.value) return false;
|
||||
const files =
|
||||
input instanceof File
|
||||
? [input]
|
||||
: Array.from(input);
|
||||
const file =
|
||||
files.find(
|
||||
(candidate) =>
|
||||
viewerKindFor(candidate.name) ||
|
||||
diagramKindFor(candidate.name) ||
|
||||
isHwp(candidate.name) ||
|
||||
isOffice(candidate.name),
|
||||
) ?? files[0];
|
||||
if (!file) return false;
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
|
||||
try {
|
||||
const viewerKind = viewerKindFor(file.name);
|
||||
if (viewerKind) {
|
||||
stageViewerFiles(viewerKind, files);
|
||||
await router.push({ name: `${viewerKind}-viewer` });
|
||||
return true;
|
||||
}
|
||||
|
||||
const diagramKind = diagramKindFor(file.name);
|
||||
if (diagramKind) {
|
||||
stageDiagramFile(diagramKind, file);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ViewerKind } from '@/config/app';
|
||||
|
||||
interface PendingViewerFiles {
|
||||
kind: ViewerKind;
|
||||
files: File[];
|
||||
}
|
||||
|
||||
let pending: PendingViewerFiles | null = null;
|
||||
|
||||
export function stageViewerFiles(kind: ViewerKind, files: readonly File[]) {
|
||||
pending = { kind, files: [...files] };
|
||||
}
|
||||
|
||||
export function consumeViewerFiles(kind: ViewerKind): File[] {
|
||||
if (pending?.kind !== kind) return [];
|
||||
const files = pending.files;
|
||||
pending = null;
|
||||
return files;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const APP_NAME = "sejong-office";
|
||||
export const APP_DISPLAY_NAME = "Sejong Office";
|
||||
|
||||
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
||||
export type ViewerKind = "cad" | "model3d";
|
||||
|
||||
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||
excalidraw: "excalidraw",
|
||||
@@ -15,6 +16,17 @@ const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||
xml: "drawio",
|
||||
};
|
||||
|
||||
const VIEWER_EXTENSION_KIND: Record<string, ViewerKind> = {
|
||||
dxf: "cad",
|
||||
dwg: "cad",
|
||||
glb: "model3d",
|
||||
gltf: "model3d",
|
||||
obj: "model3d",
|
||||
step: "model3d",
|
||||
stp: "model3d",
|
||||
ifc: "model3d",
|
||||
};
|
||||
|
||||
/** HWP/HWPX — client-side rhwp */
|
||||
export const HWP_EXTENSIONS = new Set(["hwp", "hwpx"]);
|
||||
|
||||
@@ -55,3 +67,7 @@ export function isOffice(filename: string): boolean {
|
||||
export function diagramKindFor(filename: string): DiagramKind | null {
|
||||
return DIAGRAM_EXTENSION_KIND[extensionOf(filename)] ?? null;
|
||||
}
|
||||
|
||||
export function viewerKindFor(filename: string): ViewerKind | null {
|
||||
return VIEWER_EXTENSION_KIND[extensionOf(filename)] ?? null;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type FaviconKind =
|
||||
| 'sketch'
|
||||
| 'code'
|
||||
| 'diagram'
|
||||
| 'cad'
|
||||
| 'model3d'
|
||||
| 'text'
|
||||
| 'csv'
|
||||
| 'file';
|
||||
@@ -31,6 +33,8 @@ export const ICON_PATHS: Record<FaviconKind, string> = {
|
||||
sketch: 'M3 17.25V21H6.75L17.81 9.94L14.06 6.19L3 17.25M20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18 2.9 17.35 2.9 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04Z',
|
||||
code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
|
||||
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z',
|
||||
cad: 'M3 3H21V21H3V3M5 5V19H19V5H5M7 17V15H9V13H11V11H13V9H15V7H17V17H7Z',
|
||||
model3d: 'M12 2L21 7V17L12 22L3 17V7L12 2M12 4.3L5.4 8L12 11.7L18.6 8L12 4.3M5 9.7V15.8L11 19.1V13.1L5 9.7M13 19.1L19 15.8V9.7L13 13.1V19.1Z',
|
||||
text: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||
csv: 'M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2M18 20H6V4H13V9H18V20M10 19L12 15H9V10H15V15L13 19H10',
|
||||
file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
|
||||
@@ -46,6 +50,8 @@ export const ICON_COLORS: Record<FaviconKind, string> = {
|
||||
sketch: '#7b61a8',
|
||||
code: '#d97706',
|
||||
diagram: '#2e7d8a',
|
||||
cad: '#5c6bc0',
|
||||
model3d: '#6d4c41',
|
||||
text: '#607d8b',
|
||||
csv: '#00838f',
|
||||
file: '#546e7a',
|
||||
@@ -74,6 +80,14 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
||||
mermaid: 'code',
|
||||
drawio: 'diagram',
|
||||
xml: 'diagram',
|
||||
dxf: 'cad',
|
||||
dwg: 'cad',
|
||||
glb: 'model3d',
|
||||
gltf: 'model3d',
|
||||
obj: 'model3d',
|
||||
step: 'model3d',
|
||||
stp: 'model3d',
|
||||
ifc: 'model3d',
|
||||
txt: 'text',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
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,35 @@
|
||||
import type { AnimationClip, Object3D } from 'three';
|
||||
|
||||
export interface ModelStats {
|
||||
objects: number;
|
||||
meshes: number;
|
||||
vertices: number;
|
||||
triangles: number;
|
||||
animations: number;
|
||||
}
|
||||
|
||||
export interface ModelTreeNode {
|
||||
name: string;
|
||||
type: string;
|
||||
children: ModelTreeNode[];
|
||||
}
|
||||
|
||||
export interface LoadedModel {
|
||||
object: Object3D;
|
||||
animations: AnimationClip[];
|
||||
stats: ModelStats;
|
||||
tree: ModelTreeNode;
|
||||
}
|
||||
|
||||
export interface WorkerMesh {
|
||||
name: string;
|
||||
positions: Float32Array;
|
||||
normals?: Float32Array;
|
||||
indices: Uint32Array;
|
||||
color?: [number, number, number, number?];
|
||||
matrix?: number[];
|
||||
}
|
||||
|
||||
export interface WorkerModelResult {
|
||||
meshes: WorkerMesh[];
|
||||
}
|
||||
@@ -3,4 +3,14 @@ import App from './App.vue';
|
||||
import router from './router';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).use(router).mount('#app');
|
||||
const app = createApp(App);
|
||||
let cadI18nInstalled = false;
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name !== 'cad-viewer' || cadI18nInstalled) return;
|
||||
const { i18n } = await import('@mlightcad/cad-viewer');
|
||||
app.use(i18n);
|
||||
cadI18nInstalled = true;
|
||||
});
|
||||
|
||||
app.use(router).mount('#app');
|
||||
@@ -34,6 +34,16 @@ const router = createRouter({
|
||||
name: 'drawio-editor',
|
||||
component: () => import('@/views/DrawioEditorView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/viewer/cad',
|
||||
name: 'cad-viewer',
|
||||
component: () => import('@/views/CadViewerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/viewer/3d',
|
||||
name: 'model3d-viewer',
|
||||
component: () => import('@/views/Model3DViewerView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/licenses',
|
||||
name: 'licenses',
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
@@ -50,6 +51,7 @@ body {
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
nextTick,
|
||||
onActivated,
|
||||
onMounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { MlCadViewer } from '@mlightcad/cad-viewer';
|
||||
import {
|
||||
AcApDocManager,
|
||||
AcApSettingManager,
|
||||
AcEdOpenMode,
|
||||
} from '@mlightcad/cad-simple-viewer';
|
||||
import AppShell from '@/components/AppShell.vue';
|
||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||
import { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||
|
||||
defineOptions({ name: 'CadViewerView' });
|
||||
|
||||
const WORKERS = {
|
||||
dxfParser: '/assets/dxf-parser-worker.js',
|
||||
dwgParser: '/assets/libredwg-parser-worker.js',
|
||||
mtextRender: '/assets/mtext-renderer-worker.js',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const localFile = ref<File>();
|
||||
const filename = ref('CAD 도면');
|
||||
const ready = ref(false);
|
||||
const viewerCreated = ref(false);
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const theme = useSystemTheme();
|
||||
|
||||
usePageTitle(filename);
|
||||
|
||||
const settings = AcApSettingManager.instance;
|
||||
settings.set('isShowCommandLine', false);
|
||||
settings.set('isShowCoordinate', true);
|
||||
settings.set('isShowEntityInfo', true);
|
||||
settings.set('isShowFileName', false);
|
||||
settings.set('isShowLanguageSelector', false);
|
||||
settings.set('isShowMainMenu', false);
|
||||
settings.set('isShowStats', false);
|
||||
settings.set('isShowToolbar', true);
|
||||
|
||||
function applyFiles(files: File[]) {
|
||||
const file = files.find((item) => /\.(?:dxf|dwg)$/i.test(item.name));
|
||||
if (!file) return;
|
||||
localFile.value = file;
|
||||
filename.value = file.name;
|
||||
error.value = '';
|
||||
notice.value =
|
||||
file.size > 200 * 1024 * 1024
|
||||
? '대형 CAD 파일은 브라우저 메모리를 많이 사용할 수 있습니다.'
|
||||
: '';
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
applyFiles(consumeViewerFiles('cad'));
|
||||
try {
|
||||
ready.value = await AcApDocManager.checkWebworkerReadiness(WORKERS);
|
||||
if (!ready.value) {
|
||||
error.value =
|
||||
'CAD 파싱 Worker를 불러오지 못했습니다. 정적 자산 경로를 확인해 주세요.';
|
||||
}
|
||||
} catch (reason) {
|
||||
error.value =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: 'CAD 뷰어를 초기화하지 못했습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initialize();
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
const files = consumeViewerFiles('cad');
|
||||
if (!files.length) return;
|
||||
applyFiles(files);
|
||||
});
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (!files.length) return;
|
||||
if (!files.some((file) => /\.(?:dxf|dwg)$/i.test(file.name))) {
|
||||
error.value = '.dxf 또는 .dwg 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
applyFiles(files);
|
||||
void nextTick();
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div class="cad-page">
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="false"
|
||||
:busy="!ready"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".dxf,.dwg"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
|
||||
<div class="cad-host">
|
||||
<MlCadViewer
|
||||
v-if="ready"
|
||||
:local-file="localFile"
|
||||
:base-url="'/viewer-assets/cad-resources/'"
|
||||
:theme="theme"
|
||||
:mode="AcEdOpenMode.Read"
|
||||
:use-main-thread-draw="false"
|
||||
:progressive-rendering="true"
|
||||
:fail-on-font-load-error="false"
|
||||
locale="en"
|
||||
@create="viewerCreated = true"
|
||||
/>
|
||||
<div v-if="ready && !localFile && viewerCreated" class="empty-state">
|
||||
<strong>CAD 도면을 선택해 주세요.</strong>
|
||||
<span>DXF와 DWG 파일은 브라우저 안에서만 처리됩니다.</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cad-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.cad-host {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cad-host :deep(.ml-cad-viewer-container) {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.65rem;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0.45rem 0.85rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -47,14 +47,29 @@ const diagramTypes = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const viewerTypes = [
|
||||
{
|
||||
route: 'cad-viewer',
|
||||
extension: 'dwg',
|
||||
title: 'CAD 도면',
|
||||
subtitle: 'DXF · DWG',
|
||||
},
|
||||
{
|
||||
route: 'model3d-viewer',
|
||||
extension: 'glb',
|
||||
title: '3D 모델',
|
||||
subtitle: 'GLB · glTF · OBJ · STEP · IFC',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
dragging.value = false;
|
||||
void openDocument(e.dataTransfer?.files?.[0]);
|
||||
void openDocument(e.dataTransfer?.files);
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
void openDocument(input.files?.[0]);
|
||||
void openDocument(input.files ?? undefined);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
@@ -62,7 +77,7 @@ function onCreate(type: BlankDocumentType) {
|
||||
void createAndOpenBlank(type);
|
||||
}
|
||||
|
||||
function onCreateDiagram(route: string) {
|
||||
function onOpenTool(route: string) {
|
||||
void router.push({ name: route });
|
||||
}
|
||||
</script>
|
||||
@@ -92,7 +107,8 @@ function onCreateDiagram(route: string) {
|
||||
<input
|
||||
class="drop__input"
|
||||
type="file"
|
||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml"
|
||||
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"
|
||||
multiple
|
||||
:disabled="busy"
|
||||
@change="onInput"
|
||||
/>
|
||||
@@ -100,7 +116,7 @@ function onCreateDiagram(route: string) {
|
||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||
</span>
|
||||
<span class="drop__hint">
|
||||
오피스·PDF · HWP/HWPX · 다이어그램
|
||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -142,7 +158,32 @@ function onCreateDiagram(route: string) {
|
||||
type="button"
|
||||
class="tile"
|
||||
:disabled="busy"
|
||||
@click="onCreateDiagram(item.route)"
|
||||
@click="onOpenTool(item.route)"
|
||||
>
|
||||
<FileTypeIcon :extension="item.extension" :size="36" />
|
||||
<span class="tile__text">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.subtitle }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section__title">CAD·3D 모델</h2>
|
||||
<p class="section__lead">
|
||||
서버 업로드 없이 도면과 모델을 조회합니다. glTF·OBJ의 보조 파일은
|
||||
원본과 함께 선택하세요.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="item in viewerTypes"
|
||||
:key="item.route"
|
||||
type="button"
|
||||
class="tile"
|
||||
:disabled="busy"
|
||||
@click="onOpenTool(item.route)"
|
||||
>
|
||||
<FileTypeIcon :extension="item.extension" :size="36" />
|
||||
<span class="tile__text">
|
||||
|
||||
@@ -20,6 +20,17 @@ const thirdParty = [
|
||||
license: 'MIT',
|
||||
url: 'https://github.com/mermaid-js/mermaid',
|
||||
},
|
||||
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
|
||||
{
|
||||
name: 'occt-import-js',
|
||||
license: 'LGPL-2.1',
|
||||
url: 'https://github.com/kovacsv/occt-import-js',
|
||||
},
|
||||
{
|
||||
name: 'web-ifc',
|
||||
license: 'MPL-2.0',
|
||||
url: 'https://github.com/ThatOpen/engine_web-ifc',
|
||||
},
|
||||
{ name: 'Express', license: 'MIT', url: 'https://github.com/expressjs/express' },
|
||||
{ name: 'jsonwebtoken', license: 'MIT', url: 'https://github.com/auth0/node-jsonwebtoken' },
|
||||
{ name: 'multer', license: 'MIT', url: 'https://github.com/expressjs/multer' },
|
||||
@@ -41,7 +52,7 @@ const thirdParty = [
|
||||
|
||||
<section class="card">
|
||||
<h2>본 앱</h2>
|
||||
<p>라이선스: <strong>MIT</strong></p>
|
||||
<p>라이선스: <strong>GNU General Public License v3.0 only</strong></p>
|
||||
<p v-if="APP_GIT_URL">
|
||||
소스 코드:
|
||||
<a :href="APP_GIT_URL" target="_blank" rel="noopener noreferrer">{{
|
||||
@@ -58,6 +69,55 @@ const thirdParty = [
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>MLightCAD · LibreDWG</h2>
|
||||
<p>
|
||||
CAD 화면과 렌더러는
|
||||
<a
|
||||
href="https://github.com/mlightcad/cad-viewer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>MLightCAD CAD Viewer</a
|
||||
>
|
||||
(MIT)를 사용합니다.
|
||||
</p>
|
||||
<p>
|
||||
DXF 변환기
|
||||
<a
|
||||
href="https://github.com/mlightcad/dxf-json"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>@mlightcad/dxf-json-converter</a
|
||||
>와 DWG Worker에 포함된
|
||||
<a
|
||||
href="https://www.gnu.org/software/libredwg/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>GNU LibreDWG</a
|
||||
>는 <strong>GPL-3.0</strong>입니다. 이에 따라 본 앱도
|
||||
GPL-3.0-only로 배포됩니다.
|
||||
</p>
|
||||
<p class="muted">
|
||||
CAD 폰트는 재배포 권리가 명확한 파일만 자체 호스팅해야 합니다. 본 배포본은
|
||||
상용 AutoCAD 폰트를 포함하지 않으며, 누락된 글꼴은 대체 글꼴로 표시될 수
|
||||
있습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>3D 모델 파서</h2>
|
||||
<p>
|
||||
glTF·OBJ 렌더링은 Three.js(MIT), STEP 변환은
|
||||
occt-import-js(LGPL-2.1)와 OpenCascade, IFC 변환은
|
||||
web-ifc(MPL-2.0)를 사용합니다. 각 WASM 파일은 앱과 함께 자체
|
||||
호스팅됩니다.
|
||||
</p>
|
||||
<p class="muted">
|
||||
CAD·3D 원본 파일과 보조 파일은 브라우저 메모리에서만 처리하며 앱 서버로
|
||||
업로드하거나 영구 저장하지 않습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Euro-Office Document Server</h2>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onMounted,
|
||||
ref,
|
||||
} from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
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 { consumeViewerFiles } from '@/composables/viewerSession';
|
||||
import { usePageTitle } from '@/composables/usePageTitle';
|
||||
import { useSystemTheme } from '@/composables/useSystemTheme';
|
||||
import type {
|
||||
ModelStats,
|
||||
ModelTreeNode,
|
||||
} from '@/integrations/model3d/types';
|
||||
|
||||
defineOptions({ name: 'Model3DViewerView' });
|
||||
|
||||
const PRIMARY_EXTENSIONS = new Set([
|
||||
'glb',
|
||||
'gltf',
|
||||
'obj',
|
||||
'step',
|
||||
'stp',
|
||||
'ifc',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const canvas = ref<InstanceType<typeof Model3DCanvas> | null>(null);
|
||||
const files = ref<File[]>([]);
|
||||
const filename = ref('3D 모델');
|
||||
const loading = ref(false);
|
||||
const progress = ref(0);
|
||||
const progressLabel = ref('');
|
||||
const error = ref('');
|
||||
const notice = ref('');
|
||||
const stats = ref<ModelStats | null>(null);
|
||||
const tree = ref<ModelTreeNode | null>(null);
|
||||
const theme = useSystemTheme();
|
||||
|
||||
const progressPercent = computed(() =>
|
||||
Math.round(Math.min(1, Math.max(0, progress.value)) * 100),
|
||||
);
|
||||
|
||||
usePageTitle(filename);
|
||||
|
||||
function applyFiles(nextFiles: File[]) {
|
||||
const primary = nextFiles.find((file) =>
|
||||
PRIMARY_EXTENSIONS.has(extensionOf(file.name)),
|
||||
);
|
||||
if (!primary) {
|
||||
error.value =
|
||||
'GLB, glTF, OBJ, STEP 또는 IFC 모델 파일을 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
files.value = [...nextFiles];
|
||||
filename.value = primary.name;
|
||||
loading.value = true;
|
||||
progress.value = 0;
|
||||
progressLabel.value = '모델 준비 중';
|
||||
error.value = '';
|
||||
stats.value = null;
|
||||
tree.value = null;
|
||||
const totalBytes = nextFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
notice.value =
|
||||
totalBytes > 400 * 1024 * 1024
|
||||
? '선택한 파일이 400MB를 초과합니다. 브라우저 메모리 부족이 발생할 수 있습니다.'
|
||||
: '';
|
||||
}
|
||||
|
||||
function consumePending() {
|
||||
const pending = consumeViewerFiles('model3d');
|
||||
if (pending.length) applyFiles(pending);
|
||||
}
|
||||
|
||||
onMounted(consumePending);
|
||||
onActivated(consumePending);
|
||||
|
||||
function onOpen() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function onFilesSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selected = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (selected.length) applyFiles(selected);
|
||||
}
|
||||
|
||||
function onProgress(value: number, label: string) {
|
||||
loading.value = true;
|
||||
progress.value = value;
|
||||
progressLabel.value = label;
|
||||
}
|
||||
|
||||
function onLoaded(nextStats: ModelStats, nextTree: ModelTreeNode) {
|
||||
stats.value = nextStats;
|
||||
tree.value = nextTree;
|
||||
loading.value = false;
|
||||
progress.value = 1;
|
||||
}
|
||||
|
||||
function onError(message: string) {
|
||||
error.value = message;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
void router.push({ name: 'home' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell :editor="true">
|
||||
<div class="model-page">
|
||||
<EditorToolbar
|
||||
:filename="filename"
|
||||
:show-save="false"
|
||||
:busy="loading"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
/>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".glb,.gltf,.obj,.mtl,.step,.stp,.ifc,.bin,.png,.jpg,.jpeg,.webp"
|
||||
multiple
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="viewer-message viewer-message--error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="notice" class="viewer-message">{{ notice }}</p>
|
||||
|
||||
<div class="viewer-layout">
|
||||
<main class="viewport">
|
||||
<Model3DCanvas
|
||||
v-if="files.length"
|
||||
ref="canvas"
|
||||
:files="files"
|
||||
:theme="theme"
|
||||
@progress="onProgress"
|
||||
@loaded="onLoaded"
|
||||
@error="onError"
|
||||
/>
|
||||
<div v-else class="empty-state">
|
||||
<strong>3D 모델을 선택해 주세요.</strong>
|
||||
<span>
|
||||
glTF의 BIN·텍스처나 OBJ의 MTL·텍스처도 함께 선택할 수 있습니다.
|
||||
</span>
|
||||
<button type="button" @click="onOpen">파일 열기</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="files.length"
|
||||
type="button"
|
||||
class="reset-view"
|
||||
@click="canvas?.fitCamera()"
|
||||
>
|
||||
뷰 초기화
|
||||
</button>
|
||||
<div v-if="loading" class="progress" aria-live="polite">
|
||||
<span>{{ progressLabel }}</span>
|
||||
<progress :value="progressPercent" max="100" />
|
||||
<small>{{ progressPercent }}%</small>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="inspector">
|
||||
<section>
|
||||
<h2>모델 통계</h2>
|
||||
<dl v-if="stats">
|
||||
<div><dt>객체</dt><dd>{{ stats.objects.toLocaleString() }}</dd></div>
|
||||
<div><dt>메시</dt><dd>{{ stats.meshes.toLocaleString() }}</dd></div>
|
||||
<div><dt>정점</dt><dd>{{ stats.vertices.toLocaleString() }}</dd></div>
|
||||
<div><dt>삼각형</dt><dd>{{ Math.round(stats.triangles).toLocaleString() }}</dd></div>
|
||||
<div><dt>애니메이션</dt><dd>{{ stats.animations }}</dd></div>
|
||||
</dl>
|
||||
<p v-else>모델을 열면 통계를 표시합니다.</p>
|
||||
</section>
|
||||
<section class="structure">
|
||||
<h2>구조</h2>
|
||||
<ul v-if="tree">
|
||||
<ModelTreeItem :node="tree" />
|
||||
</ul>
|
||||
<p v-else>모델 구조가 없습니다.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.viewer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 230px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
padding: 0.85rem;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.inspector h2 {
|
||||
margin: 0 0 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.inspector p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.inspector dl {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.inspector dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inspector dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.structure {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.structure > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
color: var(--ink);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
max-width: 34rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty-state button,
|
||||
.reset-view {
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 0.42rem 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-view {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 2;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
color: var(--ink);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 1rem;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 18rem) 3rem;
|
||||
gap: 0.35rem 0.65rem;
|
||||
width: min(26rem, calc(100% - 2rem));
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
color: var(--ink);
|
||||
transform: translateX(-50%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.progress progress {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.progress small {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.viewer-message {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--accent-soft);
|
||||
color: var(--ink);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.viewer-message--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.viewer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import createOcct from 'occt-import-js';
|
||||
import { IfcAPI } from 'web-ifc';
|
||||
import type {
|
||||
WorkerMesh,
|
||||
WorkerModelResult,
|
||||
} from '@/integrations/model3d/types';
|
||||
|
||||
interface ParseRequest {
|
||||
id: number;
|
||||
format: 'step' | 'ifc';
|
||||
buffer: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface ProgressMessage {
|
||||
id: number;
|
||||
type: 'progress';
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ResultMessage {
|
||||
id: number;
|
||||
type: 'result';
|
||||
result: WorkerModelResult;
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
id: number;
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
function progress(
|
||||
id: number,
|
||||
value: number,
|
||||
label: string,
|
||||
): ProgressMessage {
|
||||
return { id, type: 'progress', value, label };
|
||||
}
|
||||
|
||||
async function parseStep(id: number, buffer: ArrayBuffer) {
|
||||
self.postMessage(progress(id, 0.15, 'OpenCascade WASM 초기화 중'));
|
||||
const occt = await createOcct({
|
||||
locateFile: () => '/viewer-assets/wasm/occt-import-js.wasm',
|
||||
});
|
||||
self.postMessage(progress(id, 0.35, 'STEP 형상 분석 중'));
|
||||
const imported = occt.ReadStepFile(new Uint8Array(buffer), {
|
||||
linearUnit: 'meter',
|
||||
});
|
||||
if (!imported.success) {
|
||||
throw new Error('STEP 형상을 해석하지 못했습니다.');
|
||||
}
|
||||
|
||||
const meshes: WorkerMesh[] = imported.meshes.map((mesh, index) => ({
|
||||
name: mesh.name || `STEP Mesh ${index + 1}`,
|
||||
positions: new Float32Array(mesh.attributes.position.array),
|
||||
normals: mesh.attributes.normal
|
||||
? new Float32Array(mesh.attributes.normal.array)
|
||||
: undefined,
|
||||
indices: new Uint32Array(mesh.index.array),
|
||||
color: mesh.color ? [...mesh.color, 1] : undefined,
|
||||
}));
|
||||
self.postMessage(progress(id, 0.9, 'STEP 메시 전송 중'));
|
||||
return meshes;
|
||||
}
|
||||
|
||||
async function parseIfc(id: number, buffer: ArrayBuffer) {
|
||||
self.postMessage(progress(id, 0.1, 'web-ifc WASM 초기화 중'));
|
||||
const api = new IfcAPI();
|
||||
api.SetWasmPath('/viewer-assets/wasm/', true);
|
||||
await api.Init();
|
||||
|
||||
self.postMessage(progress(id, 0.25, 'IFC 모델 분석 중'));
|
||||
const modelId = api.OpenModel(new Uint8Array(buffer), {
|
||||
COORDINATE_TO_ORIGIN: true,
|
||||
CIRCLE_SEGMENTS: 16,
|
||||
MEMORY_LIMIT: 1024 * 1024 * 1024,
|
||||
});
|
||||
if (modelId < 0) {
|
||||
throw new Error('IFC 모델을 열지 못했습니다.');
|
||||
}
|
||||
|
||||
const meshes: WorkerMesh[] = [];
|
||||
try {
|
||||
api.StreamAllMeshes(modelId, (flatMesh, index, total) => {
|
||||
const placed = flatMesh.geometries;
|
||||
for (let i = 0; i < placed.size(); i += 1) {
|
||||
const placement = placed.get(i);
|
||||
const geometry = api.GetGeometry(
|
||||
modelId,
|
||||
placement.geometryExpressID,
|
||||
);
|
||||
const source = api.GetVertexArray(
|
||||
geometry.GetVertexData(),
|
||||
geometry.GetVertexDataSize(),
|
||||
);
|
||||
const positions = new Float32Array((source.length / 6) * 3);
|
||||
const normals = new Float32Array((source.length / 6) * 3);
|
||||
for (let sourceIndex = 0, targetIndex = 0; sourceIndex < source.length; sourceIndex += 6) {
|
||||
positions[targetIndex] = source[sourceIndex];
|
||||
normals[targetIndex] = source[sourceIndex + 3];
|
||||
positions[targetIndex + 1] = source[sourceIndex + 1];
|
||||
normals[targetIndex + 1] = source[sourceIndex + 4];
|
||||
positions[targetIndex + 2] = source[sourceIndex + 2];
|
||||
normals[targetIndex + 2] = source[sourceIndex + 5];
|
||||
targetIndex += 3;
|
||||
}
|
||||
const indices = new Uint32Array(
|
||||
api.GetIndexArray(
|
||||
geometry.GetIndexData(),
|
||||
geometry.GetIndexDataSize(),
|
||||
),
|
||||
);
|
||||
geometry.delete();
|
||||
meshes.push({
|
||||
name: `IFC #${flatMesh.expressID}`,
|
||||
positions,
|
||||
normals,
|
||||
indices,
|
||||
color: [
|
||||
placement.color.x,
|
||||
placement.color.y,
|
||||
placement.color.z,
|
||||
placement.color.w,
|
||||
],
|
||||
matrix: Array.from(placement.flatTransformation),
|
||||
});
|
||||
}
|
||||
if (index % 20 === 0 || index + 1 === total) {
|
||||
self.postMessage(
|
||||
progress(
|
||||
id,
|
||||
0.25 + (0.65 * (index + 1)) / Math.max(1, total),
|
||||
`IFC 형상 ${index + 1}/${total}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
api.CloseModel(modelId);
|
||||
}
|
||||
return meshes;
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ParseRequest>) => {
|
||||
const { id, format, buffer } = event.data;
|
||||
try {
|
||||
const meshes =
|
||||
format === 'step'
|
||||
? await parseStep(id, buffer)
|
||||
: await parseIfc(id, buffer);
|
||||
const result: ResultMessage = {
|
||||
id,
|
||||
type: 'result',
|
||||
result: { meshes },
|
||||
};
|
||||
const transfer = meshes.flatMap((mesh) => [
|
||||
mesh.positions.buffer,
|
||||
...(mesh.normals ? [mesh.normals.buffer] : []),
|
||||
mesh.indices.buffer,
|
||||
]);
|
||||
self.postMessage(result, { transfer });
|
||||
} catch (reason) {
|
||||
const response: ErrorMessage = {
|
||||
id,
|
||||
type: 'error',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: '모델 파싱 중 알 수 없는 오류가 발생했습니다.',
|
||||
};
|
||||
self.postMessage(response);
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
Reference in new issue
Block a user