캐드 지원 추가

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

+4
View File
@@ -3,6 +3,10 @@ dist/
packages/backend/public/ packages/backend/public/
packages/backend/dist/ packages/backend/dist/
packages/frontend/dist/ packages/frontend/dist/
packages/frontend/public/assets/dxf-parser-worker.js
packages/frontend/public/assets/libredwg-parser-worker.js
packages/frontend/public/assets/mtext-renderer-worker.js
packages/frontend/public/viewer-assets/
.env .env
.env.local .env.local
*.log *.log
+12 -16
View File
@@ -1,21 +1,17 @@
MIT License GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 sejong-office contributors Copyright (c) 2026 sejong-office contributors
Permission is hereby granted, free of charge, to any person obtaining a copy This program is free software: you can redistribute it and/or modify it under
of this software and associated documentation files (the "Software"), to deal the terms of the GNU General Public License as published by the Free Software
in the Software without restriction, including without limitation the rights Foundation, version 3 of the License.
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all This program is distributed in the hope that it will be useful, but WITHOUT
copies or substantial portions of the Software. ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR You should have received a copy of the GNU General Public License along with
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, this program. If not, see <https://www.gnu.org/licenses/>.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER SPDX-License-Identifier: GPL-3.0-only
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+977 -58
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "Ephemeral OnlyOffice + rhwp document editor", "description": "Ephemeral OnlyOffice + rhwp document editor",
"license": "MIT", "license": "GPL-3.0-only",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
+1
View File
@@ -2,6 +2,7 @@
"name": "@sejong-office/backend", "name": "@sejong-office/backend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
+1
View File
@@ -2,6 +2,7 @@
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sejong Office</title> <title>Sejong Office</title>
<link <link
+22 -1
View File
@@ -2,24 +2,45 @@
"name": "@sejong-office/frontend", "name": "@sejong-office/frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
"assets:viewers": "node scripts/copy-viewer-assets.mjs",
"predev": "npm run assets:viewers",
"dev": "vite", "dev": "vite",
"prebuild": "npm run assets:viewers",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@excalidraw/excalidraw": "^0.18.1", "@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", "@rhwp/editor": "^0.7.18",
"@vueuse/core": "^11.3.0",
"element-plus": "^2.14.3",
"lodash-es": "^4.17.21",
"mermaid": "^11.16.0", "mermaid": "^11.16.0",
"occt-import-js": "^0.0.23",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"three": "^0.172.0",
"vue": "^3.5.13", "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": { "devDependencies": {
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.172.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.2.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'),
);
+6 -2
View File
@@ -18,7 +18,9 @@ const viewKey = computed(() => {
if ( if (
route.name === 'excalidraw-editor' || route.name === 'excalidraw-editor' ||
route.name === 'mermaid-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); return String(route.name);
} }
@@ -35,8 +37,10 @@ const viewKey = computed(() => {
'ExcalidrawEditorView', 'ExcalidrawEditorView',
'MermaidEditorView', 'MermaidEditorView',
'DrawioEditorView', 'DrawioEditorView',
'CadViewerView',
'Model3DViewerView',
]" ]"
:max="8" :max="10"
> >
<component :is="Component" :key="viewKey" /> <component :is="Component" :key="viewKey" />
</KeepAlive> </KeepAlive>
@@ -3,12 +3,14 @@ withDefaults(
defineProps<{ defineProps<{
filename: string; filename: string;
saveLabel?: string; saveLabel?: string;
showSave?: boolean;
showExportActions?: boolean; showExportActions?: boolean;
showFilename?: boolean; showFilename?: boolean;
busy?: boolean; busy?: boolean;
}>(), }>(),
{ {
saveLabel: '저장', saveLabel: '저장',
showSave: true,
showExportActions: false, showExportActions: false,
showFilename: true, showFilename: true,
busy: false, busy: false,
@@ -36,6 +38,7 @@ defineEmits<{
열기 열기
</button> </button>
<button <button
v-if="showSave"
type="button" type="button"
class="toolbar__btn" class="toolbar__btn"
:disabled="busy" :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 { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { diagramKindFor, isHwp, isOffice } from '@/config/app'; import {
diagramKindFor,
isHwp,
isOffice,
viewerKindFor,
} from '@/config/app';
import { import {
createBlankDocument, createBlankDocument,
createSession, createSession,
@@ -13,18 +18,41 @@ import {
storeHwpFile, storeHwpFile,
} from '@/composables/useSessionLifecycle'; } from '@/composables/useSessionLifecycle';
import { stageDiagramFile } from '@/composables/diagramSession'; import { stageDiagramFile } from '@/composables/diagramSession';
import { stageViewerFiles } from '@/composables/viewerSession';
export function useOpenDocument() { export function useOpenDocument() {
const router = useRouter(); const router = useRouter();
const busy = ref(false); const busy = ref(false);
const error = ref(''); const error = ref('');
async function openDocument(file: File | undefined): Promise<boolean> { async function openDocument(
if (!file || busy.value) return false; 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 = ''; error.value = '';
busy.value = true; busy.value = true;
try { try {
const viewerKind = viewerKindFor(file.name);
if (viewerKind) {
stageViewerFiles(viewerKind, files);
await router.push({ name: `${viewerKind}-viewer` });
return true;
}
const diagramKind = diagramKindFor(file.name); const diagramKind = diagramKindFor(file.name);
if (diagramKind) { if (diagramKind) {
stageDiagramFile(diagramKind, file); 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 const APP_DISPLAY_NAME = "Sejong Office";
export type DiagramKind = "excalidraw" | "mermaid" | "drawio"; export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
export type ViewerKind = "cad" | "model3d";
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = { const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
excalidraw: "excalidraw", excalidraw: "excalidraw",
@@ -15,6 +16,17 @@ const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
xml: "drawio", 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 */ /** HWP/HWPX — client-side rhwp */
export const HWP_EXTENSIONS = new Set(["hwp", "hwpx"]); 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 { export function diagramKindFor(filename: string): DiagramKind | null {
return DIAGRAM_EXTENSION_KIND[extensionOf(filename)] ?? 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' | 'sketch'
| 'code' | 'code'
| 'diagram' | 'diagram'
| 'cad'
| 'model3d'
| 'text' | 'text'
| 'csv' | 'csv'
| 'file'; | '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', 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', code: 'M8 3L6.59 4.41L10.17 8L6.59 11.59L8 13L13 8L8 3M14 11V13H22V11H14M5 19H19V17H5V19Z',
diagram: 'M4 3H10V9H8V11H16V9H14V3H20V9H18V13H13V15H16V21H8V15H11V13H6V9H4V3M6 5V7H8V5H6M16 5V7H18V5H16M10 17V19H14V17H10Z', 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', 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', 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', 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', sketch: '#7b61a8',
code: '#d97706', code: '#d97706',
diagram: '#2e7d8a', diagram: '#2e7d8a',
cad: '#5c6bc0',
model3d: '#6d4c41',
text: '#607d8b', text: '#607d8b',
csv: '#00838f', csv: '#00838f',
file: '#546e7a', file: '#546e7a',
@@ -74,6 +80,14 @@ const EXT_KIND: Record<string, FaviconKind> = {
mermaid: 'code', mermaid: 'code',
drawio: 'diagram', drawio: 'diagram',
xml: 'diagram', xml: 'diagram',
dxf: 'cad',
dwg: 'cad',
glb: 'model3d',
gltf: 'model3d',
obj: 'model3d',
step: 'model3d',
stp: 'model3d',
ifc: 'model3d',
txt: 'text', 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 router from './router';
import './styles.css'; 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', name: 'drawio-editor',
component: () => import('@/views/DrawioEditorView.vue'), 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', path: '/licenses',
name: 'licenses', name: 'licenses',
+2
View File
@@ -41,6 +41,7 @@
html, html,
body { body {
margin: 0; margin: 0;
width: 100%;
height: 100%; height: 100%;
min-height: 100%; min-height: 100%;
font-family: var(--font-sans); font-family: var(--font-sans);
@@ -50,6 +51,7 @@ body {
} }
#app { #app {
width: 100%;
min-height: 100%; min-height: 100%;
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; ] 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) { function onDrop(e: DragEvent) {
dragging.value = false; dragging.value = false;
void openDocument(e.dataTransfer?.files?.[0]); void openDocument(e.dataTransfer?.files);
} }
function onInput(e: Event) { function onInput(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
void openDocument(input.files?.[0]); void openDocument(input.files ?? undefined);
input.value = ''; input.value = '';
} }
@@ -62,7 +77,7 @@ function onCreate(type: BlankDocumentType) {
void createAndOpenBlank(type); void createAndOpenBlank(type);
} }
function onCreateDiagram(route: string) { function onOpenTool(route: string) {
void router.push({ name: route }); void router.push({ name: route });
} }
</script> </script>
@@ -92,7 +107,8 @@ function onCreateDiagram(route: string) {
<input <input
class="drop__input" class="drop__input"
type="file" type="file"
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml" 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" :disabled="busy"
@change="onInput" @change="onInput"
/> />
@@ -100,7 +116,7 @@ function onCreateDiagram(route: string) {
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }} {{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span> </span>
<span class="drop__hint"> <span class="drop__hint">
오피스·PDF · HWP/HWPX · 다이어그램 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D 모델
</span> </span>
</label> </label>
@@ -142,7 +158,32 @@ function onCreateDiagram(route: string) {
type="button" type="button"
class="tile" class="tile"
:disabled="busy" :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" /> <FileTypeIcon :extension="item.extension" :size="36" />
<span class="tile__text"> <span class="tile__text">
+61 -1
View File
@@ -20,6 +20,17 @@ const thirdParty = [
license: 'MIT', license: 'MIT',
url: 'https://github.com/mermaid-js/mermaid', url: 'https://github.com/mermaid-js/mermaid',
}, },
{ name: 'Three.js', license: 'MIT', url: 'https://github.com/mrdoob/three.js' },
{
name: '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: 'Express', license: 'MIT', url: 'https://github.com/expressjs/express' },
{ name: 'jsonwebtoken', license: 'MIT', url: 'https://github.com/auth0/node-jsonwebtoken' }, { name: 'jsonwebtoken', license: 'MIT', url: 'https://github.com/auth0/node-jsonwebtoken' },
{ name: 'multer', license: 'MIT', url: 'https://github.com/expressjs/multer' }, { name: 'multer', license: 'MIT', url: 'https://github.com/expressjs/multer' },
@@ -41,7 +52,7 @@ const thirdParty = [
<section class="card"> <section class="card">
<h2>본 앱</h2> <h2>본 앱</h2>
<p>라이선스: <strong>MIT</strong></p> <p>라이선스: <strong>GNU General Public License v3.0 only</strong></p>
<p v-if="APP_GIT_URL"> <p v-if="APP_GIT_URL">
소스 코드: 소스 코드:
<a :href="APP_GIT_URL" target="_blank" rel="noopener noreferrer">{{ <a :href="APP_GIT_URL" target="_blank" rel="noopener noreferrer">{{
@@ -58,6 +69,55 @@ const thirdParty = [
</p> </p>
</section> </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"> <section class="card">
<h2>Euro-Office Document Server</h2> <h2>Euro-Office Document Server</h2>
<p> <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 {};