fix(installation-demo): stabilize tool model loading
This commit is contained in:
parent
7e2f20bba9
commit
d6e3be000b
@ -17,6 +17,7 @@ import {
|
||||
resolveInstallationNodeLocalGeometryCenter,
|
||||
resolveInstallationToolFollowTransform,
|
||||
} from '../lib/installationNodeGeometry'
|
||||
import { isInstallationToolObjAsset } from '../lib/installationToolAssetFormat'
|
||||
|
||||
interface ToolAttachmentRecord {
|
||||
root: THREE.Group
|
||||
@ -48,8 +49,7 @@ interface InstallationToolAttachmentOptions {
|
||||
}
|
||||
|
||||
function loadToolAsset(tool: InstallationToolItem): Promise<THREE.Group> {
|
||||
const normalizedUrl = tool.modelUrl.toLowerCase()
|
||||
if (normalizedUrl.endsWith('.obj')) {
|
||||
if (isInstallationToolObjAsset(tool)) {
|
||||
return new Promise((resolve, reject) => {
|
||||
new OBJLoader().load(tool.modelUrl, (object) => resolve(object), undefined, reject)
|
||||
})
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import { isInstallationToolObjAsset } from './installationToolAssetFormat'
|
||||
|
||||
describe('isInstallationToolObjAsset', () => {
|
||||
it('recognizes a signed OBJ URL by its pathname', () => {
|
||||
expect(
|
||||
isInstallationToolObjAsset({
|
||||
fileName: 'driver.obj',
|
||||
modelUrl: 'https://cdn.example.com/tools/driver.obj?e=123&token=temporary',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes an OBJ object key behind the stable object access route', () => {
|
||||
expect(
|
||||
isInstallationToolObjAsset({
|
||||
fileName: 'driver.obj',
|
||||
modelUrl: '/api/storage/object?key=tools%2Fdriver.obj',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not route GLB assets through the OBJ loader', () => {
|
||||
expect(
|
||||
isInstallationToolObjAsset({
|
||||
fileName: 'driver.glb',
|
||||
modelUrl: '/api/storage/object?key=tools%2Fdriver.glb',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,23 @@
|
||||
export interface InstallationToolAssetReference {
|
||||
fileName?: string | null
|
||||
modelUrl: string
|
||||
}
|
||||
|
||||
function hasObjExtension(value: string | null | undefined): boolean {
|
||||
if (!value) return false
|
||||
|
||||
try {
|
||||
const parsed = new URL(value, 'http://installation-tool.local')
|
||||
const objectKey = parsed.searchParams.get('key')
|
||||
return [parsed.pathname, objectKey].some((candidate) => /\.obj$/i.test(candidate ?? ''))
|
||||
} catch {
|
||||
return /\.obj(?:[?#]|$)/i.test(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function isInstallationToolObjAsset({
|
||||
fileName,
|
||||
modelUrl,
|
||||
}: InstallationToolAssetReference): boolean {
|
||||
return hasObjExtension(fileName) || hasObjExtension(modelUrl)
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
normalizeObjectStorageUrlForPersistence: jest.fn((value: string) =>
|
||||
value.includes('driver.obj')
|
||||
? '/api/storage/object?key=installation-tools%2Fdriver.obj'
|
||||
: value,
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/repositories/installation-demo-tool.repository', () => ({
|
||||
installationDemoToolRepository: {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByToolCode: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
listDrawingConfigs: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./installation-demo-access.service', () => ({
|
||||
installationDemoAccessService: {
|
||||
getAccess: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
|
||||
import { installationDemoToolRepository } from '@/server/repositories/installation-demo-tool.repository'
|
||||
import { installationDemoAccessService } from './installation-demo-access.service'
|
||||
import { InstallationDemoToolService } from './installation-demo-tool.service'
|
||||
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test user',
|
||||
role: 'ADMIN' as const,
|
||||
businessRoles: [],
|
||||
status: 'ACTIVE' as const,
|
||||
}
|
||||
|
||||
const legacyTool = {
|
||||
id: 'tool-1',
|
||||
toolCode: 'TOOL-DRIVER',
|
||||
name: 'Screwdriver',
|
||||
modelUrl: 'https://cdn.example.com/installation-tools/driver.obj?e=123&token=temporary',
|
||||
fileName: 'driver.obj',
|
||||
mountOffset: [0, 0, 0],
|
||||
mountRotation: [0, 0, 0, 1],
|
||||
createdAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-07-27T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
describe('InstallationDemoToolService', () => {
|
||||
const mockedRepository = jest.mocked(installationDemoToolRepository)
|
||||
const mockedAccessService = jest.mocked(installationDemoAccessService)
|
||||
const mockedNormalizeUrl = jest.mocked(normalizeObjectStorageUrlForPersistence)
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockedAccessService.getAccess.mockResolvedValue({ canView: true, canEdit: true, canShare: true })
|
||||
})
|
||||
|
||||
it('returns a stable access URL for historical tools that still store a signed download URL', async () => {
|
||||
mockedRepository.list.mockResolvedValue([legacyTool] as never)
|
||||
|
||||
const tools = await new InstallationDemoToolService().list(user)
|
||||
|
||||
expect(tools[0]?.modelUrl).toBe('/api/storage/object?key=installation-tools%2Fdriver.obj')
|
||||
expect(mockedNormalizeUrl).toHaveBeenCalledWith(legacyTool.modelUrl)
|
||||
})
|
||||
|
||||
it('persists a stable access URL when a new tool is saved', async () => {
|
||||
mockedRepository.findByToolCode.mockResolvedValue(null)
|
||||
mockedRepository.create.mockResolvedValue({
|
||||
...legacyTool,
|
||||
modelUrl: '/api/storage/object?key=installation-tools%2Fdriver.obj',
|
||||
} as never)
|
||||
|
||||
await new InstallationDemoToolService().create(user, {
|
||||
toolCode: legacyTool.toolCode,
|
||||
name: legacyTool.name,
|
||||
modelUrl: legacyTool.modelUrl,
|
||||
fileName: legacyTool.fileName,
|
||||
})
|
||||
|
||||
expect(mockedRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelUrl: '/api/storage/object?key=installation-tools%2Fdriver.obj',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,6 @@
|
||||
import 'server-only'
|
||||
|
||||
import { normalizeObjectStorageUrlForPersistence } from '@/lib/storage'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import { installationDemoToolRepository } from '@/server/repositories/installation-demo-tool.repository'
|
||||
@ -30,6 +31,19 @@ function normalizeQuaternion(value: unknown) {
|
||||
return value as [number, number, number, number]
|
||||
}
|
||||
|
||||
function normalizeToolModelUrl(value: unknown) {
|
||||
return normalizeObjectStorageUrlForPersistence(normalizeRequiredText(value, '工具模型'))
|
||||
}
|
||||
|
||||
function presentTool<T extends { modelUrl: string }>(tool: T): T {
|
||||
return {
|
||||
...tool,
|
||||
// Older records may still contain an expiring direct download URL. Always
|
||||
// return the stable application route so existing tools continue to load.
|
||||
modelUrl: normalizeObjectStorageUrlForPersistence(tool.modelUrl),
|
||||
}
|
||||
}
|
||||
|
||||
function configReferencesTool(value: unknown, toolId: string): boolean {
|
||||
if (value === toolId) return true
|
||||
if (Array.isArray(value)) return value.some((item) => configReferencesTool(item, toolId))
|
||||
@ -42,7 +56,8 @@ export class InstallationDemoToolService {
|
||||
if (!(await installationDemoAccessService.getAccess(user)).canView) {
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '没有权限查看工具箱')
|
||||
}
|
||||
return installationDemoToolRepository.list()
|
||||
const tools = await installationDemoToolRepository.list()
|
||||
return tools.map(presentTool)
|
||||
}
|
||||
|
||||
async create(user: AuthUser, input: Record<string, unknown>) {
|
||||
@ -57,7 +72,7 @@ export class InstallationDemoToolService {
|
||||
return installationDemoToolRepository.create({
|
||||
toolCode,
|
||||
name: normalizeRequiredText(input.name, '工具名称'),
|
||||
modelUrl: normalizeRequiredText(input.modelUrl, '工具模型'),
|
||||
modelUrl: normalizeToolModelUrl(input.modelUrl),
|
||||
fileName: normalizeRequiredText(input.fileName, '模型文件名'),
|
||||
mountOffset: normalizeVector3(input.mountOffset),
|
||||
mountRotation: normalizeQuaternion(input.mountRotation),
|
||||
@ -81,7 +96,7 @@ export class InstallationDemoToolService {
|
||||
return installationDemoToolRepository.update(id, {
|
||||
toolCode: nextToolCode,
|
||||
name: input.name === undefined ? existing.name : normalizeRequiredText(input.name, '工具名称'),
|
||||
modelUrl: input.modelUrl === undefined ? existing.modelUrl : normalizeRequiredText(input.modelUrl, '工具模型'),
|
||||
modelUrl: input.modelUrl === undefined ? existing.modelUrl : normalizeToolModelUrl(input.modelUrl),
|
||||
fileName: input.fileName === undefined ? existing.fileName : normalizeRequiredText(input.fileName, '模型文件名'),
|
||||
mountOffset: input.mountOffset === undefined ? normalizeVector3(existing.mountOffset) : normalizeVector3(input.mountOffset),
|
||||
mountRotation: input.mountRotation === undefined ? normalizeQuaternion(existing.mountRotation) : normalizeQuaternion(input.mountRotation),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user