캐드 지원 추가

This commit is contained in:
ysj7705 committed 2026-07-21 18:23:50 +09:00
1 parent c1ceec3fb1
commit e18eab7a64
27 files changed
+2787 -89

No files matched your search

+6 -2
View File
@@ -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;
}
+16
View File
@@ -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;
}
+14
View File
@@ -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[];
}
+11 -1
View File
@@ -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');
+10
View File
@@ -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',
+2
View File
@@ -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
View File
@@ -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 -6
View File
@@ -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">
+61 -1
View File
@@ -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 {};