Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/editor/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
hoverStyles={EDITOR_HOVER_STYLES}
onSceneReadyChange={onSceneReadyChange}
renderContext="editor"
renderPaused={!show3d && !showLoader}
sceneReadyKey={sceneReadyKey}
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
>
Expand Down
17 changes: 17 additions & 0 deletions packages/nodes/src/cabinet/flame-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Object3D } from 'three'

function isFlameObject(object: Object3D): boolean {
return Boolean(
object.userData.cabinetFlameJet ||
object.userData.cabinetFlamePulse ||
object.userData.cabinetFlameMaterialPulse,
)
}

export function collectCabinetFlameObjects(root: Object3D): Object3D[] {
const objects: Object3D[] = []
root.traverse((object) => {
if (isFlameObject(object)) objects.push(object)
})
return objects
}
31 changes: 31 additions & 0 deletions packages/nodes/src/cabinet/system.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test'
import { Group, Mesh } from 'three'
import { collectCabinetFlameObjects } from './flame-index'
import { animateCabinetFlames } from './system'

describe('collectCabinetFlameObjects', () => {
test('indexes only animated flame descendants', () => {
const root = new Group()
const staticMesh = new Mesh()
const flame = new Mesh()
flame.userData.cabinetFlamePulse = { phase: 0, amplitude: 0.1, base: 1 }
const nested = new Group()
nested.add(flame)
root.add(staticMesh, nested)

expect(collectCabinetFlameObjects(root)).toEqual([flame])
})
})

describe('animateCabinetFlames', () => {
test('continues animating after a throttled flame jet', () => {
const jet = new Mesh()
jet.userData.cabinetFlameJet = { seed: {}, burnerR: 0.1 }
const pulse = new Mesh()
pulse.userData.cabinetFlamePulse = { phase: Math.PI / 2, amplitude: 0.2, base: 1 }

animateCabinetFlames([jet, pulse], 0, false)

expect(pulse.scale.x).toBeCloseTo(1.2)
})
})
42 changes: 34 additions & 8 deletions packages/nodes/src/cabinet/system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,29 @@ import {
cabinetRunFootprint,
cabinetRunNeighborSignature,
} from './definition'
import { collectCabinetFlameObjects } from './flame-index'

function materialWithOpacity(material: Material | Material[] | undefined): Material | null {
if (!material) return null
return Array.isArray(material) ? (material[0] ?? null) : material
}

function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes: boolean) {
root.traverse((obj) => {
export function animateCabinetFlames(
objects: Object3D[],
elapsedTime: number,
updateTubes: boolean,
) {
for (const obj of objects) {
const jet = obj.userData.cabinetFlameJet as
| { seed: CooktopFlameSeed; burnerR: number }
| undefined
if (jet) {
if (!updateTubes) return
if (!updateTubes) continue
const mesh = obj as Mesh
const position = mesh.geometry.getAttribute('position') as BufferAttribute
updateCooktopFlameTube(position.array as Float32Array, elapsedTime, jet.seed, jet.burnerR)
position.needsUpdate = true
return
continue
}

const pulse = obj.userData.cabinetFlamePulse as
Expand All @@ -42,13 +47,13 @@ function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes:
const materialPulse = obj.userData.cabinetFlameMaterialPulse as
| { phase: number; amplitude: number; base: number }
| undefined
if (!materialPulse) return
if (!materialPulse) continue
const material = materialWithOpacity((obj as { material?: Material | Material[] }).material)
if (!material || !('opacity' in material)) return
if (!material || !('opacity' in material)) continue
material.opacity =
materialPulse.base +
materialPulse.amplitude * Math.sin(elapsedTime * 2.3 + materialPulse.phase)
})
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
Expand All @@ -61,6 +66,9 @@ function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes:
const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => {
const appliedRef = useRef(new Map<string, number>())
const lastTubeUpdateRef = useRef(0)
const flameObjectsRef = useRef(
new Map<string, { root: Object3D; children: Object3D[]; objects: Object3D[] }>(),
)
// Last-seen neighbor-affecting signature per run. A run whose countertop
// overhang trims against sibling runs never sees a neighbor's move in its
// own geometryKey, so when a run's signature changes here we bump the
Expand Down Expand Up @@ -118,9 +126,27 @@ const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => {
poseCabinetMovingParts(obj, value)
applied.set(id, value)
}
animateCabinetFlames(obj, clock.elapsedTime, updateTubes)
let flameEntry = flameObjectsRef.current.get(id)
const childrenChanged =
!flameEntry ||
flameEntry.children.length !== obj.children.length ||
flameEntry.children.some((child, index) => child !== obj.children[index])
if (flameEntry?.root !== obj || childrenChanged) {
flameEntry = {
root: obj,
children: [...obj.children],
objects: collectCabinetFlameObjects(obj),
}
flameObjectsRef.current.set(id, flameEntry)
}
if (flameEntry.objects.length > 0) {
animateCabinetFlames(flameEntry.objects, clock.elapsedTime, updateTubes)
}
}
}
for (const id of flameObjectsRef.current.keys()) {
if (!sceneRegistry.nodes.has(id)) flameObjectsRef.current.delete(id)
}
}, 2)

