diff --git a/client/src/components/PointCloudViewer.tsx b/client/src/components/PointCloudViewer.tsx index dcb5b43..369fe9e 100644 --- a/client/src/components/PointCloudViewer.tsx +++ b/client/src/components/PointCloudViewer.tsx @@ -1890,21 +1890,32 @@ export default function PointCloudViewer({ setPlyOverlayStatus({ status: 'loading', message: '正在加载 PLY 叠加层' }); (async () => { try { - // 用 /sample/batch 接口按 filePath 采样,最多 30 万点 - // 支持多文件(各面板 PLY),合并采样 + // 通过受限的分析结果接口加载相对 PLY 路径,支持多文件合并采样 const plyFiles: string[] = Array.isArray(overlay.files) && overlay.files.length > 0 ? overlay.files : [overlay.file]; - const resp = await fetch('/api/files/sample/batch', { + const resp = await fetch('/api/files/sample/analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ - filePaths: plyFiles.map(f => `/data/tasks/${taskId}/analysis/${resultId}/${f}`), + taskId, + resultId, + files: plyFiles, totalPoints: 300_000, }), }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.ok) { + const payload = await resp.json().catch(() => null) as { error?: string } | null; + const detail = payload?.error; + if (resp.status === 404) { + throw new Error(detail ?? '分析结果或 PLY 文件不存在'); + } + if (resp.status === 400 || resp.status === 403) { + throw new Error(detail ?? 'PLY 文件参数或访问权限无效'); + } + throw new Error(detail ?? `服务端采样失败(HTTP ${resp.status})`); + } const hasColor = resp.headers.get('X-Has-Color') === '1'; const stride = hasColor ? 7 : 4; const buf = await resp.arrayBuffer(); @@ -1950,7 +1961,10 @@ export default function PointCloudViewer({ if (e instanceof DOMException && e.name === 'AbortError') return; console.warn('[PlyOverlay] load failed:', e); setPlyOverlayData(null); - setPlyOverlayStatus({ status: 'error', message: '加载 PLY 叠加层失败' }); + setPlyOverlayStatus({ + status: 'error', + message: e instanceof Error ? e.message : '服务端采样失败,请稍后重试', + }); } })(); return () => ac.abort(); diff --git a/server/analysisPlySample.test.ts b/server/analysisPlySample.test.ts new file mode 100644 index 0000000..970fc42 --- /dev/null +++ b/server/analysisPlySample.test.ts @@ -0,0 +1,126 @@ +import express from 'express'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authenticated: true, + resultTaskId: 7, +})); + +vi.mock('./_core/auth.js', () => ({ + authenticateRequest: vi.fn(async () => mocks.authenticated + ? { id: 1, role: 'user', mustChangePassword: 0 } + : null), +})); + +vi.mock('./db', () => ({ + getTaskAnalysisResultById: vi.fn(async (id: number) => id === 11 + ? { id, taskId: mocks.resultTaskId } + : undefined), + listTaskPointcloudFiles: vi.fn(async () => []), +})); + +const dataDir = mkdtempSync(join(tmpdir(), 'analysis-ply-route-')); +process.env.DATA_DIR = dataDir; + +function writePly(relativePath: string, withColor = false): void { + const resultDir = join(dataDir, 'tasks', '7', 'analysis', '11'); + const filePath = join(resultDir, relativePath); + mkdirSync(join(filePath, '..'), { recursive: true }); + const properties = withColor + ? ['x', 'y', 'z', 'red', 'green', 'blue'] + : ['x', 'y', 'z']; + const header = Buffer.from([ + 'ply', + 'format binary_little_endian 1.0', + 'element vertex 2', + ...properties.map((name) => `property float ${name}`), + 'end_header', + '', + ].join('\n'), 'ascii'); + const values = withColor + ? [[1, 2, 3, 255, 0, 128], [4, 5, 6, 0, 255, 64]] + : [[7, 8, 9], [10, 11, 12]]; + const body = Buffer.alloc(values.length * properties.length * 4); + values.flat().forEach((value, index) => body.writeFloatLE(value, index * 4)); + writeFileSync(filePath, Buffer.concat([header, body])); +} + +describe('POST /api/files/sample/analysis', () => { + let origin: string; + let closeServer: () => Promise; + + beforeAll(async () => { + writePly('plain.ply'); + writePly('nested/color.ply', true); + const { fileRouter } = await import('./fileRoutes'); + const app = express(); + app.use(express.json()); + app.use('/api/files', fileRouter); + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + closeServer = () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + }); + + afterAll(async () => { + await closeServer(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + mocks.authenticated = true; + mocks.resultTaskId = 7; + }); + + async function post(body: unknown) { + return fetch(`${origin}/api/files/sample/analysis`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + it('采样单个无色 PLY 并返回 16 字节步长', async () => { + const response = await post({ taskId: 7, resultId: 11, files: ['plain.ply'], totalPoints: 2 }); + expect(response.status).toBe(200); + expect(response.headers.get('X-Point-Count')).toBe('2'); + expect(response.headers.get('X-Has-Color')).toBe('0'); + expect((await response.arrayBuffer()).byteLength).toBe(2 * 16); + }); + + it('采样多个 PLY,彩色响应使用 28 字节步长且不超预算', async () => { + const response = await post({ + taskId: 7, + resultId: 11, + files: ['plain.ply', 'nested/color.ply'], + totalPoints: 3, + }); + expect(response.status).toBe(200); + expect(response.headers.get('X-Point-Count')).toBe('3'); + expect(response.headers.get('X-Has-Color')).toBe('1'); + expect((await response.arrayBuffer()).byteLength).toBe(3 * 28); + }); + + it('拒绝未登录访问', async () => { + mocks.authenticated = false; + expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(401); + }); + + it('拒绝错误的任务与结果组合', async () => { + mocks.resultTaskId = 8; + expect((await post({ taskId: 7, resultId: 11, files: ['plain.ply'] })).status).toBe(404); + }); + + it.each([ + ['路径穿越', '../plain.ply', 403], + ['绝对路径', '/tmp/plain.ply', 400], + ['非 PLY 文件', 'result.json', 400], + ['缺失文件', 'missing.ply', 404], + ])('拒绝%s', async (_label, file, status) => { + expect((await post({ taskId: 7, resultId: 11, files: [file] })).status).toBe(status); + }); +}); diff --git a/server/fileRoutes.ts b/server/fileRoutes.ts index 78016e0..500e4a1 100644 --- a/server/fileRoutes.ts +++ b/server/fileRoutes.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import multer from 'multer'; import * as db from './db'; -import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js'; +import { samplePointcloudFiles } from './pointcloudSample.js'; import { forceReload as reloadPluginRegistry } from './plugins/registry.js'; import { authenticateRequest } from './_core/auth.js'; import { inspectPointcloudMetadata } from './pointcloudMetadata.js'; @@ -88,7 +88,19 @@ function formatFileSize(bytes: number): string { function safeResolve(base: string, filename: string): string | null { const resolved = path.resolve(base, decodeURIComponent(filename)); - return resolved.startsWith(path.resolve(base)) ? resolved : null; + const relative = path.relative(path.resolve(base), resolved); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative) + ? resolved + : null; +} + +function sendSampledPointcloudBatch(res: Parameters[1], batch: Awaited>) { + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('X-Point-Count', batch.pointCount); + res.setHeader('X-Has-Color', batch.hasColor ? '1' : '0'); + res.setHeader('X-Has-Error', batch.hasError ? '1' : '0'); + res.setHeader('X-File-Stats', JSON.stringify(batch.fileStats)); + res.send(batch.buffer); } // ─── 车型 CAD 文件上传 ──────────────────────────────────────────────────────── @@ -333,7 +345,6 @@ router.post('/sample/batch', requireFileRead, async (req, res) => { return res.send(empty); } - const budget = Math.min(totalPoints, 2000000); const supportedExts = ['.ply', '.txt', '.xyz', '.pts']; const fileInfos = paths.map((fp) => ({ @@ -348,66 +359,100 @@ router.post('/sample/batch', requireFileRead, async (req, res) => { console.log( `[FileRoutes] Batch sample: ${fileInfos.length} files ` + - `(${(totalBytes / 1e9).toFixed(2)} GB), budget=${budget}` + `(${(totalBytes / 1e9).toFixed(2)} GB), budget=${totalPoints}` ); try { - const quotaMap = new Map(); - for (const f of supportedFiles) { - const quota = totalBytes > 0 - ? Math.max(100, Math.round((f.size / totalBytes) * budget)) - : Math.floor(budget / supportedFiles.length); - quotaMap.set(f.filePath, quota); - } - - const sampledPerFile = await Promise.all( - fileInfos.map((f) => { - if (!supportedExts.includes(f.ext) || f.size === 0) return Promise.resolve([] as SampledPoint[]); - return samplePointcloudFile(f.filePath, f.size, quotaMap.get(f.filePath) ?? 0); - }) - ); - - const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0); - // 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节 - const hasError = sampledPerFile.some((pts) => pts.some((p) => Number.isFinite(p.error))); - const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined)); - const bytesPerPoint = hasColor ? 28 : 16; - const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint); - let offset = 0; - for (const pts of sampledPerFile) { - for (const p of pts) { - binBuf.writeFloatLE(p.x, offset); - binBuf.writeFloatLE(p.y, offset + 4); - binBuf.writeFloatLE(p.z, offset + 8); - binBuf.writeFloatLE(p.error ?? Number.NaN, offset + 12); - if (hasColor) { - binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16); - binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20); - binBuf.writeFloatLE((p.b ?? 128) / 255, offset + 24); - } - offset += bytesPerPoint; - } - } - - const fileStats = fileInfos.map((f, i) => ({ - name: f.name, - sizeGB: +(f.size / 1e9).toFixed(3), - sampled: sampledPerFile[i].length, - })); - - console.log(`[FileRoutes] Batch done: ${totalSampled} pts, ${(binBuf.length / 1e6).toFixed(1)} MB`); - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader('X-Point-Count', totalSampled); - res.setHeader('X-Has-Color', hasColor ? '1' : '0'); - res.setHeader('X-Has-Error', hasError ? '1' : '0'); - res.setHeader('X-File-Stats', JSON.stringify(fileStats)); - res.send(binBuf); + const batch = await samplePointcloudFiles(supportedFiles.map((file) => file.filePath), totalPoints); + console.log(`[FileRoutes] Batch done: ${batch.pointCount} pts, ${(batch.buffer.length / 1e6).toFixed(1)} MB`); + sendSampledPointcloudBatch(res, batch); } catch (err) { console.error('[FileRoutes] Batch sampling error:', err); res.status(500).json({ error: 'Failed to sample files' }); } }); +// ─── 分析结果 PLY 批量采样接口 ─────────────────────────────────────────────── +// POST /api/files/sample/analysis +// Body: { taskId: number, resultId: number, files: string[], totalPoints?: number } + +router.post('/sample/analysis', requireFileRead, async (req, res) => { + const { taskId, resultId, files, totalPoints = 300_000 } = req.body as { + taskId?: number; + resultId?: number; + files?: unknown; + totalPoints?: number; + }; + if (!Number.isSafeInteger(taskId) || Number(taskId) <= 0 || + !Number.isSafeInteger(resultId) || Number(resultId) <= 0 || + !Number.isSafeInteger(totalPoints) || totalPoints <= 0) { + return res.status(400).json({ error: '分析结果采样参数无效' }); + } + if (!Array.isArray(files) || files.length === 0 || + files.some((file) => typeof file !== 'string' || file.length === 0)) { + return res.status(400).json({ error: '请提供 PLY 文件列表' }); + } + + const validTaskId = taskId as number; + const validResultId = resultId as number; + const validTotalPoints = totalPoints as number; + + const row = await db.getTaskAnalysisResultById(validResultId); + if (!row || row.taskId !== validTaskId) { + return res.status(404).json({ error: '分析结果不存在或不属于当前任务' }); + } + + const baseDir = path.join(TASKS_DIR, String(validTaskId), 'analysis', String(validResultId)); + let realBaseDir: string; + try { + realBaseDir = fs.realpathSync(baseDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return res.status(404).json({ error: '分析结果目录不存在' }); + } + throw error; + } + const resolvedFiles: string[] = []; + for (const file of files as string[]) { + if (path.isAbsolute(file) || path.extname(file).toLowerCase() !== '.ply') { + return res.status(400).json({ error: '只允许读取分析结果目录中的相对 PLY 路径' }); + } + let filePath: string | null; + try { + filePath = safeResolve(baseDir, file); + } catch { + return res.status(400).json({ error: 'PLY 文件路径编码无效' }); + } + if (!filePath) return res.status(403).json({ error: 'PLY 文件路径越界' }); + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return res.status(404).json({ error: `PLY 文件不存在: ${file}` }); + } + throw error; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return res.status(400).json({ error: `PLY 路径不是普通文件: ${file}` }); + } + const realFilePath = fs.realpathSync(filePath); + const realRelative = path.relative(realBaseDir, realFilePath); + if (realRelative === '' || realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) { + return res.status(403).json({ error: 'PLY 文件路径越界' }); + } + resolvedFiles.push(realFilePath); + } + + try { + const batch = await samplePointcloudFiles(resolvedFiles, validTotalPoints, 300_000); + sendSampledPointcloudBatch(res, batch); + } catch (error) { + console.error('[FileRoutes] Analysis PLY sampling error:', error); + return res.status(500).json({ error: '分析结果 PLY 采样失败' }); + } +}); + // ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)────────────────────── // GET /api/files/read/analysis/heatmap/:taskId/:resultId diff --git a/server/pointcloudSample.test.ts b/server/pointcloudSample.test.ts index 17502a2..98a973e 100644 --- a/server/pointcloudSample.test.ts +++ b/server/pointcloudSample.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { samplePointcloudFile } from './pointcloudSample'; +import { samplePointcloudFile, samplePointcloudFiles } from './pointcloudSample'; const temporaryDirectories: string[] = []; @@ -58,3 +58,32 @@ describe('samplePointcloudFile', () => { expect(points.map((point) => point.error)).toEqual([0.25, -0.5]); }); }); + +describe('samplePointcloudFiles', () => { + it('合并彩色与无色 PLY,并使用 28 字节彩色步长', async () => { + const plain = writeBinaryPly(['x', 'y', 'z'], [[1, 2, 3], [4, 5, 6]]); + const colored = writeBinaryPly( + ['x', 'y', 'z', 'red', 'green', 'blue'], + [[7, 8, 9, 255, 128, 0], [10, 11, 12, 0, 64, 255]], + ); + + const result = await samplePointcloudFiles([plain, colored], 4, 10); + + expect(result.pointCount).toBe(4); + expect(result.hasColor).toBe(true); + expect(result.hasError).toBe(false); + expect(result.buffer.byteLength).toBe(4 * 28); + expect(result.fileStats.map((file) => file.sampled)).toEqual([2, 2]); + }); + + it('限制总采样数量,无色 PLY 使用 16 字节步长', async () => { + const first = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i, i, i])); + const second = writeBinaryPly(['x', 'y', 'z'], Array.from({ length: 10 }, (_, i) => [i + 10, i, i])); + + const result = await samplePointcloudFiles([first, second], 100, 6); + + expect(result.pointCount).toBe(6); + expect(result.hasColor).toBe(false); + expect(result.buffer.byteLength).toBe(6 * 16); + }); +}); diff --git a/server/pointcloudSample.ts b/server/pointcloudSample.ts index 498e3d5..b883c13 100644 --- a/server/pointcloudSample.ts +++ b/server/pointcloudSample.ts @@ -3,6 +3,14 @@ import * as path from 'path'; export type SampledPoint = { x: number; y: number; z: number; error?: number; r?: number; g?: number; b?: number }; +export type SampledPointcloudBatch = { + buffer: Buffer; + pointCount: number; + hasColor: boolean; + hasError: boolean; + fileStats: Array<{ name: string; sizeGB: number; sampled: number }>; +}; + /** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */ export async function randomSeekSample( filePath: string, @@ -356,3 +364,70 @@ export async function samplePointcloudFile( // .txt / .xyz / .pts / etc. — existing text line sampler return randomSeekSample(filePath, fileSize, k); } + +/** + * 对一组已校验的点云文件按文件大小分配总采样配额,并编码为浏览器消费的二进制协议。 + * 每点固定包含 x/y/z/error;任一文件带颜色时,所有点再包含归一化后的 r/g/b。 + */ +export async function samplePointcloudFiles( + filePaths: string[], + requestedTotalPoints: number, + maxTotalPoints = 2_000_000, +): Promise { + const totalPoints = Number.isFinite(requestedTotalPoints) + ? Math.max(0, Math.min(Math.floor(requestedTotalPoints), maxTotalPoints)) + : 0; + const fileInfos = filePaths.map((filePath) => ({ + filePath, + name: path.basename(filePath), + size: fs.statSync(filePath).size, + })); + const totalBytes = fileInfos.reduce((sum, file) => sum + file.size, 0); + + // floor 后把剩余名额依次补给文件,保证所有配额之和严格不超过总预算。 + const quotas = fileInfos.map((file) => totalBytes > 0 + ? Math.floor((file.size / totalBytes) * totalPoints) + : 0); + let remainder = totalPoints - quotas.reduce((sum, quota) => sum + quota, 0); + for (let index = 0; remainder > 0 && fileInfos.length > 0; index = (index + 1) % fileInfos.length) { + quotas[index] += 1; + remainder -= 1; + } + + const sampledPerFile = await Promise.all( + fileInfos.map((file, index) => samplePointcloudFile(file.filePath, file.size, quotas[index])), + ); + const pointCount = sampledPerFile.reduce((sum, points) => sum + points.length, 0); + const hasError = sampledPerFile.some((points) => points.some((point) => Number.isFinite(point.error))); + const hasColor = sampledPerFile.some((points) => points.some((point) => point.r !== undefined)); + const bytesPerPoint = hasColor ? 28 : 16; + const buffer = Buffer.allocUnsafe(pointCount * bytesPerPoint); + let offset = 0; + + for (const points of sampledPerFile) { + for (const point of points) { + buffer.writeFloatLE(point.x, offset); + buffer.writeFloatLE(point.y, offset + 4); + buffer.writeFloatLE(point.z, offset + 8); + buffer.writeFloatLE(point.error ?? Number.NaN, offset + 12); + if (hasColor) { + buffer.writeFloatLE((point.r ?? 128) / 255, offset + 16); + buffer.writeFloatLE((point.g ?? 128) / 255, offset + 20); + buffer.writeFloatLE((point.b ?? 128) / 255, offset + 24); + } + offset += bytesPerPoint; + } + } + + return { + buffer, + pointCount, + hasColor, + hasError, + fileStats: fileInfos.map((file, index) => ({ + name: file.name, + sizeGB: +(file.size / 1e9).toFixed(3), + sampled: sampledPerFile[index].length, + })), + }; +}