Optimize installation model preview and repair exploded layouts

Parse and cache OBJ geometry off the main thread, reuse the viewport, and adapt interaction rendering. Pull drawers out as complete groups, enforce panel and hardware clearance, and frame the applied layout. Add relative X/Y/Z movement in millimeters with existing persistence.
This commit is contained in:
WuYongJie 2026-10-19 16:42:09 +08:00
parent 7f864cc09e
commit 682f0827e0
28 changed files with 1063 additions and 13 deletions

View File

@ -98,6 +98,7 @@ import { useInstallationNumberingCatalog } from "./hooks/useInstallationNumberin
import { useInstallationNodeMaterialConfig } from "./hooks/useInstallationNodeMaterialConfig";
import { useInstallationPartTexturePresentation } from "./hooks/useInstallationPartTexturePresentation";
import { useInstallationPartCatalog } from "./hooks/useInstallationPartCatalog";
import { useInstallationAutoExplodeFrame } from "./hooks/useInstallationAutoExplodeFrame";
import { useInstallationPreviewController } from "./hooks/useInstallationPreviewController";
import { useInstallationDrawingTools } from "./hooks/useInstallationDrawingTools";
import { useInstallationShareLink } from "./hooks/useInstallationShareLink";
@ -482,6 +483,12 @@ export default function InstallationDemoWorkbenchClient({
localWorkflowPlaying: playing,
localWorkflowResetKey: stepPlaybackResetKey,
});
const applyAutoExplodeAndFrame = useInstallationAutoExplodeFrame(
previewEngine,
partCatalog.parts,
partCatalog.applyAutoExplode,
preview.stageView === "exploded" ? selectedDrawingId : null,
);
useInstallationDrawingOverviewCaptureFrame({
target: drawingCapture.captureTarget,
engine: previewEngine,
@ -2508,6 +2515,7 @@ export default function InstallationDemoWorkbenchClient({
return (
<div className="asset-center-obsidian flex h-[calc(100dvh-56px)] min-h-[calc(100dvh-56px)] flex-col overflow-hidden bg-[#f8f9fa] text-[#2d3335] lg:h-[calc(100dvh-64px)] lg:min-h-[calc(100dvh-64px)] lg:flex-row">
<InstallationPreviewStage
installationPerformance
drawing={catalog.selectedDrawing}
modelUrl={modelUrl}
previewTool={stagePreviewTool}
@ -3026,7 +3034,7 @@ export default function InstallationDemoWorkbenchClient({
onSelectDrawing={catalog.selectDrawing}
onUploadModel={handleUploadModel}
onRefreshCatalog={catalog.refresh}
onApplyAutoExplode={partCatalog.applyAutoExplode}
onApplyAutoExplode={applyAutoExplodeAndFrame}
onApplyControllerStyleAutoExplode={
partCatalog.applyControllerStyleAutoExplode
}
@ -3037,6 +3045,9 @@ export default function InstallationDemoWorkbenchClient({
);
}}
onResetAllParts={resetExplosionGeometry}
onTranslateSelectedNodes={(delta) =>
partCatalog.translateNodes(explosionSelectedNodeNames, delta)
}
onSelectNumberingPart={selectNumberingPart}
onNumberingCategoryChange={(nextCategory) => {
setNumberingCategory(nextCategory);

View File

@ -1,7 +1,8 @@
'use client'
import { RefreshCcw, Sparkles } from 'lucide-react'
import type { InstallationPartItem } from '../types'
import type { InstallationPartItem, InstallationVector3 } from '../types'
import { InstallationExplosionMoveInput } from './InstallationExplosionMoveInput'
interface ExplosionEditorPanelProps {
canEdit: boolean
@ -12,6 +13,7 @@ interface ExplosionEditorPanelProps {
onApplyControllerStyleAutoExplode: () => void
onResetSelectedPart: () => void
onResetAllParts: () => void
onTranslateSelectedNodes: (delta: InstallationVector3) => void
}
function hasPartTransform(part: InstallationPartItem) {
@ -44,6 +46,7 @@ export function ExplosionEditorPanel({
onApplyControllerStyleAutoExplode,
onResetSelectedPart,
onResetAllParts,
onTranslateSelectedNodes,
}: ExplosionEditorPanelProps) {
const selectedPart = parts.find((part) => part.id === selectedPartId) ?? null
const hasSavedExplosion = parts.some((part) => hasPartTransform(part))
@ -105,6 +108,11 @@ export function ExplosionEditorPanel({
: '当前还没有选中部件,先在左侧点击模型部件。按住 Ctrl / Shift 可继续多选。'}
</div>
<InstallationExplosionMoveInput
disabled={!canEdit || selectedNodeCount === 0}
onTranslate={onTranslateSelectedNodes}
/>
<div className="rounded-[6px] bg-[#f8f9fa] px-3 py-2 text-[10px] font-semibold text-[#8b9092]">
{hasSavedExplosion ? '爆炸状态已记录,可直接切换页面查看展开动画。' : '还没有保存过爆炸状态。'}
</div>

View File

@ -10,6 +10,7 @@ import type {
InstallationPartRole,
InstallationStepDraft,
InstallationToolItem,
InstallationVector3,
} from '../types'
import { ExplosionEditorPanel } from './ExplosionEditorPanel'
import { InstallationDrawingToolSelection } from './InstallationDrawingToolSelection'
@ -50,6 +51,7 @@ interface InstallationDetailsPanelProps {
onApplyControllerStyleAutoExplode: () => void
onResetSelectedPart: () => void
onResetAllParts: () => void
onTranslateSelectedNodes: (delta: InstallationVector3) => void
onSelectNumberingPart: (partId: string) => void
onExplosionNumberingCategoryChange: (category: InstallationExplosionCategory) => void
onNumberingCategoryChange: (category: InstallationNumberingDisplayCategory) => void
@ -221,6 +223,7 @@ export function InstallationDetailsPanel({
onApplyControllerStyleAutoExplode,
onResetSelectedPart,
onResetAllParts,
onTranslateSelectedNodes,
onSelectNumberingPart,
onExplosionNumberingCategoryChange,
onNumberingCategoryChange,
@ -290,6 +293,7 @@ export function InstallationDetailsPanel({
onApplyControllerStyleAutoExplode={onApplyControllerStyleAutoExplode}
onResetSelectedPart={onResetSelectedPart}
onResetAllParts={onResetAllParts}
onTranslateSelectedNodes={onTranslateSelectedNodes}
/>
<ExplosionCategoryTabs
value={explosionNumberingCategory}

View File

@ -0,0 +1,26 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InstallationExplosionMoveInput } from './InstallationExplosionMoveInput'
describe('explosion movement inputs', () => {
it('applies one complete signed movement on submit and clears it to prevent an accidental repeat', () => {
const onTranslate = jest.fn()
render(<InstallationExplosionMoveInput disabled={false} onTranslate={onTranslate} />)
fireEvent.change(screen.getByRole('spinbutton', { name: 'Z 轴位移 mm' }), { target: { value: '200' } })
expect(onTranslate).not.toHaveBeenCalled()
fireEvent.change(screen.getByRole('spinbutton', { name: 'Y 轴位移 mm' }), { target: { value: '-12.5' } })
fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' }))
expect(onTranslate).toHaveBeenCalledWith([0, -12.5, 200])
expect(screen.getByRole('spinbutton', { name: 'Z 轴位移 mm' })).toHaveValue(0)
expect(screen.getByRole('button', { name: '应用位移' })).toBeDisabled()
fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' }))
expect(onTranslate).toHaveBeenCalledTimes(1)
})
it('prevents movement without an editable selection', () => {
const onTranslate = jest.fn()
render(<InstallationExplosionMoveInput disabled onTranslate={onTranslate} />)
expect(screen.getByRole('spinbutton', { name: 'Z 轴位移 mm' })).toBeDisabled()
fireEvent.submit(screen.getByRole('form', { name: '精确移动部件' }))
expect(onTranslate).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,59 @@
'use client'
import { useState } from 'react'
import type { InstallationVector3 } from '../types'
interface InstallationExplosionMoveInputProps {
disabled: boolean
onTranslate: (delta: InstallationVector3) => void
}
export function InstallationExplosionMoveInput({ disabled, onTranslate }: InstallationExplosionMoveInputProps) {
const [draft, setDraft] = useState(['0', '0', '0'])
const [lastMove, setLastMove] = useState('')
const delta = draft.map(value => value.trim() === '' ? 0 : Number(value)) as InstallationVector3
const canApply = !disabled && delta.every(Number.isFinite) && delta.some(value => value !== 0)
return (
<form
aria-label="精确移动部件"
className="grid gap-2 border-t border-black/[0.06] pt-3"
onSubmit={event => {
event.preventDefault()
if (!canApply) return
onTranslate(delta)
setLastMove(delta.map((value, axis) => value === 0 ? '' : `${['X', 'Y', 'Z'][axis]} ${value > 0 ? '+' : ''}${value} mm`).filter(Boolean).join(','))
setDraft(['0', '0', '0'])
}}
>
<p className="text-[11px] font-black text-[#5a6062]">精确移动 <span className="font-semibold text-[#8b9092]">相对当前位置 · mm</span></p>
<div className="flex items-end gap-2">
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<label key={axis} className="grid min-w-0 flex-1 gap-1 text-[10px] font-black text-[#5a6062]">
{axis}
<input
aria-label={`${axis} 轴位移 mm`}
type="number"
inputMode="decimal"
step="any"
value={draft[index]}
disabled={disabled}
onChange={event => {
const value = event.target.value
setDraft(current => current.map((item, position) => position === index ? value : item))
setLastMove('')
}}
className="h-8 w-full min-w-0 rounded-[6px] border border-black/[0.12] bg-white px-2 text-[11px] font-semibold text-[#2d3335] outline-none focus:border-[#2d3335] disabled:opacity-35"
/>
</label>
))}
<button type="submit" disabled={!canApply} className="h-8 shrink-0 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white disabled:opacity-35">
应用位移
</button>
</div>
<p role="status" className="text-[10px] font-semibold text-[#8b9092]">
{lastMove ? `已移动:${lastMove}` : '正数沿轴正方向移动,负数反向移动;按回车应用。'}
</p>
</form>
)
}

View File

@ -16,6 +16,7 @@ import type {
InstallationStepDraft,
InstallationToolItem,
InstallationInspectorTab,
InstallationVector3,
} from '../types'
import { InstallationDetailsPanel } from './InstallationDetailsPanel'
import { InstallationDrawingToolSelection } from './InstallationDrawingToolSelection'
@ -82,6 +83,7 @@ interface InstallationInspectorProps {
onApplyControllerStyleAutoExplode: () => void
onResetSelectedPart: () => void
onResetAllParts: () => void
onTranslateSelectedNodes: (delta: InstallationVector3) => void
onSelectNumberingPart: (partId: string) => void
onExplosionNumberingCategoryChange: (category: InstallationExplosionCategory) => void
onNumberingCategoryChange: (category: InstallationNumberingDisplayCategory) => void
@ -187,6 +189,7 @@ export function InstallationInspector({
onApplyControllerStyleAutoExplode,
onResetSelectedPart,
onResetAllParts,
onTranslateSelectedNodes,
onSelectNumberingPart,
onExplosionNumberingCategoryChange,
onNumberingCategoryChange,
@ -380,6 +383,7 @@ export function InstallationInspector({
onApplyControllerStyleAutoExplode={onApplyControllerStyleAutoExplode}
onResetSelectedPart={onResetSelectedPart}
onResetAllParts={onResetAllParts}
onTranslateSelectedNodes={onTranslateSelectedNodes}
onSelectStep={onSelectStep}
onCreateStep={onCreateStep}
onMoveStep={onMoveStep}

View File

@ -130,6 +130,7 @@ interface InstallationPreviewStageProps {
playbackShowingFixedGroup?: boolean
playbackFinalizingAssembly?: boolean
viewportInstanceKey?: string
installationPerformance?: boolean
showStepModeToggle?: boolean
showStepToolbar?: boolean
showNumberingToolbar?: boolean
@ -232,6 +233,7 @@ export function InstallationPreviewStage({
playbackShowingFixedGroup = false,
playbackFinalizingAssembly = false,
viewportInstanceKey,
installationPerformance = false,
showStepModeToggle = true,
showStepToolbar = true,
showNumberingToolbar = true,
@ -584,8 +586,7 @@ export function InstallationPreviewStage({
viewportInstanceKey ??
[
drawing?.id ?? 'installation-demo-empty',
view,
view === 'preview' ? mode : 'edit',
...(installationPerformance ? [] : [view, view === 'preview' ? mode : 'edit']),
previewTool?.id ?? 'drawing-model',
].join(':')
const measuredNumberingSafeInsets = useNumberingOverlaySafeInsets({
@ -613,6 +614,7 @@ export function InstallationPreviewStage({
modelUrl={previewModelUrl}
preserveDrawingBuffer
repairDisplayNormals
installationPerformance={installationPerformance}
showFpsBadge={false}
className="h-full"
viewportControlsEnabled={

View File

@ -0,0 +1,44 @@
'use client'
import { useCallback, useEffect, useRef } from 'react'
import type { ThreeDEngine } from '@/lib/3d/engine'
import type { InstallationPartItem } from '../types'
import { buildExplodedOffsets } from '../lib/installationPreviewState'
/** Frame once after an explicit automatic layout has reached the live engine. */
export function useInstallationAutoExplodeFrame(
engine: ThreeDEngine | null,
parts: InstallationPartItem[],
applyLayout: () => void,
activeDrawingId: string | null,
) {
const pending = useRef<string | null>(null)
useEffect(() => {
if (!activeDrawingId || pending.current !== activeDrawingId || !engine) {
pending.current = null
return
}
const targetOffsets = buildExplodedOffsets(parts)
let frame: number
const frameAppliedLayout = () => {
// The preview controller commits its offsets in a subsequent render.
// Wait for those actual transforms rather than fitting the previous pose.
const applied = targetOffsets.every(({ nodeName, offset }) => {
const current = engine.getNodeWorldOffset(nodeName)
return current && offset.every((value, axis) => Math.abs(current[axis] - value) < 1e-4)
})
if (!applied) {
frame = requestAnimationFrame(frameAppliedLayout)
return
}
pending.current = null
engine.fitToNodeNames(engine.getNamedNodeNames(), 1.2)
}
frame = requestAnimationFrame(frameAppliedLayout)
return () => cancelAnimationFrame(frame)
}, [activeDrawingId, engine, parts])
return useCallback(() => {
pending.current = activeDrawingId
applyLayout()
}, [activeDrawingId, applyLayout])
}

View File

@ -9,7 +9,6 @@ import type {
InstallationVector3,
} from '../types'
import {
buildAutoExplodeOffsetsForParts,
buildAutoExplodeNodeOffsetsForParts,
buildControllerStyleExplodeOffsetsForParts,
createPartItem,
@ -35,6 +34,7 @@ import {
readInstallationConfigArrayField,
serializeInstallationConfigField,
} from '../lib/installationDemoConfigFields'
import { translateInstallationExplosionNodes } from '../lib/installationExplosionMovement'
export function useInstallationPartCatalog(
drawingId: string | null,
@ -313,6 +313,15 @@ export function useInstallationPartCatalog(
[canEdit, drawingId],
)
const translateNodes = useCallback((nodeNames: string[], delta: InstallationVector3) => {
if (!canEdit || !drawingId || nodeNames.length === 0) return
setPartsByDrawingId(current => {
const nextParts = translateInstallationExplosionNodes(current[drawingId] ?? [], nodeNames, delta)
writeStoredAutoExplodeTemplateForDrawing(drawingId, nextParts)
return { ...current, [drawingId]: nextParts }
})
}, [canEdit, drawingId])
const resetAllPartTransforms = useCallback(() => {
clearStoredAutoExplodeTemplateForDrawing(drawingId)
replaceParts(
@ -350,12 +359,12 @@ export function useInstallationPartCatalog(
const sourceParts = resolvePartCatalogForAutoExplode(parts, nodeSnapshots)
const modelParts = sourceParts.filter((part) => getPartNodeNames(part).length > 0)
if (modelParts.length === 0) return
const autoExplodeOffsets = buildAutoExplodeOffsetsForParts(modelParts, nodeSnapshots)
const autoExplodeNodeOffsets = buildAutoExplodeNodeOffsetsForParts(modelParts, nodeSnapshots)
const layoutParts: InstallationPartItem[] = sourceParts.map((part): InstallationPartItem => {
const modelIndex = modelParts.findIndex((item) => item.id === part.id)
if (modelIndex < 0) return part
const nextOffset = autoExplodeOffsets[modelIndex] ?? ([0, 0, 0] as InstallationVector3)
const primaryNodeName = getPartNodeNames(part)[0]
const nextOffset = autoExplodeNodeOffsets.get(primaryNodeName) ?? ([0, 0, 0] as InstallationVector3)
const nodeTransforms = Object.fromEntries(
getPartNodeNames(part).map((nodeName) => [
nodeName,
@ -422,6 +431,7 @@ export function useInstallationPartCatalog(
updatePartOffset,
resetPartOffset,
updatePartTransformByNodeName,
translateNodes,
resetAllPartTransforms,
resetPartTransformsByNodeNames,
applyAutoExplode,

View File

@ -0,0 +1,113 @@
import type { InstallationModelNodeSnapshot } from '../../types'
// Measured bounds only; the source OBJ and geometry are not embedded in this fixture.
const bounds: Array<[string, number, number, number, number, number, number]> = [
["panel_000",0,125.00000000000009,-191,450,190,16.000000000000057],
["panel_001",179.5,85.00000000000003,82.99990081787108,29,7.4453125,7.5],
["panel_002",-217,125.00000000000003,8,16,190.00000000000006,382.00000000000006],
["panel_003",-214.4000015258789,40.00000000000006,-64.00009536743165,10.800003051757812,10.800003051757812,10.7884292602539],
["panel_004",-214.4000015258789,40.00000000000002,95.99990463256835,10.800003051757812,10.800003051757812,10.788429260253906],
["panel_005",-214.4000015258789,40.00000000000006,-64.00009536743165,10.800003051757812,10.800003051757812,10.7884292602539],
["panel_006",-214.4000015258789,40.00000000000002,95.99990463256835,10.800003051757812,10.800003051757812,10.788429260253906],
["panel_007",-214.4000015258789,61,188.0500030517578,10.800003051757812,10.800003051757812,10.788421630859403],
["panel_008",-214.4000015258789,189,188.0500030517578,10.800003051757812,10.799987792968778,10.788421630859403],
["panel_009",-202.80003356933594,40.00000000000006,-64.00009346008302,32.399993896484375,10.5,10.499996185302727],
["panel_010",-202.80003356933594,40.00000000000002,96.00009155273436,32.399993896484375,10.5,10.5],
["panel_011",-179.5,85.00000000000003,82.99990081787108,29,7.4453125,7.5],
["panel_012",0,110,83,356,99.99999999999997,16.000000000000014],
["panel_013",179.5,135,82.9999008178711,29,7.4453125,7.5],
["panel_014",186,110.00000000000003,-46.00009536743167,16,100.00000000000003,273.99999237060547],
["panel_015",185.75,85.00000000000007,-151.00009155273438,9.5,12,12],
["panel_016",185.75,135.00000000000006,-151.00009155273438,9.5,12,12],
["panel_017",185.9614715576172,135.00000000000006,-167.00009155273438,7,7,32],
["panel_018",185.9614715576172,85.00000000000009,-167.00009155273438,7,7.000000000000014,32],
["panel_019",-179.5,135,82.9999008178711,29,7.4453125,7.5],
["panel_020",-186,110.00000000000003,-46.00009536743167,16,100.00000000000003,273.99999237060547],
["panel_021",-185.75,85.00000000000007,-151.00009155273438,9.5,12,12],
["panel_022",-185.75,135.00000000000006,-151.00009155273438,9.5,12,12],
["panel_023",-185.9614715576172,85.00000000000009,-167.00009155273438,7,7.000000000000014,32],
["panel_024",-185.9614715576172,135.00000000000006,-167.00009155273438,7,7,32],
["panel_025",-127.99996948242188,40,176.80000305175778,10.5,10.500000000000007,32.399993896484375],
["panel_026",127.99996948242188,40,176.80000305175778,10.5,10.500000000000007,32.399993896484375],
["panel_027",-128,200.75,149.00000000000003,15,11.5,15],
["panel_028",-128,201.5,188.40000152587893,10.788421630859375,10.799987792968778,10.800003051757812],
["panel_029",-128,201.5,171.00000000000003,7,7,40],
["panel_030",-64,201.5,176.00000000000003,7.984375,8,30],
["panel_031",64,201.5,176.00000000000003,7.984375,8,30],
["panel_032",128,200.75,149.00000000000003,15,11.5,15],
["panel_033",128,201.5,171.00000000000003,7,7,40],
["panel_034",202.80003356933594,40.00000000000002,96.00009155273436,32.399993896484375,10.5,10.5],
["panel_035",202.80003356933594,40.00000000000006,-64.00009346008302,32.399993896484375,10.5,10.499996185302727],
["panel_036",-197,201.50000000000006,-127.99999999999997,40,7,6.999999999999986],
["panel_037",-202,201.50000000000006,-63.999999999999986,30,8.000000000000028,7.984375],
["panel_038",-202,201.5,64.00000000000001,30,8.000000000000028,7.984375],
["panel_039",-197,201.5,128.00000000000003,40,7,7.000000000000014],
["panel_040",-197,61,188.0500030517578,40,7,7.000000000000028],
["panel_041",-202,93,188.5,30,7.984375,8],
["panel_042",-202,157,188.5,30,7.984375000000028,8],
["panel_043",-197,189,188.0500030517578,40,7,7.000000000000028],
["panel_044",-175,200.75000000000006,-127.99999999999997,15,11.5,14.999999999999986],
["panel_045",-175,200.75,128.00000000000003,15,11.5,15.000000000000014],
["panel_046",175,200.75000000000006,-127.99999999999997,15,11.5,14.999999999999986],
["panel_047",197,201.50000000000006,-127.99999999999997,40,7,6.999999999999986],
["panel_048",202,201.50000000000006,-63.999999999999986,30,8.000000000000028,7.984375],
["panel_049",202,201.5,64.00000000000001,30,8.000000000000028,7.984375],
["panel_050",195.74996948242188,15.00000000000001,128.9999961853027,11.5,15,15.000007629394531],
["panel_051",195.74996948242188,15.00000000000006,-97.00000000000003,11.5,15.000000000000004,15],
["panel_052",-175,61,188.75,15,15,11.500000000000028],
["panel_053",-175,189,188.75,15,15.000000000000028,11.500000000000028],
["panel_054",175,61,189.75,15,15,11.500000000000028],
["panel_055",175,189,189.75,15,15.000000000000028,11.500000000000028],
["panel_056",175,200.75,128.00000000000003,15,11.5,15.000000000000014],
["panel_057",197,201.5,128.00000000000003,40,7,7.000000000000014],
["panel_058",197,189,188.0500030517578,40,7,7.000000000000028],
["panel_059",197,61,188.0500030517578,40,7,7.000000000000028],
["panel_060",202,157,188.5,30,7.984375000000028,8],
["panel_061",202,93,188.5,30,7.984375,8],
["panel_062",196.49996948242188,39.00000000000007,-126.00000000000003,8,7.984375000000007,30.000000000000014],
["panel_063",196.49996948242188,39.000000000000014,157.99999999999997,8,7.984375000000007,30],
["panel_064",196.49996948242188,15.000000000000068,-119.00000000000003,7,7.000000000000009,40],
["panel_065",196.49996948242188,15.000000000000007,150.99999999999997,7,7.000000000000007,40],
["panel_066",0,203.00000000000003,2.842170943040401e-14,418,18.000000000000057,366],
["panel_067",0.000030517578125,25.00000000000007,-140.00000000000003,414,50.00000000000001,18],
["panel_068",-196.49996948242188,39.00000000000007,-126.00000000000003,8,7.984375000000007,30.000000000000014],
["panel_069",-196.49996948242188,39.000000000000014,157.99999999999997,8,7.984375000000007,30],
["panel_070",-194.74996948242188,15.00000000000006,-97.00000000000003,11.5,15.000000000000004,15],
["panel_071",-194.74996948242188,15.00000000000001,128.9999961853027,11.5,15,15.000007629394531],
["panel_072",-196.49996948242188,15.000000000000068,-119.00000000000003,7,7.000000000000009,40],
["panel_073",-196.49996948242188,15.000000000000007,150.99999999999997,7,7.000000000000007,40],
["panel_074",214.4000015258789,201.50000000000006,-127.99999999999997,10.800003051757812,10.79998779296875,10.78842163085936],
["panel_075",214.4000015258789,201.5,128.00000000000003,10.800003051757812,10.79998779296875,10.78842163085939],
["panel_076",185.96146392822266,135.00000000000006,-188.40009307861328,10.763107299804688,10.799987792968778,10.800003051757812],
["panel_077",0,125,191,418,190,16.000000000000057],
["panel_078",-127.99996948242188,40,188.40000152587888,10.788421630859375,10.800003051757812,10.800003051757812],
["panel_079",128.00003051757812,40,188.40000152587888,10.788421630859375,10.800003051757812,10.800003051757812],
["panel_080",214.4000015258789,189,188.0500030517578,10.800003051757812,10.799987792968778,10.788421630859403],
["panel_081",0.000030517578125,25.000000000000004,171.99999999999997,414,50.00000000000001,18],
["panel_082",-196.49996948242188,15.000000000000004,168.59999847412107,10.788421630859375,10.799999237060549,10.800003051757812],
["panel_083",198.00003051757812,25.000000000000036,15.999999999999972,18,50.000000000000064,294],
["panel_084",-197.99996948242188,25.000000000000036,15.999999999999972,18,50.000000000000064,294],
["panel_085",0,72.50000000000006,-54,366,5.000000000000057,268],
["panel_086",-196.49996948242188,15.000000000000071,-136.59999847412112,10.788421630859375,10.799999237060549,10.800003051757812],
["panel_087",185.96146392822266,135.00000000000006,-187.00009155273438,5.1999969482421875,5.20001220703125,8],
["panel_088",185.96146392822266,85.00000000000009,-187.00009155273438,5.1999969482421875,5.1999969482421875,8],
["panel_089",185.96146392822266,85.00000000000009,-188.40009307861328,10.763107299804688,10.800003051757812,10.800003051757812],
["panel_090",-185.96146392822266,85.00000000000009,-188.40009307861328,10.763107299804688,10.800003051757812,10.800003051757812],
["panel_091",-185.96146392822266,85.00000000000009,-187.00009155273438,5.1999969482421875,5.1999969482421875,8],
["panel_092",-185.96146392822266,135.00000000000006,-188.40009307861328,10.763107299804688,10.799987792968778,10.800003051757812],
["panel_093",-185.96146392822266,135.00000000000006,-187.00009155273438,5.1999969482421875,5.20001220703125,8],
["panel_094",196.50003051757812,15.000000000000004,168.59999847412107,10.788421630859375,10.799999237060549,10.800003051757812],
["panel_095",196.50003051757812,15.000000000000071,-136.59999847412112,10.788421630859375,10.799999237060549,10.800003051757812],
["panel_096",214.4000015258789,61,188.0500030517578,10.800003051757812,10.800003051757812,10.788421630859403],
["panel_097",217,125.00000000000003,8,16,190.00000000000006,382.00000000000006],
["panel_098",214.4000015258789,40.00000000000002,95.99990463256835,10.800003051757812,10.800003051757812,10.788429260253906],
["panel_099",214.4000015258789,40.00000000000006,-64.00009536743165,10.800003051757812,10.800003051757812,10.7884292602539],
]
export function actualDrawerModelBounds(): InstallationModelNodeSnapshot[] {
return bounds.map(([nodeName, x, y, z, width, height, depth]) => ({
nodeName, center: [x, y, z], size: [width, height, depth],
min: [x - width / 2, y - height / 2, z - depth / 2],
max: [x + width / 2, y + height / 2, z + depth / 2],
}))
}

View File

@ -0,0 +1,51 @@
import type { InstallationVector3 } from '../types'
import { actualDrawerModelBounds } from './__fixtures__/installation-drawer-model-bounds'
import { buildInstallationAssemblyNodeOffsets } from './installationAssemblyExplodeLayout'
import { resolvePartCatalogForAutoExplode } from './installationPartCatalogHelpers'
import { buildInstallationDrawerAssemblyPlan } from './installationDrawerAssemblyPlanner'
import { calculateInstallationAssemblyBounds } from './installationAssemblyContactGraph'
const drawerNames = ['panel_000', 'panel_012', 'panel_014', 'panel_020', 'panel_085']
const structuralNames = [...drawerNames, 'panel_002', 'panel_066', 'panel_067', 'panel_077', 'panel_081', 'panel_083', 'panel_084', 'panel_097']
describe('uploaded short cabinet with one drawer', () => {
const nodes = actualDrawerModelBounds()
const model = calculateInstallationAssemblyBounds(nodes)
const parts = resolvePartCatalogForAutoExplode([], nodes)
it('discovers the overlay drawer without requiring a tall cabinet shell', () => {
const plan = buildInstallationDrawerAssemblyPlan(nodes.filter(node => structuralNames.includes(node.nodeName)), {
model, frontDirection: -1, facadeTargetSignedDepth: 800,
})
expect(plan.groups).toHaveLength(1)
expect(plan.memberNames).toEqual(new Set(drawerNames))
})
it('pulls all five drawer boards completely in front of the exploded shell', () => {
const offsets = buildInstallationAssemblyNodeOffsets(parts, nodes)
const rearOfDrawer = Math.max(...nodes.filter(node => drawerNames.includes(node.nodeName)).map(node => node.max[2] + offsets.get(node.nodeName)![2]))
const frontOfShell = Math.min(...nodes.filter(node => structuralNames.includes(node.nodeName) && !drawerNames.includes(node.nodeName)).map(node => node.min[2] + offsets.get(node.nodeName)![2]))
expect(frontOfShell - rearOfDrawer).toBeGreaterThan(model.diagonal * 0.012)
})
it('keeps all 100 original nodes separated, including hardware and panels', () => {
const before = JSON.stringify(nodes)
const offsets = buildInstallationAssemblyNodeOffsets(parts, nodes)
expect(offsets.size).toBe(100)
const collisions: string[] = []
const axes = [0, 1, 2] as const
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
const left = nodes[i], right = nodes[j]
const a = offsets.get(left.nodeName) as InstallationVector3
const b = offsets.get(right.nodeName) as InstallationVector3
const clear = axes.some(axis => Math.max(
left.min[axis] + a[axis] - right.max[axis] - b[axis],
right.min[axis] + b[axis] - left.max[axis] - a[axis],
) >= model.diagonal * 0.012 - 1e-5)
if (!clear) collisions.push(`${left.nodeName}/${right.nodeName}`)
}
expect(collisions).toEqual([])
expect(JSON.stringify(nodes)).toBe(before)
expect(buildInstallationAssemblyNodeOffsets(parts, nodes)).toEqual(offsets)
})
})

View File

@ -5,6 +5,7 @@ import type {
} from '../types'
import { buildUnnamedCabinetStructureOffsets } from './installationUnnamedStructureExplodePlanner'
import { buildUnnamedProductStructureOffsets } from './installationUnnamedProductStructurePlanner'
import { finalizeInstallationExplodeLayout } from './installationExplodeFinalLayout'
import {
buildInstallationHardwareOffset,
resolveInstallationHardwareClassification,
@ -474,7 +475,7 @@ export function buildInstallationAssemblyNodeOffsets(
),
)
})
return cabinetOffsets
return finalizeInstallationExplodeLayout(geometries, model, cabinetOffsets, true)
}
const productOffsets = buildUnnamedProductStructureOffsets(
@ -496,7 +497,7 @@ export function buildInstallationAssemblyNodeOffsets(
),
)
})
return productOffsets
return finalizeInstallationExplodeLayout(geometries, model, productOffsets)
}
const frontDirection = resolveFrontDirection(structuralParts, model)
@ -526,5 +527,5 @@ export function buildInstallationAssemblyNodeOffsets(
offsets.set(nodeName, plan.offset)
})
return offsets
return finalizeInstallationExplodeLayout(geometries, model, offsets)
}

View File

@ -43,6 +43,25 @@ function buildPlan(nodes: InstallationModelNodeSnapshot[]) {
}
describe('buildInstallationDrawerAssemblyPlan', () => {
it('recognizes an overlay drawer front wider than its bottom panel', () => {
const nodes = [
node('front', [0, 125, -191], [450, 190, 16]),
node('bottom', [0, 72.5, -54], [366, 5, 268]),
node('back', [0, 110, 83], [356, 100, 16]),
node('left', [-186, 110, -46], [16, 100, 274]),
node('right', [186, 110, -46], [16, 100, 274]),
]
const plan = buildInstallationDrawerAssemblyPlan(nodes, {
model: { size: [450, 220, 398], diagonal: Math.hypot(450, 220, 398) },
frontDirection: -1,
facadeTargetSignedDepth: 800,
})
expect(plan.groups).toHaveLength(1)
expect(plan.groups[0].face.nodeName).toBe('front')
expect(plan.groups[0].back.nodeName).toBe('back')
expect(plan.memberNames.size).toBe(5)
})
it('uses the drawer bottom as the local reference for all four directions', () => {
const plan = buildPlan(drawerNodes('drawer', 0))
const bottom = plan.offsets.get('drawer-bottom') ?? [0, 0, 0]

View File

@ -80,7 +80,10 @@ function findDrawerGroup(
(candidate) =>
resolveInstallationPanelNormalAxis(candidate) === DEPTH_AXIS &&
candidate.size[LATERAL_AXIS] >= bottom.size[LATERAL_AXIS] * 0.88 &&
candidate.size[LATERAL_AXIS] <= bottom.size[LATERAL_AXIS] * 1.2 &&
// Overlay fronts can extend well beyond the drawer box on both sides.
// Contact, centered alignment and the complete five-panel topology still
// distinguish them from an unrelated cabinet door or back panel.
candidate.size[LATERAL_AXIS] <= bottom.size[LATERAL_AXIS] * 1.6 &&
Math.abs(candidate.center[LATERAL_AXIS] - bottom.center[LATERAL_AXIS]) <=
bottom.size[LATERAL_AXIS] * 0.18 &&
candidate.size[VERTICAL_AXIS] <= maximumWallHeight &&

View File

@ -0,0 +1,39 @@
import type { InstallationModelNodeSnapshot } from '../types'
import { applyInstallationExplodeClearance } from './installationExplodeClearance'
function node(nodeName: string, x: number): InstallationModelNodeSnapshot {
return { nodeName, center: [x, 0, 0], size: [10, 10, 10], min: [x - 5, -5, -5], max: [x + 5, 5, 5] }
}
describe('final exploded clearance', () => {
it('merges forbidden travel intervals so a correction cannot push a part into the next obstacle', () => {
const offsets = new Map()
const parts = [node('anchor', 0), node('next', 14), node('moving', 0)]
const placed = applyInstallationExplodeClearance([
{ nodes: [parts[0]], axis: 0, direction: 1 },
{ nodes: [parts[1]], axis: 0, direction: 1 },
{ nodes: [parts[2]], axis: 0, direction: 1 },
], offsets, 3)
expect(placed[2].min[0] - placed[1].max[0]).toBeGreaterThanOrEqual(3)
expect(offsets.get('moving')[0]).toBeLessThan(28)
})
it('moves a drawer unit together and preserves its internal spacing', () => {
const offsets = new Map()
const left = node('left', 0), right = node('right', 20)
const original = JSON.stringify([left, right])
const placed = applyInstallationExplodeClearance([
{ nodes: [left, right], axis: 0, direction: -1 },
], offsets, 3, [node('shell', 0)])
expect(offsets.get('left')).toEqual(offsets.get('right'))
expect(placed[0].min[0] - Math.max(placed[1].max[0], placed[2].max[0])).toBeGreaterThanOrEqual(3)
expect(placed[2].center[0] - placed[1].center[0]).toBe(20)
expect(JSON.stringify([left, right])).toBe(original)
})
it('retains already clear offsets without crossing an unrelated distant obstacle', () => {
const offsets = new Map()
applyInstallationExplodeClearance([{ nodes: [node('moving', 0)], axis: 0, direction: 1 }], offsets, 3, [node('far', 100)])
expect(offsets.get('moving')).toEqual([0, 0, 0])
})
})

View File

@ -0,0 +1,62 @@
import type { InstallationModelNodeSnapshot, InstallationVector3 } from '../types'
type Axis = 0 | 1 | 2
export interface InstallationExplodeUnit {
nodes: InstallationModelNodeSnapshot[]
axis: Axis
direction: -1 | 1
}
/**
* Place units along their existing explode direction. Each obstacle forbids an
* interval of travel, so merging these intervals finds the first clear position
* without iterative nudging, oscillation or changing a group's internal layout.
*/
export function applyInstallationExplodeClearance(
units: InstallationExplodeUnit[],
offsets: Map<string, InstallationVector3>,
gap: number,
obstacles: InstallationModelNodeSnapshot[] = [],
): InstallationModelNodeSnapshot[] {
const placed = [...obstacles]
for (const { nodes, axis, direction } of units) {
const intervals: Array<[number, number]> = []
const moving = nodes.map((node) => {
const offset = offsets.get(node.nodeName) ?? [0, 0, 0]
return {
...node,
min: node.min.map((value, index) => value + offset[index]) as InstallationVector3,
max: node.max.map((value, index) => value + offset[index]) as InstallationVector3,
}
})
for (const node of moving) for (const obstacle of placed) {
const crosses = ([0, 1, 2] as Axis[]).every((other) => other === axis || (
node.max[other] > obstacle.min[other] - gap &&
node.min[other] < obstacle.max[other] + gap
))
if (!crosses) continue
intervals.push(direction === 1
? [obstacle.min[axis] - gap - node.max[axis], obstacle.max[axis] + gap - node.min[axis]]
: [node.min[axis] - gap - obstacle.max[axis], node.max[axis] + gap - obstacle.min[axis]])
}
let travel = 0
for (const [start, end] of intervals.sort((left, right) => left[0] - right[0])) {
if (start > travel) break
if (end >= travel) travel = end + 1e-6
}
nodes.forEach((node, index) => {
const offset: InstallationVector3 = [...(offsets.get(node.nodeName) ?? [0, 0, 0])]
offset[axis] += direction * travel
offsets.set(node.nodeName, offset)
const bounds = moving[index]
bounds.min[axis] += direction * travel
bounds.max[axis] += direction * travel
placed.push({
...bounds,
center: bounds.min.map((value, component) => value + node.size[component] / 2) as InstallationVector3,
})
})
}
return placed
}

View File

@ -0,0 +1,65 @@
import type { InstallationVector3 } from '../types'
import { buildInstallationAssemblyContactGraph, getInstallationAssemblyContactNeighbors, resolveInstallationPanelNormalAxis } from './installationAssemblyContactGraph'
import { buildInstallationDrawerAssemblyPlan } from './installationDrawerAssemblyPlanner'
import { applyInstallationExplodeClearance, type InstallationExplodeUnit } from './installationExplodeClearance'
import { buildInstallationHardwareOffset, type InstallationExplodeModelBounds, type InstallationExplodeNodeGeometry } from './installationHardwareExplodePlanner'
type Axis = 0 | 1 | 2
const AXES: Axis[] = [0, 1, 2]
function panelUnit(node: InstallationExplodeNodeGeometry, offsets: Map<string, InstallationVector3>, model: InstallationExplodeModelBounds): InstallationExplodeUnit {
const offset = offsets.get(node.nodeName) ?? [0, 0, 0]
const axis = AXES.slice().sort((left, right) => Math.abs(offset[right]) - Math.abs(offset[left]))[0]
const primary = Math.abs(offset[axis]) > 0.001 ? axis : resolveInstallationPanelNormalAxis(node) ?? 1
return { nodes: [node], axis: primary, direction: (offset[primary] || node.center[primary] - model.center[primary]) >= 0 ? 1 : -1 }
}
/** Enforce drawer and clearance rules for every structural planner, including short cabinets. */
export function finalizeInstallationExplodeLayout(
geometries: InstallationExplodeNodeGeometry[],
model: InstallationExplodeModelBounds,
offsets: Map<string, InstallationVector3>,
bridgeSeparatedVerticalAnchors = false,
) {
const structural = geometries.filter(node => !node.isHardware && node.part.kind !== 'tool')
const plans = ([-1, 1] as const).map(frontDirection => ({
frontDirection,
plan: buildInstallationDrawerAssemblyPlan(structural, {
model,
frontDirection,
facadeTargetSignedDepth: (frontDirection === 1 ? model.max[2] : -model.min[2]) +
Math.max(model.size[2] * 1.54, model.diagonal * 0.275),
}),
}))
const score = (entry: typeof plans[number]) => entry.plan.groups.reduce((sum, group) => sum +
group.face.size[0] * group.face.size[1] - group.back.size[0] * group.back.size[1], 0)
const drawer = plans.sort((left, right) => right.plan.groups.length - left.plan.groups.length || score(right) - score(left))[0]
drawer.plan.offsets.forEach((offset, name) => offsets.set(name, offset))
const units = structural.filter(node => !drawer.plan.memberNames.has(node.nodeName))
.sort((left, right) => Math.hypot(...(offsets.get(left.nodeName) ?? [0, 0, 0])) - Math.hypot(...(offsets.get(right.nodeName) ?? [0, 0, 0])))
.map(node => panelUnit(node, offsets, model))
drawer.plan.groups.forEach(group => units.push({ nodes: Object.values(group), axis: 2, direction: drawer.frontDirection }))
const gap = Math.max(model.diagonal * 0.012, 1)
const placed = applyInstallationExplodeClearance(units, offsets, gap)
// Hardware must follow the final panel or drawer position before its own
// clearance pass; otherwise it remains at the pre-clearance anchor location.
const graph = buildInstallationAssemblyContactGraph(geometries)
const hardwareUnits = geometries.filter(node => node.isHardware).map(node => {
const neighbors = new Set(getInstallationAssemblyContactNeighbors(graph, node.nodeName).map(({ node: anchor }) => anchor.nodeName))
const offset = buildInstallationHardwareOffset(node, structural, offsets, model, neighbors, bridgeSeparatedVerticalAnchors)
offsets.set(node.nodeName, offset)
const longest = AXES.slice().sort((left, right) => node.size[right] - node.size[left])
// Leave a fitting beside its receiving face. A handle's long axis runs
// along that face, so using it would push the handle off the panel edge.
const nearest = structural.slice().sort((left, right) => {
const distance = (anchor: typeof left) => AXES.reduce<number>((sum, axis) => sum + Math.max(anchor.min[axis] - node.max[axis], node.min[axis] - anchor.max[axis], 0) ** 2, 0)
return distance(left) - distance(right)
})[0]
const axis = nearest ? resolveInstallationPanelNormalAxis(nearest) ?? longest[0] : longest[0]
const direction = nearest && node.center[axis] < nearest.center[axis] ? -1 : 1
return { nodes: [node], axis, direction } satisfies InstallationExplodeUnit
})
applyInstallationExplodeClearance(hardwareUnits, offsets, gap, placed)
return offsets
}

View File

@ -0,0 +1,37 @@
import { createPartItem, getPartNodeOffset } from './installationPartCatalogHelpers'
import { translateInstallationExplosionNodes } from './installationExplosionMovement'
describe('relative movement of exploded nodes', () => {
it('adds signed millimeters once to each selected pose and retains group spacing and rotation', () => {
const group = createPartItem({ nodeNames: ['front', 'back'], code: '1', name: 'Drawer', kind: 'part' })
group.offset = [10, 20, 80]
group.nodeTransforms.back = { offset: [10, 20, 125], rotation: [0, 0, 1, 0] }
const untouched = createPartItem({ nodeName: 'shell', code: '2', name: 'Shell', kind: 'part' })
const input = [group, untouched]
const before = JSON.stringify(input)
const moved = translateInstallationExplosionNodes(input, ['front', 'back', 'front'], [0, -12.5, 200])
expect(getPartNodeOffset(moved[0], 'front')).toEqual([10, 7.5, 280])
expect(getPartNodeOffset(moved[0], 'back')).toEqual([10, 7.5, 325])
expect(moved[0].nodeTransforms.back.rotation).toEqual([0, 0, 1, 0])
expect(moved[1]).toBe(untouched)
expect(JSON.stringify(input)).toBe(before)
const repeated = translateInstallationExplosionNodes(moved, ['front'], [0, 0, -200])
expect(getPartNodeOffset(repeated[0], 'front')).toEqual([10, 7.5, 80])
expect(getPartNodeOffset(repeated[0], 'back')).toEqual([10, 7.5, 325])
})
it('keeps an unselected sibling at its inherited pose when the primary node moves', () => {
const group = createPartItem({ nodeNames: ['primary', 'sibling'], code: '1', name: 'Group', kind: 'part' })
group.offset = [0, 0, 80]
const [moved] = translateInstallationExplosionNodes([group], ['primary'], [0, 0, 200])
expect(getPartNodeOffset(moved, 'primary')).toEqual([0, 0, 280])
expect(getPartNodeOffset(moved, 'sibling')).toEqual([0, 0, 80])
})
it('ignores invalid or empty movement', () => {
const parts = [createPartItem({ nodeName: 'panel', code: '1', name: 'Panel', kind: 'part' })]
expect(translateInstallationExplosionNodes(parts, ['panel'], [0, 0, NaN])).toBe(parts)
expect(translateInstallationExplosionNodes(parts, ['panel'], [0, 0, 0])).toBe(parts)
expect(translateInstallationExplosionNodes(parts, [], [0, 0, 200])).toEqual(parts)
})
})

View File

@ -0,0 +1,29 @@
import type { InstallationPartItem, InstallationVector3 } from '../types'
import { getPartNodeNames, getPartNodeOffset, getPartNodeRotation } from './installationPartCatalogHelpers'
/** Translate selected nodes in world axes, retaining every node's current pose. */
export function translateInstallationExplosionNodes(
parts: InstallationPartItem[],
nodeNames: string[],
delta: InstallationVector3,
): InstallationPartItem[] {
if (!delta.every(Number.isFinite) || delta.every(value => value === 0)) return parts
const selected = new Set(nodeNames)
return parts.map(part => {
const boundNames = getPartNodeNames(part)
if (!boundNames.some(name => selected.has(name))) return part
const nodeTransforms = { ...part.nodeTransforms }
// Materialize fallback poses before changing the primary node's offset:
// otherwise its siblings would inherit that delta, potentially twice.
for (const name of boundNames) {
const current = getPartNodeOffset(part, name)
const offset = selected.has(name)
? current.map((value, axis) => value + delta[axis]) as InstallationVector3
: current
if (!offset.every(Number.isFinite)) return part
nodeTransforms[name] = { ...part.nodeTransforms[name], offset, rotation: getPartNodeRotation(part, name) }
}
const primary = part.nodeName ?? boundNames[0]
return { ...part, offset: nodeTransforms[primary]?.offset ?? part.offset, nodeTransforms }
})
}

View File

@ -99,6 +99,7 @@ interface ModelViewerProps {
clearAlpha?: number;
preserveDrawingBuffer?: boolean;
repairDisplayNormals?: boolean;
installationPerformance?: boolean;
enableTranslateGizmo?: boolean;
gizmoMode?: 'translate' | 'rotate';
gizmoSize?: number;
@ -745,6 +746,7 @@ function ModelViewerComponent({
clearAlpha = 1,
preserveDrawingBuffer = false,
repairDisplayNormals = false,
installationPerformance = false,
enableTranslateGizmo = false,
gizmoMode = 'translate',
gizmoSize = 0.72,
@ -772,6 +774,7 @@ function ModelViewerComponent({
const clearAlphaRef = useRef(clearAlpha);
const preserveDrawingBufferRef = useRef(preserveDrawingBuffer);
const repairDisplayNormalsRef = useRef(repairDisplayNormals);
const installationPerformanceRef = useRef(installationPerformance);
const cameraControlsEnabledRef = useRef(cameraControlsEnabled);
const nodeLocalTransformStatesRef = useRef<NodeLocalTransformState[]>(
nodeLocalTransformStates,
@ -1363,6 +1366,7 @@ function ModelViewerComponent({
clearColor: clearColorRef.current,
clearAlpha: clearAlphaRef.current,
repairDisplayNormals: repairDisplayNormalsRef.current,
installationPerformance: installationPerformanceRef.current,
});
engineRef.current = engine;
engine.setRepairDisplayNormals(repairDisplayNormalsRef.current);
@ -1389,7 +1393,7 @@ function ModelViewerComponent({
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error("模型加载超时,请检查模型文件地址或网络连接"));
}, 20000);
}, installationPerformanceRef.current ? 60000 : 20000);
}),
]).finally(() => {
if (timeoutId) {
@ -2923,6 +2927,7 @@ export const ModelViewer = memo(
prevProps.clearAlpha === nextProps.clearAlpha &&
prevProps.preserveDrawingBuffer === nextProps.preserveDrawingBuffer &&
prevProps.repairDisplayNormals === nextProps.repairDisplayNormals &&
prevProps.installationPerformance === nextProps.installationPerformance &&
prevProps.enableTranslateGizmo === nextProps.enableTranslateGizmo &&
prevProps.gizmoMode === nextProps.gizmoMode &&
prevProps.gizmoSize === nextProps.gizmoSize &&

View File

@ -47,6 +47,7 @@ interface ModelPreviewPanelProps {
showCameraActionButtons?: boolean
preserveDrawingBuffer?: boolean
repairDisplayNormals?: boolean
installationPerformance?: boolean
defaultShowSceneGrid?: boolean
showSceneGrid?: boolean
onToggleSceneGrid?: () => void
@ -149,6 +150,7 @@ export function ModelPreviewPanel({
showCameraActionButtons = true,
preserveDrawingBuffer = false,
repairDisplayNormals = false,
installationPerformance = false,
defaultShowSceneGrid = false,
showSceneGrid: externalShowSceneGrid,
onToggleSceneGrid: externalOnToggleSceneGrid,
@ -398,6 +400,7 @@ export function ModelPreviewPanel({
onWorldNodeTransformChange={onWorldNodeTransformChange}
preserveDrawingBuffer={preserveDrawingBuffer}
repairDisplayNormals={repairDisplayNormals}
installationPerformance={installationPerformance}
onGizmoInteractionStart={onGizmoInteractionStart}
onGizmoInteractionEnd={onGizmoInteractionEnd}
onEngineReady={handleEngineReady}

View File

@ -0,0 +1,103 @@
import * as THREE from 'three'
import { InstallationViewportPerformance } from '../installation-viewport-performance'
import { installationModelCacheKey } from '../installation-model-cache'
import { canOptimizeInstallationObj, hasUsableNormals, indexObjGeometry, restoreInstallationObj, OBJ_SOURCE_MATERIAL, OBJ_SOURCE_NAME, OBJ_SOURCE_ROOT } from '../installation-obj-geometry'
jest.mock('three/examples/jsm/utils/BufferGeometryUtils.js', () => ({ toCreasedNormals: jest.fn() }))
describe('installation model geometry preservation', () => {
test('keeps multi-material OBJ node hierarchies on their existing loader path', () => {
const model = new THREE.Group()
const mesh = new THREE.Mesh(new THREE.BoxGeometry(), [new THREE.MeshPhongMaterial(), new THREE.MeshPhongMaterial()])
model.add(mesh)
expect(canOptimizeInstallationObj(model)).toBe(false)
mesh.material = [new THREE.MeshPhongMaterial()]
expect(canOptimizeInstallationObj(model)).toBe(true)
})
test('indexes exact duplicates while preserving triangle order and normal seams', () => {
const source = new THREE.BufferGeometry()
source.setAttribute('position', new THREE.Float32BufferAttribute([
0, 0, 0, 1, 0, 0, 0, 1, 0,
0, 0, 0, 0, 1, 0, 0, 0, 1,
0, 0, 0, 1, 0, 0, 0, 1, 0,
], 3))
source.setAttribute('normal', new THREE.Float32BufferAttribute([
0, 0, 1, 0, 0, 1, 0, 0, 1,
1, 0, 0, 1, 0, 0, 1, 0, 0,
0, 0, 1, 0, 0, 1, 0, 0, 1,
], 3))
source.addGroup(0, 9, 0)
const indexed = indexObjGeometry(source)
expect(indexed.getAttribute('position').count).toBe(6)
expect(indexed.index?.count).toBe(9)
expect(indexed.groups).toEqual(source.groups)
const expanded = indexed.toNonIndexed()
expect(Array.from(expanded.getAttribute('position').array)).toEqual(Array.from(source.getAttribute('position').array))
expect(Array.from(expanded.getAttribute('normal').array)).toEqual(Array.from(source.getAttribute('normal').array))
expect(source.index).toBeNull()
})
test('does not merge close but distinct positions', () => {
const source = new THREE.BufferGeometry()
source.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0, 0.000001, 0, 0, 1, 0, 0], 3))
expect(indexObjGeometry(source).getAttribute('position').count).toBe(3)
})
test('accepts supplied normals and detects missing or invalid normals', () => {
const source = new THREE.BufferGeometry()
source.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0], 3))
expect(hasUsableNormals(source)).toBe(false)
source.setAttribute('normal', new THREE.Float32BufferAttribute([0, 0, 1], 3))
expect(hasUsableNormals(source)).toBe(true)
source.setAttribute('normal', new THREE.Float32BufferAttribute([NaN, 0, 1], 3))
expect(hasUsableNormals(source)).toBe(false)
})
test('restores original names, material type and model root without moving geometry', () => {
const scene = new THREE.Group()
const root = new THREE.Group()
root.userData[OBJ_SOURCE_ROOT] = true
const original = new THREE.MeshPhongMaterial({ color: 0x123456, side: THREE.DoubleSide })
const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshStandardMaterial())
mesh.name = 'sanitized_name'
mesh.position.set(12, 34, 56)
mesh.userData[OBJ_SOURCE_NAME] = 'panel.000'
mesh.userData[OBJ_SOURCE_MATERIAL] = [original.toJSON()]
root.add(mesh)
scene.add(root)
expect(restoreInstallationObj(scene)).toBe(root)
expect(root.parent).toBeNull()
expect(mesh.name).toBe('panel.000')
expect(mesh.position.toArray()).toEqual([12, 34, 56])
expect(mesh.material).toBeInstanceOf(THREE.MeshPhongMaterial)
expect((mesh.material as THREE.MeshPhongMaterial).color.getHex()).toBe(0x123456)
})
test('only immutable platform upload IDs qualify for persistent caches', () => {
expect(installationModelCacheKey('http://localhost:3001/api/storage/object?key=models/654ccec5-23a5-4a02-9874-1605d4361b91.obj')).not.toBeNull()
expect(installationModelCacheKey('https://example.com/model.obj')).toBeNull()
})
})
describe('installation viewport resolution', () => {
test('temporarily reduces orbit resolution, keeps exports sharp, and cleans up listeners', () => {
jest.useFakeTimers()
const listeners = new Map<string, () => void>()
let ratio = 2
const renderer = { getPixelRatio: () => ratio, setPixelRatio: (next: number) => { ratio = next } }
const controls = {
addEventListener: (event: string, listener: () => void) => listeners.set(event, listener),
removeEventListener: (event: string) => listeners.delete(event),
}
const controller = new InstallationViewportPerformance(renderer as unknown as THREE.WebGLRenderer, controls as never)
listeners.get('start')?.()
expect(ratio).toBe(1)
expect(() => controller.withFullResolution(() => { expect(ratio).toBe(2); throw new Error('capture failed') })).toThrow('capture failed')
expect(ratio).toBe(1)
jest.advanceTimersByTime(250)
expect(ratio).toBe(2)
controller.dispose()
expect(listeners.size).toBe(0)
jest.useRealTimers()
})
})

View File

@ -23,6 +23,7 @@ import {
} from "./model-unit-scale";
import { shouldApplyZUpToYUpRotation } from "./model-up-axis";
import { startRenderLoop, stopRenderLoop } from "./engine-render-loop";
import { InstallationViewportPerformance } from "./installation-viewport-performance";
import { getMeshWorldGeometryCenter } from "./mesh-geometry-center";
import { withTechnicalLineCaptureScene } from "./technical-line-capture";
import {
@ -61,6 +62,7 @@ export interface EngineOptions {
clearColor?: number;
clearAlpha?: number;
repairDisplayNormals?: boolean;
installationPerformance?: boolean;
}
export interface NodePositionOffset {
@ -182,6 +184,9 @@ export class ThreeDEngine {
private directionalLightTarget: THREE.Object3D | null = null;
private sceneGrid: THREE.GridHelper | null = null;
private repairDisplayNormals: boolean;
private installationPerformance = false;
private installationLoadAbort = new AbortController();
private viewportPerformance: InstallationViewportPerformance | null = null;
private resizeObserver: ResizeObserver | null = null;
private lastExplodedProgress = -1;
private runtimeSelectionAliasMap: Map<string, string> = new Map();
@ -364,6 +369,7 @@ export class ThreeDEngine {
} = options;
this.container = container;
this.repairDisplayNormals = repairDisplayNormals;
this.installationPerformance = options.installationPerformance ?? false;
this.scene = new THREE.Scene();
@ -400,6 +406,9 @@ export class ThreeDEngine {
this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
this.controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
this.controls.mouseButtons.RIGHT = null as unknown as THREE.MOUSE;
if (this.installationPerformance) {
this.viewportPerformance = new InstallationViewportPerformance(this.renderer, this.controls);
}
this.setupLights();
this.setupEnvironment();
@ -535,6 +544,7 @@ export class ThreeDEngine {
private animate = (): void => {
this.animationId = startRenderLoop(this.animate);
this.controls.update();
if (this.viewportPerformance && !this.viewportPerformance.shouldRender(performance.now())) return;
this.renderer.render(this.scene, this.camera);
};
@ -571,7 +581,7 @@ export class ThreeDEngine {
return;
}
if (this.repairDisplayNormals) {
if (this.repairDisplayNormals && !mesh.userData.__installationPreparedNormals) {
this.repairMeshDisplayNormals(mesh);
} else if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
@ -868,6 +878,17 @@ export class ThreeDEngine {
const activeLoader =
extension === ".obj" ? this.objLoader : this.gltfLoader;
if (extension === ".obj" && this.installationPerformance && typeof Worker !== "undefined") {
const { loadInstallationObj } = await import("./installation-model-loader");
const model = await loadInstallationObj(url, this.gltfLoader, this.installationLoadAbort.signal, this.repairDisplayNormals);
if (model) {
model.rotation.x = -Math.PI / 2;
model.updateMatrixWorld(true);
this.prepareObjModel(model);
return { model, extension };
}
}
return new Promise((resolve, reject) => {
activeLoader.load(
url,
@ -1476,6 +1497,8 @@ export class ThreeDEngine {
* 鍦ㄧ粍浠跺嵏杞芥椂璋冪敤锛岄槻姝㈠唴瀛樻硠婕? */
dispose(): void {
this.cancelCameraAnimation();
this.installationLoadAbort?.abort();
this.viewportPerformance?.dispose();
// 鍋滄鍔ㄧ敾寰幆
this.animationId = stopRenderLoop(this.animationId);
@ -1703,6 +1726,12 @@ export class ThreeDEngine {
}
capturePngDataUrl(options: EngineCaptureOptions = {}): string {
return this.viewportPerformance
? this.viewportPerformance.withFullResolution(() => this.capturePngAtCurrentResolution(options))
: this.capturePngAtCurrentResolution(options);
}
private capturePngAtCurrentResolution(options: EngineCaptureOptions): string {
if (options.renderMode === "technical-line") {
const roots = [
...(this.modelRoot ? [this.modelRoot] : []),

View File

@ -0,0 +1,66 @@
const DATABASE = 'installation-demo-model-preview-v1'
const STORE = 'models'
const MAX_CACHE_BYTES = 96 * 1024 * 1024
const MAX_CACHE_MODELS = 3
interface CachedModel { key: string; glb: ArrayBuffer; savedAt: number }
function openCache(): Promise<IDBDatabase | null> {
if (typeof indexedDB === 'undefined') return Promise.resolve(null)
return new Promise((resolve) => {
const request = indexedDB.open(DATABASE, 1)
request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'key' })
request.onsuccess = () => resolve(request.result)
request.onerror = () => resolve(null)
request.onblocked = () => resolve(null)
})
}
export function installationModelCacheKey(url: string, repairDisplayNormals = false): string | null {
const parsed = new URL(url, 'http://localhost')
const key = parsed.searchParams.get('key') ?? parsed.pathname
// Only platform upload UUIDs are immutable. External mutable URLs aren't cached.
return /(?:^|\/)models\/[0-9a-f-]{36}\.obj$/i.test(key)
? `indexed-glb-v2:${repairDisplayNormals ? 'creased-18' : 'source'}:${url}` : null
}
export async function readInstallationModelCache(key: string): Promise<ArrayBuffer | null> {
const db = await openCache().catch(() => null)
if (!db) return null
try {
return await new Promise((resolve) => {
const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key)
request.onsuccess = () => resolve((request.result as CachedModel | undefined)?.glb ?? null)
request.onerror = () => resolve(null)
})
} finally { db.close() }
}
export async function writeInstallationModelCache(key: string, glb: ArrayBuffer): Promise<void> {
if (glb.byteLength > MAX_CACHE_BYTES) return
const db = await openCache().catch(() => null)
if (!db) return
try {
await new Promise<void>((resolve) => {
const transaction = db.transaction(STORE, 'readwrite')
const store = transaction.objectStore(STORE)
const request = store.getAll()
request.onsuccess = () => {
const previous = (request.result as CachedModel[])
.filter((item) => item.key !== key).sort((a, b) => b.savedAt - a.savedAt)
let bytes = glb.byteLength
let count = 1
for (const item of previous) {
bytes += item.glb.byteLength
count++
if (count > MAX_CACHE_MODELS || bytes > MAX_CACHE_BYTES) store.delete(item.key)
}
store.put({ key, glb, savedAt: Date.now() } satisfies CachedModel)
}
transaction.oncomplete = () => resolve()
// Quota or private mode must not discard an already converted live model.
transaction.onerror = () => resolve()
transaction.onabort = () => resolve()
})
} finally { db.close() }
}

View File

@ -0,0 +1,64 @@
import type { Group } from 'three'
import type { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { restoreInstallationObj } from './installation-obj-geometry'
import {
installationModelCacheKey,
readInstallationModelCache,
writeInstallationModelCache,
} from './installation-model-cache'
function convertObjInWorker(url: string, signal: AbortSignal, repairDisplayNormals: boolean): Promise<ArrayBuffer | null> {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./installation-obj.worker.ts', import.meta.url), { type: 'module' })
const finish = (glb?: ArrayBuffer | null, error?: Error) => {
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
worker.terminate()
if (glb !== undefined) resolve(glb)
else reject(error ?? new Error('模型后台解析失败'))
}
const abort = () => finish(undefined, new DOMException('模型加载已取消', 'AbortError'))
const timeout = setTimeout(() => finish(undefined, new Error('模型后台解析超时')), 55000)
signal.addEventListener('abort', abort, { once: true })
worker.onmessage = ({ data }: MessageEvent<{ glb?: ArrayBuffer; error?: string; legacy?: boolean }>) => {
finish(data.legacy ? null : data.glb, data.error ? new Error(data.error) : undefined)
}
worker.onerror = (event) => finish(undefined, new Error(event.message || '模型后台线程启动失败'))
worker.postMessage({ url, repairDisplayNormals })
})
}
/** Cached binary geometry is independent of every live viewport and its materials. */
export async function loadInstallationObj(
url: string,
loader: GLTFLoader,
signal: AbortSignal,
repairDisplayNormals: boolean,
): Promise<Group | null> {
const absoluteUrl = new URL(url, window.location.href).href
const key = installationModelCacheKey(absoluteUrl, repairDisplayNormals)
let glb = key ? await readInstallationModelCache(key) : null
signal.throwIfAborted()
if (glb) {
// A cached artifact must still respect the current session's source access.
const response = await fetch(absoluteUrl, { method: 'HEAD', credentials: 'same-origin', cache: 'no-store', signal })
if (!response.ok) throw new Error(`模型读取失败 (${response.status})`)
}
if (!glb) {
glb = await convertObjInWorker(absoluteUrl, signal, repairDisplayNormals)
if (!glb) return null
if (key) void writeInstallationModelCache(key, glb).catch(() => {})
}
signal.throwIfAborted()
const result = await loader.parseAsync(glb, '')
if (signal.aborted) {
result.scene.traverse((object) => {
const mesh = object as import('three').Mesh
mesh.geometry?.dispose()
if (mesh.material) (Array.isArray(mesh.material) ? mesh.material : [mesh.material]).forEach((m) => m.dispose())
})
signal.throwIfAborted()
}
return restoreInstallationObj(result.scene)
}

View File

@ -0,0 +1,114 @@
import * as THREE from 'three'
import { toCreasedNormals } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
export const OBJ_SOURCE_NAME = '__installationObjName'
export const OBJ_SOURCE_MATERIAL = '__installationObjMaterial'
export const OBJ_SOURCE_ROOT = '__installationObjRoot'
export function canOptimizeInstallationObj(model: THREE.Group): boolean {
let supported = true
model.traverse((object) => {
if (object.type === 'LineSegments' || object.type === 'Line' || object.type === 'Points') supported = false
// glTF splits multi-material meshes into child meshes. Keep that legacy
// path intact rather than changing the installation's node hierarchy.
if (object instanceof THREE.Mesh && Array.isArray(object.material) && object.material.length > 1) supported = false
})
return supported
}
export function hasUsableNormals(geometry: THREE.BufferGeometry): boolean {
const normal = geometry.getAttribute('normal')
const position = geometry.getAttribute('position')
if (!normal || !position || normal.count !== position.count) return false
for (let i = 0; i < normal.count; i++) {
const lengthSq = normal.getX(i) ** 2 + normal.getY(i) ** 2 + normal.getZ(i) ** 2
if (!Number.isFinite(lengthSq) || lengthSq < 1e-12) return false
}
return true
}
/** Share only vertices whose complete attribute values are exactly equal. */
export function indexObjGeometry(source: THREE.BufferGeometry): THREE.BufferGeometry {
if (source.index) return source
const attributes = Object.entries(source.attributes)
const count = source.getAttribute('position')?.count ?? 0
const vertexIds = new Map<string, number>()
const representatives: number[] = []
const indices = new Uint32Array(count)
for (let i = 0; i < count; i++) {
let key = ''
for (const [, attribute] of attributes) {
for (let component = 0; component < attribute.itemSize; component++) {
key += `${attribute.getComponent(i, component)},`
}
key += '|'
}
let index = vertexIds.get(key)
if (index === undefined) {
index = representatives.length
representatives.push(i)
vertexIds.set(key, index)
}
indices[i] = index
}
const result = new THREE.BufferGeometry()
for (const [name, attribute] of attributes) {
const array = new Float32Array(representatives.length * attribute.itemSize)
representatives.forEach((sourceIndex, targetIndex) => {
for (let component = 0; component < attribute.itemSize; component++) {
array[targetIndex * attribute.itemSize + component] = attribute.getComponent(sourceIndex, component)
}
})
result.setAttribute(name, new THREE.BufferAttribute(array, attribute.itemSize))
}
result.setIndex(new THREE.BufferAttribute(indices, 1))
source.groups.forEach(({ start, count: groupCount, materialIndex }) => {
result.addGroup(start, groupCount, materialIndex)
})
result.setDrawRange(source.drawRange.start, source.drawRange.count)
return result
}
/** Runs in the worker; the source object and its normals remain recoverable. */
export function prepareInstallationObj(model: THREE.Group, repairDisplayNormals = false): void {
model.userData[OBJ_SOURCE_ROOT] = true
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
object.userData[OBJ_SOURCE_NAME] = object.name
const originalMaterials = Array.isArray(object.material) ? object.material : [object.material]
object.userData[OBJ_SOURCE_MATERIAL] = originalMaterials.map((material) => material.toJSON())
const source = object.geometry
const normals = !repairDisplayNormals && hasUsableNormals(source)
? source : toCreasedNormals(source, Math.PI / 10)
object.geometry = indexObjGeometry(normals)
const exportMaterials = originalMaterials.map((material) => new THREE.MeshStandardMaterial({
color: (material as THREE.MeshPhongMaterial).color ?? 0xffffff,
side: material.side,
vertexColors: material.vertexColors,
transparent: material.transparent,
opacity: material.opacity,
}))
object.material = Array.isArray(object.material) ? exportMaterials : exportMaterials[0]
})
}
export function restoreInstallationObj(scene: THREE.Group): THREE.Group {
let root = scene
const loader = new THREE.MaterialLoader()
scene.traverse((object) => {
if (object.userData[OBJ_SOURCE_ROOT]) root = object as THREE.Group
if (!(object instanceof THREE.Mesh)) return
const name = object.userData[OBJ_SOURCE_NAME]
if (typeof name === 'string') object.name = name
const json = object.userData[OBJ_SOURCE_MATERIAL]
if (!Array.isArray(json) || json.length === 0) return
const previous = Array.isArray(object.material) ? object.material : [object.material]
previous.forEach((material) => material.dispose())
const materials = json.map((value) => loader.parse(value))
object.material = materials.length === 1 ? materials[0] : materials
object.userData.__installationPreparedNormals = true
delete object.userData[OBJ_SOURCE_MATERIAL]
})
root.removeFromParent()
return root
}

View File

@ -0,0 +1,26 @@
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import { canOptimizeInstallationObj, prepareInstallationObj } from './installation-obj-geometry'
const scope = self as unknown as {
onmessage: ((event: MessageEvent<{ url: string; repairDisplayNormals: boolean }>) => void) | null
postMessage(message: unknown, transfer?: Transferable[]): void
}
scope.onmessage = async ({ data }) => {
try {
const response = await fetch(data.url, { credentials: 'same-origin' })
if (!response.ok) throw new Error(`模型读取失败 (${response.status})`)
const model = new OBJLoader().parse(await response.text())
if (!canOptimizeInstallationObj(model)) {
scope.postMessage({ legacy: true })
return
}
prepareInstallationObj(model, data.repairDisplayNormals)
const glb = await new GLTFExporter().parseAsync(model, { binary: true, onlyVisible: false })
if (!(glb instanceof ArrayBuffer)) throw new Error('模型转换没有返回 GLB')
scope.postMessage({ glb }, [glb])
} catch (error) {
scope.postMessage({ error: error instanceof Error ? error.message : '模型后台解析失败' })
}
}

View File

@ -0,0 +1,53 @@
import type { WebGLRenderer } from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
/** Local to installation viewports; exports always use the original screen density. */
export class InstallationViewportPerformance {
private readonly nativeRatio: number
private restoreTimer: ReturnType<typeof setTimeout> | null = null
private lastMotion = -Infinity
private lastFrame = -Infinity
constructor(private renderer: WebGLRenderer, private controls: OrbitControls) {
this.nativeRatio = renderer.getPixelRatio()
controls.addEventListener('start', this.onMotion)
controls.addEventListener('change', this.onMotion)
controls.addEventListener('end', this.onMotion)
}
private onMotion = () => {
this.lastMotion = performance.now()
const ratio = Math.min(this.nativeRatio, 1)
if (this.renderer.getPixelRatio() !== ratio) this.renderer.setPixelRatio(ratio)
if (this.restoreTimer) clearTimeout(this.restoreTimer)
this.restoreTimer = setTimeout(() => {
this.renderer.setPixelRatio(this.nativeRatio)
this.restoreTimer = null
}, 240)
}
shouldRender(now: number): boolean {
if (document.hidden) return false
// Orbit motion stays at display refresh; resting views and playback use 30fps.
const interval = now - this.lastMotion < 260 ? 0 : 1000 / 30 - 1
if (now - this.lastFrame < interval) return false
this.lastFrame = now
return true
}
withFullResolution<T>(capture: () => T): T {
const previous = this.renderer.getPixelRatio()
if (previous !== this.nativeRatio) this.renderer.setPixelRatio(this.nativeRatio)
try { return capture() }
finally {
if (previous !== this.nativeRatio) this.renderer.setPixelRatio(previous)
}
}
dispose(): void {
if (this.restoreTimer) clearTimeout(this.restoreTimer)
this.controls.removeEventListener('start', this.onMotion)
this.controls.removeEventListener('change', this.onMotion)
this.controls.removeEventListener('end', this.onMotion)
}
}