Merge branch 'main' into feat/upgrade-and-bug-fix
This commit is contained in:
@@ -1,15 +1,25 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { Component } from 'react'
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallback: ReactNode
|
||||
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
|
||||
state = { hasError: false }
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
componentDidCatch(_e: Error, _i: ErrorInfo) {}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error(
|
||||
`[viewer] ErrorBoundary caught${this.props.scope ? ` (${this.props.scope})` : ''}:`,
|
||||
error,
|
||||
info.componentStack,
|
||||
)
|
||||
}
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||
import {
|
||||
requestItemMeshMetadataSync,
|
||||
setItemMeshMetadataSourceRoot,
|
||||
} from '../../../systems/item-mesh-metadata/sync-request'
|
||||
import { ErrorBoundary } from '../../error-boundary'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
@@ -107,6 +111,19 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}, [node.parentId])
|
||||
|
||||
// Re-sync when GLTF `scene` or external `metadata` edits should invalidate cached footprint/bounds.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — asset load and metadata drive mesh-metadata sync
|
||||
useEffect(() => {
|
||||
const cloneRoot = ref.current
|
||||
if (!cloneRoot) return
|
||||
|
||||
setItemMeshMetadataSourceRoot(node.id, cloneRoot)
|
||||
requestItemMeshMetadataSync(node.id)
|
||||
return () => {
|
||||
setItemMeshMetadataSourceRoot(node.id, null)
|
||||
}
|
||||
}, [node.id, node.metadata, scene])
|
||||
|
||||
useEffect(() => {
|
||||
const interactive = interactiveRef.current
|
||||
if (!interactive) return
|
||||
|
||||
@@ -18,10 +18,12 @@ import * as THREE from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
||||
import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system'
|
||||
import { LevelSystem } from '../../systems/level/level-system'
|
||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { ErrorBoundary } from '../error-boundary'
|
||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
@@ -63,6 +65,22 @@ declare module '@react-three/fiber' {
|
||||
|
||||
extend(THREE as any)
|
||||
|
||||
// R3F's <Canvas> useLayoutEffect has no deps, so any re-render (theme switch,
|
||||
// parent re-render, StrictMode double-mount) re-invokes `configure()`. With a
|
||||
// sync `gl` factory that's harmless — the renderer is created once and reused.
|
||||
// With an async factory (WebGPURenderer needs `await init()`), two configure
|
||||
// calls can race: both see `state.gl == null` and both create a renderer. The
|
||||
// first to resolve gets `setSize`/`setDpr` called on it; the second overwrites
|
||||
// `state.gl` but R3F's store already holds the new size/dpr, so the new
|
||||
// renderer is never resized and stays at the canvas's 300×150 default.
|
||||
//
|
||||
// Caching by canvas guarantees both branches return the same instance, so
|
||||
// "duplicate" configure calls become no-ops on an already-sized renderer.
|
||||
// We cache the in-flight Promise (not just the resolved renderer) so two
|
||||
// concurrent configure() calls await the same init instead of creating two
|
||||
// renderers in parallel and only caching the second.
|
||||
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
|
||||
|
||||
/**
|
||||
* Monitors the WebGPU device for loss events and logs them.
|
||||
* WebGPU device loss can happen when:
|
||||
@@ -77,6 +95,10 @@ type WebGPUDeviceLossInfo = {
|
||||
|
||||
type WebGPUDeviceLike = {
|
||||
lost: Promise<WebGPUDeviceLossInfo>
|
||||
label?: string
|
||||
features?: Set<string>
|
||||
addEventListener?: (type: string, listener: EventListener) => void
|
||||
removeEventListener?: (type: string, listener: EventListener) => void
|
||||
}
|
||||
|
||||
function GPUDeviceWatcher() {
|
||||
@@ -86,7 +108,18 @@ function GPUDeviceWatcher() {
|
||||
const backend = (gl as any).backend
|
||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||
|
||||
if (!device) return
|
||||
if (!device) {
|
||||
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||
backend: backend?.constructor?.name ?? 'unknown',
|
||||
rendererType: (gl as any).constructor?.name ?? 'unknown',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer] WebGPU device ready', {
|
||||
label: device.label,
|
||||
features: device.features ? Array.from(device.features) : [],
|
||||
})
|
||||
|
||||
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||
console.error(
|
||||
@@ -94,6 +127,17 @@ function GPUDeviceWatcher() {
|
||||
'The page must be reloaded to recover the GPU context.',
|
||||
)
|
||||
})
|
||||
|
||||
// Uncaptured errors are normally silent (only console-warned by Chrome at
|
||||
// best). Pipe them to console.error so silent mobile crashes show up.
|
||||
const onUncapturedError = (event: any) => {
|
||||
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
|
||||
}
|
||||
device.addEventListener?.('uncapturederror', onUncapturedError)
|
||||
|
||||
return () => {
|
||||
device.removeEventListener?.('uncapturederror', onUncapturedError)
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
return null
|
||||
@@ -119,19 +163,43 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="never"
|
||||
gl={async (props) => {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 0.9
|
||||
// Awaiting init() is required when the browser falls back to the
|
||||
// WebGL2 backend (Safari without the WebGPU flag, older Chrome on
|
||||
// machines without a WebGPU device). In native WebGPU mode the
|
||||
// init resolves almost instantly. Without this await, the first
|
||||
// render throws "Renderer: .render() called before the backend is
|
||||
// initialized" from the post-processing fallback path.
|
||||
await renderer.init()
|
||||
return renderer
|
||||
}}
|
||||
gl={
|
||||
((props: { canvas?: HTMLCanvasElement }) => {
|
||||
const canvas = props.canvas
|
||||
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
|
||||
if (cached) return cached
|
||||
// Surface the env we're about to ask WebGPU for — catches "no
|
||||
// navigator.gpu" / "adapter request failed" silently failing in
|
||||
// mobile WebViews where WebGPU is gated behind flags.
|
||||
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||
console.log('[viewer] Creating WebGPURenderer', {
|
||||
hasNavigatorGPU: hasGpu,
|
||||
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
|
||||
})
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 0.9
|
||||
await renderer.init()
|
||||
console.log('[viewer] WebGPURenderer ready', {
|
||||
backend: (renderer as any).backend?.constructor?.name,
|
||||
isWebGPU: (renderer as any).isWebGPURenderer === true,
|
||||
})
|
||||
return renderer
|
||||
} catch (err) {
|
||||
// Drop the failed promise from the cache so a future Canvas
|
||||
// mount on the same DOM can retry instead of inheriting the
|
||||
// rejection forever.
|
||||
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
|
||||
console.error('[viewer] WebGPURenderer init failed', err)
|
||||
throw err
|
||||
}
|
||||
})()
|
||||
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
|
||||
return promise
|
||||
}) as any
|
||||
}
|
||||
resize={{
|
||||
debounce: 100,
|
||||
}}
|
||||
@@ -143,38 +211,41 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<FrameLimiter fps={50} />
|
||||
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
||||
<ViewerCamera />
|
||||
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
/> */}
|
||||
<Lights />
|
||||
<Bvh>
|
||||
<SceneRenderer />
|
||||
</Bvh>
|
||||
|
||||
{/* Default Systems */}
|
||||
<LevelSystem />
|
||||
<GuideSystem />
|
||||
<ScanSystem />
|
||||
<WallCutout />
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
<ItemSystem />
|
||||
<RoofSystem />
|
||||
<SlabSystem />
|
||||
<StairSystem />
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing hoverStyles={hoverStyles} />
|
||||
{/* <DebugRenderer /> */}
|
||||
<GPUDeviceWatcher />
|
||||
|
||||
<ItemLightSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
/> */}
|
||||
<Lights />
|
||||
<Bvh>
|
||||
<SceneRenderer />
|
||||
</Bvh>
|
||||
|
||||
{/* Default Systems */}
|
||||
<LevelSystem />
|
||||
<GuideSystem />
|
||||
<ScanSystem />
|
||||
<WallCutout />
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
<ItemSystem />
|
||||
<RoofSystem />
|
||||
<SlabSystem />
|
||||
<StairSystem />
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing hoverStyles={hoverStyles} />
|
||||
{/* <DebugRenderer /> */}
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,9 +174,22 @@ const PostProcessingPasses = ({
|
||||
void pipelineVersion
|
||||
|
||||
if (!(renderer && scene && camera)) {
|
||||
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||
hasRenderer: !!renderer,
|
||||
hasScene: !!scene,
|
||||
hasCamera: !!camera,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[viewer/post-processing] Building pipeline', {
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
hoverHighlightMode,
|
||||
projectId,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
})
|
||||
|
||||
hasPipelineErrorRef.current = false
|
||||
|
||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||
@@ -318,10 +331,16 @@ const PostProcessingPasses = ({
|
||||
renderPipeline.outputNode = finalOutput
|
||||
renderPipelineRef.current = renderPipeline
|
||||
retryCountRef.current = 0
|
||||
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
console.error(
|
||||
'[viewer] Failed to set up post-processing pipeline. Rendering without post FX.',
|
||||
'[viewer/post-processing] Failed to set up post-processing pipeline. Rendering without post FX.',
|
||||
{
|
||||
version: pipelineVersion,
|
||||
ssgi: SSGI_PARAMS.enabled,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
},
|
||||
error,
|
||||
)
|
||||
if (renderPipelineRef.current) {
|
||||
@@ -366,7 +385,7 @@ const PostProcessingPasses = ({
|
||||
}
|
||||
;(renderer as any).render(scene, camera)
|
||||
} catch (fallbackError) {
|
||||
console.error('[viewer] Fallback render failed.', fallbackError)
|
||||
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -378,7 +397,11 @@ const PostProcessingPasses = ({
|
||||
renderPipelineRef.current.render()
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
console.error('[viewer] Post-processing render pass failed.', error)
|
||||
console.error('[viewer/post-processing] Render pass failed.', {
|
||||
retryCount: retryCountRef.current,
|
||||
rendererCtor: (renderer as any).constructor?.name,
|
||||
error,
|
||||
})
|
||||
if (renderPipelineRef.current) {
|
||||
renderPipelineRef.current.dispose()
|
||||
}
|
||||
@@ -388,7 +411,7 @@ const PostProcessingPasses = ({
|
||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||
retryCountRef.current++
|
||||
console.warn(
|
||||
`[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||
)
|
||||
if (rebuildTimeoutRef.current !== null) {
|
||||
clearTimeout(rebuildTimeoutRef.current)
|
||||
@@ -396,7 +419,7 @@ const PostProcessingPasses = ({
|
||||
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
||||
} else {
|
||||
console.error(
|
||||
'[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',
|
||||
'[viewer/post-processing] Retries exhausted. Rendering without post FX for this session.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,14 @@ export class MergedOutlineNode extends TempNode {
|
||||
private readonly _cacheA = new Set<Object3D>()
|
||||
private readonly _cacheB = new Set<Object3D>()
|
||||
|
||||
// Tracks whether either group rendered last frame. We use this to decide
|
||||
// when it's safe to skip renderer state manipulation entirely — touching
|
||||
// the renderer (resetRendererAndSceneState + setRenderTarget + clearColor)
|
||||
// corrupts the FBO state on the WebGL2 backend (iOS Chrome fallback) and
|
||||
// the subsequent scene render comes out blank.
|
||||
private _wroteGroupALastFrame = false
|
||||
private _wroteGroupBLastFrame = false
|
||||
|
||||
private readonly _textureNodeA: any
|
||||
private readonly _textureNodeB: any
|
||||
|
||||
@@ -294,6 +302,17 @@ export class MergedOutlineNode extends TempNode {
|
||||
updateBefore(frame: any) {
|
||||
const hasPrimary = this.primaryObjects.length > 0
|
||||
const hasSecondary = this.secondaryObjects.length > 0
|
||||
const hasAny = hasPrimary || hasSecondary
|
||||
|
||||
// Fast-path: nothing to render and nothing was rendered last frame either,
|
||||
// so there are no stale composites to clear. Touch nothing — on the WebGL2
|
||||
// backend (iOS Chrome fallback) even an empty reset/setRenderTarget cycle
|
||||
// corrupts the framebuffer state and the next scene render goes blank.
|
||||
const needsCleanupA = !hasPrimary && this._wroteGroupALastFrame
|
||||
const needsCleanupB = !hasSecondary && this._wroteGroupBLastFrame
|
||||
if (!(hasAny || needsCleanupA || needsCleanupB)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { renderer } = frame
|
||||
const { camera, scene } = this
|
||||
@@ -303,24 +322,27 @@ export class MergedOutlineNode extends TempNode {
|
||||
const size = renderer.getDrawingBufferSize(_size)
|
||||
this.setSize(size.width, size.height)
|
||||
|
||||
// Clear composites for inactive groups so stale outlines don't persist on GPU.
|
||||
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
|
||||
if (!hasPrimary) {
|
||||
// Clear composites for groups that just transitioned from "has content"
|
||||
// to "empty" — without this, the previous outline lingers on the GPU.
|
||||
if (needsCleanupA) {
|
||||
renderer.setRenderTarget(this._groupA.composite)
|
||||
renderer.clearColor()
|
||||
this._wroteGroupALastFrame = false
|
||||
}
|
||||
if (!hasSecondary) {
|
||||
if (needsCleanupB) {
|
||||
renderer.setRenderTarget(this._groupB.composite)
|
||||
renderer.clearColor()
|
||||
this._wroteGroupBLastFrame = false
|
||||
}
|
||||
|
||||
const hasAny = hasPrimary || hasSecondary
|
||||
if (!hasAny) {
|
||||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||
return
|
||||
}
|
||||
|
||||
renderer.setClearColor(0xff_ff_ff, 1)
|
||||
this._wroteGroupALastFrame = hasPrimary
|
||||
this._wroteGroupBLastFrame = hasSecondary
|
||||
|
||||
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||||
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Object3D } from 'three'
|
||||
import { Box3, Matrix4, Vector3 } from 'three'
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
export type MeshLocalBounds = {
|
||||
min: [number, number, number]
|
||||
max: [number, number, number]
|
||||
}
|
||||
|
||||
/** Plan footprint in the item root's horizontal (x, z) plane — stored as floorplan polygon. */
|
||||
export function computePlanFootprintPolygonLocal(object: Object3D): Point[] {
|
||||
object.updateWorldMatrix(true, true)
|
||||
|
||||
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
|
||||
const localMatrix = new Matrix4()
|
||||
const scratchBounds = new Box3()
|
||||
const scratchPosition = new Vector3()
|
||||
const footprintPoints: Point[] = []
|
||||
|
||||
const collectPoints = (child: Object3D) => {
|
||||
const mesh = child as Object3D & {
|
||||
isMesh?: boolean
|
||||
name?: string
|
||||
geometry?: {
|
||||
boundingBox: Box3 | null
|
||||
computeBoundingBox?: () => void
|
||||
attributes?: {
|
||||
position?: {
|
||||
count: number
|
||||
getX: (index: number) => number
|
||||
getY: (index: number) => number
|
||||
getZ: (index: number) => number
|
||||
}
|
||||
}
|
||||
}
|
||||
matrixWorld: Matrix4
|
||||
}
|
||||
|
||||
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
|
||||
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
|
||||
mesh.geometry.computeBoundingBox()
|
||||
}
|
||||
|
||||
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
|
||||
|
||||
const vertexPositions = mesh.geometry.attributes?.position
|
||||
if (vertexPositions && vertexPositions.count > 0) {
|
||||
for (let index = 0; index < vertexPositions.count; index += 1) {
|
||||
scratchPosition
|
||||
.set(
|
||||
vertexPositions.getX(index),
|
||||
vertexPositions.getY(index),
|
||||
vertexPositions.getZ(index),
|
||||
)
|
||||
.applyMatrix4(localMatrix)
|
||||
|
||||
if (Number.isFinite(scratchPosition.x) && Number.isFinite(scratchPosition.z)) {
|
||||
footprintPoints.push({ x: scratchPosition.x, y: scratchPosition.z })
|
||||
}
|
||||
}
|
||||
} else if (mesh.geometry.boundingBox) {
|
||||
scratchBounds.copy(mesh.geometry.boundingBox)
|
||||
scratchBounds.applyMatrix4(localMatrix)
|
||||
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
|
||||
footprintPoints.push(
|
||||
{ x: scratchBounds.min.x, y: scratchBounds.min.z },
|
||||
{ x: scratchBounds.max.x, y: scratchBounds.min.z },
|
||||
{ x: scratchBounds.max.x, y: scratchBounds.max.z },
|
||||
{ x: scratchBounds.min.x, y: scratchBounds.max.z },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grandchild of child.children) {
|
||||
collectPoints(grandchild)
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of object.children) {
|
||||
collectPoints(child)
|
||||
}
|
||||
|
||||
return getMinimumAreaBoundingRect(footprintPoints) ?? []
|
||||
}
|
||||
|
||||
export function computeMeshLocalBoundsFromObject(object: Object3D): MeshLocalBounds | null {
|
||||
object.updateWorldMatrix(true, true)
|
||||
|
||||
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
|
||||
const localMatrix = new Matrix4()
|
||||
const localBounds = new Box3()
|
||||
const scratchBounds = new Box3()
|
||||
let hasBounds = false
|
||||
|
||||
const expandBounds = (child: Object3D) => {
|
||||
const mesh = child as Object3D & {
|
||||
isMesh?: boolean
|
||||
name?: string
|
||||
geometry?: {
|
||||
boundingBox: Box3 | null
|
||||
computeBoundingBox?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
|
||||
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
|
||||
mesh.geometry.computeBoundingBox()
|
||||
}
|
||||
|
||||
if (mesh.geometry.boundingBox) {
|
||||
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
|
||||
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
|
||||
if (!hasBounds) {
|
||||
localBounds.copy(scratchBounds)
|
||||
hasBounds = true
|
||||
} else {
|
||||
localBounds.union(scratchBounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const grandchild of child.children) {
|
||||
expandBounds(grandchild)
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of object.children) {
|
||||
expandBounds(child)
|
||||
}
|
||||
|
||||
if (!hasBounds) return null
|
||||
|
||||
return {
|
||||
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
|
||||
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
|
||||
}
|
||||
}
|
||||
|
||||
function getMinimumAreaBoundingRect(points: Point[]) {
|
||||
if (points.length === 0) return null
|
||||
if (points.length < 3) return points
|
||||
|
||||
const hull = getConvexHull(points)
|
||||
if (hull.length < 3) return hull
|
||||
|
||||
let bestArea = Number.POSITIVE_INFINITY
|
||||
let bestRect: Point[] | null = null
|
||||
|
||||
for (let index = 0; index < hull.length; index += 1) {
|
||||
const nextIndex = (index + 1) % hull.length
|
||||
const current = hull[index]!
|
||||
const next = hull[nextIndex]!
|
||||
const angle = Math.atan2(next.y - current.y, next.x - current.x)
|
||||
const cos = Math.cos(-angle)
|
||||
const sin = Math.sin(-angle)
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const point of hull) {
|
||||
const rx = point.x * cos - point.y * sin
|
||||
const ry = point.x * sin + point.y * cos
|
||||
minX = Math.min(minX, rx)
|
||||
maxX = Math.max(maxX, rx)
|
||||
minY = Math.min(minY, ry)
|
||||
maxY = Math.max(maxY, ry)
|
||||
}
|
||||
|
||||
const area = (maxX - minX) * (maxY - minY)
|
||||
if (area >= bestArea) continue
|
||||
bestArea = area
|
||||
|
||||
const unrotate = (x: number, y: number): Point => ({
|
||||
x: x * Math.cos(angle) - y * Math.sin(angle),
|
||||
y: x * Math.sin(angle) + y * Math.cos(angle),
|
||||
})
|
||||
|
||||
bestRect = [
|
||||
unrotate(minX, minY),
|
||||
unrotate(maxX, minY),
|
||||
unrotate(maxX, maxY),
|
||||
unrotate(minX, maxY),
|
||||
]
|
||||
}
|
||||
|
||||
return bestRect
|
||||
}
|
||||
|
||||
function getConvexHull(points: Point[]) {
|
||||
if (points.length <= 1) return points
|
||||
|
||||
const sorted = [...points].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x))
|
||||
const cross = (o: Point, a: Point, b: Point) =>
|
||||
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
|
||||
|
||||
const lower: Point[] = []
|
||||
for (const point of sorted) {
|
||||
while (
|
||||
lower.length >= 2 &&
|
||||
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0
|
||||
) {
|
||||
lower.pop()
|
||||
}
|
||||
lower.push(point)
|
||||
}
|
||||
|
||||
const upper: Point[] = []
|
||||
for (let index = sorted.length - 1; index >= 0; index -= 1) {
|
||||
const point = sorted[index]!
|
||||
while (
|
||||
upper.length >= 2 &&
|
||||
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0
|
||||
) {
|
||||
upper.pop()
|
||||
}
|
||||
upper.push(point)
|
||||
}
|
||||
|
||||
lower.pop()
|
||||
upper.pop()
|
||||
return [...lower, ...upper]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type { Object3D } from 'three'
|
||||
import {
|
||||
computeMeshLocalBoundsFromObject,
|
||||
computePlanFootprintPolygonLocal,
|
||||
} from './compute-item-mesh-metadata'
|
||||
import { drainItemMeshMetadataSyncRequests, getItemMeshMetadataSourceRoot } from './sync-request'
|
||||
|
||||
function isMetadataUnchanged(
|
||||
nextPolygon: [number, number][] | null,
|
||||
nextBounds: { min: [number, number, number]; max: [number, number, number] } | null,
|
||||
metadata: Record<string, unknown>,
|
||||
): boolean {
|
||||
const currentPolygon = metadata.meshLocalPlanPolygon
|
||||
const currentBounds =
|
||||
typeof metadata.meshLocalBounds === 'object' &&
|
||||
metadata.meshLocalBounds !== null &&
|
||||
!Array.isArray(metadata.meshLocalBounds)
|
||||
? (metadata.meshLocalBounds as { min?: unknown; max?: unknown })
|
||||
: null
|
||||
|
||||
const polygonUnchanged =
|
||||
(nextPolygon === null &&
|
||||
(currentPolygon === undefined || currentPolygon === null || currentPolygon === false)) ||
|
||||
(Array.isArray(currentPolygon) &&
|
||||
nextPolygon !== null &&
|
||||
currentPolygon.length === nextPolygon.length &&
|
||||
currentPolygon.every(
|
||||
(point, index) =>
|
||||
Array.isArray(point) &&
|
||||
point[0] === nextPolygon[index]?.[0] &&
|
||||
point[1] === nextPolygon[index]?.[1],
|
||||
))
|
||||
|
||||
const boundsUnchanged =
|
||||
(nextBounds === null && (currentBounds === undefined || currentBounds === null)) ||
|
||||
(nextBounds !== null &&
|
||||
Array.isArray(currentBounds?.min) &&
|
||||
Array.isArray(currentBounds?.max) &&
|
||||
currentBounds.min[0] === nextBounds.min[0] &&
|
||||
currentBounds.min[1] === nextBounds.min[1] &&
|
||||
currentBounds.min[2] === nextBounds.min[2] &&
|
||||
currentBounds.max[0] === nextBounds.max[0] &&
|
||||
currentBounds.max[1] === nextBounds.max[1] &&
|
||||
currentBounds.max[2] === nextBounds.max[2])
|
||||
|
||||
return polygonUnchanged && boundsUnchanged
|
||||
}
|
||||
|
||||
function trySyncItemMeshMetadata(itemId: string, nodes: Record<string, AnyNode | undefined>) {
|
||||
const node = nodes[itemId]
|
||||
if (!node || node.type !== 'item') return
|
||||
const root =
|
||||
getItemMeshMetadataSourceRoot(itemId) ??
|
||||
(sceneRegistry.nodes.get(itemId) as Object3D | undefined)
|
||||
if (!root) return
|
||||
|
||||
const polygon = computePlanFootprintPolygonLocal(root)
|
||||
const bounds = computeMeshLocalBoundsFromObject(root)
|
||||
if (polygon.length < 3 && !bounds) return
|
||||
|
||||
const nextPolygon =
|
||||
polygon.length >= 3 ? polygon.map(({ x, y }) => [x, y] as [number, number]) : null
|
||||
const nextBounds = bounds ? { min: bounds.min, max: bounds.max } : null
|
||||
|
||||
const metadata =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
if (isMetadataUnchanged(nextPolygon, nextBounds, metadata)) return
|
||||
|
||||
useScene.getState().updateNode(itemId as AnyNodeId, {
|
||||
metadata: {
|
||||
...metadata,
|
||||
...(nextPolygon ? { meshLocalPlanPolygon: nextPolygon } : {}),
|
||||
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `meshLocalPlanPolygon` / `meshLocalBounds` from loaded item meshes.
|
||||
* ModelRenderer requests sync via `requestItemMeshMetadataSync` when GLTF is ready.
|
||||
*/
|
||||
export function ItemMeshMetadataSystem() {
|
||||
useFrame(() => {
|
||||
const ids = drainItemMeshMetadataSyncRequests()
|
||||
if (ids.length === 0) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const id of ids) {
|
||||
trySyncItemMeshMetadata(id, nodes)
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Object3D } from 'three'
|
||||
|
||||
const pendingIds = new Set<string>()
|
||||
/** Preferred root for footprint math (Clone root). Falls back to sceneRegistry item root. */
|
||||
const sourceRoots = new Map<string, Object3D>()
|
||||
|
||||
/** Called when an item's loaded GLTF (or metadata driving footprint) may need re-syncing. */
|
||||
export function requestItemMeshMetadataSync(itemId: string) {
|
||||
pendingIds.add(itemId)
|
||||
}
|
||||
|
||||
export function setItemMeshMetadataSourceRoot(itemId: string, root: Object3D | null) {
|
||||
if (root) {
|
||||
sourceRoots.set(itemId, root)
|
||||
} else {
|
||||
sourceRoots.delete(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getItemMeshMetadataSourceRoot(itemId: string): Object3D | undefined {
|
||||
return sourceRoots.get(itemId)
|
||||
}
|
||||
|
||||
export function drainItemMeshMetadataSyncRequests(): string[] {
|
||||
if (pendingIds.size === 0) return []
|
||||
const ids = [...pendingIds]
|
||||
pendingIds.clear()
|
||||
return ids
|
||||
}
|
||||
Reference in New Issue
Block a user