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/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ export {
beginSceneHistoryDraft,
clearSceneHistory,
default as useScene,
getSceneDocument,
runSceneHistoryDraftWrite,
type SceneMaterialPatch,
type SceneNodePatch,
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/store/history-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import useScene, {
applySceneSnapshot,
beginSceneHistoryDraft,
clearSceneHistory,
getSceneDocument,
runSceneHistoryDraftWrite,
sceneHistoryDraftRevertUpdates,
} from './use-scene'
Expand Down Expand Up @@ -71,6 +72,36 @@ const wallStart = () => (node(wallId) as WallNode).start
const levelChildren = () => (node(levelId) as LevelNode).children

describe('scene history drafts', () => {
test('saving during a move keeps its original pose and host, then saves the committed drop', () => {
const end = beginSceneHistoryDraft(itemId, node(itemId)!)
try {
runSceneHistoryDraftWrite(() =>
useScene.getState().updateNode(itemId, {
position: [3, 0, 3],
parentId: wallId,
visible: false,
metadata: { isTransient: true },
}),
)
useScene.getState().updateNode(itemId, { name: 'Renamed while moving' })
const document = getSceneDocument()
expect(document.nodes[itemId]).toMatchObject({
position: [1, 0, 1],
parentId: levelId,
visible: true,
name: 'Renamed while moving',
})
expect(document.nodes[itemId]!.metadata?.isTransient).toBeUndefined()
expect((document.nodes[levelId] as LevelNode).children).toContain(itemId)
expect((document.nodes[wallId] as WallNode).children).not.toContain(itemId)
expect((node(itemId) as ItemNode).position).toEqual([3, 0, 3])
} finally {
end()
}
expect(getSceneDocument().nodes[itemId]).toBe(node(itemId)!)
expect((getSceneDocument().nodes[itemId] as ItemNode).position).toEqual([3, 0, 3])
})

test.each([
[true, false],
[true, true],
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/store/use-scene-commits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ItemNode } from '../schema/nodes/item'
import { LevelNode } from '../schema/nodes/level'
import { SlabNode } from '../schema/nodes/slab'
import { WallNode } from '../schema/nodes/wall'
import { WindowNode } from '../schema/nodes/window'
import { SceneMaterial, type SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { migrateCeilingRoomLinks, migrateRoomZones } from '../utils/room-zone-migration'
Expand All @@ -28,7 +29,9 @@ import useScene, {
applySceneOperationPatch,
applyScenePatch,
applySceneSnapshot,
beginSceneHistoryDraft,
clearSceneHistory,
getSceneDocument,
type SceneOperationPatch,
} from './use-scene'

Expand Down Expand Up @@ -234,6 +237,52 @@ describe('scene commit boundary', () => {
expect(useScene.getState().nodes[draftWall.id]).toBeDefined()
})

test('saves leave out only the drafts an interaction owns, never a node by its metadata', () => {
expect(getSceneDocument().nodes).toBe(useScene.getState().nodes)
// A preset tile clones its subtree into the level before any click (place-preset).
const presetDraft = WindowNode.parse({
id: 'window_preset_draft',
parentId: LEVEL_ID,
metadata: { isNew: true, presetId: 'item_window_preset' },
})
// A draw tool's draft is registered as a created carry draft instead.
const toolDraft = ItemNode.parse({
id: 'item_tool_draft',
parentId: LEVEL_ID,
metadata: { isTransient: true },
asset: { id: 'chair', name: 'Chair', category: 'seating', thumbnail: '', src: '/chair.glb' },
})
// Some movers commit a duplicate in place and leave its `isNew` flag on: it is the user's.
const placedDuplicate = WallNode.parse({
id: 'wall_placed_duplicate',
parentId: LEVEL_ID,
start: [0, 0],
end: [4, 0],
metadata: { isNew: true },
})
useScene.getState().createNode(placedDuplicate, LEVEL_ID)
useScene.getState().createNode(presetDraft, LEVEL_ID)
pauseSceneHistory(useScene)
useScene.getState().createNode(toolDraft, LEVEL_ID)
const endToolDraft = beginSceneHistoryDraft(toolDraft.id, null)

const armed = getSceneDocument([presetDraft.id])
expect(armed.nodes[presetDraft.id]).toBeUndefined()
expect(armed.nodes[toolDraft.id]).toBeUndefined()
expect(armed.nodes[placedDuplicate.id]).toBeDefined()
expect((armed.nodes[LEVEL_ID] as { children: string[] }).children).toEqual([placedDuplicate.id])
expect(useScene.getState().nodes[presetDraft.id]).toBeDefined()

useScene.getState().deleteNode(toolDraft.id)
endToolDraft()
resumeSceneHistory(useScene)
useScene.getState().updateNode(presetDraft.id, { metadata: { presetId: 'item_window_preset' } })

const placed = Object.values(getSceneDocument().nodes).filter((node) => node.type === 'window')
expect(placed.map((node) => node.id)).toEqual([presetDraft.id])
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
})

test('coalesces a compound transaction into one commit and one undo step', () => {
const commits: SceneCommit[] = []
unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
Expand Down
133 changes: 79 additions & 54 deletions packages/core/src/store/use-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1215,53 +1215,19 @@ type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
temporal: StoreApi<TemporalState<SceneSnapshot>>
}

function sceneHistorySnapshotFromState(
state: Pick<
SceneState,
| 'nodes'
| 'rootNodeIds'
| 'collections'
| 'materials'
| 'installedPlugins'
| 'hasExplicitPluginInstallState'
>,
): SceneSnapshot {
const {
nodes,
rootNodeIds,
collections,
materials,
installedPlugins,
hasExplicitPluginInstallState,
} = state
// Fresh placement nodes are renderable drafts, not document history. Excluding their
// entire subtree here protects both local undo and external commit subscribers. Carried
// drafts (history-drafts.ts) are kept out the same way.
const transientNodeIds = new Set<AnyNodeId>(
createdSceneHistoryDraftIds().filter((id) => Boolean(nodes[id])),
)
for (const node of Object.values(nodes)) {
const metadata = node.metadata
if (
metadata &&
typeof metadata === 'object' &&
!Array.isArray(metadata) &&
(metadata as Record<string, unknown>).isNew === true
) {
transientNodeIds.add(node.id)
}
}
type SceneDocumentParts = Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>

if (transientNodeIds.size === 0) {
return {
nodes: withAdoptedDraftsAsOriginal(nodes, nodes),
rootNodeIds,
collections,
materials,
installedPlugins,
hasExplicitPluginInstallState,
}
}
/**
* `parts` without the subtrees of `draftIds`, and without the references to them (children,
* procedural attachments, collections, roots). Returns `parts` itself when there is nothing to drop.
*/
function withoutDraftSubtrees(
parts: SceneDocumentParts,
draftIds: Iterable<AnyNodeId>,
): SceneDocumentParts {
const { nodes, rootNodeIds, collections } = parts
const transientNodeIds = new Set<AnyNodeId>([...draftIds].filter((id) => Boolean(nodes[id])))
if (transientNodeIds.size === 0) return parts

const childIdsByParentId = new Map<AnyNodeId, Set<AnyNodeId>>()
const addChild = (parentId: AnyNodeId, childId: AnyNodeId) => {
Expand All @@ -1285,7 +1251,7 @@ function sceneHistorySnapshotFromState(
}
}

const historyNodes = {} as Record<AnyNodeId, AnyNode>
const documentNodes = {} as Record<AnyNodeId, AnyNode>
for (const [id, sourceNode] of Object.entries(nodes) as [AnyNodeId, AnyNode][]) {
if (transientNodeIds.has(id)) continue
let node = sourceNode
Expand All @@ -1299,38 +1265,97 @@ function sceneHistorySnapshotFromState(
if (attachments) node = { ...node, attachments }
}
if (!('children' in node && Array.isArray(node.children))) {
historyNodes[id] = node
documentNodes[id] = node
continue
}
const children = (node.children as AnyNodeId[]).filter(
(childId) => !transientNodeIds.has(childId as AnyNodeId),
)
historyNodes[id] =
documentNodes[id] =
children.length === node.children.length ? node : ({ ...node, children } as AnyNode)
}

const historyCollections = {} as Record<CollectionId, Collection>
const documentCollections = {} as Record<CollectionId, Collection>
for (const [id, collection] of Object.entries(collections) as [CollectionId, Collection][]) {
const nodeIds = collection.nodeIds.filter((nodeId) => !transientNodeIds.has(nodeId))
if (collection.controlNodeId && transientNodeIds.has(collection.controlNodeId)) {
const { controlNodeId: _controlNodeId, ...rest } = collection
historyCollections[id] = { ...rest, nodeIds }
documentCollections[id] = { ...rest, nodeIds }
} else {
historyCollections[id] =
documentCollections[id] =
nodeIds.length === collection.nodeIds.length ? collection : { ...collection, nodeIds }
}
}

return {
nodes: withAdoptedDraftsAsOriginal(nodes, historyNodes),
nodes: documentNodes,
rootNodeIds: rootNodeIds.filter((id) => !transientNodeIds.has(id)),
collections: historyCollections,
collections: documentCollections,
}
}

function isFreshPlacementNode(node: AnyNode): boolean {
const metadata = node.metadata
return (
!!metadata &&
typeof metadata === 'object' &&
!Array.isArray(metadata) &&
(metadata as Record<string, unknown>).isNew === true
)
}

/**
* Fresh placement nodes are renderable drafts, not document history. Excluding their entire
* subtree here protects both local undo and external commit subscribers. Carried drafts
* (history-drafts.ts) are kept out the same way.
*/
function sceneHistorySnapshotFromState(
state: Pick<
SceneState,
| 'nodes'
| 'rootNodeIds'
| 'collections'
| 'materials'
| 'installedPlugins'
| 'hasExplicitPluginInstallState'
>,
): SceneSnapshot {
const { nodes, materials, installedPlugins, hasExplicitPluginInstallState } = state
const kept = withoutDraftSubtrees(state, [
...createdSceneHistoryDraftIds(),
...Object.values(nodes)
.filter(isFreshPlacementNode)
.map((node) => node.id),
])
return {
nodes: withAdoptedDraftsAsOriginal(nodes, kept.nodes),
rootNodeIds: kept.rootNodeIds,
collections: kept.collections,
materials,
installedPlugins,
hasExplicitPluginInstallState,
}
}

/**
* The current scene as every save writes it: without the drafts an interaction is carrying —
* created carry drafts (history-drafts.ts) and the `drafts` the caller owns — and with adopted
* carry drafts as they were before the carry. Never inferred from metadata: committed nodes can
* still carry `metadata.isNew`, so only explicitly owned drafts are left out.
*/
export function getSceneDocument(drafts: Iterable<AnyNodeId> = []): SceneSnapshot {
const state = useScene.getState()
const kept = withoutDraftSubtrees(state, [...createdSceneHistoryDraftIds(), ...drafts])
return {
nodes: withAdoptedDraftsAsOriginal(state.nodes, kept.nodes),
rootNodeIds: kept.rootNodeIds,
collections: kept.collections,
materials: state.materials,
installedPlugins: state.installedPlugins,
hasExplicitPluginInstallState: state.hasExplicitPluginInstallState,
}
}

/**
* A dirty mark is a promise that some system will rebuild the node and clear
* the mark, so marks are only accepted for kinds with a dirty consumer: kinds
Expand Down
13 changes: 7 additions & 6 deletions packages/editor/src/components/editor/export-manager.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { emitter, useScene } from '@pascal-app/core'
import { emitter } from '@pascal-app/core'
import { disposeObject3DResources, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
Expand All @@ -25,6 +25,7 @@ import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scop
import { exportSceneToPrintStl, mergePrintExportDiagnostics } from '../../lib/print-export'
import { applySemanticPrintFeatureThickness } from '../../lib/print-feature-thickness'
import { compileSemanticPrintShellWithManifold } from '../../lib/print-shell-compiler-manifold-worker'
import { getSavedSceneDocument } from '../../lib/scene'
import { exportSceneToUsdz } from '../../lib/usdz-export'
import useEditor from '../../store/use-editor'

Expand Down Expand Up @@ -75,11 +76,12 @@ export function ExportManager() {
// without it every plant exports as its raycast collider, a white box).
useViewer.getState().setExporting(true)
try {
await waitForExportGeometry(useScene.getState().nodes, options)
await waitForExportGeometry(getSavedSceneDocument().nodes, options)
const { nodes, collections } = getSavedSceneDocument()

if (format === 'glb') {
const warnings: string[] = []
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, {
const buffer = await exportSceneToGlb(sceneGroup, nodes, {
...options,
onWarning: (warning) => warnings.push(warning),
})
Expand All @@ -89,7 +91,7 @@ export function ExportManager() {

if (format === 'usdz') {
const warnings: string[] = []
const data = await exportSceneToUsdz(sceneGroup, useScene.getState().nodes, {
const data = await exportSceneToUsdz(sceneGroup, nodes, {
...options,
onWarning: (warning) => warnings.push(warning),
})
Expand All @@ -103,7 +105,6 @@ export function ExportManager() {
// window, so the export snapshots the clean building, then restore.
emitter.emit('thumbnail:before-capture', undefined)
const restoreLevels = snapLevelsToTruePositions()
const nodes = useScene.getState().nodes
let prepared: ReturnType<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, nodes, {
Expand Down Expand Up @@ -216,7 +217,7 @@ export function ExportManager() {

if (format === 'ifc') {
const { data, warnings } = exportPreparedSceneToIfc(exportScene, nodes, {
collections: useScene.getState().collections,
collections,
projectName: options.projectName,
onlyVisible: options.onlyVisible,
excludedNodeTypes: options.excludedNodeTypes,
Expand Down
Loading
Loading