From 80eae206b6a3e874f27181de8f9e53e7254be4e4 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Thu, 23 Jul 2026 11:49:29 +0900 Subject: [PATCH] support no face .ply file --- packages/frontend/index.html | 6 +- .../src/components/model3d/Model3DCanvas.vue | 67 +++++++++++++-- .../src/integrations/model3d/pointCloudPly.ts | 84 +++++++++++++++++++ packages/frontend/src/styles.css | 6 +- packages/frontend/src/views/LicensesView.vue | 5 ++ .../frontend/src/views/MermaidEditorView.vue | 2 +- .../frontend/src/views/Model3DViewerView.vue | 3 +- 7 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 packages/frontend/src/integrations/model3d/pointCloudPly.ts 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는 포인트 + 클라우드로 표시됩니다.