Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
06e4cd7
feat(export): baked GLB export with identity, clips, cutout fix (phas…
wass08 Jun 22, 2026
0a7cbd2
feat(viewer): GLB-consuming /viewer path (baked-glb-export phase 2)
wass08 Jun 23, 2026
064be98
feat(export): reusable exportSceneToGlb + BakeExporter for headless bake
wass08 Jun 23, 2026
0469b25
feat(viewer): distance-based LOD switching in GLB scene
wass08 Jun 23, 2026
c8bd29e
revert(viewer): drop distance-based LOD switching from GLB scene
wass08 Jun 23, 2026
0629c7d
fix(export): snap levels to stacked positions before GLB export
wass08 Jun 23, 2026
6cfc6ae
fix(export): stamp openable only when an open clip bakes
wass08 Jun 23, 2026
5aedc36
feat(viewer): GLB walkthrough in viewer, monochrome, spawn/export polish
wass08 Jun 24, 2026
0e55d86
fix(viewer): keep ceiling-hosted items visible in dollhouse
wass08 Jun 24, 2026
d80ffd6
feat(viewer): re-light + re-control baked GLBs from the scene graph
wass08 Jun 24, 2026
0514ec1
feat(bake): bake catalog item clips (fan spin) into the GLB + play in…
wass08 Jun 24, 2026
a5c08b5
feat(viewer): pool GLB item lights + stop overlay click propagation
wass08 Jun 25, 2026
df901b1
perf(viewer): keep GLB light pool at a fixed visible count (no WebGPU…
wass08 Jun 25, 2026
5938cb6
feat(viewer): strip scans/guides from the bake, re-add from scene data
wass08 Jun 25, 2026
636f8ed
style(viewer): wrap three import in glb-interactive (formatter)
wass08 Jun 25, 2026
708fa52
Merge remote-tracking branch 'origin/main' into feat/baked-glb-export
wass08 Jun 25, 2026
b7386f2
fix(viewer): block body for zone-shape forEach (useIterableCallbackRe…
wass08 Jun 25, 2026
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
19 changes: 19 additions & 0 deletions packages/core/src/hooks/scene-registry/item-clip-registry.ts
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>()

Copy link
Copy Markdown

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 itemClipRegistry is documented as cleared on scene unload, but only per-item delete runs on renderer unmount. resetEditorInteractionState still calls sceneRegistry.clear() without clearing this map, so stale catalog clips can remain keyed by old node ids.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 636f8ed. Configure here.

1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type {
ZoneEvent,
} from './events/bus'
export { emitter, eventSuffixes } from './events/bus'
export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry'
export {
sceneRegistry,
useRegistry,
Expand Down
35 changes: 35 additions & 0 deletions packages/editor/src/components/editor/bake-exporter.tsx
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BakeExporter cannot rebake

Medium Severity

The BakeExporter component's doneRef flag prevents the export logic from running more than once per component mount. Since doneRef is never reset, any subsequent attempts to trigger an export (e.g., by toggling active or after a previous export failed) are silently skipped.

Fix in Cursor Fix in Web

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Item clips missing early bake

Medium Severity

Catalog item clips are registered only after each item’s GLTF animations load in a useEffect, while BakeExporter can export as soon as active is true. An early bake often finds an empty itemClipRegistry, so fan-style ambient clips are omitted from the GLB.

Additional Locations (1)
Fix in Cursor Fix in Web

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
}
84 changes: 22 additions & 62 deletions packages/editor/src/components/editor/export-manager.tsx
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)
Expand All @@ -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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OBJ STL skip level snap

Medium Severity

This refactor routes GLB export through exportSceneToGlb, which snaps levels to stacked positions before cloning, but OBJ/STL still call prepareSceneForExport on the live tree with no snap. With exploded or solo level mode, mesh exports can bake wrong vertical offsets while GLB/thumbnails match the stacked building.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7386f2. Configure here.

const { scene: exportScene, animations } = prepared

if (format === 'stl') {
const exporter = new STLExporter()
Expand All @@ -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)
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
} from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import '../../three-types'
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
import {
closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
Expand All @@ -64,8 +65,6 @@ import {
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} from './first-person/build-collider-world'
import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl'
import BVHEcctrl from './first-person/bvh-ecctrl'

const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002
Expand Down
2 changes: 2 additions & 0 deletions packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
// they're referenced throughout the editor's own internals; the public
// surface uses the shorter, shell-friendly names from the unified
// preset-system spec.
export { BakeExporter } from './components/editor/bake-exporter'
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
// Embed surface — the editor's real in-canvas affordances, so a host can mount
// authentic selection handles, interactive build tools, and the mover on top
Expand Down Expand Up @@ -265,6 +266,7 @@ export {
getFloorplanWallThickness,
} from './lib/floorplan'
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
export { exportSceneToGlb } from './lib/glb-export'
export {
buildResetSurfaceMaterialUpdates,
buildRoofSurfaceMaterialPatch,
Expand Down
Loading
Loading