diff --git a/packages/frontend/index.html b/packages/frontend/index.html
index c0674bb..b89ad6e 100644
--- a/packages/frontend/index.html
+++ b/packages/frontend/index.html
@@ -10,11 +10,11 @@
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%230f6cbd' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E"
/>
-
-
diff --git a/packages/frontend/src/components/model3d/Model3DCanvas.vue b/packages/frontend/src/components/model3d/Model3DCanvas.vue
index b5b03fc..9907b3d 100644
--- a/packages/frontend/src/components/model3d/Model3DCanvas.vue
+++ b/packages/frontend/src/components/model3d/Model3DCanvas.vue
@@ -35,6 +35,11 @@ import {
meshInstancePropertyGroups,
modelPropertyGroups,
} from '@/integrations/model3d/o3dv';
+import {
+ createPointCloudFromPly,
+ isNoFacesImportError,
+ isPlyFileName,
+} from '@/integrations/model3d/pointCloudPly';
const props = defineProps<{
files: readonly File[];
@@ -67,6 +72,8 @@ const host = ref(null);
let viewer: OV.Viewer | null = null;
let loader: OV.ThreeModelLoader | null = null;
let model: OV.Model | null = null;
+let pointCloudMode = false;
+let pointCloudGroups: ModelPropertyGroup[] = [];
let canvas: HTMLCanvasElement | null = null;
let resizeObserver: ResizeObserver | null = null;
let loadSequence = 0;
@@ -98,7 +105,7 @@ function applyEnvironment() {
}
function applyEdges(enabled: boolean) {
- if (!viewer) return;
+ if (!viewer || pointCloudMode) return;
viewer.SetEdgeSettings(
new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15),
);
@@ -119,7 +126,7 @@ function applyUp(axis: 'y' | 'z') {
}
function fitCamera() {
- if (!viewer || !model) return;
+ if (!viewer || (!model && !pointCloudMode)) return;
const sphere = viewer.GetBoundingSphere(() => true);
if (!sphere) return;
viewer.AdjustClippingPlanesToSphere(sphere);
@@ -159,7 +166,7 @@ function addMeasureLine(a: Vector3, b: Vector3) {
}
function applyVisibility() {
- if (!viewer) return;
+ if (!viewer || pointCloudMode) return;
viewer.SetMeshesVisibility((userData: {
originalMeshInstance?: OV.MeshInstance;
}) => {
@@ -170,7 +177,7 @@ function applyVisibility() {
}
function applyHighlight() {
- if (!viewer) return;
+ if (!viewer || pointCloudMode) return;
viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: {
originalMeshInstance?: OV.MeshInstance;
}) => {
@@ -180,6 +187,10 @@ function applyHighlight() {
}
function selectByKey(key: string | null) {
+ if (pointCloudMode) {
+ emit('select', null, pointCloudGroups);
+ return;
+ }
if (!model) {
emit('select', null, []);
return;
@@ -191,9 +202,10 @@ function selectByKey(key: string | null) {
}
function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
- if (!viewer || !model) return;
+ if (!viewer || (!model && !pointCloudMode)) return;
if (props.measureMode) {
+ if (pointCloudMode) return;
const hit = viewer.GetMeshIntersectionUnderMouse(
OV.IntersectionMode.MeshOnly,
mouseCoords,
@@ -224,6 +236,11 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
return;
}
+ if (pointCloudMode) {
+ selectByKey(null);
+ return;
+ }
+
const userData = viewer.GetMeshUserDataUnderMouse(
OV.IntersectionMode.MeshOnly,
mouseCoords,
@@ -232,11 +249,33 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
selectByKey(key);
}
+async function loadPointCloudPly(file: File, sequence: number) {
+ emit('progress', 0.4, '포인트 클라우드 읽는 중');
+ const result = await createPointCloudFromPly(file);
+ if (sequence !== loadSequence || !viewer) return;
+ pointCloudMode = true;
+ pointCloudGroups = result.groups;
+ model = null;
+ viewer.Clear();
+ viewer.SetMainObject(result.object);
+ applyProjection(props.projection);
+ applyUp(props.upAxis);
+ void nextTick(() => {
+ if (sequence !== loadSequence) return;
+ fitCamera();
+ emit('progress', 1, '포인트 클라우드 준비 완료');
+ emit('loaded', result.stats, result.tree, result.groups, []);
+ selectByKey(null);
+ });
+}
+
async function load(files: readonly File[]) {
const sequence = ++loadSequence;
if (!viewer || !loader) return;
clearMeasure();
model = null;
+ pointCloudMode = false;
+ pointCloudGroups = [];
viewer.Clear();
if (!files.length) return;
@@ -263,6 +302,8 @@ async function load(files: readonly File[]) {
onTextureLoaded: () => viewer?.Render(),
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
if (sequence !== loadSequence) return;
+ pointCloudMode = false;
+ pointCloudGroups = [];
viewer?.SetMainObject(threeObject);
model = importResult.model;
applyVisibility();
@@ -285,7 +326,21 @@ async function load(files: readonly File[]) {
},
onLoadError: (importError: OV.ImportError) => {
if (sequence !== loadSequence) return;
- emit('error', importErrorMessage(importError));
+ const message = importErrorMessage(importError);
+ const ply = files.find((file) => isPlyFileName(file.name));
+ if (ply && isNoFacesImportError(message)) {
+ void loadPointCloudPly(ply, sequence).catch((reason) => {
+ if (sequence !== loadSequence) return;
+ emit(
+ 'error',
+ reason instanceof Error
+ ? reason.message
+ : '포인트 클라우드 PLY를 열지 못했습니다.',
+ );
+ });
+ return;
+ }
+ emit('error', message);
},
});
}
diff --git a/packages/frontend/src/integrations/model3d/pointCloudPly.ts b/packages/frontend/src/integrations/model3d/pointCloudPly.ts
new file mode 100644
index 0000000..19bba44
--- /dev/null
+++ b/packages/frontend/src/integrations/model3d/pointCloudPly.ts
@@ -0,0 +1,84 @@
+import {
+ BufferGeometry,
+ Color,
+ Group,
+ Points,
+ PointsMaterial,
+} from 'three';
+import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader.js';
+import type { ModelPropertyGroup, ModelStats, ModelTreeNode } from './types';
+
+export function isPlyFileName(name: string) {
+ return /\.ply$/i.test(name);
+}
+
+export function isNoFacesImportError(message: string) {
+ return /no faces/i.test(message);
+}
+
+export async function createPointCloudFromPly(file: File): Promise<{
+ object: Group;
+ pointCount: number;
+ hasColors: boolean;
+ stats: ModelStats;
+ tree: ModelTreeNode;
+ groups: ModelPropertyGroup[];
+}> {
+ const buffer = await file.arrayBuffer();
+ const geometry = new PLYLoader().parse(buffer) as BufferGeometry;
+ const position = geometry.getAttribute('position');
+ if (!position || position.count < 1) {
+ throw new Error('PLY에 표시할 포인트가 없습니다.');
+ }
+
+ geometry.computeBoundingSphere();
+ const hasColors = Boolean(geometry.getAttribute('color'));
+ const radius = geometry.boundingSphere?.radius || 1;
+ const material = new PointsMaterial({
+ size: Math.max(radius / 250, 0.0005),
+ sizeAttenuation: true,
+ vertexColors: hasColors,
+ color: hasColors ? new Color(0xffffff) : new Color(0x4fc3f7),
+ });
+
+ const points = new Points(geometry, material);
+ points.name = file.name;
+ const object = new Group();
+ object.name = file.name;
+ object.add(points);
+
+ const pointCount = position.count;
+ const stats: ModelStats = {
+ nodes: 1,
+ meshes: 0,
+ meshInstances: 0,
+ vertices: pointCount,
+ triangles: 0,
+ materials: 1,
+ };
+ const tree: ModelTreeNode = {
+ id: 'pointcloud-root',
+ name: file.name,
+ type: '포인트 클라우드',
+ children: [
+ {
+ id: 'pointcloud-points',
+ name: `포인트 ${pointCount.toLocaleString()}개`,
+ type: 'points',
+ children: [],
+ },
+ ],
+ };
+ const groups: ModelPropertyGroup[] = [
+ {
+ name: '포인트 클라우드',
+ properties: [
+ { name: '파일', value: file.name },
+ { name: '포인트', value: pointCount.toLocaleString() },
+ { name: '정점 색', value: hasColors ? '있음' : '없음' },
+ ],
+ },
+ ];
+
+ return { object, pointCount, hasColors, stats, tree, groups };
+}
diff --git a/packages/frontend/src/styles.css b/packages/frontend/src/styles.css
index d13f61f..68d97da 100644
--- a/packages/frontend/src/styles.css
+++ b/packages/frontend/src/styles.css
@@ -10,8 +10,10 @@
--card: #ffffff;
--card-hover: #f5f5f5;
--danger: #c62828;
- --font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
- --font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
+ --font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
+ system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
+ --font-display: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
+ system-ui, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
color-scheme: light;
}
diff --git a/packages/frontend/src/views/LicensesView.vue b/packages/frontend/src/views/LicensesView.vue
index 436614c..f18f0df 100644
--- a/packages/frontend/src/views/LicensesView.vue
+++ b/packages/frontend/src/views/LicensesView.vue
@@ -86,6 +86,11 @@ const thirdParty = [
license: 'Apache-2.0',
url: 'https://github.com/mozilla/pdf.js',
},
+ {
+ name: 'Pretendard',
+ license: 'SIL OFL 1.1',
+ url: 'https://github.com/orioncactus/pretendard',
+ },
{
name: 'Noto Sans KR',
license: 'SIL OFL 1.1',
diff --git a/packages/frontend/src/views/MermaidEditorView.vue b/packages/frontend/src/views/MermaidEditorView.vue
index d705605..bc4d003 100644
--- a/packages/frontend/src/views/MermaidEditorView.vue
+++ b/packages/frontend/src/views/MermaidEditorView.vue
@@ -58,7 +58,7 @@ async function renderDiagram() {
startOnLoad: false,
securityLevel: 'strict',
theme: theme.value === 'dark' ? 'dark' : 'default',
- fontFamily: 'IBM Plex Sans KR, sans-serif',
+ fontFamily: 'Pretendard Variable, Pretendard, sans-serif',
htmlLabels: false,
});
await mermaid.parse(code.value);
diff --git a/packages/frontend/src/views/Model3DViewerView.vue b/packages/frontend/src/views/Model3DViewerView.vue
index 1bb60f6..0e95d21 100644
--- a/packages/frontend/src/views/Model3DViewerView.vue
+++ b/packages/frontend/src/views/Model3DViewerView.vue
@@ -331,7 +331,8 @@ function onClose() {
3D 모델을 선택해 주세요.
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
- 이 영역으로 끌어다 놓을 수 있습니다.
+ 이 영역으로 끌어다 놓을 수 있습니다. 면 없는 PLY는 포인트
+ 클라우드로 표시됩니다.