return null
Expand Down
19 changes: 17 additions & 2 deletions packages/nodes/src/shared/mep-ghost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type {
PipeSegmentNode,
} from '@pascal-app/core'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { useMemo } from 'react'
import { Mesh, MeshBasicMaterial } from 'three'
import { disposeObject3DResources } from '@pascal-app/viewer'
import { useEffect, useMemo } from 'react'
import { type Material, Mesh, MeshBasicMaterial } from 'three'
import { buildDuctFittingGeometry } from '../duct-fitting/geometry'
import { buildDuctSegmentGeometry } from '../duct-segment/geometry'
import { buildPipeFittingGeometry } from '../pipe-fitting/geometry'
Expand All @@ -34,8 +35,15 @@ function ghostColor(tint: GhostTint): number | string {
/** Repaint every mesh in `group` as a translucent, depth-test-free preview. */
function ghostify(group: { traverse: (cb: (child: object) => void) => void }, tint: GhostTint) {
const color = ghostColor(tint)
const replacedMaterials = new Set<Material>()
group.traverse((child) => {
if (child instanceof Mesh) {
const previous = child.material
if (Array.isArray(previous)) {
for (const material of previous) replacedMaterials.add(material)
} else {
replacedMaterials.add(previous)
}
child.layers.set(EDITOR_LAYER)
child.material = new MeshBasicMaterial({
color,
Expand All @@ -46,6 +54,9 @@ function ghostify(group: { traverse: (cb: (child: object) => void) => void }, ti
child.renderOrder = 999
}
})
for (const material of replacedMaterials) {
if (!material.userData.__pascalCachedMaterial) material.dispose()
}
}

/**
Expand All @@ -62,6 +73,7 @@ export function FittingGhost({ fitting, tint }: { fitting: DuctFittingNode; tint
ghostify(group, tint)
return group
}, [fitting, tint])
useEffect(() => () => disposeObject3DResources(ghost), [ghost])
return <primitive object={ghost} />
}

Expand All @@ -76,6 +88,7 @@ export function DuctSegmentGhost({ duct, tint }: { duct: DuctSegmentNode; tint?:
ghostify(group, tint)
return group
}, [duct, tint])
useEffect(() => () => disposeObject3DResources(ghost), [ghost])
return <primitive object={ghost} />
}

Expand All @@ -93,6 +106,7 @@ export function PipeFittingGhost({
ghostify(group, tint)
return group
}, [fitting, tint])
useEffect(() => () => disposeObject3DResources(ghost), [ghost])
return <primitive object={ghost} />
}

Expand All @@ -102,5 +116,6 @@ export function PipeSegmentGhost({ pipe, tint }: { pipe: PipeSegmentNode; tint?:
ghostify(group, tint)
return group
}, [pipe, tint])
useEffect(() => () => disposeObject3DResources(ghost), [ghost])
return <primitive object={ghost} />
}
18 changes: 15 additions & 3 deletions packages/viewer/src/components/viewer/frame-limiter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import useViewer from '../../store/use-viewer'

type FrameLimiterProps = {
fps?: number
paused?: boolean
}

export type FrameClock = {
Expand Down Expand Up @@ -56,7 +57,7 @@ const DRAW_DISABLED =
.map((s) => s.trim()),
).has('draw')

const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50, paused = false }) => {
const { advance, set, frameloop: initFrameloop } = useThree()
const nextFrameTimeRef = useRef(0)
const renderer = useThree((state) => state.gl)
Expand All @@ -66,7 +67,7 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
const renderPaused = useViewer((s) => s.renderPaused)

useLayoutEffect(() => {
if (renderPaused) return
if (renderPaused || paused) return
const clock = createFrameClock(nextFrameTimeRef.current)
let raf: number | null = null
let timer: ReturnType<typeof setInterval> | null = null
Expand Down Expand Up @@ -126,7 +127,18 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
window.removeEventListener('pageshow', kick)
set({ frameloop: initFrameloop })
}
}, [advance, dpr, fps, initFrameloop, renderPaused, renderer, set, size.height, size.width])
}, [
advance,
dpr,
fps,
initFrameloop,
paused,
renderPaused,
renderer,
set,
size.height,
size.width,
])

return null
}
Expand Down
5 changes: 4 additions & 1 deletion packages/viewer/src/components/viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ interface ViewerProps {
* `?disable=postFx` diagnostic URL flag, but host-controlled.
*/
disablePostFx?: boolean
/** Keep the mounted renderer/context warm without advancing scene frames. */
renderPaused?: boolean
}

/** Imperative handle exposed via `ref` on `<Viewer>`. */
Expand Down Expand Up @@ -404,6 +406,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
sceneReadyMaxWaitMs,
maxFps = 50,
disablePostFx = false,
renderPaused = false,
},
ref,
) {
Expand Down Expand Up @@ -569,7 +572,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
enabled: shadowsEnabled,
}}
>
<FrameLimiter fps={maxFps} />
<FrameLimiter fps={maxFps} paused={renderPaused} />
<ViewerCamera />
<GPUDeviceWatcher />
<ToneMappingExposure />
Expand Down
4 changes: 2 additions & 2 deletions packages/viewer/src/components/viewer/perf-monitor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const PerfMonitor = () => {
}
}, [gl])

