Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
3731eb3
Add roof surface placement support for items
sudhir9297 May 18, 2026
ed53bc2
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
fd8e02c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
7c1e383
fixed conflict
sudhir9297 May 20, 2026
b3377da
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 20, 2026
f177a65
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
9af7491
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 22, 2026
fd27524
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 27, 2026
b516298
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 May 28, 2026
ebfc8ce
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 3, 2026
b7b313b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
b2ad645
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 4, 2026
bffdb4a
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 8, 2026
ee7b10c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 9, 2026
7d4b474
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 10, 2026
3a3318c
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 13, 2026
26df69f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 17, 2026
5376e07
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 22, 2026
d2204aa
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 23, 2026
f2a5186
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jun 29, 2026
5841052
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 1, 2026
a6acaa3
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 8, 2026
e0fec5b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 10, 2026
7fa9276
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 13, 2026
c3ff9d6
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 14, 2026
00d84d5
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 19, 2026
2c2dabc
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 22, 2026
29f914f
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 22, 2026
1cbf910
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 23, 2026
8f59da7
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 24, 2026
650abd6
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 27, 2026
1f8dc8b
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 28, 2026
7c4b791
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Jul 31, 2026
ef7184f
Merge branch 'main' of github.com:sudhir9297/editor; branch 'main' of…
sudhir9297 Aug 10, 2026
c92a376
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Aug 12, 2026
da49781
Merge branch 'main' of github.com:pascalorg/editor
sudhir9297 Aug 19, 2026
228b233
feat(nodes): improve block edit interactions
sudhir9297 Aug 20, 2026
528ec46
fix(editor): detach items leaving block faces
sudhir9297 Aug 20, 2026
9b99730
feat(block): improve edit mode interactions
sudhir9297 Aug 20, 2026
39f9e2e
feat(block): support multi-component mesh operations
sudhir9297 Aug 20, 2026
bc87ca2
feat(block): assign accent material to new slots
sudhir9297 Aug 20, 2026
df066c8
refactor(block): isolate edit mode services
sudhir9297 Aug 20, 2026
6c7452d
fix(block): preserve constrained transform intent
sudhir9297 Aug 20, 2026
b15178b
fix(block): key last operation controls
sudhir9297 Aug 20, 2026
384eb92
docs: remove stale wiki research notes
sudhir9297 Aug 20, 2026
b3d58cb
fix block gizmo reuse and operation panel
sudhir9297 Aug 20, 2026
61766b7
add extrusion axis locking and fix z direction
sudhir9297 Aug 21, 2026
cd3b6aa
fix: align block editing interactions
sudhir9297 Aug 22, 2026
126835b
Merge remote-tracking branch 'upstream/main' into t3code/improve-bloc…
sudhir9297 Aug 22, 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
9 changes: 6 additions & 3 deletions apps/editor/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,15 @@ export default function Home() {
return (
<div className="relative h-screen w-screen">
{PROJECT_ID === 'local-editor' && (
<div className="pointer-events-none absolute top-3 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-auto flex max-w-[min(92vw,42rem)] flex-wrap items-center justify-center gap-x-3 gap-y-1 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
<div className="pointer-events-none absolute top-14 left-1/2 z-40 -translate-x-1/2">
<div className="pointer-events-none flex max-w-[min(92vw,42rem)] flex-wrap items-center justify-center gap-x-3 gap-y-1 rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs shadow-sm backdrop-blur">
<span className="text-muted-foreground">
Blank canvas — saved scenes are under Scenes (not this page).
</span>
<Link className="font-medium text-foreground hover:underline" href="/scenes">
<Link
className="pointer-events-auto font-medium text-foreground hover:underline"
href="/scenes"
>
Open saved scenes
</Link>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
'use client'

import { type AnyNodeId, useScene } from '@pascal-app/core'
import {
type AnyNodeId,
createSceneApi,
runAsSingleSceneHistoryStep,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, Suspense, useMemo } from 'react'
import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
import type {
SelectionAffordanceHistoryApi,
SelectionAffordanceProps,
} from './selection-affordance-services'

/**
* Editor-mounted dispatcher for a kind's selection-time editing UI.
Expand All @@ -19,20 +28,43 @@ import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
*/
export function SelectionAffordanceManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedKind = useScene((s) => {
const selectedNode = useScene((s) => {
if (selectedIds.length !== 1) return null
return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null
return s.nodes[selectedIds[0] as AnyNodeId] ?? null
})
const readOnly = useScene((s) => s.readOnly)
const sceneApi = useMemo(() => createSceneApi(useScene), [])
const historyApi = useMemo<SelectionAffordanceHistoryApi>(
() => ({
depth: () => useScene.temporal.getState().pastStates.length,
replaceLatest: (expectedDepth, replace) => {
if (useScene.temporal.getState().pastStates.length !== expectedDepth) return false
let replaced = false
runAsSingleSceneHistoryStep(useScene, () => {
useScene.temporal.getState().undo()
replaced = replace()
if (!replaced) useScene.temporal.getState().redo()
})
return replaced
Comment thread
cursor[bot] marked this conversation as resolved.
},
}),
[],
)

const Component = useMemo<ComponentType | null>(() => {
if (!selectedKind) return null
return getRegistryAffordanceTool(selectedKind, 'selection')
}, [selectedKind])
const Component = useMemo<ComponentType<SelectionAffordanceProps> | null>(() => {
if (!selectedNode) return null
return getRegistryAffordanceTool(selectedNode.type, 'selection')
}, [selectedNode])

if (!Component) return null
if (!(Component && selectedNode)) return null
return (
<Suspense fallback={null}>
<Component />
<Component
historyApi={historyApi}
node={selectedNode}
readOnly={readOnly}
sceneApi={sceneApi}
/>
</Suspense>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { AnyNode, SceneApi } from '@pascal-app/core'

export type SelectionAffordanceHistoryApi = {
depth: () => number
replaceLatest: (expectedDepth: number, replace: () => boolean) => boolean
}

export type SelectionAffordanceProps = {
historyApi: SelectionAffordanceHistoryApi
node: AnyNode
readOnly: boolean
sceneApi: SceneApi
}
Original file line number Diff line number Diff line change
@@ -1,73 +1,73 @@
import { describe, expect, test } from 'bun:test'
import { Group } from 'three'
import {
applyBlockPreviewPose,
clampBlockFaceCenterPosition,
clampBlockFacePosition,
resolveBlockFaceSwitch,
shouldDetachBlockFaceOnLeave,
} from './block-preview'
applyFaceHostPreviewPose,
clampFaceHostCenterPosition,
clampFaceHostPosition,
resolveFaceHostSwitch,
shouldDetachFaceHostOnLeave,
} from './face-host-preview'

describe('applyBlockPreviewPose', () => {
describe('applyFaceHostPreviewPose', () => {
test('moves the rendered draft synchronously with the pointer result', () => {
const mesh = new Group()
mesh.position.set(8, 8, 8)
mesh.rotation.set(1, 1, 1)

applyBlockPreviewPose(mesh, [1.25, -0.5, 0], [0, 0, 0])
applyFaceHostPreviewPose(mesh, [1.25, -0.5, 0], [0, 0, 0])

expect(mesh.position.toArray()).toEqual([1.25, -0.5, 0])
expect(mesh.rotation.toArray().slice(0, 3)).toEqual([0, 0, 0])
})
})

describe('resolveBlockFaceSwitch', () => {
describe('resolveFaceHostSwitch', () => {
test('ignores a single adjacent-face hit and accepts a repeated hit', () => {
const first = resolveBlockFaceSwitch('face-a', 'face-b', null)
const first = resolveFaceHostSwitch('face-a', 'face-b', null)
expect(first).toEqual({ accept: false, pendingFaceId: 'face-b' })

const second = resolveBlockFaceSwitch('face-a', 'face-b', first.pendingFaceId)
const second = resolveFaceHostSwitch('face-a', 'face-b', first.pendingFaceId)
expect(second).toEqual({ accept: true, pendingFaceId: null })
})

test('clears a pending switch when the pointer returns to the current face', () => {
expect(resolveBlockFaceSwitch('face-a', 'face-a', 'face-b')).toEqual({
expect(resolveFaceHostSwitch('face-a', 'face-a', 'face-b')).toEqual({
accept: true,
pendingFaceId: null,
})
})
})

describe('shouldDetachBlockFaceOnLeave', () => {
test('keeps attached items on the last valid block face during transient leave events', () => {
expect(shouldDetachBlockFaceOnLeave('wall')).toBe(false)
expect(shouldDetachBlockFaceOnLeave('wall-side')).toBe(false)
expect(shouldDetachBlockFaceOnLeave('ceiling')).toBe(false)
describe('shouldDetachFaceHostOnLeave', () => {
test('detaches attached items when they leave a block face', () => {
expect(shouldDetachFaceHostOnLeave('wall')).toBe(true)
expect(shouldDetachFaceHostOnLeave('wall-side')).toBe(true)
expect(shouldDetachFaceHostOnLeave('ceiling')).toBe(true)
})

test('allows free floor items to leave a block face', () => {
expect(shouldDetachBlockFaceOnLeave(undefined)).toBe(true)
expect(shouldDetachFaceHostOnLeave(undefined)).toBe(true)
})
})

describe('clampBlockFacePosition', () => {
describe('clampFaceHostPosition', () => {
test('keeps the complete wall item inside the face after snapping', () => {
expect(
clampBlockFacePosition([1.9, 2.8, 0], { minU: -2, maxU: 2, minV: 0, maxV: 3 }, [1, 1]),
clampFaceHostPosition([1.9, 2.8, 0], { minU: -2, maxU: 2, minV: 0, maxV: 3 }, [1, 1]),
).toEqual([1.5, 2, 0])
})

test('rejects a face that is smaller than the item', () => {
expect(
clampBlockFacePosition([0, 0, 0], { minU: -0.25, maxU: 0.25, minV: 0, maxV: 0.5 }, [1, 1]),
clampFaceHostPosition([0, 0, 0], { minU: -0.25, maxU: 0.25, minV: 0, maxV: 0.5 }, [1, 1]),
).toBeNull()
})
})

describe('clampBlockFaceCenterPosition', () => {
describe('clampFaceHostCenterPosition', () => {
test('keeps a ceiling fixture footprint inside the face on both axes', () => {
expect(
clampBlockFaceCenterPosition(
clampFaceHostCenterPosition(
[1.9, 1.9, 0.25],
{ minU: -2, maxU: 2, minV: -2, maxV: 2 },
[1, 1],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type FaceBounds = {
maxV: number
}

export function applyBlockPreviewPose(
export function applyFaceHostPreviewPose(
mesh: Object3D,
position: Vector3Tuple,
rotation: Vector3Tuple,
Expand All @@ -18,7 +18,7 @@ export function applyBlockPreviewPose(
mesh.rotation.set(rotation[0], rotation[1], rotation[2])
}

export function resolveBlockFaceSwitch(
export function resolveFaceHostSwitch(
currentFaceId: string | null | undefined,
nextFaceId: string | null | undefined,
pendingFaceId: string | null,
Expand All @@ -34,11 +34,16 @@ export function resolveBlockFaceSwitch(
return { accept: false, pendingFaceId: nextFaceId }
}

export function shouldDetachBlockFaceOnLeave(attachTo: string | undefined): boolean {
return !attachTo
export function shouldDetachFaceHostOnLeave(attachTo: string | undefined): boolean {
return (
attachTo === undefined ||
attachTo === 'wall' ||
attachTo === 'wall-side' ||
attachTo === 'ceiling'
)
}

export function clampBlockFacePosition(
export function clampFaceHostPosition(
position: Vector3Tuple,
bounds: FaceBounds,
dimensions: readonly [width: number, height: number],
Expand All @@ -57,7 +62,7 @@ export function clampBlockFacePosition(
]
}

export function clampBlockFaceCenterPosition(
export function clampFaceHostCenterPosition(
position: Vector3Tuple,
bounds: FaceBounds,
dimensions: readonly [width: number, depth: number],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ import {
resolvePointerSupportElevation,
resolvePointerSupportSurface,
} from '../shared/pointer-support-cap'
import {
applyBlockPreviewPose,
resolveBlockFaceSwitch,
shouldDetachBlockFaceOnLeave,
} from './block-preview'
import { shouldCreateFloorDraft } from './draft-creation'
import { commitFaceHostClick, resolveFaceHostPreviewCommit } from './face-host-commit'
import {
applyFaceHostPreviewPose,
resolveFaceHostSwitch,
shouldDetachFaceHostOnLeave,
} from './face-host-preview'
import {
getDetachedAttachmentPreviewLift,
getGridAlignedDimensions,
Expand Down Expand Up @@ -1569,7 +1569,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.stopPropagation()
const draft = draftNode.current
const nextFaceId = result.hostFaceId
const faceSwitch = resolveBlockFaceSwitch(
const faceSwitch = resolveFaceHostSwitch(
currentFaceHostId(draft),
nextFaceId,
pendingFaceHostId,
Expand All @@ -1594,7 +1594,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
Object.assign(draft, result.nodeUpdate)
const mesh = sceneRegistry.nodes.get(draft.id)
const rotation = result.nodeUpdate.rotation ?? draft.rotation
if (mesh) applyBlockPreviewPose(mesh, result.gridPosition, rotation)
if (mesh) applyFaceHostPreviewPose(mesh, result.gridPosition, rotation)
useLiveNodeOverrides.getState().set(draft.id, {
position: result.gridPosition,
rotation,
Expand Down Expand Up @@ -1622,7 +1622,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea

const onFaceHostLeave = (event: NodeEvent) => {
pendingFaceHostId = null
if (!shouldDetachBlockFaceOnLeave(asset.attachTo)) {
if (!shouldDetachFaceHostOnLeave(asset.attachTo)) {
event.stopPropagation()
return
}
Expand Down Expand Up @@ -2700,7 +2700,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
} else if (placementState.current.surface === 'block-face') {
const rotation = draftNode.current.rotation
applyBlockPreviewPose(
applyFaceHostPreviewPose(
mesh,
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
rotation,
Expand Down
20 changes: 19 additions & 1 deletion packages/editor/src/hooks/use-keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
} from '@pascal-app/core'
import { meshEditScope } from '../lib/interaction/scope'
import useInteractionScope from '../store/use-interaction-scope'
import { runHistoryShortcut } from './use-keyboard'
import {
canCycleSnappingModeShortcut,
canRunGlobalRotationShortcut,
runHistoryShortcut,
} from './use-keyboard'

type RafFn = (callback: (time: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
Expand Down Expand Up @@ -42,6 +46,20 @@ afterEach(() => {
})

describe('history shortcuts during block editing', () => {
test('reserves global rotation shortcuts for the active mesh editor', () => {
expect(canRunGlobalRotationShortcut()).toBe(true)
useInteractionScope.getState().begin(meshEditScope(NODE_ID))
expect(canRunGlobalRotationShortcut()).toBe(false)
})

test('keeps Shift available to cycle snapping while a mesh operation is active', () => {
useInteractionScope.getState().begin(meshEditScope(NODE_ID))
expect(canCycleSnappingModeShortcut(true)).toBe(true)

useInteractionScope.getState().begin(meshEditScope(NODE_ID, 'operating', 'translate'))
expect(canCycleSnappingModeShortcut(true)).toBe(true)
})

test('undoes and redoes mesh changes without leaving component selection mode', () => {
useInteractionScope.getState().begin(meshEditScope(NODE_ID))

Expand Down
Loading
Loading