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
@@ -10,11 +10,11 @@
|
|||||||
type="image/svg+xml"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
|
as="style"
|
||||||
|
crossorigin
|
||||||
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ import {
|
|||||||
meshInstancePropertyGroups,
|
meshInstancePropertyGroups,
|
||||||
modelPropertyGroups,
|
modelPropertyGroups,
|
||||||
} from '@/integrations/model3d/o3dv';
|
} from '@/integrations/model3d/o3dv';
|
||||||
|
import {
|
||||||
|
createPointCloudFromPly,
|
||||||
|
isNoFacesImportError,
|
||||||
|
isPlyFileName,
|
||||||
|
} from '@/integrations/model3d/pointCloudPly';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
files: readonly File[];
|
files: readonly File[];
|
||||||
@@ -67,6 +72,8 @@ const host = ref<HTMLElement | null>(null);
|
|||||||
let viewer: OV.Viewer | null = null;
|
let viewer: OV.Viewer | null = null;
|
||||||
let loader: OV.ThreeModelLoader | null = null;
|
let loader: OV.ThreeModelLoader | null = null;
|
||||||
let model: OV.Model | null = null;
|
let model: OV.Model | null = null;
|
||||||
|
let pointCloudMode = false;
|
||||||
|
let pointCloudGroups: ModelPropertyGroup[] = [];
|
||||||
let canvas: HTMLCanvasElement | null = null;
|
let canvas: HTMLCanvasElement | null = null;
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
let loadSequence = 0;
|
let loadSequence = 0;
|
||||||
@@ -98,7 +105,7 @@ function applyEnvironment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyEdges(enabled: boolean) {
|
function applyEdges(enabled: boolean) {
|
||||||
if (!viewer) return;
|
if (!viewer || pointCloudMode) return;
|
||||||
viewer.SetEdgeSettings(
|
viewer.SetEdgeSettings(
|
||||||
new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15),
|
new OV.EdgeSettings(enabled, new OV.RGBColor(0, 0, 0), 15),
|
||||||
);
|
);
|
||||||
@@ -119,7 +126,7 @@ function applyUp(axis: 'y' | 'z') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fitCamera() {
|
function fitCamera() {
|
||||||
if (!viewer || !model) return;
|
if (!viewer || (!model && !pointCloudMode)) return;
|
||||||
const sphere = viewer.GetBoundingSphere(() => true);
|
const sphere = viewer.GetBoundingSphere(() => true);
|
||||||
if (!sphere) return;
|
if (!sphere) return;
|
||||||
viewer.AdjustClippingPlanesToSphere(sphere);
|
viewer.AdjustClippingPlanesToSphere(sphere);
|
||||||
@@ -159,7 +166,7 @@ function addMeasureLine(a: Vector3, b: Vector3) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyVisibility() {
|
function applyVisibility() {
|
||||||
if (!viewer) return;
|
if (!viewer || pointCloudMode) return;
|
||||||
viewer.SetMeshesVisibility((userData: {
|
viewer.SetMeshesVisibility((userData: {
|
||||||
originalMeshInstance?: OV.MeshInstance;
|
originalMeshInstance?: OV.MeshInstance;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -170,7 +177,7 @@ function applyVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyHighlight() {
|
function applyHighlight() {
|
||||||
if (!viewer) return;
|
if (!viewer || pointCloudMode) return;
|
||||||
viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: {
|
viewer.SetMeshesHighlight(new OV.RGBColor(255, 200, 0), (userData: {
|
||||||
originalMeshInstance?: OV.MeshInstance;
|
originalMeshInstance?: OV.MeshInstance;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -180,6 +187,10 @@ function applyHighlight() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectByKey(key: string | null) {
|
function selectByKey(key: string | null) {
|
||||||
|
if (pointCloudMode) {
|
||||||
|
emit('select', null, pointCloudGroups);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!model) {
|
if (!model) {
|
||||||
emit('select', null, []);
|
emit('select', null, []);
|
||||||
return;
|
return;
|
||||||
@@ -191,9 +202,10 @@ function selectByKey(key: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
||||||
if (!viewer || !model) return;
|
if (!viewer || (!model && !pointCloudMode)) return;
|
||||||
|
|
||||||
if (props.measureMode) {
|
if (props.measureMode) {
|
||||||
|
if (pointCloudMode) return;
|
||||||
const hit = viewer.GetMeshIntersectionUnderMouse(
|
const hit = viewer.GetMeshIntersectionUnderMouse(
|
||||||
OV.IntersectionMode.MeshOnly,
|
OV.IntersectionMode.MeshOnly,
|
||||||
mouseCoords,
|
mouseCoords,
|
||||||
@@ -224,6 +236,11 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pointCloudMode) {
|
||||||
|
selectByKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const userData = viewer.GetMeshUserDataUnderMouse(
|
const userData = viewer.GetMeshUserDataUnderMouse(
|
||||||
OV.IntersectionMode.MeshOnly,
|
OV.IntersectionMode.MeshOnly,
|
||||||
mouseCoords,
|
mouseCoords,
|
||||||
@@ -232,11 +249,33 @@ function onMouseClick(_button: number, mouseCoords: { x: number; y: number }) {
|
|||||||
selectByKey(key);
|
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[]) {
|
async function load(files: readonly File[]) {
|
||||||
const sequence = ++loadSequence;
|
const sequence = ++loadSequence;
|
||||||
if (!viewer || !loader) return;
|
if (!viewer || !loader) return;
|
||||||
clearMeasure();
|
clearMeasure();
|
||||||
model = null;
|
model = null;
|
||||||
|
pointCloudMode = false;
|
||||||
|
pointCloudGroups = [];
|
||||||
viewer.Clear();
|
viewer.Clear();
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
@@ -263,6 +302,8 @@ async function load(files: readonly File[]) {
|
|||||||
onTextureLoaded: () => viewer?.Render(),
|
onTextureLoaded: () => viewer?.Render(),
|
||||||
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
|
onModelFinished: (importResult: OV.ImportResult, threeObject: unknown) => {
|
||||||
if (sequence !== loadSequence) return;
|
if (sequence !== loadSequence) return;
|
||||||
|
pointCloudMode = false;
|
||||||
|
pointCloudGroups = [];
|
||||||
viewer?.SetMainObject(threeObject);
|
viewer?.SetMainObject(threeObject);
|
||||||
model = importResult.model;
|
model = importResult.model;
|
||||||
applyVisibility();
|
applyVisibility();
|
||||||
@@ -285,7 +326,21 @@ async function load(files: readonly File[]) {
|
|||||||
},
|
},
|
||||||
onLoadError: (importError: OV.ImportError) => {
|
onLoadError: (importError: OV.ImportError) => {
|
||||||
if (sequence !== loadSequence) return;
|
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: #ffffff;
|
||||||
--card-hover: #f5f5f5;
|
--card-hover: #f5f5f5;
|
||||||
--danger: #c62828;
|
--danger: #c62828;
|
||||||
--font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
|
||||||
--font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif;
|
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;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ const thirdParty = [
|
|||||||
license: 'Apache-2.0',
|
license: 'Apache-2.0',
|
||||||
url: 'https://github.com/mozilla/pdf.js',
|
url: 'https://github.com/mozilla/pdf.js',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Pretendard',
|
||||||
|
license: 'SIL OFL 1.1',
|
||||||
|
url: 'https://github.com/orioncactus/pretendard',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Noto Sans KR',
|
name: 'Noto Sans KR',
|
||||||
license: 'SIL OFL 1.1',
|
license: 'SIL OFL 1.1',
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async function renderDiagram() {
|
|||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
securityLevel: 'strict',
|
securityLevel: 'strict',
|
||||||
theme: theme.value === 'dark' ? 'dark' : 'default',
|
theme: theme.value === 'dark' ? 'dark' : 'default',
|
||||||
fontFamily: 'IBM Plex Sans KR, sans-serif',
|
fontFamily: 'Pretendard Variable, Pretendard, sans-serif',
|
||||||
htmlLabels: false,
|
htmlLabels: false,
|
||||||
});
|
});
|
||||||
await mermaid.parse(code.value);
|
await mermaid.parse(code.value);
|
||||||
|
|||||||
@@ -331,7 +331,8 @@ function onClose() {
|
|||||||
<strong>3D 모델을 선택해 주세요.</strong>
|
<strong>3D 모델을 선택해 주세요.</strong>
|
||||||
<span>
|
<span>
|
||||||
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
|
Online3DViewer가 지원하는 형식과 ZIP, 보조 텍스처를 함께 열거나
|
||||||
이 영역으로 끌어다 놓을 수 있습니다.
|
이 영역으로 끌어다 놓을 수 있습니다. 면 없는 PLY는 포인트
|
||||||
|
클라우드로 표시됩니다.
|
||||||
</span>
|
</span>
|
||||||
<button type="button" @click="onOpen">파일 열기</button>
|
<button type="button" @click="onOpen">파일 열기</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user