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:
parent
7f864cc09e
commit
682f0827e0
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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])
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
}))
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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])
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
@ -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 &&
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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] : []),
|
||||
|
||||
66
furniture-3d-system/src/lib/3d/installation-model-cache.ts
Normal file
66
furniture-3d-system/src/lib/3d/installation-model-cache.ts
Normal 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() }
|
||||
}
|
||||
64
furniture-3d-system/src/lib/3d/installation-model-loader.ts
Normal file
64
furniture-3d-system/src/lib/3d/installation-model-loader.ts
Normal 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)
|
||||
}
|
||||
114
furniture-3d-system/src/lib/3d/installation-obj-geometry.ts
Normal file
114
furniture-3d-system/src/lib/3d/installation-obj-geometry.ts
Normal 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
|
||||
}
|
||||
26
furniture-3d-system/src/lib/3d/installation-obj.worker.ts
Normal file
26
furniture-3d-system/src/lib/3d/installation-obj.worker.ts
Normal 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 : '模型后台解析失败' })
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user