useFrame(({ gl, scene, clock }) => {
useFrame(({ gl, scene, clock }, delta) => {
frameCount.current++
const now = clock.elapsedTime
const dt = now - elapsed.current
Expand Down Expand Up @@ -123,7 +123,7 @@ export const PerfMonitor = () => {
elapsed.current = now
}

lastMs.current = Math.round(clock.getDelta() * 1000 * 10) / 10
lastMs.current = Math.round(delta * 1000 * 10) / 10
})

return (
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export {
prepareBrushForCSG,
SUBTRACTION,
} from './lib/csg-utils'
export { disposeObject3DResources } from './lib/dispose-object3d'
export type { EdgeMode } from './lib/edge-style'
export {
computeHeroFraming,
Expand Down
36 changes: 36 additions & 0 deletions packages/viewer/src/lib/dispose-object3d.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test'
import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three'
import { disposeObject3DResources } from './dispose-object3d'

describe('disposeObject3DResources', () => {
test('disposes nested geometry and materials once', () => {
const root = new Group()
const nested = new Group()
const geometry = new BoxGeometry()
const material = new MeshBasicMaterial()
let geometryDisposals = 0
let materialDisposals = 0
geometry.addEventListener('dispose', () => geometryDisposals++)
material.addEventListener('dispose', () => materialDisposals++)
nested.add(new Mesh(geometry, material), new Mesh(geometry, material))
root.add(nested)

disposeObject3DResources(root)

expect(geometryDisposals).toBe(1)
expect(materialDisposals).toBe(1)
})

test('preserves Pascal material-cache ownership', () => {
const root = new Group()
const material = new MeshBasicMaterial()
material.userData.__pascalCachedMaterial = true
let materialDisposals = 0
material.addEventListener('dispose', () => materialDisposals++)
root.add(new Mesh(new BoxGeometry(), material))

disposeObject3DResources(root)

expect(materialDisposals).toBe(0)
})
})
30 changes: 30 additions & 0 deletions packages/viewer/src/lib/dispose-object3d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { BufferGeometry, Material, Object3D } from 'three'

function isCachedMaterial(material: Material): boolean {
return Boolean(material.userData?.__pascalCachedMaterial)
}

/** Dispose geometry and non-cached materials owned by an Object3D subtree. */
export function disposeObject3DResources(root: Object3D): void {
const geometries = new Set<BufferGeometry>()
const materials = new Set<Material>()

root.traverse((object) => {
const renderable = object as Object3D & {
geometry?: BufferGeometry
material?: Material | Material[]
}
if (renderable.geometry) geometries.add(renderable.geometry)
const objectMaterials = renderable.material
if (Array.isArray(objectMaterials)) {
for (const material of objectMaterials) materials.add(material)
} else if (objectMaterials) {
materials.add(objectMaterials)
}
})

for (const geometry of geometries) geometry.dispose()
for (const material of materials) {
if (!isCachedMaterial(material)) material.dispose()
}
}
Loading
Loading