-
Notifications
You must be signed in to change notification settings - Fork 2.3k
viewer: baked GLB export + GLB-consuming /viewer (lights, clips, perf) #441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06e4cd7
0a7cbd2
064be98
0469b25
c8bd29e
0629c7d
6cfc6ae
5aedc36
0e55d86
d80ffd6
0514ec1
a5c08b5
df901b1
5938cb6
636f8ed
708fa52
b7386f2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import type * as THREE from 'three' | ||
|
|
||
| export type ItemClipEntry = { | ||
| /** The catalog clip to re-emit (e.g. a fan's "On" spin). */ | ||
| clip: THREE.AnimationClip | ||
| /** Plays looping in the baked viewer (ambient motion) vs once. */ | ||
| loop: boolean | ||
| } | ||
|
|
||
| /** | ||
| * Catalog-item animation clips the bake needs to re-emit. A catalog GLB ships | ||
| * its own clips (the live item renderer loads + plays them), but those clips | ||
| * are not part of the editor scene graph, so the GLB export can't see them on | ||
| * its own. The item renderer registers the resolved clip per node id while the | ||
| * scene is live; `glb-export` reads this and retargets the clip onto the baked | ||
| * item subtree. Door/window motion is synthesized separately and never goes | ||
| * here. Keyed by node id; cleared with the rest of the scene refs on unload. | ||
| */ | ||
| export const itemClipRegistry = new Map<string, ItemClipEntry>() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| 'use client' | ||
|
|
||
| import { useScene } from '@pascal-app/core' | ||
| import { useThree } from '@react-three/fiber' | ||
| import { useEffect, useRef } from 'react' | ||
| import { exportSceneToGlb } from '../../lib/glb-export' | ||
|
|
||
| export function BakeExporter({ | ||
| active, | ||
| onComplete, | ||
| onError, | ||
| }: { | ||
| active: boolean | ||
| onComplete: (buffer: ArrayBuffer) => void | ||
| onError: (message: string) => void | ||
| }) { | ||
| const scene = useThree((s) => s.scene) | ||
| const doneRef = useRef(false) | ||
| useEffect(() => { | ||
| if (!(active && !doneRef.current)) return | ||
| doneRef.current = true | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BakeExporter cannot rebakeMedium Severity The Reviewed by Cursor Bugbot for commit 636f8ed. Configure here. |
||
| const run = async () => { | ||
| try { | ||
| const sceneGroup = scene.getObjectByName('scene-renderer') | ||
| if (!sceneGroup) throw new Error('scene-renderer group not found') | ||
| const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) | ||
| onComplete(buffer) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Item clips missing early bakeMedium Severity Catalog item clips are registered only after each item’s GLTF animations load in a Additional Locations (1)Reviewed by Cursor Bugbot for commit b7386f2. Configure here. |
||
| } catch (err) { | ||
| onError(err instanceof Error ? err.message : String(err)) | ||
| } | ||
| } | ||
| void run() | ||
| }, [active, scene, onComplete, onError]) | ||
| return null | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,12 @@ | ||
| 'use client' | ||
|
|
||
| import { emitter, useScene } from '@pascal-app/core' | ||
| import { useViewer } from '@pascal-app/viewer' | ||
| import { useThree } from '@react-three/fiber' | ||
| import { useEffect } from 'react' | ||
| import type { Mesh, Object3D } from 'three' | ||
| import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' | ||
| import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' | ||
| import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' | ||
| import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' | ||
| import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export' | ||
|
|
||
| export function ExportManager() { | ||
| const scene = useThree((state) => state.scene) | ||
|
|
@@ -23,7 +22,26 @@ export function ExportManager() { | |
| } | ||
|
|
||
| const date = new Date().toISOString().split('T')[0] | ||
| const exportScene = prepareSceneForExport(sceneGroup) | ||
|
|
||
| if (format === 'glb') { | ||
| const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) | ||
| const blob = new Blob([buffer], { type: 'model/gltf-binary' }) | ||
| downloadBlob(blob, `model_${date}.glb`) | ||
| return | ||
| } | ||
|
|
||
| // Hide editor affordances that live on the scene layer (selection handles, | ||
| // ceiling/site brackets) and let wall-cutout reveal all walls — the same | ||
| // synchronous capture path thumbnails use. We clone the scene inside the | ||
| // window, so the export snapshots the clean building, then restore. | ||
| emitter.emit('thumbnail:before-capture', undefined) | ||
| let prepared: ReturnType<typeof prepareSceneForExport> | ||
| try { | ||
| prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes) | ||
| } finally { | ||
| emitter.emit('thumbnail:after-capture', undefined) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OBJ STL skip level snapMedium Severity This refactor routes GLB export through Reviewed by Cursor Bugbot for commit b7386f2. Configure here. |
||
| const { scene: exportScene, animations } = prepared | ||
|
|
||
| if (format === 'stl') { | ||
| const exporter = new STLExporter() | ||
|
|
@@ -40,37 +58,6 @@ export function ExportManager() { | |
| downloadBlob(blob, `model_${date}.obj`) | ||
| return | ||
| } | ||
|
|
||
| // Default: GLB export (existing behavior) | ||
| const exporter = new GLTFExporter() | ||
|
|
||
| // Compressed (KTX2/basis) textures must be decompressed during export or | ||
| // three r184's GLTFExporter throws "setTextureUtils() must be called". | ||
| // The app renders with WebGPURenderer, so use the WebGPU texture utils. | ||
| // We intentionally do NOT pass the live renderer: decompress() resizes | ||
| // whatever renderer it's given (and never restores it), which would | ||
| // corrupt the visible canvas. Omitting it lets three spin up and dispose | ||
| // its own throwaway renderer for the blit instead. | ||
| exporter.setTextureUtils({ | ||
| decompress: (texture, maxTextureSize) => | ||
| WebGPUTextureUtils.decompress(texture, maxTextureSize), | ||
| }) | ||
|
|
||
| return new Promise<void>((resolve, reject) => { | ||
| exporter.parse( | ||
| exportScene, | ||
| (gltf) => { | ||
| const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' }) | ||
| downloadBlob(blob, `model_${date}.glb`) | ||
| resolve() | ||
| }, | ||
| (error) => { | ||
| console.error('Export error:', error) | ||
| reject(error) | ||
| }, | ||
| { binary: true }, | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| setExportScene(exportFn) | ||
|
|
@@ -83,33 +70,6 @@ export function ExportManager() { | |
| return null | ||
| } | ||
|
|
||
| function prepareSceneForExport(source: Object3D) { | ||
| const clone = source.clone(true) | ||
| const meshesToRemove: Mesh[] = [] | ||
|
|
||
| clone.traverse((object) => { | ||
| if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object) | ||
| }) | ||
|
|
||
| for (const mesh of meshesToRemove) { | ||
| mesh.removeFromParent() | ||
| } | ||
|
|
||
| return clone | ||
| } | ||
|
|
||
| function isMeshWithInvalidGeometry(object: Object3D): object is Mesh { | ||
| if (!isMesh(object)) return false | ||
|
|
||
| // Three exporters can crash when a Mesh has no readable position attribute. | ||
| const position = object.geometry?.getAttribute('position') | ||
| return !position || position.count === 0 | ||
| } | ||
|
|
||
| function isMesh(object: Object3D): object is Mesh { | ||
| return (object as Mesh).isMesh === true | ||
| } | ||
|
|
||
| function downloadBlob(blob: Blob, filename: string) { | ||
| const url = URL.createObjectURL(blob) | ||
| const link = document.createElement('a') | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clip registry survives scene reset
Medium Severity
The new
itemClipRegistryis documented as cleared on scene unload, but only per-itemdeleteruns on renderer unmount.resetEditorInteractionStatestill callssceneRegistry.clear()without clearing this map, so stale catalog clips can remain keyed by old node ids.Reviewed by Cursor Bugbot for commit 636f8ed. Configure here.