support no face .ply file

This commit is contained in:
ysj7705 committed 2026-07-23 11:49:29 +09:00
1 parent 309193a3a7
commit 80eae206b6
7 files changed
+160 -13

No files matched your search

@@ -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<HTMLElement | null>(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);
},
});
}
@@ -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 };
}
+4 -2
View File
@@ -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;
}
@@ -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',
@@ -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);
@@ -331,7 +331,8 @@ function onClose() {
<strong>3D 모델을 선택해 주세요.</strong>
<span>
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
이 영역으로 끌어다 놓을 수 있습니다.
이 영역으로 끌어다 놓을 수 있습니다. 면 없는 PLY는 포인트
클라우드로 표시됩니다.
</span>
<button type="button" @click="onOpen">파일 열기</button>
</div>