support no face .ply file
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user