From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/12] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed205..eefaa2a799 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304d..112273a41d 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e87240810..5563268b8e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 5382865806..69a3d5ee3e 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635d..bac2b78fc1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 02/12] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f857232..d7c86be966 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e93..df67ca1690 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca750..a3eccc116d 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1ddc..b86e426c47 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 228b233e018648e839050e00808b11ddf2d4c65f Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 10:25:13 +0530 Subject: [PATCH 03/12] feat(nodes): improve block edit interactions --- .../editor/src/hooks/use-keyboard.test.ts | 8 +- packages/editor/src/hooks/use-keyboard.ts | 6 +- .../nodes/src/block/contextual-help.test.ts | 1 - packages/nodes/src/block/edit-session.test.ts | 5 - packages/nodes/src/block/edit-session.ts | 11 +- .../nodes/src/block/material-slots.test.ts | 85 ++-- packages/nodes/src/block/material-slots.ts | 100 ++-- .../nodes/src/block/modal-transform.test.ts | 41 ++ packages/nodes/src/block/modal-transform.ts | 68 +++ packages/nodes/src/block/panel.tsx | 218 +++----- .../nodes/src/block/rotation-drag.test.ts | 28 +- packages/nodes/src/block/rotation-drag.ts | 12 + .../nodes/src/block/selection-model.test.ts | 7 + packages/nodes/src/block/selection-model.ts | 12 + packages/nodes/src/block/selection.tsx | 469 ++++++++++++++++-- 15 files changed, 754 insertions(+), 317 deletions(-) create mode 100644 packages/nodes/src/block/modal-transform.test.ts create mode 100644 packages/nodes/src/block/modal-transform.ts diff --git a/packages/editor/src/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts index 1902f88df0..d0f139dea8 100644 --- a/packages/editor/src/hooks/use-keyboard.test.ts +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -8,7 +8,7 @@ import { } from '@pascal-app/core' import { meshEditScope } from '../lib/interaction/scope' import useInteractionScope from '../store/use-interaction-scope' -import { runHistoryShortcut } from './use-keyboard' +import { canRunGlobalRotationShortcut, runHistoryShortcut } from './use-keyboard' type RafFn = (callback: (time: number) => void) => number ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( @@ -42,6 +42,12 @@ afterEach(() => { }) describe('history shortcuts during block editing', () => { + test('reserves R for the active mesh editor', () => { + expect(canRunGlobalRotationShortcut()).toBe(true) + useInteractionScope.getState().begin(meshEditScope(NODE_ID)) + expect(canRunGlobalRotationShortcut()).toBe(false) + }) + test('undoes and redoes mesh changes without leaving component selection mode', () => { useInteractionScope.getState().begin(meshEditScope(NODE_ID)) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index a8479cdfbb..5b489471fe 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -171,6 +171,9 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => { return true } +export const canRunGlobalRotationShortcut = () => + useInteractionScope.getState().scope.kind !== 'mesh-editing' + export const useKeyboard = ({ isVersionPreviewMode = false, disabled = false, @@ -480,7 +483,8 @@ export const useKeyboard = ({ !e.metaKey && !e.ctrlKey && !isVersionPreviewMode && - !isPlacingOpening() + !isPlacingOpening() && + canRunGlobalRotationShortcut() ) { // `!metaKey && !ctrlKey` lets Cmd/Ctrl+R reach the browser reload instead // of rotating/flipping the selected node. diff --git a/packages/nodes/src/block/contextual-help.test.ts b/packages/nodes/src/block/contextual-help.test.ts index 72d9a5d323..4c2c4917bd 100644 --- a/packages/nodes/src/block/contextual-help.test.ts +++ b/packages/nodes/src/block/contextual-help.test.ts @@ -9,7 +9,6 @@ describe('block contextual help', () => { useBlockEditSession.setState({ nodeId: null, selection: createBlockSelection('face'), - activeMaterialSlotId: null, }) }) diff --git a/packages/nodes/src/block/edit-session.test.ts b/packages/nodes/src/block/edit-session.test.ts index 429894db1f..1f08e02c0a 100644 --- a/packages/nodes/src/block/edit-session.test.ts +++ b/packages/nodes/src/block/edit-session.test.ts @@ -8,7 +8,6 @@ describe('block edit session', () => { useBlockEditSession.setState({ nodeId: null, selection: createBlockSelection('face'), - activeMaterialSlotId: null, }) }) @@ -32,13 +31,11 @@ describe('block edit session', () => { const selection = createBlockSelection('face', ['f-top']) useBlockEditSession.getState().begin('block_1', selection) useBlockEditSession.getState().setSelection('block_2', createBlockSelection('vertex', ['v0'])) - useBlockEditSession.getState().setActiveMaterialSlot('block_2', 'accent') useBlockEditSession.getState().end('block_2') expect(useBlockEditSession.getState()).toMatchObject({ nodeId: 'block_1', selection, - activeMaterialSlotId: null, }) }) @@ -60,13 +57,11 @@ describe('block edit session', () => { test('ends only the owned session and resets transient selection', () => { useBlockEditSession.getState().begin('block_1', createBlockSelection('face', ['f-top'])) - useBlockEditSession.getState().setActiveMaterialSlot('block_1', 'accent') useBlockEditSession.getState().end('block_1') expect(useBlockEditSession.getState()).toMatchObject({ nodeId: null, selection: { mode: 'face', ids: [], activeId: null }, - activeMaterialSlotId: null, }) }) }) diff --git a/packages/nodes/src/block/edit-session.ts b/packages/nodes/src/block/edit-session.ts index b12305df4e..8d8e1e01cc 100644 --- a/packages/nodes/src/block/edit-session.ts +++ b/packages/nodes/src/block/edit-session.ts @@ -5,11 +5,9 @@ import { type BlockSelectionState, createBlockSelection } from './selection-mode type BlockEditSessionState = { nodeId: string | null selection: BlockSelectionState - activeMaterialSlotId: string | null begin: (nodeId: string, selection: BlockSelectionState) => void end: (nodeId: string) => void setSelection: (nodeId: string, selection: BlockSelectionState) => void - setActiveMaterialSlot: (nodeId: string, slotId: string) => void reconcileSelection: (nodeId: string, topology: BlockTopology) => void } @@ -18,18 +16,13 @@ const emptySelection = () => createBlockSelection('face') const useBlockEditSession = create((set) => ({ nodeId: null, selection: emptySelection(), - activeMaterialSlotId: null, - begin: (nodeId, selection) => set({ nodeId, selection, activeMaterialSlotId: null }), + begin: (nodeId, selection) => set({ nodeId, selection }), end: (nodeId) => set((state) => - state.nodeId === nodeId - ? { nodeId: null, selection: emptySelection(), activeMaterialSlotId: null } - : state, + state.nodeId === nodeId ? { nodeId: null, selection: emptySelection() } : state, ), setSelection: (nodeId, selection) => set((state) => (state.nodeId === nodeId ? { selection } : state)), - setActiveMaterialSlot: (nodeId, activeMaterialSlotId) => - set((state) => (state.nodeId === nodeId ? { activeMaterialSlotId } : state)), reconcileSelection: (nodeId, topology) => set((state) => { if (state.nodeId !== nodeId) return state diff --git a/packages/nodes/src/block/material-slots.test.ts b/packages/nodes/src/block/material-slots.test.ts index 75d702aa2c..f1caf3b596 100644 --- a/packages/nodes/src/block/material-slots.test.ts +++ b/packages/nodes/src/block/material-slots.test.ts @@ -4,33 +4,15 @@ import { assignBlockMaterial, blockMaterialSelection, blockMaterialSlotIds, - collectReusableBlockMaterialRefs, + createAssignedBlockMaterialSlot, createBlockMaterialSlot, removeBlockMaterialSlot, renameBlockMaterialSlot, - selectBlockFacesByMaterialSlot, setBlockMaterialSlot, + unpaintedBlockMaterialSlotIds, } from './material-slots' describe('block material slots', () => { - test('offers catalog refs already used in scene slots when no scene materials exist', () => { - expect( - collectReusableBlockMaterialRefs([{ slots: { body: 'library:metal-steel' } }], []), - ).toEqual(['library:metal-steel']) - }) - - test('deduplicates used refs and includes unused reusable scene materials', () => { - expect( - collectReusableBlockMaterialRefs( - [ - { slots: { body: 'scene:mat_shared', accent: 'library:oak' } }, - { slots: { trim: 'scene:mat_shared', invalid: 'not-a-material-ref' } }, - ], - ['mat_shared', 'mat_unused'], - ), - ).toEqual(['scene:mat_shared', 'library:oak', 'scene:mat_unused']) - }) - test('lists body, persisted, and face-referenced slots in stable order', () => { const topology = createBoxBlockTopology() topology.faces[0] = { ...topology.faces[0], materialSlot: 'orphaned' } @@ -56,6 +38,36 @@ describe('block material slots', () => { ).toEqual({ body: 'Body', 'slot-1': 'Trim' }) }) + test('creates a slot and assigns it to the selected faces in one operation', () => { + const topology = createBoxBlockTopology() + const result = createAssignedBlockMaterialSlot(topology, undefined, { body: 'Body' }, [ + 'f-top', + 'f-front', + ]) + + expect(result.changed).toBe(true) + expect(result.slotId).toBe('slot-1') + expect(result.slotNames).toEqual({ body: 'Body', 'slot-1': 'Slot 1' }) + expect(result.topology.faces.map((face) => face.materialSlot)).toEqual([ + 'body', + 'slot-1', + 'slot-1', + 'body', + 'body', + 'body', + ]) + }) + + test('does not create an empty slot when no faces are selected', () => { + const topology = createBoxBlockTopology() + const slotNames = { body: 'Body' } + const result = createAssignedBlockMaterialSlot(topology, undefined, slotNames, []) + + expect(result.changed).toBe(false) + expect(result.topology).toBe(topology) + expect(result.slotNames).toBe(slotNames) + }) + test('updates a slot material without changing face assignments', () => { const slots = { body: 'library:wood' } expect(setBlockMaterialSlot(slots, 'body', 'library:metal-steel')).toEqual({ @@ -68,6 +80,19 @@ describe('block material slots', () => { }) }) + test('identifies unpainted non-body slots for the edit-mode tint', () => { + const topology = createBoxBlockTopology() + topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } + + expect( + unpaintedBlockMaterialSlotIds( + topology, + { body: 'library:wood', painted: 'library:metal-steel' }, + { accent: 'Accent', painted: 'Painted' }, + ), + ).toEqual(['accent']) + }) + test('reports single and mixed face assignments using the active face', () => { const topology = createBoxBlockTopology() topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } @@ -87,26 +112,6 @@ describe('block material slots', () => { }) }) - test('selects and deselects every face assigned to a slot without replacing other selection', () => { - const topology = createBoxBlockTopology() - topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } - topology.faces[2] = { ...topology.faces[2], materialSlot: 'accent' } - - expect(selectBlockFacesByMaterialSlot(topology, ['f-bottom'], 'accent', 'select')).toEqual([ - 'f-bottom', - 'f-top', - 'f-front', - ]) - expect( - selectBlockFacesByMaterialSlot( - topology, - ['f-bottom', 'f-top', 'f-front'], - 'accent', - 'deselect', - ), - ).toEqual(['f-bottom']) - }) - test('removes a material slot and remaps all of its faces to the first slot', () => { const topology = createBoxBlockTopology() topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } diff --git a/packages/nodes/src/block/material-slots.ts b/packages/nodes/src/block/material-slots.ts index 23878bd3de..55d1d3169b 100644 --- a/packages/nodes/src/block/material-slots.ts +++ b/packages/nodes/src/block/material-slots.ts @@ -1,9 +1,4 @@ -import { - type BlockTopology, - type MaterialRef, - parseMaterialRef, - toSceneMaterialRef, -} from '@pascal-app/core' +import type { BlockTopology, MaterialRef } from '@pascal-app/core' export const BLOCK_BODY_SLOT_ID = 'body' @@ -42,29 +37,19 @@ export type BlockMaterialSlotCreationResult = { slotNames: Record } -function materialSlotsFromNode(node: unknown): Record | null { - if (!node || typeof node !== 'object' || !('slots' in node)) return null - const slots = (node as { slots?: unknown }).slots - return slots && typeof slots === 'object' && !Array.isArray(slots) - ? (slots as Record) - : null -} - -export function collectReusableBlockMaterialRefs( - nodes: readonly unknown[], - sceneMaterialIds: readonly string[], -): MaterialRef[] { - const refs = new Set() - for (const node of nodes) { - const slots = materialSlotsFromNode(node) - if (!slots) continue - for (const value of Object.values(slots)) { - if (typeof value === 'string' && parseMaterialRef(value)) refs.add(value) +export type BlockAssignedMaterialSlotCreationResult = + | (BlockMaterialSlotCreationResult & { + topology: BlockTopology + slots: BlockMaterialSlots + changed: true + }) + | { + topology: BlockTopology + slots: BlockMaterialSlots + slotId: null + slotNames: BlockMaterialSlotNames + changed: false } - } - for (const id of sceneMaterialIds) refs.add(toSceneMaterialRef(id)) - return [...refs] -} export function blockMaterialSlotIds( topology: BlockTopology, @@ -78,6 +63,16 @@ export function blockMaterialSlotIds( return [...slotIds] } +export function unpaintedBlockMaterialSlotIds( + topology: BlockTopology, + slots: BlockMaterialSlots, + slotNames?: BlockMaterialSlotNames, +): string[] { + return blockMaterialSlotIds(topology, slots, slotNames).filter( + (slotId) => slotId !== BLOCK_BODY_SLOT_ID && !slots?.[slotId], + ) +} + export function createBlockMaterialSlot( topology: BlockTopology, slots: BlockMaterialSlots, @@ -93,6 +88,33 @@ export function createBlockMaterialSlot( } } +export function createAssignedBlockMaterialSlot( + topology: BlockTopology, + slots: BlockMaterialSlots, + slotNames: BlockMaterialSlotNames, + selectedFaceIds: readonly string[], +): BlockAssignedMaterialSlotCreationResult { + const selected = new Set(selectedFaceIds) + if (!topology.faces.some((face) => selected.has(face.id))) { + return { topology, slots, slotId: null, slotNames, changed: false } + } + const created = createBlockMaterialSlot(topology, slots, slotNames) + const assigned = assignBlockMaterial( + topology, + slots, + selectedFaceIds, + { kind: 'slot', slotId: created.slotId }, + created.slotNames, + ) + return { + topology: assigned.topology, + slots: assigned.slots, + slotId: created.slotId, + slotNames: created.slotNames, + changed: true, + } +} + export function renameBlockMaterialSlot( topology: BlockTopology, slots: BlockMaterialSlots, @@ -192,28 +214,6 @@ export function removeBlockMaterialSlot( } } -export function selectBlockFacesByMaterialSlot( - topology: BlockTopology, - selectedFaceIds: readonly string[], - slotId: string, - operation: 'select' | 'deselect', -): string[] { - const matching = new Set( - topology.faces.filter((face) => face.materialSlot === slotId).map((face) => face.id), - ) - if (operation === 'deselect') { - return selectedFaceIds.filter((faceId) => !matching.has(faceId)) - } - - const selected = new Set(selectedFaceIds) - return [ - ...selectedFaceIds, - ...topology.faces - .filter((face) => matching.has(face.id) && !selected.has(face.id)) - .map((face) => face.id), - ] -} - export function assignBlockMaterial( topology: BlockTopology, slots: BlockMaterialSlots, diff --git a/packages/nodes/src/block/modal-transform.test.ts b/packages/nodes/src/block/modal-transform.test.ts new file mode 100644 index 0000000000..cb9498c79a --- /dev/null +++ b/packages/nodes/src/block/modal-transform.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test' +import { + blockAxisDelta, + blockAxisVisualState, + blockModalTransformStatus, + blockRotationPointerAngle, + blockTransformAxisFromKey, +} from './modal-transform' + +describe('block modal transform', () => { + test('recognizes case-insensitive transform-axis shortcuts', () => { + expect(blockTransformAxisFromKey('X')).toBe('x') + expect(blockTransformAxisFromKey('y')).toBe('y') + expect(blockTransformAxisFromKey('G')).toBeNull() + }) + + test('constrains movement to one local axis', () => { + expect(blockAxisDelta('x', 1.25)).toEqual([1.25, 0, 0]) + expect(blockAxisDelta('y', -0.5)).toEqual([0, -0.5, 0]) + expect(blockAxisDelta('z', 2)).toEqual([0, 0, 2]) + }) + + test('keeps only the locked operation axis colorful', () => { + const active = { operation: 'translate', constraint: 'y' } as const + expect(blockAxisVisualState(active, 'translate', 'y')).toBe('active') + expect(blockAxisVisualState(active, 'translate', 'x')).toBe('faded') + expect(blockAxisVisualState(active, 'rotate', 'y')).toBe('faded') + }) + + test('describes the current operation and constraint', () => { + expect(blockModalTransformStatus({ operation: 'rotate', constraint: 'z' })).toBe( + 'Rotate · Z axis · X/Y/Z constrains · click applies · Esc cancels', + ) + }) + + test('rotates from horizontal movement when the gesture starts on the pivot', () => { + expect( + blockRotationPointerAngle({ x: 100, y: 100 }, { x: 100, y: 100 }, { x: 120, y: 100 }), + ).not.toBe(0) + }) +}) diff --git a/packages/nodes/src/block/modal-transform.ts b/packages/nodes/src/block/modal-transform.ts new file mode 100644 index 0000000000..59a8fe1c48 --- /dev/null +++ b/packages/nodes/src/block/modal-transform.ts @@ -0,0 +1,68 @@ +export type BlockTransformAxis = 'x' | 'y' | 'z' +export type BlockTransformOperation = 'translate' | 'rotate' | 'scale' +export type BlockTransformConstraint = BlockTransformAxis | 'free' | 'uniform' + +export type BlockActiveTransform = { + operation: BlockTransformOperation + constraint: BlockTransformConstraint +} + +export type BlockAxisVisualState = 'normal' | 'active' | 'faded' + +export type BlockScreenPoint = { x: number; y: number } + +export function blockRotationPointerAngle( + pivot: BlockScreenPoint, + start: BlockScreenPoint, + current: BlockScreenPoint, +): number { + const startDistanceSquared = (start.x - pivot.x) ** 2 + (start.y - pivot.y) ** 2 + if (startDistanceSquared < 64) { + return (current.x - start.x - (current.y - start.y)) * 0.01 + } + return ( + Math.atan2(current.y - pivot.y, current.x - pivot.x) - + Math.atan2(start.y - pivot.y, start.x - pivot.x) + ) +} + +export function blockTransformAxisFromKey(key: string): BlockTransformAxis | null { + const normalized = key.toLowerCase() + return normalized === 'x' || normalized === 'y' || normalized === 'z' ? normalized : null +} + +export function blockAxisDelta( + axis: BlockTransformAxis, + distance: number, +): [number, number, number] { + return [axis === 'x' ? distance : 0, axis === 'y' ? distance : 0, axis === 'z' ? distance : 0] +} + +export function blockAxisVisualState( + activeTransform: BlockActiveTransform | null, + operation: BlockTransformOperation, + axis: BlockTransformAxis, +): BlockAxisVisualState { + if (!activeTransform) return 'normal' + if (activeTransform.operation !== operation) return 'faded' + if (activeTransform.constraint === 'free' || activeTransform.constraint === 'uniform') { + return 'normal' + } + return activeTransform.constraint === axis ? 'active' : 'faded' +} + +export function blockModalTransformStatus(activeTransform: BlockActiveTransform): string { + const operation = + activeTransform.operation === 'translate' + ? 'Move' + : activeTransform.operation === 'rotate' + ? 'Rotate' + : 'Scale' + const constraint = + activeTransform.constraint === 'free' + ? 'free' + : activeTransform.constraint === 'uniform' + ? 'uniform' + : `${activeTransform.constraint.toUpperCase()} axis` + return `${operation} · ${constraint} · X/Y/Z constrains · click applies · Esc cancels` +} diff --git a/packages/nodes/src/block/panel.tsx b/packages/nodes/src/block/panel.tsx index fc0f7a4f1c..2f305e0eaf 100644 --- a/packages/nodes/src/block/panel.tsx +++ b/packages/nodes/src/block/panel.tsx @@ -19,22 +19,18 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Check, Move, Plus, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import useBlockEditSession from './edit-session' import { assignBlockMaterial, BLOCK_BODY_SLOT_ID, blockMaterialSelection, - collectReusableBlockMaterialRefs, - createBlockMaterialSlot, + createAssignedBlockMaterialSlot, removeBlockMaterialSlot, renameBlockMaterialSlot, - selectBlockFacesByMaterialSlot, - setBlockMaterialSlot, } from './material-slots' import { blockSlots } from './slots' -const REUSABLE_MATERIAL_REF_SEPARATOR = '\u001f' const SLOT_TRAILING_ACTION_CLASS = 'm-2 ml-0 flex w-8 shrink-0 items-center justify-center rounded-md' const SLOT_DISABLED_ACTION_CLASS = @@ -83,38 +79,13 @@ export default function BlockPanel() { const nodeRef = useRef(node) nodeRef.current = node const sceneMaterials = useScene((state) => state.materials) - const reusableMaterialRefsKey = useScene((state) => - collectReusableBlockMaterialRefs(Object.values(state.nodes), Object.keys(state.materials)).join( - REUSABLE_MATERIAL_REF_SEPARATOR, - ), - ) - const reusableMaterialRefs = reusableMaterialRefsKey - ? reusableMaterialRefsKey.split(REUSABLE_MATERIAL_REF_SEPARATOR) - : [] const readOnly = useScene((state) => state.readOnly) const editing = useInteractionScope( (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === selectedId, ) const sessionNodeId = useBlockEditSession((state) => state.nodeId) const selection = useBlockEditSession((state) => state.selection) - const activeMaterialSlotId = useBlockEditSession((state) => state.activeMaterialSlotId) - - const activeFaceSlotId = useMemo(() => { - if (!(node && sessionNodeId === node.id && selection.mode === 'face')) return null - return node.topology.faces.find((face) => face.id === selection.activeId)?.materialSlot ?? null - }, [node, selection.activeId, selection.mode, sessionNodeId]) - const nodeId = node?.id ?? null - const activeFaceId = - sessionNodeId === nodeId && selection.mode === 'face' ? selection.activeId : null - const syncedActiveFaceRef = useRef(null) - - useEffect(() => { - if (!(nodeId && activeFaceId && activeFaceSlotId)) return - const syncKey = `${nodeId}:${activeFaceId}:${activeFaceSlotId}` - if (syncedActiveFaceRef.current === syncKey) return - syncedActiveFaceRef.current = syncKey - useBlockEditSession.getState().setActiveMaterialSlot(nodeId, activeFaceSlotId) - }, [activeFaceId, activeFaceSlotId, nodeId]) + const [slotNotice, setSlotNotice] = useState<{ nodeId: string; text: string } | null>(null) const close = useCallback(() => { setViewerSelection({ selectedIds: [] }) @@ -162,10 +133,6 @@ export default function BlockPanel() { selection.activeId, ) const slotDeclarations = blockSlots(node) - const activeSlotId = - (sessionNodeId === node.id ? activeMaterialSlotId : materialSelection.activeSlotId) ?? - BLOCK_BODY_SLOT_ID - const activeSlotRef = activeSlotId ? node.slots?.[activeSlotId] : undefined const canOperateOnFaces = editing && selection.mode === 'face' const slotEditTitle = !editing ? 'Enter Edit Mode to use slot actions' @@ -177,25 +144,24 @@ export default function BlockPanel() { faceCountBySlot.set(face.materialSlot, (faceCountBySlot.get(face.materialSlot) ?? 0) + 1) } - const chooseSlot = (slotId: string) => { - useBlockEditSession.getState().setActiveMaterialSlot(node.id, slotId) - } - - const chooseReusableMaterial = (materialRef: string) => { - if (!(activeSlotId && materialRef)) return - const result = setBlockMaterialSlot(node.slots, activeSlotId, materialRef) - if (result.changed) { - useScene.getState().updateNode(node.id, { - slots: result.slots, - }) - triggerSFX('sfx:menu-click') - } - } - const addMaterialSlot = () => { - const result = createBlockMaterialSlot(node.topology, node.slots, node.slotNames) - useScene.getState().updateNode(node.id, { slotNames: result.slotNames }) - useBlockEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) + const result = createAssignedBlockMaterialSlot( + node.topology, + node.slots, + node.slotNames, + selectedFaceIds, + ) + if (!result.changed) return + useScene.getState().updateNode(node.id, { + topology: result.topology, + slots: result.slots, + slotNames: result.slotNames, + }) + const faceLabel = selectedFaceIds.length === 1 ? 'face' : 'faces' + setSlotNotice({ + nodeId: node.id, + text: `${result.slotNames[result.slotId] ?? result.slotId} applied to ${selectedFaceIds.length} ${faceLabel}. Use Paint (P) to choose its material.`, + }) triggerSFX('sfx:menu-click') } @@ -211,23 +177,15 @@ export default function BlockPanel() { useScene.getState().updateNode(node.id, { slotNames }) } - const reusableMaterialLabel = (ref: string) => { - const parsed = parseMaterialRef(ref) - if (!parsed) return ref - return parsed.kind === 'scene' - ? (sceneMaterials[parsed.id as keyof typeof sceneMaterials]?.name ?? ref) - : (getCatalogMaterialById(parsed.id)?.label ?? ref) - } - - const assignMaterial = () => { - if (!activeSlotId) return + const assignSlot = (slotId: string) => { + setSlotNotice(null) const result = assignBlockMaterial( node.topology, node.slots, selectedFaceIds, { kind: 'slot', - slotId: activeSlotId, + slotId, }, node.slotNames, ) @@ -236,28 +194,9 @@ export default function BlockPanel() { topology: result.topology, slots: result.slots, }) - useBlockEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) triggerSFX('sfx:menu-click') } - const filterSelection = (operation: 'select' | 'deselect') => { - if (!(canOperateOnFaces && activeSlotId)) return - const ids = selectBlockFacesByMaterialSlot( - node.topology, - selection.ids, - activeSlotId, - operation, - ) - const activeId = ids.includes(selection.activeId ?? '') - ? selection.activeId - : (ids.at(-1) ?? null) - useBlockEditSession.getState().setSelection(node.id, { - mode: 'face', - ids, - activeId, - }) - } - const removeMaterialSlot = (slotId: string) => { const result = removeBlockMaterialSlot(node.topology, node.slots, slotId, node.slotNames) if (!result.changed) return @@ -266,7 +205,7 @@ export default function BlockPanel() { slots: result.slots, slotNames: result.slotNames, }) - useBlockEditSession.getState().setActiveMaterialSlot(node.id, result.fallbackSlotId) + setSlotNotice(null) triggerSFX('sfx:menu-click') } @@ -312,49 +251,67 @@ export default function BlockPanel() {
} label="Add slot" onClick={addMaterialSlot} - title={slotEditTitle} + title={ + slotEditTitle ?? + (selectedFaceIds.length === 0 ? 'Select one or more faces first' : undefined) + } />
+ {slotNotice?.nodeId === node.id ? ( +
+ {slotNotice.text} +
+ ) : null} +
{slotDeclarations.map((slot, index) => { const ref = node.slots?.[slot.slotId] - const active = activeSlotId === slot.slotId + const active = + materialSelection.kind === 'single' && materialSelection.slotId === slot.slotId const preview = materialRefPreview(ref, sceneMaterials) const faceCount = faceCountBySlot.get(slot.slotId) ?? 0 - const materialLabel = materialRefLabel(ref, sceneMaterials) + const materialLabel = + ref || slot.slotId === BLOCK_BODY_SLOT_ID + ? materialRefLabel(ref, sceneMaterials) + : 'Unpainted' return (
0 ? 'border-t' : '' } ${active ? 'bg-primary/15' : 'hover:bg-white/[0.035]'}`} key={slot.slotId} > + - + { - if (editing) chooseSlot(slot.slotId) - }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() if (event.key === 'Escape') { @@ -379,12 +333,14 @@ export default function BlockPanel() { {slot.slotId === BLOCK_BODY_SLOT_ID ? ( - + {active ? ) : (
- -
- - -
- -
- filterSelection('select')} - title={slotEditTitle} - /> - filterSelection('deselect')} - title={slotEditTitle} - /> - -
diff --git a/packages/nodes/src/block/rotation-drag.test.ts b/packages/nodes/src/block/rotation-drag.test.ts index 61a20a8b10..fd2746f703 100644 --- a/packages/nodes/src/block/rotation-drag.test.ts +++ b/packages/nodes/src/block/rotation-drag.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' import { Vector3 } from 'three' -import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' +import { + lockedRotationAngleFromHits, + signedAngleAroundAxis, + unwrapRotationDelta, +} from './rotation-drag' describe('block rotation drag', () => { test('derives rotation direction around the chosen axis', () => { @@ -18,4 +22,26 @@ describe('block rotation drag', () => { expect(unwrapRotationDelta(previous, current)).toBeCloseTo((2 * Math.PI) / 180) expect(unwrapRotationDelta(current, previous)).toBeCloseTo((-2 * Math.PI) / 180) }) + + test('uses the gizmo direction when rotation is locked to Y', () => { + const angle = lockedRotationAngleFromHits( + new Vector3(), + new Vector3(1, 0, 0), + new Vector3(0, 0, -1), + new Vector3(0, 1, 0), + ) + + expect(angle).toBeCloseTo(Math.PI / 2) + }) + + test('waits for a direction when axis locking starts on the pivot', () => { + expect( + lockedRotationAngleFromHits( + new Vector3(), + new Vector3(), + new Vector3(0, 0, -1), + new Vector3(0, 1, 0), + ), + ).toBeNull() + }) }) diff --git a/packages/nodes/src/block/rotation-drag.ts b/packages/nodes/src/block/rotation-drag.ts index b171a66f6e..b9c113272d 100644 --- a/packages/nodes/src/block/rotation-drag.ts +++ b/packages/nodes/src/block/rotation-drag.ts @@ -4,6 +4,18 @@ export function signedAngleAroundAxis(from: Vector3, to: Vector3, axis: Vector3) return Math.atan2(axis.dot(from.clone().cross(to)), from.dot(to)) } +export function lockedRotationAngleFromHits( + origin: Vector3, + initialHit: Vector3, + currentHit: Vector3, + axis: Vector3, +): number | null { + const initialVector = initialHit.clone().sub(origin).projectOnPlane(axis) + const currentVector = currentHit.clone().sub(origin).projectOnPlane(axis) + if (initialVector.lengthSq() < 1e-6 || currentVector.lengthSq() < 1e-6) return null + return signedAngleAroundAxis(initialVector.normalize(), currentVector.normalize(), axis) +} + export function unwrapRotationDelta(previous: number, current: number): number { let delta = current - previous if (delta > Math.PI) delta -= Math.PI * 2 diff --git a/packages/nodes/src/block/selection-model.test.ts b/packages/nodes/src/block/selection-model.test.ts index 69bd141edd..f53635759e 100644 --- a/packages/nodes/src/block/selection-model.test.ts +++ b/packages/nodes/src/block/selection-model.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { createBoxBlockTopology } from '@pascal-app/core' import { + blockSelectionChanged, convertBlockSelection, createBlockSelection, invertBlockSelection, @@ -39,4 +40,10 @@ describe('block component selection', () => { expect(inverse.ids).toHaveLength(10) expect(inverse.ids).not.toContain('e0') }) + + test('treats an identical selection as a no-op', () => { + const selection = createBlockSelection('face') + expect(blockSelectionChanged(selection, { ...selection, ids: [...selection.ids] })).toBe(false) + expect(blockSelectionChanged(selection, createBlockSelection('face', ['f-top']))).toBe(true) + }) }) diff --git a/packages/nodes/src/block/selection-model.ts b/packages/nodes/src/block/selection-model.ts index 7f51022f5d..ad82e8df06 100644 --- a/packages/nodes/src/block/selection-model.ts +++ b/packages/nodes/src/block/selection-model.ts @@ -11,6 +11,18 @@ export type BlockSelectionState = BlockSelection & { activeId: string | null } +export function blockSelectionChanged( + previous: BlockSelectionState, + next: BlockSelectionState, +): boolean { + return ( + previous.mode !== next.mode || + previous.activeId !== next.activeId || + previous.ids.length !== next.ids.length || + previous.ids.some((id, index) => id !== next.ids[index]) + ) +} + function idsForMode(topology: BlockTopology, mode: BlockComponentMode): string[] { switch (mode) { case 'vertex': diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 340f02bd64..5435659561 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -36,6 +36,7 @@ import { Eye, EyeOff, Move3D, + Rotate3D, Rows3, Scaling, ScanLine, @@ -55,12 +56,15 @@ import { import { BufferGeometry, type Camera, + Color, ConeGeometry, CylinderGeometry, DoubleSide, Float32BufferAttribute, type Group, LineSegments, + type Material, + Mesh, type Object3D, Plane, PlaneGeometry, @@ -86,9 +90,26 @@ import { triangulateBlockFace } from './geometry' import { BLOCK_WHEEL_OPTIONS, consumeBlockGestureWheel } from './gesture-wheel' import { type BlockSfxAction, blockSfx } from './interaction-sfx' import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' -import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' +import { BLOCK_BODY_SLOT_ID, unpaintedBlockMaterialSlotIds } from './material-slots' +import { + type BlockActiveTransform, + type BlockAxisVisualState, + type BlockTransformAxis, + type BlockTransformOperation, + blockAxisDelta, + blockAxisVisualState, + blockModalTransformStatus, + blockRotationPointerAngle, + blockTransformAxisFromKey, +} from './modal-transform' +import { + lockedRotationAngleFromHits, + signedAngleAroundAxis, + unwrapRotationDelta, +} from './rotation-drag' import { type BlockSelectionState, + blockSelectionChanged, clearBlockSelection, convertBlockSelection, invertBlockSelection, @@ -108,13 +129,10 @@ import { type ComponentMode = BlockSelection['mode'] type Point = [number, number, number] -type Axis = 'x' | 'y' | 'z' +type Axis = BlockTransformAxis type PlaneAxes = 'xy' | 'xz' | 'yz' -type TransformOperation = 'translate' | 'rotate' | 'scale' -type ActiveTransform = { - operation: TransformOperation - constraint: Axis | 'uniform' -} +type TransformOperation = BlockTransformOperation +type ActiveTransform = BlockActiveTransform type TransformTool = 'transform' | 'loop-cut' | 'bevel' type TopologyOperator = 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete' type ToolbarPanel = 'operations' | 'selection' | null @@ -628,16 +646,18 @@ function AxisTransformHandle({ axis, length, radius, - moveActive, - scaleActive, + moveState, + scaleState, + disabled, onMovePointerDown, onScalePointerDown, }: { axis: Axis length: number radius: number - moveActive: boolean - scaleActive: boolean + moveState: BlockAxisVisualState + scaleState: BlockAxisVisualState + disabled: boolean onMovePointerDown: (axis: Axis, event: ThreeEvent) => void onScalePointerDown: (axis: Axis, event: ThreeEvent) => void }) { @@ -689,12 +709,14 @@ function AxisTransformHandle({ ) useEffect(() => { moveMaterial.color.set( - moveActive || hovered === 'translate' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + hovered === 'translate' && moveState !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], ) + moveMaterial.opacity = moveState === 'faded' ? 0.14 : 1 scaleMaterial.color.set( - scaleActive || hovered === 'scale' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + hovered === 'scale' && scaleState !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], ) - }, [axis, hovered, moveActive, moveMaterial, scaleActive, scaleMaterial]) + scaleMaterial.opacity = scaleState === 'faded' ? 0.14 : 1 + }, [axis, hovered, moveMaterial, moveState, scaleMaterial, scaleState]) useEffect( () => () => { shaftGeometry.dispose() @@ -744,12 +766,14 @@ function AxisTransformHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onMovePointerDown(axis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered('translate') document.body.style.cursor = 'grab' @@ -759,6 +783,7 @@ function AxisTransformHandle({ if (document.body.style.cursor === 'grab') document.body.style.cursor = '' }} position={[0, length * 0.5, 0]} + raycast={disabled ? () => {} : undefined} renderOrder={GIZMO_HIT_RENDER_ORDER} /> { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onScalePointerDown(axis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered('scale') document.body.style.cursor = 'grab' @@ -789,6 +816,7 @@ function AxisTransformHandle({ if (document.body.style.cursor === 'grab') document.body.style.cursor = '' }} position={[0, scalePosition, 0]} + raycast={disabled ? () => {} : undefined} renderOrder={GIZMO_HIT_RENDER_ORDER} /> @@ -799,13 +827,15 @@ function PlaneMoveHandle({ plane, offset, size, - active, + state, + disabled, onPointerDown, }: { plane: PlaneAxes offset: number size: number - active: boolean + state: BlockAxisVisualState + disabled: boolean onPointerDown: (axis: Axis, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) @@ -837,8 +867,9 @@ function PlaneMoveHandle({ [], ) useEffect(() => { - material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[normalAxis]) - }, [active, hovered, material, normalAxis]) + material.color.set(hovered && state !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[normalAxis]) + material.opacity = state === 'faded' ? 0.1 : 1 + }, [hovered, material, normalAxis, state]) useEffect( () => () => { geometry.dispose() @@ -871,12 +902,14 @@ function PlaneMoveHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onPointerDown(normalAxis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered(true) document.body.style.cursor = 'move' @@ -885,6 +918,7 @@ function PlaneMoveHandle({ setHovered(false) if (document.body.style.cursor === 'move') document.body.style.cursor = '' }} + raycast={disabled ? () => {} : undefined} renderOrder={GIZMO_HIT_RENDER_ORDER} /> @@ -895,13 +929,15 @@ function RotationHandle({ axis, radius, tube, - active, + state, + disabled, onPointerDown, }: { axis: Axis radius: number tube: number - active: boolean + state: BlockAxisVisualState + disabled: boolean onPointerDown: (axis: Axis, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) @@ -935,8 +971,9 @@ function RotationHandle({ [axis], ) useEffect(() => { - material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis]) - }, [active, axis, hovered, material]) + material.color.set(hovered && state !== 'faded' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis]) + material.opacity = state === 'faded' ? 0.14 : 1 + }, [axis, hovered, material, state]) useEffect( () => () => { ringGeometry.dispose() @@ -963,12 +1000,14 @@ function RotationHandle({ layers={EDITOR_LAYER} material={hitMaterial} onPointerDown={(event) => { + if (disabled) return event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() onPointerDown(axis, event) }} onPointerEnter={(event) => { + if (disabled) return event.stopPropagation() setHovered(true) document.body.style.cursor = 'grab' @@ -977,6 +1016,7 @@ function RotationHandle({ setHovered(false) if (document.body.style.cursor === 'grab') document.body.style.cursor = '' }} + raycast={disabled ? () => {} : undefined} renderOrder={GIZMO_HIT_RENDER_ORDER} /> @@ -1284,6 +1324,7 @@ function BlockEditor({ const [xray, setXray] = useState(false) const [previewTopology, setPreviewTopology] = useState(null) const [activeTransform, setActiveTransform] = useState(null) + const [keyboardTransformActive, setKeyboardTransformActive] = useState(false) const [loopCutSegments, setLoopCutSegments] = useState<[Point, Point][] | null>(null) const [loopCutEdgeId, setLoopCutEdgeId] = useState(null) const [loopCutSliding, setLoopCutSliding] = useState(false) @@ -1359,6 +1400,7 @@ function BlockEditor({ setPreviewTopology(null) setTransformTool('transform') setActiveTransform(null) + setKeyboardTransformActive(false) setLoopCutSegments(null) setLoopCutEdgeId(null) setLoopCutSliding(false) @@ -1391,9 +1433,49 @@ function BlockEditor({ setLoopCutEdgeId(null) setLoopCutSliding(false) setActiveTransform(null) + setKeyboardTransformActive(false) useBlockEditSession.getState().end(node.id) }, [editing, node.id]) + useEffect(() => { + if (!editing) return + const unpaintedSlotIds = new Set( + unpaintedBlockMaterialSlotIds(node.topology, node.slots, node.slotNames), + ) + if (unpaintedSlotIds.size === 0) return + + const restores: Array<{ mesh: Mesh; material: Material | Material[] }> = [] + const ownedMaterials: Material[] = [] + target.traverse((child) => { + if (!(child instanceof Mesh)) return + const slotIds = Array.isArray(child.userData.slotIds) + ? (child.userData.slotIds as string[]) + : [] + if (!slotIds.some((slotId) => unpaintedSlotIds.has(slotId))) return + const previousMaterial = child.material + const sourceMaterials = Array.isArray(previousMaterial) + ? previousMaterial + : [previousMaterial] + const nextMaterials = sourceMaterials.map((material, index) => { + const slotId = slotIds[index] + if (!(slotId && slotId !== BLOCK_BODY_SLOT_ID && unpaintedSlotIds.has(slotId))) { + return material + } + const tinted = material.clone() + if ('color' in tinted && tinted.color instanceof Color) tinted.color.set('#7768d8') + ownedMaterials.push(tinted) + return tinted + }) + restores.push({ mesh: child, material: previousMaterial }) + child.material = Array.isArray(previousMaterial) ? nextMaterials : nextMaterials[0]! + }) + + return () => { + for (const restore of restores) restore.mesh.material = restore.material + for (const material of ownedMaterials) material.dispose() + } + }, [editing, node.slotNames, node.slots, node.topology, target]) + useEffect(() => { if (!editing) return const onToolCancel = () => { @@ -1439,7 +1521,10 @@ function BlockEditor({ const onGridClick = () => { const scope = useInteractionScope.getState().scope if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return - useBlockEditSession.getState().setSelection(node.id, { mode, ids: [], activeId: null }) + const session = useBlockEditSession.getState() + const next = { mode, ids: [], activeId: null } + if (!blockSelectionChanged(session.selection, next)) return + session.setSelection(node.id, next) setError(null) playBlockSfx('component-select') } @@ -1563,6 +1648,7 @@ function BlockEditor({ (id: string, additive: boolean, event: ThreeEvent) => { if (!componentIsVisible(id, event)) return const next = selectBlockComponent({ mode, ids: selectedIds, activeId }, id, additive) + if (!blockSelectionChanged({ mode, ids: selectedIds, activeId }, next)) return useBlockEditSession.getState().setSelection(node.id, next) setError(null) playBlockSfx('component-select') @@ -1596,6 +1682,270 @@ function BlockEditor({ [camera, gl.domElement], ) + const beginKeyboardTransformModal = useCallback( + (operation: 'translate' | 'rotate') => { + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return false + const origin = selectionCentroid(displayTopology, selection) + if (!origin) return false + const pivotClient = localPointToClient(origin, target, camera, gl.domElement) + if (!pivotClient) return false + + target.updateWorldMatrix(true, false) + const originLocal = new Vector3(...origin) + const worldOrigin = target.localToWorld(originLocal.clone()) + const startPointer = + lastPointerClientRef.current?.clone() ?? pivotClient.clone().add(new Vector2(80, 0)) + const startRay = makeRay(startPointer.x, startPointer.y) + const viewAxisWorld = camera.getWorldDirection(new Vector3()).normalize() + const viewPlane = new Plane().setFromNormalAndCoplanarPoint(viewAxisWorld, worldOrigin) + const startPlaneHit = startRay.intersectPlane(viewPlane, new Vector3()) ?? worldOrigin.clone() + const targetWorldQuaternion = target.getWorldQuaternion(new Quaternion()) + const freeRotationAxis = viewAxisWorld + .clone() + .applyQuaternion(targetWorldQuaternion.clone().invert()) + .normalize() + const baseTopology = displayTopology + const baseSelection = selection + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let activeAxis: Axis | null = null + let latestTopology: BlockTopology | null = null + let latestMagnitude = 0 + let previousWrappedAngle = 0 + let accumulatedAngle = 0 + let lockedRotationInitialHit: Vector3 | null = null + let lockedRotationPlane: Plane | null = null + let lockedRotationWorldAxis: Vector3 | null = null + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastSnapValue: string | number | null = null + let finished = false + + const worldAxisFor = (axis: Axis) => + target + .localToWorld(originLocal.clone().add(new Vector3(...AXIS_VECTORS[axis]))) + .sub(worldOrigin) + .normalize() + + const updatePreview = (clientX: number, clientY: number, altKey: boolean) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + const ray = makeRay(clientX, clientY) + let command: BlockCommand + let snapValue: string | number + + if (operation === 'translate') { + let delta: Point + if (activeAxis) { + const worldAxis = worldAxisFor(activeAxis) + const startParameter = closestAxisParameterToRay(worldOrigin, worldAxis, startRay) + const currentParameter = closestAxisParameterToRay(worldOrigin, worldAxis, ray) + const localPoint = target.worldToLocal( + worldOrigin.clone().addScaledVector(worldAxis, currentParameter - startParameter), + ) + const axisIndex = activeAxis === 'x' ? 0 : activeAxis === 'y' ? 1 : 2 + delta = blockAxisDelta( + activeAxis, + localPoint.getComponent(axisIndex) - origin[axisIndex], + ) + } else { + const currentHit = ray.intersectPlane(viewPlane, new Vector3()) + if (!currentHit) return + const localPoint = target.worldToLocal( + worldOrigin.clone().add(currentHit.clone().sub(startPlaneHit)), + ) + delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] + } + const snapping = isGridSnapActive() && !altKey + if (snapping) { + const step = useEditor.getState().gridSnapStep + if (step > 0) delta = delta.map((value) => Math.round(value / step) * step) as Point + } + latestMagnitude = Math.hypot(...delta) + snapValue = delta.join(':') + if (snapping && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { + playBlockSfx('move-step') + } + command = { type: 'translate-components', selection: baseSelection, delta } + } else { + let wrappedAngle: number + if ( + activeAxis && + lockedRotationInitialHit && + lockedRotationPlane && + lockedRotationWorldAxis + ) { + const currentHit = ray.intersectPlane(lockedRotationPlane, new Vector3()) + if (!currentHit) return + const lockedAngle = lockedRotationAngleFromHits( + worldOrigin, + lockedRotationInitialHit, + currentHit, + lockedRotationWorldAxis, + ) + if (lockedAngle === null) { + if (currentHit.distanceToSquared(worldOrigin) > 1e-6) { + lockedRotationInitialHit = currentHit.clone() + } + wrappedAngle = 0 + } else { + wrappedAngle = lockedAngle + } + } else { + wrappedAngle = blockRotationPointerAngle( + pivotClient, + startPointer, + new Vector2(clientX, clientY), + ) + } + accumulatedAngle += unwrapRotationDelta(previousWrappedAngle, wrappedAngle) + previousWrappedAngle = wrappedAngle + let angle = accumulatedAngle + const snapping = isAngleSnapActive() && !altKey + if (snapping) { + const step = (ROTATION_SNAP_ANGLE_DEGREES * Math.PI) / 180 + angle = Math.round(angle / step) * step + } + latestMagnitude = Math.abs(angle) + snapValue = angle + if (snapping && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { + playBlockSfx('rotate-step') + } + command = { + type: 'rotate-components', + selection: baseSelection, + pivot: origin, + axis: activeAxis ? AXIS_VECTORS[activeAxis] : (freeRotationAxis.toArray() as Point), + angle, + } + } + + lastSnapValue = snapValue + const result = applyBlockCommand(baseTopology, command) + if (!result.ok) { + setError(result.error) + return + } + latestTopology = result.topology + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('contextmenu', onContextMenu, true) + window.removeEventListener('blur', onCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setActiveTransform(null) + setKeyboardTransformActive(false) + if (commit && latestTopology && latestMagnitude > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + playBlockSfx('finish') + } else if (!commit) { + playBlockSfx('cancel') + } + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + swallowNextClick() + } + + const onMove = (pointerEvent: PointerEvent) => { + lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) + updatePreview(pointerEvent.clientX, pointerEvent.clientY, pointerEvent.altKey) + } + const onPointerDown = (pointerEvent: PointerEvent) => { + if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(pointerEvent.button === 0) + } + const onKeyDown = (keyboardEvent: KeyboardEvent) => { + const element = keyboardEvent.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) + return + const axis = blockTransformAxisFromKey(keyboardEvent.key) + if (axis) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + activeAxis = axis + if (operation === 'rotate') { + lockedRotationWorldAxis = worldAxisFor(axis) + lockedRotationPlane = new Plane().setFromNormalAndCoplanarPoint( + lockedRotationWorldAxis, + worldOrigin, + ) + lockedRotationInitialHit = makeRay(lastClientX, lastClientY).intersectPlane( + lockedRotationPlane, + new Vector3(), + ) + previousWrappedAngle = 0 + accumulatedAngle = 0 + } + setActiveTransform({ operation, constraint: axis }) + lastSnapValue = null + updatePreview(lastClientX, lastClientY, lastAltKey) + } else if (keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(true) + } else if (keyboardEvent.key === 'Escape') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(false) + } + } + const onContextMenu = (event: Event) => { + event.preventDefault() + event.stopImmediatePropagation() + } + const onCancel = () => finish(false) + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operation)) + playBlockSfx('drag-start') + useViewer.getState().setInputDragging(true) + setTransformTool('transform') + setToolbarPanel(null) + setActiveTransform({ operation, constraint: 'free' }) + setKeyboardTransformActive(true) + setError(null) + document.body.style.cursor = operation === 'translate' ? 'move' : 'crosshair' + cancelDragRef.current = onCancel + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('contextmenu', onContextMenu, true) + window.addEventListener('blur', onCancel, { once: true }) + return true + }, + [ + camera, + displayTopology, + gl.domElement, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + ], + ) + const beginTranslationDrag = useCallback( (axis: Axis, event: ThreeEvent) => { if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return @@ -2460,6 +2810,7 @@ function BlockEditor({ } const updateSelection = (next: BlockSelectionState) => { + if (!blockSelectionChanged({ mode, ids: selectedIds, activeId }, next)) return useBlockEditSession.getState().setSelection(node.id, next) setError(null) playBlockSfx('component-select') @@ -2473,6 +2824,7 @@ function BlockEditor({ updateSelection(clearBlockSelection({ mode, ids: selectedIds, activeId })) const keyboardActionsRef = useRef({ + beginKeyboardTransformModal, beginUniformScaleModal, canBevel: mode === 'edge', clearSelection, @@ -2486,6 +2838,7 @@ function BlockEditor({ selectAll, }) keyboardActionsRef.current = { + beginKeyboardTransformModal, beginUniformScaleModal, canBevel: mode === 'edge', clearSelection, @@ -2526,10 +2879,7 @@ function BlockEditor({ } else if (key === 'i' && (event.ctrlKey || event.metaKey)) { actions.invertSelection() } else if (key === 'g') { - if (actions.hasSelection) { - playBlockSfx('tool-select') - setTransformTool('transform') - } + if (actions.hasSelection) actions.beginKeyboardTransformModal('translate') } else if (key === 'e') { actions.extrudeSelectedFace() } else if (key === 'i') { @@ -2540,8 +2890,7 @@ function BlockEditor({ setTransformTool('loop-cut') setToolbarPanel(null) } else if (actions.hasSelection) { - playBlockSfx('tool-select') - setTransformTool('transform') + actions.beginKeyboardTransformModal('rotate') } } else if (key === 's') { if (actions.hasSelection) { @@ -2584,14 +2933,17 @@ function BlockEditor({ const operationAvailability = blockOperationAvailability(mode, selectedIds.length) const loopCutActive = transformTool === 'loop-cut' const bevelActive = transformTool === 'bevel' - const componentStatus = blockComponentStatus({ - mode, - selectedCount: selectedIds.length, - tool: transformTool, - loopCutCount, - loopCutFactor, - bevelSegments, - }) + const componentStatus = + keyboardTransformActive && activeTransform + ? blockModalTransformStatus(activeTransform) + : blockComponentStatus({ + mode, + selectedCount: selectedIds.length, + tool: transformTool, + loopCutCount, + loopCutFactor, + bevelSegments, + }) return ( @@ -2665,42 +3017,41 @@ function BlockEditor({ {(['x', 'y', 'z'] as const).map((axis) => ( ))} {(Object.keys(PLANE_NORMAL) as PlaneAxes[]).map((plane) => ( ))} {(['x', 'y', 'z'] as const).map((axis) => ( ))} @@ -2804,6 +3155,22 @@ function BlockEditor({ {toolbarPanel === 'operations' ? (
+ beginKeyboardTransformModal('translate')} + shortcut="G" + > + + + beginKeyboardTransformModal('rotate')} + shortcut="R" + > + + Date: Thu, 20 Aug 2026 10:37:36 +0530 Subject: [PATCH 04/12] fix(editor): detach items leaving block faces --- .../src/components/tools/item/block-preview.test.ts | 8 ++++---- .../editor/src/components/tools/item/block-preview.ts | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/editor/src/components/tools/item/block-preview.test.ts b/packages/editor/src/components/tools/item/block-preview.test.ts index 1884da461e..5be496cea2 100644 --- a/packages/editor/src/components/tools/item/block-preview.test.ts +++ b/packages/editor/src/components/tools/item/block-preview.test.ts @@ -39,10 +39,10 @@ describe('resolveBlockFaceSwitch', () => { }) 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) + test('detaches attached items when they leave a block face', () => { + expect(shouldDetachBlockFaceOnLeave('wall')).toBe(true) + expect(shouldDetachBlockFaceOnLeave('wall-side')).toBe(true) + expect(shouldDetachBlockFaceOnLeave('ceiling')).toBe(true) }) test('allows free floor items to leave a block face', () => { diff --git a/packages/editor/src/components/tools/item/block-preview.ts b/packages/editor/src/components/tools/item/block-preview.ts index 871824356b..88fb3cb12a 100644 --- a/packages/editor/src/components/tools/item/block-preview.ts +++ b/packages/editor/src/components/tools/item/block-preview.ts @@ -35,7 +35,12 @@ export function resolveBlockFaceSwitch( } export function shouldDetachBlockFaceOnLeave(attachTo: string | undefined): boolean { - return !attachTo + return ( + attachTo === undefined || + attachTo === 'wall' || + attachTo === 'wall-side' || + attachTo === 'ceiling' + ) } export function clampBlockFacePosition( From 9b9973021fc873ad74275b56c57d369d51d57067 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 12:00:38 +0530 Subject: [PATCH 05/12] feat(block): improve edit mode interactions --- .../editor/src/hooks/use-keyboard.test.ts | 14 +- packages/editor/src/hooks/use-keyboard.ts | 10 +- packages/nodes/src/block/edit-session.test.ts | 25 + packages/nodes/src/block/edit-session.ts | 12 +- .../nodes/src/block/geometry-snap.test.ts | 84 ++ packages/nodes/src/block/geometry-snap.ts | 177 +++ .../nodes/src/block/last-operation.test.ts | 91 ++ packages/nodes/src/block/last-operation.ts | 166 +++ .../src/block/modal-face-operation.test.ts | 38 + .../nodes/src/block/modal-face-operation.ts | 35 + .../nodes/src/block/modal-transform.test.ts | 101 +- packages/nodes/src/block/modal-transform.ts | 147 ++- packages/nodes/src/block/selection.tsx | 1149 ++++++++++++++--- .../nodes/src/block/toolbar-state.test.ts | 15 + packages/nodes/src/block/toolbar-state.ts | 5 +- 15 files changed, 1901 insertions(+), 168 deletions(-) create mode 100644 packages/nodes/src/block/geometry-snap.test.ts create mode 100644 packages/nodes/src/block/geometry-snap.ts create mode 100644 packages/nodes/src/block/last-operation.test.ts create mode 100644 packages/nodes/src/block/last-operation.ts create mode 100644 packages/nodes/src/block/modal-face-operation.test.ts create mode 100644 packages/nodes/src/block/modal-face-operation.ts diff --git a/packages/editor/src/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts index d0f139dea8..86f62acf75 100644 --- a/packages/editor/src/hooks/use-keyboard.test.ts +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -8,7 +8,11 @@ import { } from '@pascal-app/core' import { meshEditScope } from '../lib/interaction/scope' import useInteractionScope from '../store/use-interaction-scope' -import { canRunGlobalRotationShortcut, 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 ??= ( @@ -48,6 +52,14 @@ describe('history shortcuts during block editing', () => { expect(canRunGlobalRotationShortcut()).toBe(false) }) + test('reserves held Shift for precision 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(false) + }) + test('undoes and redoes mesh changes without leaving component selection mode', () => { useInteractionScope.getState().begin(meshEditScope(NODE_ID)) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 5b489471fe..b100f55663 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -174,6 +174,11 @@ export const runHistoryShortcut = (direction: 'undo' | 'redo') => { export const canRunGlobalRotationShortcut = () => useInteractionScope.getState().scope.kind !== 'mesh-editing' +export const canCycleSnappingModeShortcut = (hasActiveContext = getActiveSnapContext() != null) => { + const scope = useInteractionScope.getState().scope + return hasActiveContext && !(scope.kind === 'mesh-editing' && scope.phase === 'operating') +} + export const useKeyboard = ({ isVersionPreviewMode = false, disabled = false, @@ -203,7 +208,6 @@ export const useKeyboard = ({ // every node move (including wall-hosted items + door/window openings, which // now declare `snapProfile`), and endpoint/polygon reshaping, so the keys // never silently stop working. Force-place lives on Alt where a tool supports it. - const isSnappingCycleContext = () => getActiveSnapContext() != null // A "clean tap" of Ctrl/Meta (pressed and released with NO other key in // between) cycles the grid step — same context as the Shift snapping-mode // cycle. `ctrlTapClean` starts true the moment Ctrl/Meta goes down alone @@ -273,7 +277,7 @@ export const useKeyboard = ({ return } - if (e.key === 'Shift' && !e.repeat && isSnappingCycleContext()) { + if (e.key === 'Shift' && !e.repeat && canCycleSnappingModeShortcut()) { // Cycle the global snapping mode (grid → lines → angles → off). // `'off'` is the snap bypass now, so Shift no longer holds-to-bypass. e.preventDefault() @@ -703,7 +707,7 @@ export const useKeyboard = ({ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return } - if (!isSnappingCycleContext()) return + if (!canCycleSnappingModeShortcut()) return // Cycle the grid / measurement step (0.5 → 0.25 → 0.1 → 0.05). useEditor.getState().cycleGridSnapStep() sfxEmitter.emit('sfx:grid-snap') diff --git a/packages/nodes/src/block/edit-session.test.ts b/packages/nodes/src/block/edit-session.test.ts index 1f08e02c0a..7b4c51253a 100644 --- a/packages/nodes/src/block/edit-session.test.ts +++ b/packages/nodes/src/block/edit-session.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { createBoxBlockTopology } from '@pascal-app/core' import useBlockEditSession from './edit-session' +import type { BlockLastOperation } from './last-operation' import { createBlockSelection } from './selection-model' describe('block edit session', () => { @@ -8,6 +9,7 @@ describe('block edit session', () => { useBlockEditSession.setState({ nodeId: null, selection: createBlockSelection('face'), + lastOperation: null, }) }) @@ -64,4 +66,27 @@ describe('block edit session', () => { selection: { mode: 'face', ids: [], activeId: null }, }) }) + + test('keeps the latest adjustable operation only for its owning block', () => { + const operation = { + nodeId: 'block_1', + label: 'Move', + baseTopology: createBoxBlockTopology(), + resultTopology: createBoxBlockTopology(), + resultSelection: { mode: 'vertex', ids: ['v0'] }, + command: { + type: 'translate-components', + selection: { mode: 'vertex', ids: ['v0'] }, + delta: [1, 0, 0], + }, + historyDepth: 1, + } as BlockLastOperation + useBlockEditSession.getState().begin('block_1', createBlockSelection('vertex', ['v0'])) + useBlockEditSession.getState().setLastOperation('block_2', operation) + expect(useBlockEditSession.getState().lastOperation).toBeNull() + useBlockEditSession.getState().setLastOperation('block_1', operation) + expect(useBlockEditSession.getState().lastOperation).toBe(operation) + useBlockEditSession.getState().end('block_1') + expect(useBlockEditSession.getState().lastOperation).toBeNull() + }) }) diff --git a/packages/nodes/src/block/edit-session.ts b/packages/nodes/src/block/edit-session.ts index 8d8e1e01cc..4327d3bec7 100644 --- a/packages/nodes/src/block/edit-session.ts +++ b/packages/nodes/src/block/edit-session.ts @@ -1,14 +1,17 @@ import type { BlockTopology } from '@pascal-app/core' import { create } from 'zustand' +import type { BlockLastOperation } from './last-operation' import { type BlockSelectionState, createBlockSelection } from './selection-model' type BlockEditSessionState = { nodeId: string | null selection: BlockSelectionState + lastOperation: BlockLastOperation | null begin: (nodeId: string, selection: BlockSelectionState) => void end: (nodeId: string) => void setSelection: (nodeId: string, selection: BlockSelectionState) => void reconcileSelection: (nodeId: string, topology: BlockTopology) => void + setLastOperation: (nodeId: string, operation: BlockLastOperation | null) => void } const emptySelection = () => createBlockSelection('face') @@ -16,13 +19,18 @@ const emptySelection = () => createBlockSelection('face') const useBlockEditSession = create((set) => ({ nodeId: null, selection: emptySelection(), - begin: (nodeId, selection) => set({ nodeId, selection }), + lastOperation: null, + begin: (nodeId, selection) => set({ nodeId, selection, lastOperation: null }), end: (nodeId) => set((state) => - state.nodeId === nodeId ? { nodeId: null, selection: emptySelection() } : state, + state.nodeId === nodeId + ? { nodeId: null, selection: emptySelection(), lastOperation: null } + : state, ), setSelection: (nodeId, selection) => set((state) => (state.nodeId === nodeId ? { selection } : state)), + setLastOperation: (nodeId, lastOperation) => + set((state) => (state.nodeId === nodeId ? { lastOperation } : state)), reconcileSelection: (nodeId, topology) => set((state) => { if (state.nodeId !== nodeId) return state diff --git a/packages/nodes/src/block/geometry-snap.test.ts b/packages/nodes/src/block/geometry-snap.test.ts new file mode 100644 index 0000000000..14e733b893 --- /dev/null +++ b/packages/nodes/src/block/geometry-snap.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' +import { createBoxBlockTopology } from '@pascal-app/core' +import { PerspectiveCamera, Vector3 } from 'three' +import { blockGeometrySnapThreshold, resolveBlockGeometrySnap } from './geometry-snap' + +describe('block geometry snapping', () => { + test('keeps the acquisition radius consistent in screen pixels as the camera moves', () => { + const camera = new PerspectiveCamera(60, 1, 0.1, 100) + camera.position.set(0, 0, 5) + camera.updateMatrixWorld() + const nearThreshold = blockGeometrySnapThreshold( + camera, + new Vector3(0, 0, 0), + 1000, + new Vector3(1, 1, 1), + ) + + camera.position.z = 10 + camera.updateMatrixWorld() + const farThreshold = blockGeometrySnapThreshold( + camera, + new Vector3(0, 0, 0), + 1000, + new Vector3(1, 1, 1), + ) + + expect(farThreshold / nearThreshold).toBeCloseTo(2) + }) + + test('snaps a selected vertex to another vertex', () => { + const snap = resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'vertex', ids: ['v6'], activeId: 'v6' }, + [-1.96, 0, 0], + 'free', + 0.1, + ) + + expect(snap?.kind).toBe('vertex') + expect(snap?.targetId).toBe('v7') + expect(snap?.delta).toEqual([-2, 0, 0]) + }) + + test('respects an axis constraint while snapping selection center to an edge', () => { + const snap = resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'edge', ids: ['e5'], activeId: 'e5' }, + [-1.93, 0, 0], + 'x', + 0.1, + ) + + expect(snap?.kind).toBe('edge') + expect(snap?.targetId).toBe('e7') + expect(snap?.delta[1]).toBe(0) + expect(snap?.delta[2]).toBe(0) + }) + + test('snaps an active face center onto another face surface', () => { + const snap = resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'face', ids: ['f-top'], activeId: 'f-top' }, + [0, -2.35, 0], + 'y', + 0.1, + ) + + expect(snap?.kind).toBe('face') + expect(snap?.targetId).toBe('f-bottom') + expect(snap?.delta).toEqual([0, -2.4, 0]) + }) + + test('returns no snap outside the acquisition threshold', () => { + expect( + resolveBlockGeometrySnap( + createBoxBlockTopology(), + { mode: 'vertex', ids: ['v6'], activeId: 'v6' }, + [-1.5, 0, 0], + 'free', + 0.1, + ), + ).toBeNull() + }) +}) diff --git a/packages/nodes/src/block/geometry-snap.ts b/packages/nodes/src/block/geometry-snap.ts new file mode 100644 index 0000000000..0381fa505e --- /dev/null +++ b/packages/nodes/src/block/geometry-snap.ts @@ -0,0 +1,177 @@ +import type { BlockTopology } from '@pascal-app/core' +import { + type Camera, + MathUtils, + OrthographicCamera, + PerspectiveCamera, + Triangle, + Vector3, +} from 'three' +import { type BlockSelection, blockSelectionVertexIds } from './commands' +import { triangulateBlockFace } from './geometry' +import type { BlockTransformConstraint } from './modal-transform' + +type Point = [number, number, number] + +export type BlockGeometrySnap = { + delta: Point + kind: 'vertex' | 'edge' | 'face' + source: Point + target: Point + targetId: string +} + +export function blockGeometrySnapThreshold( + camera: Camera, + worldPoint: Vector3, + viewportHeight: number, + worldScale: Vector3, + radiusPixels = 18, +): number { + let worldUnitsPerPixel = 0 + if (camera instanceof PerspectiveCamera) { + const cameraDepth = Math.abs(worldPoint.clone().applyMatrix4(camera.matrixWorldInverse).z) + worldUnitsPerPixel = + (2 * cameraDepth * Math.tan(MathUtils.degToRad(camera.getEffectiveFOV() * 0.5))) / + Math.max(viewportHeight, 1) + } else if (camera instanceof OrthographicCamera) { + worldUnitsPerPixel = (camera.top - camera.bottom) / Math.max(camera.zoom * viewportHeight, 1) + } + const largestWorldScale = Math.max( + Math.abs(worldScale.x), + Math.abs(worldScale.y), + Math.abs(worldScale.z), + 1e-6, + ) + return (worldUnitsPerPixel * radiusPixels) / largestWorldScale +} + +function centroid(points: readonly Point[]): Point | null { + if (points.length === 0) return null + const total = points.reduce( + (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]] as Point, + [0, 0, 0] as Point, + ) + return total.map((value) => value / points.length) as Point +} + +function closestPointOnSegment(point: Point, start: Point, end: Point): Point { + const segment = new Vector3(...end).sub(new Vector3(...start)) + const lengthSquared = segment.lengthSq() + if (lengthSquared < 1e-12) return [...start] + const factor = Math.min( + 1, + Math.max(0, new Vector3(...point).sub(new Vector3(...start)).dot(segment) / lengthSquared), + ) + return new Vector3(...start).addScaledVector(segment, factor).toArray() as Point +} + +function constrainedCorrection(correction: Point, constraint: BlockTransformConstraint): Point { + if (constraint === 'free' || constraint === 'uniform') return correction + return correction.map((value, index) => { + const axis = index === 0 ? 'x' : index === 1 ? 'y' : 'z' + return constraint.includes(axis) ? value : 0 + }) as Point +} + +export function resolveBlockGeometrySnap( + topology: BlockTopology, + selection: BlockSelection & { activeId?: string | null }, + proposedDelta: Point, + constraint: BlockTransformConstraint, + threshold: number, +): BlockGeometrySnap | null { + if (!(threshold > 0)) return null + const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) + const selectedVertexIds = blockSelectionVertexIds(topology, selection) + const selectedPoints = [...selectedVertexIds] + .map((id) => vertexById.get(id)) + .filter((point): point is Point => Boolean(point)) + if (selectedPoints.length === 0) return null + + const sources: Point[] = [] + if (selection.activeId) { + if (selection.mode === 'vertex') { + const point = vertexById.get(selection.activeId) + if (point) sources.push(point) + } else if (selection.mode === 'edge') { + const edge = topology.edges.find((entry) => entry.id === selection.activeId) + const points = edge?.vertexIds + .map((id) => vertexById.get(id)) + .filter((point): point is Point => Boolean(point)) + const point = points ? centroid(points) : null + if (point) sources.push(point) + } else { + const face = topology.faces.find((entry) => entry.id === selection.activeId) + const points = face?.vertexIds + .map((id) => vertexById.get(id)) + .filter((point): point is Point => Boolean(point)) + const point = points ? centroid(points) : null + if (point) sources.push(point) + } + } + sources.push(...selectedPoints) + const selectionCenter = centroid(selectedPoints) + if (selectionCenter) sources.push(selectionCenter) + + let best: (BlockGeometrySnap & { distance: number }) | null = null + const consider = ( + source: Point, + target: Point, + kind: BlockGeometrySnap['kind'], + targetId: string, + ) => { + const movedSource = source.map((value, index) => value + proposedDelta[index]!) as Point + const correction = target.map((value, index) => value - movedSource[index]!) as Point + const distance = Math.hypot(...correction) + if (distance > threshold || (best && distance >= best.distance)) return + const allowedCorrection = constrainedCorrection(correction, constraint) + best = { + delta: proposedDelta.map((value, index) => value + allowedCorrection[index]!) as Point, + distance, + kind, + source, + target, + targetId, + } + } + + for (const source of sources) { + const movedSource = source.map((value, index) => value + proposedDelta[index]!) as Point + for (const vertex of topology.vertices) { + if (!selectedVertexIds.has(vertex.id)) consider(source, vertex.position, 'vertex', vertex.id) + } + for (const edge of topology.edges) { + if (edge.vertexIds.some((id) => selectedVertexIds.has(id))) continue + const start = vertexById.get(edge.vertexIds[0]) + const end = vertexById.get(edge.vertexIds[1]) + if (start && end) { + consider(source, closestPointOnSegment(movedSource, start, end), 'edge', edge.id) + } + } + for (const face of topology.faces) { + if (face.vertexIds.some((id) => selectedVertexIds.has(id))) continue + const triangulated = triangulateBlockFace(topology, face) + if (!triangulated) continue + for (const points of triangulated.triangles) { + const triangle = new Triangle( + new Vector3(...points[0]), + new Vector3(...points[1]), + new Vector3(...points[2]), + ) + const target = triangle.closestPointToPoint(new Vector3(...movedSource), new Vector3()) + consider(source, target.toArray() as Point, 'face', face.id) + } + } + } + + if (!best) return null + const resolved = best as BlockGeometrySnap & { distance: number } + return { + delta: resolved.delta, + kind: resolved.kind, + source: resolved.source, + target: resolved.target, + targetId: resolved.targetId, + } +} diff --git a/packages/nodes/src/block/last-operation.test.ts b/packages/nodes/src/block/last-operation.test.ts new file mode 100644 index 0000000000..0446e45d41 --- /dev/null +++ b/packages/nodes/src/block/last-operation.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { BlockNode, useScene } from '@pascal-app/core' +import { applyBlockCommand } from './commands' +import { + recordCommittedBlockOperation, + repeatCommittedBlockOperation, + replaceCommittedBlockOperation, +} from './last-operation' + +globalThis.requestAnimationFrame ??= (callback: FrameRequestCallback) => { + callback(0) + return 0 +} +globalThis.cancelAnimationFrame ??= () => {} + +describe('block last operation history transaction', () => { + const node = BlockNode.parse({ name: 'Adjustable block' }) + + beforeEach(() => { + useScene.setState({ nodes: { [node.id]: node }, dirtyNodes: new Set(), readOnly: false }) + useScene.temporal.getState().clear() + }) + + afterEach(() => { + useScene.setState({ nodes: {}, dirtyNodes: new Set(), readOnly: false }) + useScene.temporal.getState().clear() + }) + + test('replaces the committed result while preserving one undo step', () => { + const firstCommand = { type: 'extrude-face', faceId: 'f-top', distance: 0.25 } as const + const first = applyBlockCommand(node.topology, firstCommand) + expect(first.ok).toBe(true) + if (!first.ok) return + useScene.getState().updateNode(node.id, { topology: first.topology }) + const record = recordCommittedBlockOperation( + node.id, + 'Extrude', + node.topology, + firstCommand, + first, + ) + + const adjusted = replaceCommittedBlockOperation(record, { + ...firstCommand, + distance: 0.5, + }) + + expect(adjusted.ok).toBe(true) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + const current = useScene.getState().nodes[node.id] + expect(current?.type).toBe('block') + if (current?.type !== 'block') return + const top = current.topology.faces.find((face) => face.id === 'f-top') + expect( + top?.vertexIds.map( + (id) => current.topology.vertices.find((vertex) => vertex.id === id)!.position[1], + ), + ).toEqual([2.9, 2.9, 2.9, 2.9]) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[node.id]).toEqual(node) + }) + + test('repeats the operation from its latest result as a new undo step', () => { + const command = { type: 'extrude-face', faceId: 'f-top', distance: 0.25 } as const + const first = applyBlockCommand(node.topology, command) + expect(first.ok).toBe(true) + if (!first.ok) return + useScene.getState().updateNode(node.id, { topology: first.topology }) + const record = recordCommittedBlockOperation(node.id, 'Extrude', node.topology, command, first) + + const repeated = repeatCommittedBlockOperation(record, { + mode: 'face', + ids: ['f-top'], + activeId: 'f-top', + }) + + expect(repeated.ok).toBe(true) + expect(useScene.temporal.getState().pastStates).toHaveLength(2) + const current = useScene.getState().nodes[node.id] + expect(current?.type).toBe('block') + if (current?.type !== 'block') return + const top = current.topology.faces.find((face) => face.id === 'f-top') + expect( + top?.vertexIds.map( + (id) => current.topology.vertices.find((vertex) => vertex.id === id)!.position[1], + ), + ).toEqual([2.9, 2.9, 2.9, 2.9]) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[node.id]).toMatchObject({ topology: first.topology }) + }) +}) diff --git a/packages/nodes/src/block/last-operation.ts b/packages/nodes/src/block/last-operation.ts new file mode 100644 index 0000000000..2c736db2b4 --- /dev/null +++ b/packages/nodes/src/block/last-operation.ts @@ -0,0 +1,166 @@ +import { + type AnyNodeId, + type BlockTopology, + runAsSingleSceneHistoryStep, + useScene, +} from '@pascal-app/core' +import { + applyBlockCommand, + type BlockCommand, + type BlockCommandResult, + type BlockSelection, + blockSelectionVertexIds, +} from './commands' + +type SuccessfulBlockCommandResult = Extract + +export type BlockLastOperation = { + baseTopology: BlockTopology + command: BlockCommand + historyDepth: number + label: string + nodeId: AnyNodeId + resultSelection: BlockSelection + resultTopology: BlockTopology +} + +export type BlockLastOperationReplacement = + | { ok: true; operation: BlockLastOperation } + | { ok: false; error: string } + +type RepeatSelection = BlockSelection & { activeId: string | null } +type Point = [number, number, number] + +function sameTopology(left: BlockTopology, right: BlockTopology): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function selectionCentroid(topology: BlockTopology, selection: BlockSelection): Point | null { + const selectedIds = blockSelectionVertexIds(topology, selection) + const points = topology.vertices.filter((vertex) => selectedIds.has(vertex.id)) + if (points.length === 0) return null + const total = points.reduce( + (sum, vertex) => vertex.position.map((value, index) => sum[index]! + value) as Point, + [0, 0, 0] as Point, + ) + return total.map((value) => value / points.length) as Point +} + +function commandForRepeat( + command: BlockCommand, + topology: BlockTopology, + selection: RepeatSelection, +): BlockCommand | null { + const activeId = selection.activeId ?? selection.ids.at(-1) + switch (command.type) { + case 'translate-components': + return selection.ids.length > 0 ? { ...command, selection } : null + case 'rotate-components': { + const pivot = selectionCentroid(topology, selection) + return pivot ? { ...command, selection, pivot } : null + } + case 'scale-components': { + const pivot = selectionCentroid(topology, selection) + return pivot ? { ...command, selection, pivot } : null + } + case 'extrude-face': + case 'inset-face': + return selection.mode === 'face' && activeId ? { ...command, faceId: activeId } : null + case 'bevel-edge': + return selection.mode === 'edge' && activeId ? { ...command, edgeId: activeId } : null + case 'loop-cut': + return selection.mode === 'edge' && activeId ? { ...command, edgeId: activeId } : null + default: + return null + } +} + +export function recordCommittedBlockOperation( + nodeId: AnyNodeId, + label: string, + baseTopology: BlockTopology, + command: BlockCommand, + result: SuccessfulBlockCommandResult, +): BlockLastOperation { + return { + baseTopology, + command, + historyDepth: useScene.temporal.getState().pastStates.length, + label, + nodeId, + resultSelection: result.selection, + resultTopology: result.topology, + } +} + +export function replaceCommittedBlockOperation( + operation: BlockLastOperation, + command: BlockCommand, +): BlockLastOperationReplacement { + const scene = useScene.getState() + const current = scene.nodes[operation.nodeId] + if (scene.readOnly) return { ok: false, error: 'Scene is read-only' } + if (current?.type !== 'block' || !sameTopology(current.topology, operation.resultTopology)) { + return { ok: false, error: 'The last operation is no longer the latest scene change' } + } + if (useScene.temporal.getState().pastStates.length !== operation.historyDepth) { + return { ok: false, error: 'Scene history changed after the last operation' } + } + + const result = applyBlockCommand(operation.baseTopology, command) + if (!result.ok) return result + + let restored = false + runAsSingleSceneHistoryStep(useScene, () => { + useScene.temporal.getState().undo() + const baseline = useScene.getState().nodes[operation.nodeId] + restored = baseline?.type === 'block' && sameTopology(baseline.topology, operation.baseTopology) + if (!restored) { + useScene.temporal.getState().redo() + return + } + useScene.getState().updateNode(operation.nodeId, { topology: result.topology }) + }) + if (!restored) return { ok: false, error: 'Could not restore the operation baseline' } + + return { + ok: true, + operation: recordCommittedBlockOperation( + operation.nodeId, + operation.label, + operation.baseTopology, + command, + result, + ), + } +} + +export function repeatCommittedBlockOperation( + operation: BlockLastOperation, + selection: RepeatSelection, +): BlockLastOperationReplacement { + const scene = useScene.getState() + const current = scene.nodes[operation.nodeId] + if (scene.readOnly) return { ok: false, error: 'Scene is read-only' } + if (current?.type !== 'block' || !sameTopology(current.topology, operation.resultTopology)) { + return { ok: false, error: 'The last operation is no longer the latest scene change' } + } + if (useScene.temporal.getState().pastStates.length !== operation.historyDepth) { + return { ok: false, error: 'Scene history changed after the last operation' } + } + const command = commandForRepeat(operation.command, current.topology, selection) + if (!command) return { ok: false, error: 'The current selection cannot repeat this operation' } + const result = applyBlockCommand(current.topology, command) + if (!result.ok) return result + useScene.getState().updateNode(operation.nodeId, { topology: result.topology }) + return { + ok: true, + operation: recordCommittedBlockOperation( + operation.nodeId, + operation.label, + current.topology, + command, + result, + ), + } +} diff --git a/packages/nodes/src/block/modal-face-operation.test.ts b/packages/nodes/src/block/modal-face-operation.test.ts new file mode 100644 index 0000000000..3503b86b4d --- /dev/null +++ b/packages/nodes/src/block/modal-face-operation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { + blockFaceOperationCommand, + blockFaceOperationValueFromPointer, + blockModalFaceOperationStatus, +} from './modal-face-operation' + +describe('block modal face operation', () => { + test('maps pointer travel to signed extrusion distance and bounded inset amount', () => { + expect(blockFaceOperationValueFromPointer('extrude', 60, -30, 2)).toBeCloseTo(0.9) + expect(blockFaceOperationValueFromPointer('extrude', -60, 30, 2)).toBeCloseTo(-0.9) + expect(blockFaceOperationValueFromPointer('inset', 60, -30, 2)).toBeCloseTo(0.45) + expect(blockFaceOperationValueFromPointer('inset', 500, -500, 2)).toBe(0.95) + }) + + test('creates a pure topology command from the modal value', () => { + expect(blockFaceOperationCommand('extrude', 'f-top', -0.4)).toEqual({ + type: 'extrude-face', + faceId: 'f-top', + distance: -0.4, + }) + expect(blockFaceOperationCommand('inset', 'f-top', 0.2)).toEqual({ + type: 'inset-face', + faceId: 'f-top', + amount: 0.2, + depth: 0, + }) + }) + + test('reports operation value and modal controls', () => { + expect(blockModalFaceOperationStatus('extrude', '0.35', 'grid')).toBe( + 'Extrude · 0.35 m · Grid snap · type value · click applies · Esc cancels', + ) + expect(blockModalFaceOperationStatus('inset', '0.2')).toBe( + 'Inset · 0.2 ratio · Free · type value · click applies · Esc cancels', + ) + }) +}) diff --git a/packages/nodes/src/block/modal-face-operation.ts b/packages/nodes/src/block/modal-face-operation.ts new file mode 100644 index 0000000000..4e1d36a22e --- /dev/null +++ b/packages/nodes/src/block/modal-face-operation.ts @@ -0,0 +1,35 @@ +import type { BlockCommand } from './commands' +import { type BlockModalFeedbackMode, blockModalFeedbackLabel } from './modal-transform' + +export type BlockModalFaceOperation = 'extrude' | 'inset' + +export function blockFaceOperationValueFromPointer( + operation: BlockModalFaceOperation, + deltaX: number, + deltaY: number, + topologyExtent: number, +): number { + const pointerTravel = deltaX - deltaY + if (operation === 'extrude') return pointerTravel * 0.01 + return Math.min(0.95, Math.max(0, pointerTravel / (Math.max(0.5, topologyExtent) * 100))) +} + +export function blockFaceOperationCommand( + operation: BlockModalFaceOperation, + faceId: string, + value: number, +): BlockCommand { + return operation === 'extrude' + ? { type: 'extrude-face', faceId, distance: value } + : { type: 'inset-face', faceId, amount: value, depth: 0 } +} + +export function blockModalFaceOperationStatus( + operation: BlockModalFaceOperation, + value: string, + feedbackMode: BlockModalFeedbackMode = 'free', +): string { + const label = operation === 'extrude' ? 'Extrude' : 'Inset' + const unit = operation === 'extrude' ? 'm' : 'ratio' + return `${label} · ${value} ${unit} · ${blockModalFeedbackLabel(feedbackMode)} · type value · click applies · Esc cancels` +} diff --git a/packages/nodes/src/block/modal-transform.test.ts b/packages/nodes/src/block/modal-transform.test.ts index cb9498c79a..56e0978a82 100644 --- a/packages/nodes/src/block/modal-transform.test.ts +++ b/packages/nodes/src/block/modal-transform.test.ts @@ -1,13 +1,47 @@ import { describe, expect, test } from 'bun:test' import { + blockAccumulatePrecisionPointer, blockAxisDelta, blockAxisVisualState, blockModalTransformStatus, + blockNumericDeltaForConstraint, + blockPlaneVisualState, + blockPrecisionSnapStep, blockRotationPointerAngle, + blockScaleFactorsForConstraint, blockTransformAxisFromKey, + blockTransformConstraintFromKey, + blockTransformDisplayValue, + blockTransformNumericInputFromKey, + blockTransformNumericValue, } from './modal-transform' describe('block modal transform', () => { + test('accumulates held-Shift pointer movement at one tenth speed without jumping', () => { + expect( + blockAccumulatePrecisionPointer( + { x: 120, y: 100 }, + { x: 120, y: 100 }, + { x: 140, y: 80 }, + true, + ), + ).toEqual({ x: 122, y: 98 }) + expect( + blockAccumulatePrecisionPointer( + { x: 122, y: 98 }, + { x: 140, y: 80 }, + { x: 150, y: 90 }, + false, + ), + ).toEqual({ x: 132, y: 108 }) + }) + + test('uses finer grid and angle increments during precision movement', () => { + expect(blockPrecisionSnapStep(0.5, false)).toBe(0.5) + expect(blockPrecisionSnapStep(0.5, true)).toBe(0.05) + expect(blockPrecisionSnapStep(15, true)).toBe(1.5) + }) + test('recognizes case-insensitive transform-axis shortcuts', () => { expect(blockTransformAxisFromKey('X')).toBe('x') expect(blockTransformAxisFromKey('y')).toBe('y') @@ -27,15 +61,80 @@ describe('block modal transform', () => { expect(blockAxisVisualState(active, 'rotate', 'y')).toBe('faded') }) + test('maps shifted axis shortcuts to the plane that excludes that axis', () => { + expect(blockTransformConstraintFromKey('X', true)).toBe('yz') + expect(blockTransformConstraintFromKey('y', true)).toBe('xz') + expect(blockTransformConstraintFromKey('Z', true)).toBe('xy') + expect(blockTransformConstraintFromKey('x', false)).toBe('x') + }) + + test('keeps the constrained plane axes and plane handle colorful', () => { + const active = { operation: 'translate', constraint: 'xz' } as const + expect(blockAxisVisualState(active, 'translate', 'x')).toBe('active') + expect(blockAxisVisualState(active, 'translate', 'z')).toBe('active') + expect(blockAxisVisualState(active, 'translate', 'y')).toBe('faded') + expect(blockPlaneVisualState(active, 'xz')).toBe('active') + expect(blockPlaneVisualState(active, 'xy')).toBe('faded') + }) + + test('keeps typed movement inside the active plane', () => { + expect(blockNumericDeltaForConstraint('xz', [3, 10, 4], 5)).toEqual([3, 0, 4]) + expect(blockNumericDeltaForConstraint('y', [3, 10, 4], -2)).toEqual([0, -2, 0]) + expect(blockNumericDeltaForConstraint('free', [0, 0, 0], 1.5)).toEqual([1.5, 0, 0]) + }) + + test('scales only the axes included by the active constraint', () => { + expect(blockScaleFactorsForConstraint('yz', 2)).toEqual([1, 2, 2]) + expect(blockScaleFactorsForConstraint('x', 0.5)).toEqual([0.5, 1, 1]) + expect(blockScaleFactorsForConstraint('uniform', 1.25)).toEqual([1.25, 1.25, 1.25]) + }) + test('describes the current operation and constraint', () => { expect(blockModalTransformStatus({ operation: 'rotate', constraint: 'z' })).toBe( - 'Rotate · Z axis · X/Y/Z constrains · click applies · Esc cancels', + 'Rotate · Z axis · Free · X/Y/Z constrains · click applies · Esc cancels', ) }) + test('formats live values in the operation user-facing unit', () => { + expect(blockTransformDisplayValue('translate', 1.23456)).toBe('1.235') + expect(blockTransformDisplayValue('rotate', Math.PI / 2)).toBe('90') + expect(blockTransformDisplayValue('scale', 1.25)).toBe('1.25') + }) + test('rotates from horizontal movement when the gesture starts on the pivot', () => { expect( blockRotationPointerAngle({ x: 100, y: 100 }, { x: 100, y: 100 }, { x: 120, y: 100 }), ).not.toBe(0) }) + + test('builds signed decimal input and supports correction', () => { + let input = '' + for (const key of ['2', '.', '5']) { + input = blockTransformNumericInputFromKey(input, key)! + } + expect(input).toBe('2.5') + expect(blockTransformNumericInputFromKey(input, '-')).toBe('-2.5') + expect(blockTransformNumericInputFromKey('-2.5', 'Backspace')).toBe('-2.') + expect(blockTransformNumericInputFromKey('2.5', '.')).toBe('2.5') + expect(blockTransformNumericInputFromKey('2.5', 'x')).toBeNull() + }) + + test('interprets typed distance, angle, and scale values in their user-facing units', () => { + expect(blockTransformNumericValue('2.5', 'translate')).toBe(2.5) + expect(blockTransformNumericValue('-45', 'rotate')).toBeCloseTo(-Math.PI / 4) + expect(blockTransformNumericValue('1.25', 'scale')).toBe(1.25) + expect(blockTransformNumericValue('-', 'translate')).toBeNull() + }) + + test('includes the typed value in modal feedback', () => { + expect( + blockModalTransformStatus({ operation: 'translate', constraint: 'z' }, '-1.25', 'exact'), + ).toBe('Move · Z axis · -1.25 m · Exact · X/Y/Z constrains · click applies · Esc cancels') + expect(blockModalTransformStatus({ operation: 'rotate', constraint: 'y' }, '45', 'angle')).toBe( + 'Rotate · Y axis · 45° · Angle snap · X/Y/Z constrains · click applies · Esc cancels', + ) + expect(blockModalTransformStatus({ operation: 'translate', constraint: 'xz' })).toBe( + 'Move · XZ plane · Free · X/Y/Z constrains · click applies · Esc cancels', + ) + }) }) diff --git a/packages/nodes/src/block/modal-transform.ts b/packages/nodes/src/block/modal-transform.ts index 59a8fe1c48..ea234d0368 100644 --- a/packages/nodes/src/block/modal-transform.ts +++ b/packages/nodes/src/block/modal-transform.ts @@ -1,6 +1,8 @@ export type BlockTransformAxis = 'x' | 'y' | 'z' +export type BlockTransformPlane = 'xy' | 'xz' | 'yz' export type BlockTransformOperation = 'translate' | 'rotate' | 'scale' -export type BlockTransformConstraint = BlockTransformAxis | 'free' | 'uniform' +export type BlockTransformConstraint = BlockTransformAxis | BlockTransformPlane | 'free' | 'uniform' +export type BlockModalFeedbackMode = 'free' | 'grid' | 'angle' | 'exact' | 'geometry' | 'precision' export type BlockActiveTransform = { operation: BlockTransformOperation @@ -11,6 +13,23 @@ export type BlockAxisVisualState = 'normal' | 'active' | 'faded' export type BlockScreenPoint = { x: number; y: number } +export function blockAccumulatePrecisionPointer( + effective: BlockScreenPoint, + previousRaw: BlockScreenPoint, + currentRaw: BlockScreenPoint, + precision: boolean, +): BlockScreenPoint { + const factor = precision ? 0.1 : 1 + return { + x: effective.x + (currentRaw.x - previousRaw.x) * factor, + y: effective.y + (currentRaw.y - previousRaw.y) * factor, + } +} + +export function blockPrecisionSnapStep(step: number, precision: boolean): number { + return precision ? step * 0.1 : step +} + export function blockRotationPointerAngle( pivot: BlockScreenPoint, start: BlockScreenPoint, @@ -31,6 +50,60 @@ export function blockTransformAxisFromKey(key: string): BlockTransformAxis | nul return normalized === 'x' || normalized === 'y' || normalized === 'z' ? normalized : null } +export function blockTransformConstraintFromKey( + key: string, + planeLock: boolean, +): BlockTransformAxis | BlockTransformPlane | null { + const axis = blockTransformAxisFromKey(key) + if (!axis || !planeLock) return axis + return axis === 'x' ? 'yz' : axis === 'y' ? 'xz' : 'xy' +} + +export function blockTransformNumericInputFromKey(current: string, key: string): string | null { + if (/^\d$/.test(key)) return `${current}${key}` + if (key === '.') { + if (current.includes('.')) return current + if (current === '') return '0.' + if (current === '-') return '-0.' + return `${current}.` + } + if (key === '-') return current.startsWith('-') ? current.slice(1) : `-${current}` + if (key === 'Backspace') return current.slice(0, -1) + return null +} + +export function blockTransformNumericValue( + input: string, + operation: BlockTransformOperation, +): number | null { + if (input === '' || input === '-' || input === '.' || input === '-.') return null + const value = Number(input) + if (!Number.isFinite(value)) return null + return operation === 'rotate' ? (value * Math.PI) / 180 : value +} + +export function blockTransformDisplayValue( + operation: BlockTransformOperation, + value: number, +): string { + const displayed = operation === 'rotate' ? (value * 180) / Math.PI : value + return String(Math.round(displayed * 1000) / 1000) +} + +export function blockModalFeedbackLabel(mode: BlockModalFeedbackMode): string { + return mode === 'grid' + ? 'Grid snap' + : mode === 'angle' + ? 'Angle snap' + : mode === 'exact' + ? 'Exact' + : mode === 'geometry' + ? 'Geometry snap' + : mode === 'precision' + ? 'Precision' + : 'Free' +} + export function blockAxisDelta( axis: BlockTransformAxis, distance: number, @@ -38,6 +111,39 @@ export function blockAxisDelta( return [axis === 'x' ? distance : 0, axis === 'y' ? distance : 0, axis === 'z' ? distance : 0] } +export function blockNumericDeltaForConstraint( + constraint: BlockTransformConstraint, + pointerDelta: [number, number, number], + distance: number, +): [number, number, number] { + if (constraint === 'x' || constraint === 'y' || constraint === 'z') { + return blockAxisDelta(constraint, distance) + } + if (constraint === 'xy' || constraint === 'xz' || constraint === 'yz') { + const delta: [number, number, number] = [ + constraint.includes('x') ? pointerDelta[0] : 0, + constraint.includes('y') ? pointerDelta[1] : 0, + constraint.includes('z') ? pointerDelta[2] : 0, + ] + const length = Math.hypot(...delta) + if (length > 1e-8) return delta.map((value) => (value / length) * distance) as typeof delta + return blockAxisDelta(constraint[0] as BlockTransformAxis, distance) + } + return blockAxisDelta('x', distance) +} + +export function blockScaleFactorsForConstraint( + constraint: BlockTransformConstraint, + factor: number, +): [number, number, number] { + if (constraint === 'uniform' || constraint === 'free') return [factor, factor, factor] + return [ + constraint.includes('x') ? factor : 1, + constraint.includes('y') ? factor : 1, + constraint.includes('z') ? factor : 1, + ] +} + export function blockAxisVisualState( activeTransform: BlockActiveTransform | null, operation: BlockTransformOperation, @@ -48,10 +154,32 @@ export function blockAxisVisualState( if (activeTransform.constraint === 'free' || activeTransform.constraint === 'uniform') { return 'normal' } + if (activeTransform.constraint.length === 2) { + return activeTransform.constraint.includes(axis) ? 'active' : 'faded' + } return activeTransform.constraint === axis ? 'active' : 'faded' } -export function blockModalTransformStatus(activeTransform: BlockActiveTransform): string { +export function blockPlaneVisualState( + activeTransform: BlockActiveTransform | null, + plane: BlockTransformPlane, +): BlockAxisVisualState { + if (!activeTransform) return 'normal' + if ( + activeTransform.operation !== 'translate' || + activeTransform.constraint === 'uniform' || + activeTransform.constraint === 'free' + ) { + return activeTransform.constraint === 'free' ? 'normal' : 'faded' + } + return activeTransform.constraint === plane ? 'active' : 'faded' +} + +export function blockModalTransformStatus( + activeTransform: BlockActiveTransform, + typedInput = '', + feedbackMode: BlockModalFeedbackMode = 'free', +): string { const operation = activeTransform.operation === 'translate' ? 'Move' @@ -63,6 +191,17 @@ export function blockModalTransformStatus(activeTransform: BlockActiveTransform) ? 'free' : activeTransform.constraint === 'uniform' ? 'uniform' - : `${activeTransform.constraint.toUpperCase()} axis` - return `${operation} · ${constraint} · X/Y/Z constrains · click applies · Esc cancels` + : activeTransform.constraint.length === 2 + ? `${activeTransform.constraint.toUpperCase()} plane` + : `${activeTransform.constraint.toUpperCase()} axis` + const typedValue = typedInput + ? ` · ${typedInput}${ + activeTransform.operation === 'translate' + ? ' m' + : activeTransform.operation === 'rotate' + ? '°' + : '×' + }` + : '' + return `${operation} · ${constraint}${typedValue} · ${blockModalFeedbackLabel(feedbackMode)} · X/Y/Z constrains · click applies · Esc cancels` } diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 5435659561..a671f3bdf1 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -16,6 +16,7 @@ import { getFloatingMenuScale, isAngleSnapActive, isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, meshEditScope, NodeActionMenu, @@ -87,20 +88,44 @@ import { } from './commands' import useBlockEditSession from './edit-session' import { triangulateBlockFace } from './geometry' +import { blockGeometrySnapThreshold, resolveBlockGeometrySnap } from './geometry-snap' import { BLOCK_WHEEL_OPTIONS, consumeBlockGestureWheel } from './gesture-wheel' import { type BlockSfxAction, blockSfx } from './interaction-sfx' +import { + type BlockLastOperation, + recordCommittedBlockOperation, + repeatCommittedBlockOperation, + replaceCommittedBlockOperation, +} from './last-operation' import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' import { BLOCK_BODY_SLOT_ID, unpaintedBlockMaterialSlotIds } from './material-slots' +import { + type BlockModalFaceOperation, + blockFaceOperationCommand, + blockFaceOperationValueFromPointer, + blockModalFaceOperationStatus, +} from './modal-face-operation' import { type BlockActiveTransform, type BlockAxisVisualState, + type BlockModalFeedbackMode, type BlockTransformAxis, + type BlockTransformConstraint, type BlockTransformOperation, + type BlockTransformPlane, + blockAccumulatePrecisionPointer, blockAxisDelta, blockAxisVisualState, blockModalTransformStatus, + blockNumericDeltaForConstraint, + blockPlaneVisualState, + blockPrecisionSnapStep, blockRotationPointerAngle, - blockTransformAxisFromKey, + blockScaleFactorsForConstraint, + blockTransformConstraintFromKey, + blockTransformDisplayValue, + blockTransformNumericInputFromKey, + blockTransformNumericValue, } from './modal-transform' import { lockedRotationAngleFromHits, @@ -130,7 +155,7 @@ import { type ComponentMode = BlockSelection['mode'] type Point = [number, number, number] type Axis = BlockTransformAxis -type PlaneAxes = 'xy' | 'xz' | 'yz' +type PlaneAxes = BlockTransformPlane type TransformOperation = BlockTransformOperation type ActiveTransform = BlockActiveTransform type TransformTool = 'transform' | 'loop-cut' | 'bevel' @@ -172,6 +197,14 @@ const OPERATION_INPUT_CLASS = const playBlockSfx = (action: BlockSfxAction) => triggerSFX(blockSfx(action)) +function isAxisConstraint(constraint: BlockTransformConstraint): constraint is Axis { + return constraint === 'x' || constraint === 'y' || constraint === 'z' +} + +function isPlaneConstraint(constraint: BlockTransformConstraint): constraint is PlaneAxes { + return constraint === 'xy' || constraint === 'xz' || constraint === 'yz' +} + function preferredFace(topology: BlockTopology): BlockFace | null { return ( topology.faces @@ -245,6 +278,23 @@ function localPointToClient( ) } +function geometrySnapThreshold( + camera: Camera, + worldPoint: Vector3, + target: Object3D, + canvas: HTMLCanvasElement, + extent: number, +): number { + target.updateWorldMatrix(true, false) + const screenThreshold = blockGeometrySnapThreshold( + camera, + worldPoint, + canvas.getBoundingClientRect().height, + target.getWorldScale(new Vector3()), + ) + return Math.min(extent * 0.15, Math.max(0.02, screenThreshold)) +} + function VertexHandle({ id, position, @@ -836,7 +886,7 @@ function PlaneMoveHandle({ size: number state: BlockAxisVisualState disabled: boolean - onPointerDown: (axis: Axis, event: ThreeEvent) => void + onPointerDown: (constraint: Axis | PlaneAxes, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) const geometry = useMemo(() => new PlaneGeometry(size, size), [size]) @@ -906,7 +956,7 @@ function PlaneMoveHandle({ event.stopPropagation() event.nativeEvent.stopImmediatePropagation() swallowNextClick() - onPointerDown(normalAxis, event) + onPointerDown(plane, event) }} onPointerEnter={(event) => { if (disabled) return @@ -1295,6 +1345,108 @@ function ToolbarPanelFrame({ ) } +function LastOperationControls({ + operation, + onChange, +}: { + operation: BlockLastOperation + onChange: (command: BlockCommand) => void +}) { + const command = operation.command + const input = ( + label: string, + value: number, + update: (value: number) => BlockCommand, + options: { min?: number; max?: number; step?: number } = {}, + ) => ( + + ) + + switch (command.type) { + case 'translate-components': + return ( +
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => + input(`${axis} distance`, command.delta[index]!, (value) => ({ + ...command, + delta: command.delta.map((current, currentIndex) => + currentIndex === index ? value : current, + ) as Point, + })), + )} +
+ ) + case 'rotate-components': + return input( + 'Angle', + (command.angle * 180) / Math.PI, + (value) => ({ ...command, angle: (value * Math.PI) / 180 }), + { step: 1 }, + ) + case 'scale-components': + return ( +
+ {(['X', 'Y', 'Z'] as const).map((axis, index) => + input(`${axis} scale`, command.factors[index]!, (value) => ({ + ...command, + factors: command.factors.map((current, currentIndex) => + currentIndex === index ? value : current, + ) as Point, + })), + )} +
+ ) + case 'extrude-face': + return input('Distance', command.distance, (distance) => ({ ...command, distance })) + case 'inset-face': + return input('Amount', command.amount, (amount) => ({ ...command, amount }), { + min: 0, + max: 0.95, + }) + case 'bevel-edge': + return ( +
+ {input('Width', command.width, (width) => ({ ...command, width }), { min: 0 })} + {input( + 'Segments', + command.segments, + (segments) => ({ ...command, segments: Math.min(12, Math.max(1, segments)) }), + { min: 1, max: 12, step: 1 }, + )} +
+ ) + case 'loop-cut': + return ( +
+ {input('Position', command.factor, (factor) => ({ ...command, factor }), { + min: 0.02, + max: 0.98, + })} + {input( + 'Cuts', + command.cuts ?? 1, + (cuts) => ({ ...command, cuts: Math.min(32, Math.max(1, cuts)) }), + { min: 1, max: 32, step: 1 }, + )} +
+ ) + default: + return null + } +} + function BlockEditor({ node, target, @@ -1320,19 +1472,27 @@ function BlockEditor({ const activeId = useBlockEditSession((state) => state.nodeId === node.id ? state.selection.activeId : null, ) + const lastOperation = useBlockEditSession((state) => + state.nodeId === node.id ? state.lastOperation : null, + ) const [transformTool, setTransformTool] = useState('transform') const [xray, setXray] = useState(false) const [previewTopology, setPreviewTopology] = useState(null) const [activeTransform, setActiveTransform] = useState(null) - const [keyboardTransformActive, setKeyboardTransformActive] = useState(false) + const [transformNumericInput, setTransformNumericInput] = useState('') + const [modalFeedbackMode, setModalFeedbackMode] = useState('free') + const [activeFaceOperation, setActiveFaceOperation] = useState( + null, + ) + const [faceOperationValue, setFaceOperationValue] = useState('') const [loopCutSegments, setLoopCutSegments] = useState<[Point, Point][] | null>(null) const [loopCutEdgeId, setLoopCutEdgeId] = useState(null) const [loopCutSliding, setLoopCutSliding] = useState(false) const [loopCutCount, setLoopCutCount] = useState(1) const [loopCutFactor, setLoopCutFactor] = useState(0.5) - const [extrudeDistance, setExtrudeDistance] = useState('0.25') - const [insetAmount, setInsetAmount] = useState('0.15') const [bevelSegments, setBevelSegments] = useState(DEFAULT_BEVEL_SEGMENTS) + const [bevelWidth, setBevelWidth] = useState(0) + const [lastOperationPanelOpen, setLastOperationPanelOpen] = useState(false) const [toolbarPanel, setToolbarPanel] = useState(null) const [error, setError] = useState(null) const cancelDragRef = useRef<(() => void) | null>(null) @@ -1400,7 +1560,10 @@ function BlockEditor({ setPreviewTopology(null) setTransformTool('transform') setActiveTransform(null) - setKeyboardTransformActive(false) + setTransformNumericInput('') + setModalFeedbackMode('free') + setActiveFaceOperation(null) + setFaceOperationValue('') setLoopCutSegments(null) setLoopCutEdgeId(null) setLoopCutSliding(false) @@ -1433,7 +1596,10 @@ function BlockEditor({ setLoopCutEdgeId(null) setLoopCutSliding(false) setActiveTransform(null) - setKeyboardTransformActive(false) + setTransformNumericInput('') + setModalFeedbackMode('free') + setActiveFaceOperation(null) + setFaceOperationValue('') useBlockEditSession.getState().end(node.id) }, [editing, node.id]) @@ -1682,6 +1848,30 @@ function BlockEditor({ [camera, gl.domElement], ) + const commitAdjustableOperation = useCallback( + (baseTopology: BlockTopology, command: BlockCommand, label: string) => { + const result = applyBlockCommand(baseTopology, command) + if (!result.ok) { + setError(result.error) + return false + } + useScene.getState().updateNode(node.id, { topology: result.topology }) + const session = useBlockEditSession.getState() + session.setSelection(node.id, { + ...result.selection, + activeId: result.selection.ids.at(-1) ?? null, + }) + session.setLastOperation( + node.id, + recordCommittedBlockOperation(node.id, label, baseTopology, command, result), + ) + setLastOperationPanelOpen(true) + setError(null) + return true + }, + [node.id], + ) + const beginKeyboardTransformModal = useCallback( (operation: 'translate' | 'rotate') => { if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return false @@ -1708,18 +1898,25 @@ function BlockEditor({ const baseSelection = selection const previousInputDragging = useViewer.getState().inputDragging const previousCursor = document.body.style.cursor - let activeAxis: Axis | null = null + let activeConstraint: Axis | PlaneAxes | null = null let latestTopology: BlockTopology | null = null + let latestCommand: BlockCommand | null = null let latestMagnitude = 0 let previousWrappedAngle = 0 let accumulatedAngle = 0 let lockedRotationInitialHit: Vector3 | null = null let lockedRotationPlane: Plane | null = null let lockedRotationWorldAxis: Vector3 | null = null + let lockedTranslationInitialHit: Vector3 | null = null + let lockedTranslationPlane: Plane | null = null let lastClientX = startPointer.x let lastClientY = startPointer.y let lastAltKey = false + let lastShiftKey = false + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { x: startPointer.x, y: startPointer.y } let lastSnapValue: string | number | null = null + let typedInput = '' let finished = false const worldAxisFor = (axis: Axis) => @@ -1728,28 +1925,49 @@ function BlockEditor({ .sub(worldOrigin) .normalize() - const updatePreview = (clientX: number, clientY: number, altKey: boolean) => { + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { lastClientX = clientX lastClientY = clientY lastAltKey = altKey + lastShiftKey = precision const ray = makeRay(clientX, clientY) + const numericValue = blockTransformNumericValue(typedInput, operation) let command: BlockCommand let snapValue: string | number if (operation === 'translate') { let delta: Point - if (activeAxis) { - const worldAxis = worldAxisFor(activeAxis) + if (activeConstraint && isAxisConstraint(activeConstraint)) { + const worldAxis = worldAxisFor(activeConstraint) const startParameter = closestAxisParameterToRay(worldOrigin, worldAxis, startRay) const currentParameter = closestAxisParameterToRay(worldOrigin, worldAxis, ray) const localPoint = target.worldToLocal( worldOrigin.clone().addScaledVector(worldAxis, currentParameter - startParameter), ) - const axisIndex = activeAxis === 'x' ? 0 : activeAxis === 'y' ? 1 : 2 + const axisIndex = activeConstraint === 'x' ? 0 : activeConstraint === 'y' ? 1 : 2 delta = blockAxisDelta( - activeAxis, + activeConstraint, localPoint.getComponent(axisIndex) - origin[axisIndex], ) + } else if ( + activeConstraint && + isPlaneConstraint(activeConstraint) && + lockedTranslationInitialHit && + lockedTranslationPlane + ) { + const currentHit = ray.intersectPlane(lockedTranslationPlane, new Vector3()) + if (!currentHit) return + const localPoint = target.worldToLocal( + worldOrigin.clone().add(currentHit.sub(lockedTranslationInitialHit)), + ) + delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] + const excludedAxis = PLANE_NORMAL[activeConstraint] + delta[excludedAxis === 'x' ? 0 : excludedAxis === 'y' ? 1 : 2] = 0 } else { const currentHit = ray.intersectPlane(viewPlane, new Vector3()) if (!currentHit) return @@ -1758,21 +1976,55 @@ function BlockEditor({ ) delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] } - const snapping = isGridSnapActive() && !altKey + if (numericValue !== null) { + delta = blockNumericDeltaForConstraint(activeConstraint ?? 'free', delta, numericValue) + } + const snapping = numericValue === null && isGridSnapActive() && !altKey if (snapping) { - const step = useEditor.getState().gridSnapStep + const step = blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) if (step > 0) delta = delta.map((value) => Math.round(value / step) * step) as Point } + const geometrySnap = + numericValue === null && isMagneticSnapActive() && !altKey + ? resolveBlockGeometrySnap( + baseTopology, + baseSelection, + delta, + activeConstraint ?? 'free', + geometrySnapThreshold(camera, worldOrigin, target, gl.domElement, extent), + ) + : null + if (geometrySnap) delta = geometrySnap.delta latestMagnitude = Math.hypot(...delta) - snapValue = delta.join(':') - if (snapping && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { + const signedDistance = + activeConstraint && isAxisConstraint(activeConstraint) + ? delta[activeConstraint === 'x' ? 0 : activeConstraint === 'y' ? 1 : 2] + : latestMagnitude + setTransformNumericInput( + typedInput || blockTransformDisplayValue('translate', signedDistance), + ) + setModalFeedbackMode( + typedInput + ? 'exact' + : geometrySnap + ? 'geometry' + : precision + ? 'precision' + : snapping + ? 'grid' + : 'free', + ) + snapValue = geometrySnap + ? `${geometrySnap.kind}:${geometrySnap.targetId}` + : delta.join(':') + if ((snapping || geometrySnap) && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { playBlockSfx('move-step') } command = { type: 'translate-components', selection: baseSelection, delta } } else { let wrappedAngle: number if ( - activeAxis && + activeConstraint?.length === 1 && lockedRotationInitialHit && lockedRotationPlane && lockedRotationWorldAxis @@ -1803,12 +2055,18 @@ function BlockEditor({ accumulatedAngle += unwrapRotationDelta(previousWrappedAngle, wrappedAngle) previousWrappedAngle = wrappedAngle let angle = accumulatedAngle - const snapping = isAngleSnapActive() && !altKey + if (numericValue !== null) angle = numericValue + const snapping = numericValue === null && isAngleSnapActive() && !altKey if (snapping) { - const step = (ROTATION_SNAP_ANGLE_DEGREES * Math.PI) / 180 + const step = + (blockPrecisionSnapStep(ROTATION_SNAP_ANGLE_DEGREES, precision) * Math.PI) / 180 angle = Math.round(angle / step) * step } latestMagnitude = Math.abs(angle) + setTransformNumericInput(typedInput || blockTransformDisplayValue('rotate', angle)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapping ? 'angle' : 'free', + ) snapValue = angle if (snapping && latestMagnitude > 1e-6 && snapValue !== lastSnapValue) { playBlockSfx('rotate-step') @@ -1817,7 +2075,10 @@ function BlockEditor({ type: 'rotate-components', selection: baseSelection, pivot: origin, - axis: activeAxis ? AXIS_VECTORS[activeAxis] : (freeRotationAxis.toArray() as Point), + axis: + activeConstraint && isAxisConstraint(activeConstraint) + ? AXIS_VECTORS[activeConstraint] + : (freeRotationAxis.toArray() as Point), angle, } } @@ -1829,6 +2090,7 @@ function BlockEditor({ return } latestTopology = result.topology + latestCommand = command setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) useScene.getState().markDirty(node.id) @@ -1850,9 +2112,14 @@ function BlockEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) - setKeyboardTransformActive(false) - if (commit && latestTopology && latestMagnitude > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + setTransformNumericInput('') + setModalFeedbackMode('free') + if (commit && latestTopology && latestCommand && latestMagnitude > 1e-6) { + commitAdjustableOperation( + baseTopology, + latestCommand, + operation === 'translate' ? 'Move' : 'Rotate', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -1863,7 +2130,19 @@ function BlockEditor({ const onMove = (pointerEvent: PointerEvent) => { lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) - updatePreview(pointerEvent.clientX, pointerEvent.clientY, pointerEvent.altKey) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) } const onPointerDown = (pointerEvent: PointerEvent) => { if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return @@ -1879,12 +2158,16 @@ function BlockEditor({ element?.isContentEditable ) return - const axis = blockTransformAxisFromKey(keyboardEvent.key) - if (axis) { + const constraint = blockTransformConstraintFromKey( + keyboardEvent.key, + operation === 'translate' && keyboardEvent.shiftKey, + ) + if (constraint) { keyboardEvent.preventDefault() keyboardEvent.stopImmediatePropagation() - activeAxis = axis + activeConstraint = constraint if (operation === 'rotate') { + const axis = constraint as Axis lockedRotationWorldAxis = worldAxisFor(axis) lockedRotationPlane = new Plane().setFromNormalAndCoplanarPoint( lockedRotationWorldAxis, @@ -1896,18 +2179,41 @@ function BlockEditor({ ) previousWrappedAngle = 0 accumulatedAngle = 0 + } else if (isPlaneConstraint(constraint)) { + const normalAxis = PLANE_NORMAL[constraint] + lockedTranslationPlane = new Plane().setFromNormalAndCoplanarPoint( + worldAxisFor(normalAxis), + worldOrigin, + ) + lockedTranslationInitialHit = makeRay(lastClientX, lastClientY).intersectPlane( + lockedTranslationPlane, + new Vector3(), + ) + } else { + lockedTranslationPlane = null + lockedTranslationInitialHit = null } - setActiveTransform({ operation, constraint: axis }) + setActiveTransform({ operation, constraint }) lastSnapValue = null - updatePreview(lastClientX, lastClientY, lastAltKey) - } else if (keyboardEvent.key === 'Enter') { - keyboardEvent.preventDefault() - keyboardEvent.stopImmediatePropagation() - finish(true) - } else if (keyboardEvent.key === 'Escape') { - keyboardEvent.preventDefault() - keyboardEvent.stopImmediatePropagation() - finish(false) + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else { + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + lastSnapValue = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(true) + } else if (keyboardEvent.key === 'Escape') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(false) + } } } const onContextMenu = (event: Event) => { @@ -1922,7 +2228,8 @@ function BlockEditor({ setTransformTool('transform') setToolbarPanel(null) setActiveTransform({ operation, constraint: 'free' }) - setKeyboardTransformActive(true) + setTransformNumericInput('') + setModalFeedbackMode('free') setError(null) document.body.style.cursor = operation === 'translate' ? 'move' : 'crosshair' cancelDragRef.current = onCancel @@ -1935,7 +2242,9 @@ function BlockEditor({ }, [ camera, + commitAdjustableOperation, displayTopology, + extent, gl.domElement, makeRay, node.id, @@ -1947,20 +2256,28 @@ function BlockEditor({ ) const beginTranslationDrag = useCallback( - (axis: Axis, event: ThreeEvent) => { + (constraint: Axis | PlaneAxes, event: ThreeEvent) => { if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return const origin = selectionCentroid(displayTopology, selection) if (!origin) return target.updateWorldMatrix(true, false) const originLocal = new Vector3(...origin) const worldOrigin = target.localToWorld(originLocal.clone()) - const localAxis = new Vector3(...AXIS_VECTORS[axis]) + const normalAxis = isPlaneConstraint(constraint) ? PLANE_NORMAL[constraint] : constraint + const localAxis = new Vector3(...AXIS_VECTORS[normalAxis]) const worldAxis = target .localToWorld(originLocal.clone().add(localAxis)) .sub(worldOrigin) .normalize() - const initialParameter = closestAxisParameterToRay(worldOrigin, worldAxis, event.ray) - const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + const dragPlane = isPlaneConstraint(constraint) + ? new Plane().setFromNormalAndCoplanarPoint(worldAxis, worldOrigin) + : null + const initialPlaneHit = dragPlane ? event.ray.intersectPlane(dragPlane, new Vector3()) : null + if (dragPlane && !initialPlaneHit) return + const initialParameter = isAxisConstraint(constraint) + ? closestAxisParameterToRay(worldOrigin, worldAxis, event.ray) + : 0 + const axisIndex = normalAxis === 'x' ? 0 : normalAxis === 'y' ? 1 : 2 const baseTopology = displayTopology const baseSelection = selection const previousInputDragging = useViewer.getState().inputDragging @@ -1969,35 +2286,90 @@ function BlockEditor({ let latestDelta: Point = [0, 0, 0] let lastSnapDelta: string | null = null let finished = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'translate')) playBlockSfx('drag-start') useViewer.getState().setInputDragging(true) - setActiveTransform({ operation: 'translate', constraint: axis }) + setActiveTransform({ operation: 'translate', constraint }) + setTransformNumericInput('0') + setModalFeedbackMode('free') document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { - const ray = makeRay(pointerEvent.clientX, pointerEvent.clientY) - const delta: Point = [0, 0, 0] - const parameter = closestAxisParameterToRay(worldOrigin, worldAxis, ray) - const worldPoint = worldOrigin - .clone() - .addScaledVector(worldAxis, parameter - initialParameter) - const localPoint = target.worldToLocal(worldPoint) - delta[axisIndex] = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + const ray = makeRay(effectivePointer.x, effectivePointer.y) + let delta: Point + if (dragPlane && initialPlaneHit) { + const currentHit = ray.intersectPlane(dragPlane, new Vector3()) + if (!currentHit) return + const localPoint = target.worldToLocal( + worldOrigin.clone().add(currentHit.sub(initialPlaneHit)), + ) + delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] + delta[axisIndex] = 0 + } else { + delta = [0, 0, 0] + const parameter = closestAxisParameterToRay(worldOrigin, worldAxis, ray) + const worldPoint = worldOrigin + .clone() + .addScaledVector(worldAxis, parameter - initialParameter) + const localPoint = target.worldToLocal(worldPoint) + delta[axisIndex] = + localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + } const snapping = isGridSnapActive() && !pointerEvent.altKey if (snapping) { - const step = useEditor.getState().gridSnapStep + const step = blockPrecisionSnapStep( + useEditor.getState().gridSnapStep, + pointerEvent.shiftKey, + ) if (step > 0) { - delta[axisIndex] = Math.round(delta[axisIndex] / step) * step + delta = delta.map((value) => Math.round(value / step) * step) as Point } } + const geometrySnap = + isMagneticSnapActive() && !pointerEvent.altKey + ? resolveBlockGeometrySnap( + baseTopology, + baseSelection, + delta, + constraint, + geometrySnapThreshold(camera, worldOrigin, target, gl.domElement, extent), + ) + : null + if (geometrySnap) delta.splice(0, 3, ...geometrySnap.delta) const snapDelta = delta.join(':') const magnitude = Math.hypot(...delta) - if (snapping && magnitude > 1e-6 && snapDelta !== lastSnapDelta) { - lastSnapDelta = snapDelta + setTransformNumericInput( + blockTransformDisplayValue( + 'translate', + isAxisConstraint(constraint) ? delta[axisIndex] : Math.hypot(...delta), + ), + ) + setModalFeedbackMode( + geometrySnap + ? 'geometry' + : pointerEvent.shiftKey + ? 'precision' + : snapping + ? 'grid' + : 'free', + ) + const activeSnap = geometrySnap + ? `${geometrySnap.kind}:${geometrySnap.targetId}` + : snapDelta + if ((snapping || geometrySnap) && magnitude > 1e-6 && activeSnap !== lastSnapDelta) { + lastSnapDelta = activeSnap playBlockSfx('move-step') - } else if (!snapping) { + } else if (!(snapping || geometrySnap)) { lastSnapDelta = null } const result = applyBlockCommand(baseTopology, { @@ -2030,8 +2402,14 @@ function BlockEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.hypot(...latestDelta) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { type: 'translate-components', selection: baseSelection, delta: latestDelta }, + 'Move', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -2049,7 +2427,19 @@ function BlockEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + [ + camera, + commitAdjustableOperation, + displayTopology, + extent, + gl.domElement, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + ], ) const beginRotationDrag = useCallback( @@ -2082,15 +2472,26 @@ function BlockEditor({ let lastSnapAngle: number | null = null let latestTopology: BlockTopology | null = null let finished = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'rotate')) playBlockSfx('drag-start') useViewer.getState().setInputDragging(true) setActiveTransform({ operation: 'rotate', constraint: axis }) + setTransformNumericInput('0') + setModalFeedbackMode('free') document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { - const hit = makeRay(pointerEvent.clientX, pointerEvent.clientY).intersectPlane( + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + const hit = makeRay(effectivePointer.x, effectivePointer.y).intersectPlane( rotationPlane, new Vector3(), ) @@ -2104,7 +2505,9 @@ function BlockEditor({ let angle = accumulatedAngle const snapping = !pointerEvent.altKey && isAngleSnapActive() if (snapping) { - const step = (ROTATION_SNAP_ANGLE_DEGREES * Math.PI) / 180 + const step = + (blockPrecisionSnapStep(ROTATION_SNAP_ANGLE_DEGREES, pointerEvent.shiftKey) * Math.PI) / + 180 angle = Math.round(angle / step) * step } if (snapping && Math.abs(angle) > 1e-6 && angle !== lastSnapAngle) { @@ -2113,6 +2516,8 @@ function BlockEditor({ } else if (!snapping) { lastSnapAngle = null } + setTransformNumericInput(blockTransformDisplayValue('rotate', angle)) + setModalFeedbackMode(pointerEvent.shiftKey ? 'precision' : snapping ? 'angle' : 'free') const result = applyBlockCommand(baseTopology, { type: 'rotate-components', selection: baseSelection, @@ -2145,8 +2550,20 @@ function BlockEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.abs(latestAngle) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { + type: 'rotate-components', + selection: baseSelection, + pivot: origin, + axis: AXIS_VECTORS[axis], + angle: latestAngle, + }, + 'Rotate', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -2164,7 +2581,16 @@ function BlockEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + [ + commitAdjustableOperation, + displayTopology, + makeRay, + node.id, + ownsEditSession, + selectedIds.length, + selection, + target, + ], ) const beginScaleDrag = useCallback( @@ -2190,18 +2616,29 @@ function BlockEditor({ let lastSnapFactor: number | null = null let latestTopology: BlockTopology | null = null let finished = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) playBlockSfx('drag-start') useViewer.getState().setInputDragging(true) setActiveTransform({ operation: 'scale', constraint: axis }) + setTransformNumericInput('1') + setModalFeedbackMode('free') document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } const parameter = closestAxisParameterToRay( worldOrigin, worldAxis, - makeRay(pointerEvent.clientX, pointerEvent.clientY), + makeRay(effectivePointer.x, effectivePointer.y), ) const worldPoint = worldOrigin .clone() @@ -2209,8 +2646,12 @@ function BlockEditor({ const localPoint = target.worldToLocal(worldPoint) const distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) const snapStep = - !pointerEvent.altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + !pointerEvent.altKey && isGridSnapActive() + ? blockPrecisionSnapStep(useEditor.getState().gridSnapStep, pointerEvent.shiftKey) + : 0 const factor = blockScaleFactorFromDrag(distance, gizmoLength, snapStep) + setTransformNumericInput(blockTransformDisplayValue('scale', factor)) + setModalFeedbackMode(pointerEvent.shiftKey ? 'precision' : snapStep > 0 ? 'grid' : 'free') if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { lastSnapFactor = factor playBlockSfx('resize-step') @@ -2249,8 +2690,19 @@ function BlockEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { + type: 'scale-components', + selection: baseSelection, + pivot: origin, + factors: blockScaleFactors(axis, latestFactor), + }, + 'Scale', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -2270,6 +2722,7 @@ function BlockEditor({ }, [ displayTopology, + commitAdjustableOperation, gizmoLength, makeRay, node.id, @@ -2299,13 +2752,34 @@ function BlockEditor({ let latestFactor = 1 let lastSnapFactor: number | null = null let latestTopology: BlockTopology | null = null + let activeConstraint: BlockTransformConstraint = 'uniform' + let typedInput = '' + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastShiftKey = false + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { ...effectivePointer } let finished = false - const updatePreview = (clientX: number, clientY: number, altKey: boolean) => { + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + lastShiftKey = precision const pointer = new Vector2(clientX, clientY) const distance = pointer.distanceTo(pivotClient) - initialDistance - const snapStep = !altKey && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 - const factor = blockScaleFactorFromDrag(distance, initialDistance, snapStep) + const numericValue = blockTransformNumericValue(typedInput, 'scale') + const snapStep = + numericValue === null && !altKey && isGridSnapActive() + ? blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) + : 0 + const factor = numericValue ?? blockScaleFactorFromDrag(distance, initialDistance, snapStep) if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { lastSnapFactor = factor playBlockSfx('resize-step') @@ -2316,13 +2790,17 @@ function BlockEditor({ type: 'scale-components', selection: baseSelection, pivot: origin, - factors: blockScaleFactors('uniform', factor), + factors: blockScaleFactorsForConstraint(activeConstraint, factor), }) if (!result.ok) { setError(result.error) return } latestFactor = factor + setTransformNumericInput(typedInput || blockTransformDisplayValue('scale', factor)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapStep > 0 ? 'grid' : 'free', + ) latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) @@ -2345,8 +2823,19 @@ function BlockEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) setActiveTransform(null) + setTransformNumericInput('') + setModalFeedbackMode('free') if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) + commitAdjustableOperation( + baseTopology, + { + type: 'scale-components', + selection: baseSelection, + pivot: origin, + factors: blockScaleFactorsForConstraint(activeConstraint, latestFactor), + }, + 'Scale', + ) playBlockSfx('finish') } else if (!commit) { playBlockSfx('cancel') @@ -2359,7 +2848,19 @@ function BlockEditor({ const onMove = (pointerEvent: PointerEvent) => { lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) - updatePreview(pointerEvent.clientX, pointerEvent.clientY, pointerEvent.altKey) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) } const onPointerDown = (pointerEvent: PointerEvent) => { pointerEvent.preventDefault() @@ -2384,7 +2885,23 @@ function BlockEditor({ element?.isContentEditable ) return - if (keyboardEvent.key === 'Enter') { + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + const constraint = blockTransformConstraintFromKey(keyboardEvent.key, keyboardEvent.shiftKey) + if (constraint) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + activeConstraint = constraint + setActiveTransform({ operation: 'scale', constraint }) + lastSnapFactor = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + lastSnapFactor = null + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { keyboardEvent.preventDefault() keyboardEvent.stopImmediatePropagation() finish(true) @@ -2406,6 +2923,8 @@ function BlockEditor({ setTransformTool('transform') setToolbarPanel(null) setActiveTransform({ operation: 'scale', constraint: 'uniform' }) + setTransformNumericInput('') + setModalFeedbackMode('free') setError(null) document.body.style.cursor = 'nwse-resize' cancelDragRef.current = onCancel @@ -2417,6 +2936,7 @@ function BlockEditor({ return true }, [ camera, + commitAdjustableOperation, displayTopology, gizmoLength, gl.domElement, @@ -2443,6 +2963,8 @@ function BlockEditor({ let latestTopology: BlockTopology | null = null let latestSelection: BlockSelection | null = null let finished = false + let effectivePointer = { x: startClientX, y: startClientY } + let previousRawPointer = { ...effectivePointer } useBlockEditSession.getState().setSelection(node.id, { mode: 'edge', @@ -2451,6 +2973,7 @@ function BlockEditor({ }) setToolbarPanel(null) setError(null) + setBevelWidth(0) useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'bevel')) playBlockSfx('operation-start') useViewer.getState().setInputDragging(true) @@ -2472,6 +2995,7 @@ function BlockEditor({ } activeSegments = segments latestWidth = width + setBevelWidth(width) latestTopology = result.topology latestSelection = result.selection setPreviewTopology(result.topology) @@ -2482,8 +3006,15 @@ function BlockEditor({ } const onMove = (pointerEvent: PointerEvent) => { - const deltaX = pointerEvent.clientX - startClientX - const deltaY = pointerEvent.clientY - startClientY + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + const deltaX = effectivePointer.x - startClientX + const deltaY = effectivePointer.y - startClientY if (Math.hypot(deltaX, deltaY) < 2) return const width = blockBevelWidthFromDrag(deltaX, deltaY, extent, viewportHeight) const widthStep = Math.floor(width / Math.max(0.01, extent * 0.025)) @@ -2520,11 +3051,18 @@ function BlockEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) if (commit && latestTopology && latestSelection && latestWidth > 1e-6) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) - useBlockEditSession.getState().setSelection(node.id, { - ...latestSelection, - activeId: latestSelection.ids.at(-1) ?? null, - }) + commitAdjustableOperation( + baseTopology, + { + type: 'bevel-edge', + edgeId, + width: latestWidth, + segments: activeSegments, + profile: 0.5, + clampOverlap: true, + }, + 'Bevel', + ) playBlockSfx('operation-commit') } else if (!commit) { playBlockSfx('cancel') @@ -2543,7 +3081,15 @@ function BlockEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [bevelSegments, displayTopology, extent, gl.domElement, node.id, ownsEditSession], + [ + bevelSegments, + commitAdjustableOperation, + displayTopology, + extent, + gl.domElement, + node.id, + ownsEditSession, + ], ) const previewLoopCut = useCallback((edgeId: string | null) => { @@ -2632,6 +3178,8 @@ function BlockEditor({ let lastSnapFactor: number | null = null let finished = false let confirmationAttached = false + let effectivePointer = { x: event.nativeEvent.clientX, y: event.nativeEvent.clientY } + let previousRawPointer = { ...effectivePointer } const updatePreview = (factor: number) => { const effectiveFactor = resolveLoopCutSlideFactor(activeCuts, factor) @@ -2667,10 +3215,17 @@ function BlockEditor({ const onMove = (pointerEvent: PointerEvent) => { if (activeCuts > 1) return + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } const parameter = closestAxisParameterToRay( worldStart, worldAxis, - makeRay(pointerEvent.clientX, pointerEvent.clientY), + makeRay(effectivePointer.x, effectivePointer.y), ) let factor = Math.min( 0.98, @@ -2678,7 +3233,10 @@ function BlockEditor({ ) const snapping = isGridSnapActive() && !pointerEvent.altKey if (snapping) { - const step = useEditor.getState().gridSnapStep + const step = blockPrecisionSnapStep( + useEditor.getState().gridSnapStep, + pointerEvent.shiftKey, + ) if (step > 0) factor = Math.min( 0.98, @@ -2713,11 +3271,16 @@ function BlockEditor({ setLoopCutEdgeId(null) setLoopCutSliding(false) if (outcome !== 'cancel' && latestTopology && latestSelection && latestFactor > 0) { - useScene.getState().updateNode(node.id, { topology: latestTopology }) - useBlockEditSession.getState().setSelection(node.id, { - ...latestSelection, - activeId: latestSelection.ids.at(-1) ?? null, - }) + commitAdjustableOperation( + baseTopology, + { + type: 'loop-cut', + edgeId, + factor: latestFactor, + cuts: activeCuts, + }, + 'Loop Cut', + ) playBlockSfx('operation-commit') } else if (outcome === 'cancel') { playBlockSfx('cancel') @@ -2750,7 +3313,233 @@ function BlockEditor({ window.addEventListener('pointerdown', onConfirm, true) }) }, - [loopCutCount, makeRay, node.id, node.topology, ownsEditSession, target], + [ + commitAdjustableOperation, + loopCutCount, + makeRay, + node.id, + node.topology, + ownsEditSession, + target, + ], + ) + + const beginFaceOperationModal = useCallback( + (operation: BlockModalFaceOperation) => { + if ( + !ownsEditSession() || + mode !== 'face' || + selectedIds.length !== 1 || + cancelDragRef.current + ) + return false + const faceId = selectedIds[0]! + if (!displayTopology.faces.some((face) => face.id === faceId)) return false + const origin = selectionCentroid(displayTopology, selection) + if (!origin) return false + const pivotClient = localPointToClient(origin, target, camera, gl.domElement) + if (!pivotClient) return false + + const startPointer = + lastPointerClientRef.current?.clone() ?? pivotClient.clone().add(new Vector2(80, 0)) + const baseTopology = displayTopology + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let latestTopology: BlockTopology | null = null + let latestSelection: BlockSelection | null = null + let latestValue = 0 + let typedInput = '' + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastShiftKey = false + let lastSnapValue: number | null = null + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { ...effectivePointer } + let finished = false + + const displayValue = (value: number) => String(Math.round(value * 1000) / 1000) + + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + lastShiftKey = precision + const typedValue = blockTransformNumericValue( + typedInput, + operation === 'extrude' ? 'translate' : 'scale', + ) + let value = + typedValue ?? + blockFaceOperationValueFromPointer( + operation, + clientX - startPointer.x, + clientY - startPointer.y, + extent, + ) + const snapping = + operation === 'extrude' && typedValue === null && isGridSnapActive() && !altKey + if (snapping) { + const step = blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) + if (step > 0) value = Math.round(value / step) * step + } + setFaceOperationValue(typedInput || displayValue(value)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapping ? 'grid' : 'free', + ) + if (Math.abs(value) <= 1e-6) { + latestTopology = null + latestSelection = null + latestValue = 0 + setPreviewTopology(null) + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + return + } + if (snapping && value !== lastSnapValue) { + lastSnapValue = value + playBlockSfx('move-step') + } else if (!snapping) { + lastSnapValue = null + } + const result = applyBlockCommand( + baseTopology, + blockFaceOperationCommand(operation, faceId, value), + ) + if (!result.ok) { + setError(result.error) + return + } + latestTopology = result.topology + latestSelection = result.selection + latestValue = value + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('contextmenu', onContextMenu, true) + window.removeEventListener('blur', onCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setActiveFaceOperation(null) + setFaceOperationValue('') + setTransformNumericInput('') + setModalFeedbackMode('free') + if (commit && latestTopology && latestSelection && Math.abs(latestValue) > 1e-6) { + commitAdjustableOperation( + baseTopology, + blockFaceOperationCommand(operation, faceId, latestValue), + operation === 'extrude' ? 'Extrude' : 'Inset', + ) + playBlockSfx('operation-commit') + } else if (!commit) { + playBlockSfx('cancel') + } + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + swallowNextClick() + } + + const onMove = (pointerEvent: PointerEvent) => { + lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) + } + const onPointerDown = (pointerEvent: PointerEvent) => { + if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(pointerEvent.button === 0) + } + const onKeyDown = (keyboardEvent: KeyboardEvent) => { + const element = keyboardEvent.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) + return + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(true) + } else if (keyboardEvent.key === 'Escape') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(false) + } + } + const onContextMenu = (event: Event) => { + event.preventDefault() + event.stopImmediatePropagation() + } + const onCancel = () => finish(false) + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operation)) + playBlockSfx('operation-start') + useViewer.getState().setInputDragging(true) + setToolbarPanel(null) + setActiveFaceOperation(operation) + setFaceOperationValue('0') + setTransformNumericInput('') + setModalFeedbackMode('free') + setError(null) + document.body.style.cursor = operation === 'extrude' ? 'ns-resize' : 'nwse-resize' + cancelDragRef.current = onCancel + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('contextmenu', onContextMenu, true) + window.addEventListener('blur', onCancel, { once: true }) + return true + }, + [ + camera, + commitAdjustableOperation, + displayTopology, + extent, + gl.domElement, + mode, + node.id, + ownsEditSession, + selectedIds, + selection, + target, + ], ) const commitCommand = (command: BlockCommand, operator: TopologyOperator) => { @@ -2763,36 +3552,22 @@ function BlockEditor({ return } useScene.getState().updateNode(node.id, { topology: result.topology }) - useBlockEditSession.getState().setSelection(node.id, { + const session = useBlockEditSession.getState() + session.setSelection(node.id, { ...result.selection, activeId: result.selection.ids.at(-1) ?? null, }) + session.setLastOperation(node.id, null) + setLastOperationPanelOpen(false) setToolbarPanel(null) setError(null) if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) playBlockSfx(operator === 'delete' ? 'delete' : 'operation-commit') } - const extrudeSelectedFace = () => { - if (mode !== 'face' || selectedIds.length !== 1) return - commitCommand( - { type: 'extrude-face', faceId: selectedIds[0]!, distance: Number(extrudeDistance) }, - 'extrude', - ) - } + const extrudeSelectedFace = () => beginFaceOperationModal('extrude') - const insetSelectedFace = () => { - if (mode !== 'face' || selectedIds.length !== 1) return - commitCommand( - { - type: 'inset-face', - faceId: selectedIds[0]!, - amount: Number(insetAmount), - depth: 0, - }, - 'inset', - ) - } + const insetSelectedFace = () => beginFaceOperationModal('inset') const deleteSelection = () => { if (selectedIds.length === 0) return @@ -2809,6 +3584,46 @@ function BlockEditor({ commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') } + const adjustLastOperation = (command: BlockCommand) => { + if (!lastOperation || cancelDragRef.current) return + const replacement = replaceCommittedBlockOperation(lastOperation, command) + if (!replacement.ok) { + setError(replacement.error) + setLastOperationPanelOpen(false) + return + } + const session = useBlockEditSession.getState() + session.setLastOperation(node.id, replacement.operation) + session.setSelection(node.id, { + ...replacement.operation.resultSelection, + activeId: replacement.operation.resultSelection.ids.at(-1) ?? null, + }) + setError(null) + playBlockSfx('resize-step') + } + + const repeatLastOperation = () => { + if (!lastOperation || cancelDragRef.current) return + const repeated = repeatCommittedBlockOperation(lastOperation, { + mode, + ids: selectedIds, + activeId, + }) + if (!repeated.ok) { + setError(repeated.error) + return + } + const session = useBlockEditSession.getState() + session.setLastOperation(node.id, repeated.operation) + session.setSelection(node.id, { + ...repeated.operation.resultSelection, + activeId: repeated.operation.resultSelection.ids.at(-1) ?? null, + }) + setLastOperationPanelOpen(true) + setError(null) + playBlockSfx('operation-commit') + } + const updateSelection = (next: BlockSelectionState) => { if (!blockSelectionChanged({ mode, ids: selectedIds, activeId }, next)) return useBlockEditSession.getState().setSelection(node.id, next) @@ -2832,10 +3647,13 @@ function BlockEditor({ dissolveSelection, extrudeSelectedFace, hasSelection: selectedIds.length > 0, + hasLastOperation: Boolean(lastOperation), insetSelectedFace, invertSelection, mergeSelection, selectAll, + repeatLastOperation, + showLastOperation: () => setLastOperationPanelOpen(true), }) keyboardActionsRef.current = { beginKeyboardTransformModal, @@ -2846,10 +3664,13 @@ function BlockEditor({ dissolveSelection, extrudeSelectedFace, hasSelection: selectedIds.length > 0, + hasLastOperation: Boolean(lastOperation), insetSelectedFace, invertSelection, mergeSelection, selectAll, + repeatLastOperation, + showLastOperation: () => setLastOperationPanelOpen(true), } useEffect(() => { @@ -2866,7 +3687,10 @@ function BlockEditor({ const key = event.key.toLowerCase() const actions = keyboardActionsRef.current let handled = true - if (key === 'b' && (event.ctrlKey || event.metaKey)) { + if (event.key === 'F9') { + if (actions.hasLastOperation) actions.showLastOperation() + else handled = false + } else if (key === 'b' && (event.ctrlKey || event.metaKey)) { if (actions.canBevel) { playBlockSfx('tool-select') setBevelSegments(DEFAULT_BEVEL_SEGMENTS) @@ -2884,6 +3708,9 @@ function BlockEditor({ actions.extrudeSelectedFace() } else if (key === 'i') { actions.insetSelectedFace() + } else if (key === 'r' && event.shiftKey) { + if (actions.hasLastOperation) actions.repeatLastOperation() + else handled = false } else if (key === 'r') { if (event.ctrlKey || event.metaKey) { playBlockSfx('tool-select') @@ -2933,9 +3760,14 @@ function BlockEditor({ const operationAvailability = blockOperationAvailability(mode, selectedIds.length) const loopCutActive = transformTool === 'loop-cut' const bevelActive = transformTool === 'bevel' - const componentStatus = - keyboardTransformActive && activeTransform - ? blockModalTransformStatus(activeTransform) + const componentStatus = activeFaceOperation + ? blockModalFaceOperationStatus( + activeFaceOperation, + faceOperationValue || '0', + modalFeedbackMode, + ) + : activeTransform + ? blockModalTransformStatus(activeTransform, transformNumericInput, modalFeedbackMode) : blockComponentStatus({ mode, selectedCount: selectedIds.length, @@ -2943,6 +3775,7 @@ function BlockEditor({ loopCutCount, loopCutFactor, bevelSegments, + bevelWidth, }) return ( @@ -3035,13 +3868,7 @@ function BlockEditor({ onPointerDown={beginTranslationDrag} plane={plane} size={planeHandleSize} - state={ - !activeTransform || - (activeTransform.operation === 'translate' && - activeTransform.constraint === 'free') - ? 'normal' - : 'faded' - } + state={blockPlaneVisualState(activeTransform, plane)} /> ))} {(['x', 'y', 'z'] as const).map((axis) => ( @@ -3172,22 +3999,6 @@ function BlockEditor({
setExtrudeDistance(event.target.value)} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - extrudeSelectedFace() - }} - step="0.05" - type="number" - value={extrudeDistance} - /> - } disabled={!operationAvailability.extrude} label="Extrude face" onClick={extrudeSelectedFace} @@ -3196,24 +4007,6 @@ function BlockEditor({ setInsetAmount(event.target.value)} - onKeyDown={(event) => { - if (event.key !== 'Enter') return - event.preventDefault() - insetSelectedFace() - }} - step="0.05" - type="number" - value={insetAmount} - /> - } disabled={!operationAvailability.inset} label="Inset face" onClick={insetSelectedFace} @@ -3284,6 +4077,50 @@ function BlockEditor({ ) : null}
+ {lastOperation ? ( +
+ setLastOperationPanelOpen((open) => !open)} + > + + + {lastOperationPanelOpen ? ( + +
+
+
{lastOperation.label}
+
+ Adjust Last Operation · F9 +
+
+ +
+ + +
+ ) : null} +
+ ) : null} + diff --git a/packages/nodes/src/block/toolbar-state.test.ts b/packages/nodes/src/block/toolbar-state.test.ts index 0d4e79e679..14dde23260 100644 --- a/packages/nodes/src/block/toolbar-state.test.ts +++ b/packages/nodes/src/block/toolbar-state.test.ts @@ -47,10 +47,25 @@ describe('block toolbar state', () => { loopCutCount: 1, loopCutFactor: 0.5, bevelSegments: 6, + bevelWidth: 0, }), ).toBeNull() }) + test('shows live bevel width and segment count', () => { + expect( + blockComponentStatus({ + mode: 'edge', + selectedCount: 1, + tool: 'bevel', + loopCutCount: 1, + loopCutFactor: 0.5, + bevelSegments: 6, + bevelWidth: 0.2, + }), + ).toBe('Bevel · width 0.2 m · 6 segments · drag changes width · wheel changes segments') + }) + test('builds uniform and axis-specific scale factors', () => { expect(blockScaleFactors('uniform', 1.5)).toEqual([1.5, 1.5, 1.5]) expect(blockScaleFactors('x', 1.5)).toEqual([1.5, 1, 1]) diff --git a/packages/nodes/src/block/toolbar-state.ts b/packages/nodes/src/block/toolbar-state.ts index c5737399d4..23a0e60b76 100644 --- a/packages/nodes/src/block/toolbar-state.ts +++ b/packages/nodes/src/block/toolbar-state.ts @@ -51,6 +51,7 @@ export function blockComponentStatus({ loopCutCount, loopCutFactor, bevelSegments, + bevelWidth, }: { mode: BlockToolbarMode selectedCount: number @@ -58,12 +59,14 @@ export function blockComponentStatus({ loopCutCount: number loopCutFactor: number bevelSegments: number + bevelWidth: number }): string | null { if (tool === 'loop-cut') { return `Loop Cut · ${loopCutCount} cut${loopCutCount === 1 ? '' : 's'} · factor ${loopCutFactor.toFixed(2)} · click or drag an edge · release applies · wheel changes count` } if (tool === 'bevel') { - return `Bevel · drag an edge to peel it · wheel changes segments (${bevelSegments}) · release to apply` + const width = String(Math.round(bevelWidth * 1000) / 1000) + return `Bevel · width ${width} m · ${bevelSegments} segments · drag changes width · wheel changes segments` } return selectedCount === 0 ? `Click a ${mode} to select it` : null } From 39f9e2e87a15761c9fb81ba57f044d994c81af2c Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 13:07:31 +0530 Subject: [PATCH 06/12] feat(block): support multi-component mesh operations --- packages/nodes/src/block/commands.test.ts | 200 +++++++++-- packages/nodes/src/block/commands.ts | 323 ++++++++++++++---- packages/nodes/src/block/geometry.test.ts | 8 +- .../nodes/src/block/last-operation.test.ts | 4 +- packages/nodes/src/block/last-operation.ts | 17 +- .../src/block/modal-face-operation.test.ts | 12 +- .../nodes/src/block/modal-face-operation.ts | 6 +- packages/nodes/src/block/selection.tsx | 46 +-- .../nodes/src/block/toolbar-state.test.ts | 6 +- packages/nodes/src/block/toolbar-state.ts | 6 +- 10 files changed, 493 insertions(+), 135 deletions(-) diff --git a/packages/nodes/src/block/commands.test.ts b/packages/nodes/src/block/commands.test.ts index 788f9a0635..034aa11f75 100644 --- a/packages/nodes/src/block/commands.test.ts +++ b/packages/nodes/src/block/commands.test.ts @@ -6,8 +6,8 @@ describe('applyBlockCommand', () => { test('extrudes a face while retaining valid stable topology', () => { const topology = createBoxBlockTopology() const result = applyBlockCommand(topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) @@ -28,16 +28,16 @@ describe('applyBlockCommand', () => { test('can extrude the resulting cap again without colliding IDs', () => { const first = applyBlockCommand(createBoxBlockTopology(), { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) expect(first.ok).toBe(true) if (!first.ok) return const second = applyBlockCommand(first.topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) expect(second.ok).toBe(true) @@ -61,8 +61,8 @@ describe('applyBlockCommand', () => { ) const originalFaceIds = new Set(topology.faces.map((face) => face.id)) const result = applyBlockCommand(topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) @@ -75,12 +75,37 @@ describe('applyBlockCommand', () => { expect(inheritedFaces.every((face) => face.materialSlot === 'accent')).toBe(true) }) + test('extrudes a connected face region without walls along its internal edges', () => { + const base = createBoxBlockTopology() + const first = applyBlockCommand(base, { + type: 'extrude-faces', + faceIds: ['f-top'], + distance: 0.25, + }) + expect(first.ok).toBe(true) + if (!first.ok) return + const originalFaceIds = new Set(base.faces.map((face) => face.id)) + const sideFace = first.topology.faces.find((face) => !originalFaceIds.has(face.id))! + + const result = applyBlockCommand(first.topology, { + type: 'extrude-faces', + faceIds: ['f-top', sideFace.id], + distance: 0.25, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces).toHaveLength(16) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top', sideFace.id] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('reports an invalid face selection without changing topology', () => { const topology = createBoxBlockTopology() expect( applyBlockCommand(topology, { - type: 'extrude-face', - faceId: 'missing', + type: 'extrude-faces', + faceIds: ['missing'], distance: 0.25, }), ).toEqual({ ok: false, error: 'Face not found: missing' }) @@ -156,8 +181,8 @@ describe('applyBlockCommand', () => { test('insets a face into a valid inner face and surrounding ring', () => { const result = applyBlockCommand(createBoxBlockTopology(), { - type: 'inset-face', - faceId: 'f-top', + type: 'inset-faces', + faceIds: ['f-top'], amount: 0.2, depth: 0, }) @@ -177,8 +202,8 @@ describe('applyBlockCommand', () => { ) const originalFaceIds = new Set(topology.faces.map((face) => face.id)) const result = applyBlockCommand(topology, { - type: 'inset-face', - faceId: 'f-top', + type: 'inset-faces', + faceIds: ['f-top'], amount: 0.2, depth: 0, }) @@ -192,6 +217,23 @@ describe('applyBlockCommand', () => { expect(inheritedFaces.every((face) => face.materialSlot === 'accent')).toBe(true) }) + test('insets multiple selected faces in one command and keeps every new cap selected', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'inset-faces', + faceIds: ['f-top', 'f-bottom'], + amount: 0.2, + depth: 0, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.vertices).toHaveLength(16) + expect(result.topology.edges).toHaveLength(28) + expect(result.topology.faces).toHaveLength(14) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top', 'f-bottom'] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('deletes selected faces, edges, or vertices without invalid references', () => { for (const selection of [ { mode: 'face' as const, ids: ['f-top'] }, @@ -209,6 +251,33 @@ describe('applyBlockCommand', () => { } }) + test('deletes multiple components according to the active component mode', () => { + for (const selection of [ + { mode: 'face' as const, ids: ['f-top', 'f-bottom'] }, + { mode: 'edge' as const, ids: ['e0', 'e6'] }, + { mode: 'vertex' as const, ids: ['v0', 'v6'] }, + ]) { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'delete-components', + selection, + }) + + expect(result.ok).toBe(true) + if (!result.ok) continue + expect(result.selection).toEqual({ mode: selection.mode, ids: [] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + if (selection.mode === 'face') { + expect(result.topology.faces.some((face) => selection.ids.includes(face.id))).toBe(false) + } else if (selection.mode === 'edge') { + expect(result.topology.edges.some((edge) => selection.ids.includes(edge.id))).toBe(false) + } else { + expect(result.topology.vertices.some((vertex) => selection.ids.includes(vertex.id))).toBe( + false, + ) + } + } + }) + test('merges selected vertices at their center and collapses duplicate boundaries', () => { const result = applyBlockCommand(createBoxBlockTopology(), { type: 'merge-vertices', @@ -219,17 +288,32 @@ describe('applyBlockCommand', () => { if (!result.ok) return expect(result.topology.vertices).toHaveLength(7) expect(result.topology.edges).toHaveLength(11) - expect(result.selection).toEqual({ mode: 'vertex', ids: ['v4'] }) - expect(result.topology.vertices.find((vertex) => vertex.id === 'v4')?.position).toEqual([ + expect(result.selection).toEqual({ mode: 'vertex', ids: ['v5'] }) + expect(result.topology.vertices.find((vertex) => vertex.id === 'v5')?.position).toEqual([ 0, 2.4, -1, ]) expect(inspectBlockTopology(result.topology)).toEqual([]) }) + test('merges multiple vertices while retaining the last-selected active vertex ID', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'merge-vertices', + vertexIds: ['v4', 'v5', 'v6'], + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.selection).toEqual({ mode: 'vertex', ids: ['v6'] }) + expect(result.topology.vertices.some((vertex) => vertex.id === 'v6')).toBe(true) + expect(result.topology.vertices.some((vertex) => vertex.id === 'v4')).toBe(false) + expect(result.topology.vertices.some((vertex) => vertex.id === 'v5')).toBe(false) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('dissolves a shared edge into one valid face loop', () => { const result = applyBlockCommand(createBoxBlockTopology(), { - type: 'dissolve-edge', - edgeId: 'e4', + type: 'dissolve-edges', + edgeIds: ['e4'], }) expect(result.ok).toBe(true) @@ -258,8 +342,8 @@ describe('applyBlockCommand', () => { : face, ) const result = applyBlockCommand(topology, { - type: 'dissolve-edge', - edgeId: 'e4', + type: 'dissolve-edges', + edgeIds: ['e4'], }) expect(result.ok).toBe(true) @@ -268,6 +352,34 @@ describe('applyBlockCommand', () => { expect(result.topology.faces.some((face) => face.id === 'f-front')).toBe(false) }) + test('dissolves multiple selected edges in one valid transaction', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'dissolve-edges', + edgeIds: ['e4', 'e6'], + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.edges).toHaveLength(10) + expect(result.topology.faces).toHaveLength(4) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + + test('dissolves the internal boundaries of a selected face region', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'dissolve-faces', + faceIds: ['f-top', 'f-front', 'f-back'], + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.edges).toHaveLength(10) + expect(result.topology.faces).toHaveLength(4) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + test('cuts a connected quad ring and selects the inserted loop', () => { const result = applyBlockCommand(createBoxBlockTopology(), { type: 'loop-cut', @@ -326,8 +438,8 @@ describe('applyBlockCommand', () => { test('stops a loop cut cleanly before a non-quad face', () => { const dissolved = applyBlockCommand(createBoxBlockTopology(), { - type: 'dissolve-edge', - edgeId: 'e4', + type: 'dissolve-edges', + edgeIds: ['e4'], }) expect(dissolved.ok).toBe(true) if (!dissolved.ok) return @@ -381,8 +493,8 @@ describe('applyBlockCommand', () => { test('bevels a manifold box edge with width, segments, profile, and overlap clamping', () => { const result = applyBlockCommand(createBoxBlockTopology(), { - type: 'bevel-edge', - edgeId: 'e0', + type: 'bevel-edges', + edgeIds: ['e0'], width: 0.2, segments: 3, profile: 0.5, @@ -416,8 +528,8 @@ describe('applyBlockCommand', () => { ) const originalFaceIds = new Set(topology.faces.map((face) => face.id)) const result = applyBlockCommand(topology, { - type: 'bevel-edge', - edgeId: 'e0', + type: 'bevel-edges', + edgeIds: ['e0'], width: 0.2, segments: 3, profile: 0.5, @@ -431,4 +543,40 @@ describe('applyBlockCommand', () => { expect(bevelBands.every((face) => face.materialSlot === 'bottom')).toBe(true) expect(result.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe('front') }) + + test('bevels multiple independent selected edges in one command', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'bevel-edges', + edgeIds: ['e0', 'e6'], + width: 0.2, + segments: 3, + profile: 0.5, + clampOverlap: true, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces).toHaveLength(12) + expect(result.selection.mode).toBe('edge') + expect(result.selection.ids).toHaveLength(8) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) + + test('bevels adjacent selected edges after remapping their changed corner endpoints', () => { + const result = applyBlockCommand(createBoxBlockTopology(), { + type: 'bevel-edges', + edgeIds: ['e0', 'e1'], + width: 0.2, + segments: 3, + profile: 0.5, + clampOverlap: true, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces).toHaveLength(12) + expect(result.selection.mode).toBe('edge') + expect(result.selection.ids.length).toBeGreaterThan(0) + expect(inspectBlockTopology(result.topology)).toEqual([]) + }) }) diff --git a/packages/nodes/src/block/commands.ts b/packages/nodes/src/block/commands.ts index de0e13a252..26fda9f8ae 100644 --- a/packages/nodes/src/block/commands.ts +++ b/packages/nodes/src/block/commands.ts @@ -16,8 +16,8 @@ type Point = [number, number, number] export type BlockCommand = | { - type: 'extrude-face' - faceId: string + type: 'extrude-faces' + faceIds: string[] distance: number } | { @@ -39,8 +39,8 @@ export type BlockCommand = factors: Point } | { - type: 'inset-face' - faceId: string + type: 'inset-faces' + faceIds: string[] amount: number depth: number } @@ -53,8 +53,12 @@ export type BlockCommand = vertexIds: string[] } | { - type: 'dissolve-edge' - edgeId: string + type: 'dissolve-edges' + edgeIds: string[] + } + | { + type: 'dissolve-faces' + faceIds: string[] } | { type: 'loop-cut' @@ -63,8 +67,8 @@ export type BlockCommand = cuts?: number } | { - type: 'bevel-edge' - edgeId: string + type: 'bevel-edges' + edgeIds: string[] width: number segments: number profile: number @@ -503,12 +507,18 @@ function rebuildEdgesFromFaces( return edges } -function bevelEdge( +type BevelParameters = Pick< + Extract, + 'width' | 'segments' | 'profile' | 'clampOverlap' +> + +function bevelOneEdge( topology: BlockTopology, - command: Extract, + edgeId: string, + command: BevelParameters, ): BlockCommandResult { - const edge = topology.edges.find((entry) => entry.id === command.edgeId) - if (!edge) return { ok: false, error: `Edge not found: ${command.edgeId}` } + const edge = topology.edges.find((entry) => entry.id === edgeId) + if (!edge) return { ok: false, error: `Edge not found: ${edgeId}` } const segments = Math.floor(command.segments) if (!Number.isFinite(command.width) || command.width <= 0) return { ok: false, error: 'Bevel width must be positive' } @@ -708,21 +718,115 @@ function bevelEdge( } } -function extrudeFace( +function bevelEdges( topology: BlockTopology, - command: Extract, + command: Extract, ): BlockCommandResult { - const faceIndex = topology.faces.findIndex((face) => face.id === command.faceId) - const face = topology.faces[faceIndex] - if (!face) return { ok: false, error: `Face not found: ${command.faceId}` } + const edgeIds = [...new Set(command.edgeIds)] + if (edgeIds.length === 0) return { ok: false, error: 'Select an edge to bevel' } + const selectedEdges = edgeIds.map((id) => topology.edges.find((edge) => edge.id === id)) + const missingIndex = selectedEdges.findIndex((edge) => !edge) + if (missingIndex >= 0) return { ok: false, error: `Edge not found: ${edgeIds[missingIndex]}` } + const originalVertexById = new Map( + topology.vertices.map((vertex) => [vertex.id, vertex.position]), + ) + const originalSegments = new Map( + selectedEdges.map((edge) => [ + edge!.id, + [ + originalVertexById.get(edge!.vertexIds[0])!, + originalVertexById.get(edge!.vertexIds[1])!, + ] as [Point, Point], + ]), + ) + + let current = topology + const selectedResultIds: string[] = [] + for (const edgeId of edgeIds) { + const originalSegment = originalSegments.get(edgeId)! + const vertexById = new Map(current.vertices.map((vertex) => [vertex.id, vertex.position])) + const distance = (left: Point, right: Point) => + Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]) + const remappedEdge = + current.edges.find((edge) => edge.id === edgeId) ?? + current.edges.reduce<{ edge: BlockEdge; score: number } | null>((best, edge) => { + const start = vertexById.get(edge.vertexIds[0])! + const end = vertexById.get(edge.vertexIds[1])! + const score = Math.min( + distance(start, originalSegment[0]) + distance(end, originalSegment[1]), + distance(start, originalSegment[1]) + distance(end, originalSegment[0]), + ) + return !best || score < best.score ? { edge, score } : best + }, null)?.edge + if (!remappedEdge) return { ok: false, error: `Could not remap bevel edge: ${edgeId}` } + const result = bevelOneEdge(current, remappedEdge.id, command) + if (!result.ok) return result + current = result.topology + selectedResultIds.push(...result.selection.ids) + } + const survivingIds = new Set(current.edges.map((edge) => edge.id)) + return { + ok: true, + topology: current, + selection: { + mode: 'edge', + ids: [...new Set(selectedResultIds)].filter((id) => survivingIds.has(id)), + }, + } +} + +function extrudeFaces( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const selectedFaceIds = new Set(command.faceIds) + const selectedFaces = topology.faces.filter((face) => selectedFaceIds.has(face.id)) + if (selectedFaces.length !== selectedFaceIds.size || selectedFaces.length === 0) { + const missing = command.faceIds.find((id) => !topology.faces.some((face) => face.id === id)) + return { ok: false, error: missing ? `Face not found: ${missing}` : 'Select a face to extrude' } + } if (!Number.isFinite(command.distance) || Math.abs(command.distance) < 1e-6) { return { ok: false, error: 'Extrude distance must be a non-zero finite number', } } - const normal = blockFaceNormal(topology, face) - if (!normal) return { ok: false, error: `Face has no usable normal: ${face.id}` } + const edgeKeysByFace = new Map( + selectedFaces.map((face) => [ + face.id, + face.vertexIds.map((id, index) => + blockUndirectedEdgeKey(id, face.vertexIds[(index + 1) % face.vertexIds.length]!), + ), + ]), + ) + const connected = new Set([selectedFaces[0]!.id]) + const queue = [selectedFaces[0]!.id] + while (queue.length > 0) { + const faceId = queue.shift()! + const keys = new Set(edgeKeysByFace.get(faceId)) + for (const candidate of selectedFaces) { + if (connected.has(candidate.id)) continue + if (edgeKeysByFace.get(candidate.id)!.some((key) => keys.has(key))) { + connected.add(candidate.id) + queue.push(candidate.id) + } + } + } + if (connected.size !== selectedFaces.length) { + return { ok: false, error: 'Extrude Region requires connected faces' } + } + const normals = selectedFaces.map((face) => blockFaceNormal(topology, face)) + if (normals.some((normal) => !normal)) { + const invalidFace = selectedFaces[normals.findIndex((normal) => !normal)]! + return { ok: false, error: `Face has no usable normal: ${invalidFace.id}` } + } + const normal = normalize( + normals.reduce( + (sum, value) => [sum[0] + value![0], sum[1] + value![1], sum[2] + value![2]], + [0, 0, 0], + ), + ) + if (!normal) return { ok: false, error: 'Selected face normals cancel each other out' } const verticesById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) const allocateVertexId = nextNumericId( @@ -740,12 +844,13 @@ function extrudeFace( const duplicateIds = new Map() const newVertices: BlockVertex[] = [] - for (const vertexId of face.vertexIds) { + const selectedVertexIds = new Set(selectedFaces.flatMap((face) => face.vertexIds)) + for (const vertexId of selectedVertexIds) { const vertex = verticesById.get(vertexId) if (!vertex) return { ok: false, - error: `Face references missing vertex: ${vertexId}`, + error: `Selected face references missing vertex: ${vertexId}`, } const id = allocateVertexId() duplicateIds.set(vertexId, id) @@ -759,36 +864,52 @@ function extrudeFace( }) } - const capVertexIds = face.vertexIds.map((vertexId) => duplicateIds.get(vertexId)!) - const newEdges: BlockEdge[] = [] + const boundaryByKey = new Map< + string, + { count: number; a: string; b: string; source: BlockFace } + >() + for (const face of selectedFaces) { + for (let index = 0; index < face.vertexIds.length; index += 1) { + const a = face.vertexIds[index]! + const b = face.vertexIds[(index + 1) % face.vertexIds.length]! + const key = blockUndirectedEdgeKey(a, b) + const boundary = boundaryByKey.get(key) + if (boundary) boundary.count += 1 + else boundaryByKey.set(key, { count: 1, a, b, source: face }) + } + } const sideFaces: BlockFace[] = [] - for (let index = 0; index < face.vertexIds.length; index += 1) { - const a = face.vertexIds[index]! - const b = face.vertexIds[(index + 1) % face.vertexIds.length]! + for (const boundary of boundaryByKey.values()) { + if (boundary.count !== 1) continue + const { a, b, source } = boundary const newA = duplicateIds.get(a)! const newB = duplicateIds.get(b)! - newEdges.push({ id: allocateEdgeId(), vertexIds: [newA, newB] }) - newEdges.push({ id: allocateEdgeId(), vertexIds: [a, newA] }) sideFaces.push({ id: allocateFaceId(), vertexIds: [a, b, newB, newA], - materialSlot: face.materialSlot, + materialSlot: source.materialSlot, }) } - const faces = topology.faces.slice() - faces[faceIndex] = { ...face, vertexIds: capVertexIds } + const faces = [ + ...topology.faces.map((face) => + selectedFaceIds.has(face.id) + ? { ...face, vertexIds: face.vertexIds.map((id) => duplicateIds.get(id)!) } + : face, + ), + ...sideFaces, + ] const nextTopology: BlockTopology = { vertices: [...topology.vertices, ...newVertices], - edges: [...topology.edges, ...newEdges], - faces: [...faces, ...sideFaces], + edges: rebuildEdgesFromFaces(topology, faces, allocateEdgeId), + faces, } const issues = inspectBlockTopology(nextTopology) if (issues.length > 0) return { ok: false, error: issues[0]!.message } return { ok: true, topology: nextTopology, - selection: { mode: 'face', ids: [face.id] }, + selection: { mode: 'face', ids: selectedFaces.map((face) => face.id) }, } } @@ -922,20 +1043,22 @@ function scaleComponents( ]) } -function insetFace( +function insetOneFace( topology: BlockTopology, - command: Extract, + faceId: string, + amount: number, + depth: number, ): BlockCommandResult { - const faceIndex = topology.faces.findIndex((face) => face.id === command.faceId) + const faceIndex = topology.faces.findIndex((face) => face.id === faceId) const face = topology.faces[faceIndex] - if (!face) return { ok: false, error: `Face not found: ${command.faceId}` } - if (!Number.isFinite(command.amount) || command.amount <= 0 || command.amount >= 1) { + if (!face) return { ok: false, error: `Face not found: ${faceId}` } + if (!Number.isFinite(amount) || amount <= 0 || amount >= 1) { return { ok: false, error: 'Inset amount must be greater than 0 and less than 1', } } - if (!Number.isFinite(command.depth)) return { ok: false, error: 'Inset depth must be finite' } + if (!Number.isFinite(depth)) return { ok: false, error: 'Inset depth must be finite' } const centroid = blockFaceCentroid(topology, face) const normal = blockFaceNormal(topology, face) if (!(centroid && normal)) return { ok: false, error: `Face cannot be inset: ${face.id}` } @@ -967,15 +1090,9 @@ function insetFace( newVertices.push({ id, position: [ - vertex.position[0] + - (centroid[0] - vertex.position[0]) * command.amount + - normal[0] * command.depth, - vertex.position[1] + - (centroid[1] - vertex.position[1]) * command.amount + - normal[1] * command.depth, - vertex.position[2] + - (centroid[2] - vertex.position[2]) * command.amount + - normal[2] * command.depth, + vertex.position[0] + (centroid[0] - vertex.position[0]) * amount + normal[0] * depth, + vertex.position[1] + (centroid[1] - vertex.position[1]) * amount + normal[1] * depth, + vertex.position[2] + (centroid[2] - vertex.position[2]) * amount + normal[2] * depth, ], }) } @@ -1011,6 +1128,24 @@ function insetFace( } } +function insetFaces( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const faceIds = [...new Set(command.faceIds)] + if (faceIds.length === 0) return { ok: false, error: 'Select a face to inset' } + const missing = faceIds.find((id) => !topology.faces.some((face) => face.id === id)) + if (missing) return { ok: false, error: `Face not found: ${missing}` } + + let current = topology + for (const faceId of faceIds) { + const result = insetOneFace(current, faceId, command.amount, command.depth) + if (!result.ok) return result + current = result.topology + } + return { ok: true, topology: current, selection: { mode: 'face', ids: faceIds } } +} + function deleteComponents( topology: BlockTopology, command: Extract, @@ -1063,7 +1198,9 @@ function mergeVertices( const selectedVertices = topology.vertices.filter((vertex) => selected.has(vertex.id)) if (selectedVertices.length < 2) return { ok: false, error: 'Select at least two vertices to merge' } - const keepId = selectedVertices[0]!.id + const keepId = [...command.vertexIds] + .reverse() + .find((id) => selectedVertices.some((v) => v.id === id))! const center = selectedVertices.reduce( (sum, vertex) => [ sum[0] + vertex.position[0], @@ -1151,12 +1288,9 @@ function longFacePath(face: BlockFace, start: string, end: string): string[] | n return backward.at(-1) === end && backward.length > 2 ? backward : null } -function dissolveEdge( - topology: BlockTopology, - command: Extract, -): BlockCommandResult { - const edge = topology.edges.find((entry) => entry.id === command.edgeId) - if (!edge) return { ok: false, error: `Edge not found: ${command.edgeId}` } +function dissolveOneEdge(topology: BlockTopology, edgeId: string): BlockCommandResult { + const edge = topology.edges.find((entry) => entry.id === edgeId) + if (!edge) return { ok: false, error: `Edge not found: ${edgeId}` } const [a, b] = edge.vertexIds const adjacentFaces = topology.faces.filter((face) => faceContainsEdge(face, a, b)) if (adjacentFaces.length !== 2) { @@ -1195,6 +1329,65 @@ function dissolveEdge( } } +function dissolveEdges( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const edgeIds = [...new Set(command.edgeIds)] + if (edgeIds.length === 0) return { ok: false, error: 'Select an edge to dissolve' } + const missing = edgeIds.find((id) => !topology.edges.some((edge) => edge.id === id)) + if (missing) return { ok: false, error: `Edge not found: ${missing}` } + + let current = topology + const resultFaceIds: string[] = [] + for (const edgeId of edgeIds) { + const result = dissolveOneEdge(current, edgeId) + if (!result.ok) return result + current = result.topology + resultFaceIds.push(...result.selection.ids) + } + const survivingFaceIds = new Set(current.faces.map((face) => face.id)) + return { + ok: true, + topology: current, + selection: { + mode: 'face', + ids: [...new Set(resultFaceIds)].filter((id) => survivingFaceIds.has(id)), + }, + } +} + +function dissolveFaces( + topology: BlockTopology, + command: Extract, +): BlockCommandResult { + const faceIds = new Set(command.faceIds) + if (faceIds.size < 2) return { ok: false, error: 'Select at least two faces to dissolve' } + const missing = [...faceIds].find((id) => !topology.faces.some((face) => face.id === id)) + if (missing) return { ok: false, error: `Face not found: ${missing}` } + const internalEdgeIds = topology.edges + .filter((edge) => { + const incidentSelectedFaces = topology.faces.filter( + (face) => faceIds.has(face.id) && faceContainsEdge(face, ...edge.vertexIds), + ) + return incidentSelectedFaces.length === 2 + }) + .map((edge) => edge.id) + if (internalEdgeIds.length === 0) { + return { ok: false, error: 'Selected faces do not share a dissolvable boundary' } + } + const result = dissolveEdges(topology, { type: 'dissolve-edges', edgeIds: internalEdgeIds }) + if (!result.ok) return result + const survivingSelectedIds = result.topology.faces + .filter((face) => faceIds.has(face.id)) + .map((face) => face.id) + return { + ok: true, + topology: result.topology, + selection: { mode: 'face', ids: survivingSelectedIds }, + } +} + export function applyBlockCommand( topology: BlockTopology, command: BlockCommand, @@ -1202,25 +1395,27 @@ export function applyBlockCommand( const issues = inspectBlockTopology(topology) if (issues.length > 0) return { ok: false, error: issues[0]!.message } switch (command.type) { - case 'extrude-face': - return extrudeFace(topology, command) + case 'extrude-faces': + return extrudeFaces(topology, command) case 'translate-components': return translateComponents(topology, command) case 'rotate-components': return rotateComponents(topology, command) case 'scale-components': return scaleComponents(topology, command) - case 'inset-face': - return insetFace(topology, command) + case 'inset-faces': + return insetFaces(topology, command) case 'delete-components': return deleteComponents(topology, command) case 'merge-vertices': return mergeVertices(topology, command) - case 'dissolve-edge': - return dissolveEdge(topology, command) + case 'dissolve-edges': + return dissolveEdges(topology, command) + case 'dissolve-faces': + return dissolveFaces(topology, command) case 'loop-cut': return loopCut(topology, command) - case 'bevel-edge': - return bevelEdge(topology, command) + case 'bevel-edges': + return bevelEdges(topology, command) } } diff --git a/packages/nodes/src/block/geometry.test.ts b/packages/nodes/src/block/geometry.test.ts index 697c0f6804..c69c110961 100644 --- a/packages/nodes/src/block/geometry.test.ts +++ b/packages/nodes/src/block/geometry.test.ts @@ -277,8 +277,8 @@ describe('buildBlockGeometry', () => { test('rebuilds the extruded topology into additional face triangles', () => { const node = BlockNode.parse({ name: 'Box' }) const result = applyBlockCommand(node.topology, { - type: 'extrude-face', - faceId: 'f-top', + type: 'extrude-faces', + faceIds: ['f-top'], distance: 0.25, }) expect(result.ok).toBe(true) @@ -295,8 +295,8 @@ describe('buildBlockGeometry', () => { test('smooths rounded bevel bands without softening the original box corners', () => { const node = BlockNode.parse({ name: 'Box' }) const result = applyBlockCommand(node.topology, { - type: 'bevel-edge', - edgeId: 'e0', + type: 'bevel-edges', + edgeIds: ['e0'], width: 0.2, segments: 6, profile: 0.5, diff --git a/packages/nodes/src/block/last-operation.test.ts b/packages/nodes/src/block/last-operation.test.ts index 0446e45d41..22111ad469 100644 --- a/packages/nodes/src/block/last-operation.test.ts +++ b/packages/nodes/src/block/last-operation.test.ts @@ -27,7 +27,7 @@ describe('block last operation history transaction', () => { }) test('replaces the committed result while preserving one undo step', () => { - const firstCommand = { type: 'extrude-face', faceId: 'f-top', distance: 0.25 } as const + const firstCommand = { type: 'extrude-faces', faceIds: ['f-top'], distance: 0.25 } as const const first = applyBlockCommand(node.topology, firstCommand) expect(first.ok).toBe(true) if (!first.ok) return @@ -61,7 +61,7 @@ describe('block last operation history transaction', () => { }) test('repeats the operation from its latest result as a new undo step', () => { - const command = { type: 'extrude-face', faceId: 'f-top', distance: 0.25 } as const + const command = { type: 'extrude-faces', faceIds: ['f-top'], distance: 0.25 } as const const first = applyBlockCommand(node.topology, command) expect(first.ok).toBe(true) if (!first.ok) return diff --git a/packages/nodes/src/block/last-operation.ts b/packages/nodes/src/block/last-operation.ts index 2c736db2b4..5a358718e4 100644 --- a/packages/nodes/src/block/last-operation.ts +++ b/packages/nodes/src/block/last-operation.ts @@ -63,11 +63,18 @@ function commandForRepeat( const pivot = selectionCentroid(topology, selection) return pivot ? { ...command, selection, pivot } : null } - case 'extrude-face': - case 'inset-face': - return selection.mode === 'face' && activeId ? { ...command, faceId: activeId } : null - case 'bevel-edge': - return selection.mode === 'edge' && activeId ? { ...command, edgeId: activeId } : null + case 'extrude-faces': + return selection.mode === 'face' && selection.ids.length > 0 + ? { ...command, faceIds: selection.ids } + : null + case 'inset-faces': + return selection.mode === 'face' && selection.ids.length > 0 + ? { ...command, faceIds: selection.ids } + : null + case 'bevel-edges': + return selection.mode === 'edge' && selection.ids.length > 0 + ? { ...command, edgeIds: selection.ids } + : null case 'loop-cut': return selection.mode === 'edge' && activeId ? { ...command, edgeId: activeId } : null default: diff --git a/packages/nodes/src/block/modal-face-operation.test.ts b/packages/nodes/src/block/modal-face-operation.test.ts index 3503b86b4d..b5847434e9 100644 --- a/packages/nodes/src/block/modal-face-operation.test.ts +++ b/packages/nodes/src/block/modal-face-operation.test.ts @@ -14,14 +14,14 @@ describe('block modal face operation', () => { }) test('creates a pure topology command from the modal value', () => { - expect(blockFaceOperationCommand('extrude', 'f-top', -0.4)).toEqual({ - type: 'extrude-face', - faceId: 'f-top', + expect(blockFaceOperationCommand('extrude', ['f-top'], -0.4)).toEqual({ + type: 'extrude-faces', + faceIds: ['f-top'], distance: -0.4, }) - expect(blockFaceOperationCommand('inset', 'f-top', 0.2)).toEqual({ - type: 'inset-face', - faceId: 'f-top', + expect(blockFaceOperationCommand('inset', ['f-top'], 0.2)).toEqual({ + type: 'inset-faces', + faceIds: ['f-top'], amount: 0.2, depth: 0, }) diff --git a/packages/nodes/src/block/modal-face-operation.ts b/packages/nodes/src/block/modal-face-operation.ts index 4e1d36a22e..6a52f9e754 100644 --- a/packages/nodes/src/block/modal-face-operation.ts +++ b/packages/nodes/src/block/modal-face-operation.ts @@ -16,12 +16,12 @@ export function blockFaceOperationValueFromPointer( export function blockFaceOperationCommand( operation: BlockModalFaceOperation, - faceId: string, + faceIds: string[], value: number, ): BlockCommand { return operation === 'extrude' - ? { type: 'extrude-face', faceId, distance: value } - : { type: 'inset-face', faceId, amount: value, depth: 0 } + ? { type: 'extrude-faces', faceIds, distance: value } + : { type: 'inset-faces', faceIds, amount: value, depth: 0 } } export function blockModalFaceOperationStatus( diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index a671f3bdf1..fd81985b5d 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -1408,14 +1408,14 @@ function LastOperationControls({ )}
) - case 'extrude-face': + case 'extrude-faces': return input('Distance', command.distance, (distance) => ({ ...command, distance })) - case 'inset-face': + case 'inset-faces': return input('Amount', command.amount, (amount) => ({ ...command, amount }), { min: 0, max: 0.95, }) - case 'bevel-edge': + case 'bevel-edges': return (
{input('Width', command.width, (width) => ({ ...command, width }), { min: 0 })} @@ -2951,6 +2951,7 @@ function BlockEditor({ (edgeId: string, event: ThreeEvent) => { if (event.nativeEvent.button !== 0 || !ownsEditSession() || cancelDragRef.current) return if (!displayTopology.edges.some((edge) => edge.id === edgeId)) return + const edgeIds = mode === 'edge' && selectedIds.includes(edgeId) ? [...selectedIds] : [edgeId] const baseTopology = displayTopology const startClientX = event.nativeEvent.clientX const startClientY = event.nativeEvent.clientY @@ -2968,7 +2969,7 @@ function BlockEditor({ useBlockEditSession.getState().setSelection(node.id, { mode: 'edge', - ids: [edgeId], + ids: edgeIds, activeId: edgeId, }) setToolbarPanel(null) @@ -2982,8 +2983,8 @@ function BlockEditor({ const updatePreview = (width: number, segments = activeSegments) => { if (width <= 1e-6) return false const result = applyBlockCommand(baseTopology, { - type: 'bevel-edge', - edgeId, + type: 'bevel-edges', + edgeIds, width, segments, profile: 0.5, @@ -3054,8 +3055,8 @@ function BlockEditor({ commitAdjustableOperation( baseTopology, { - type: 'bevel-edge', - edgeId, + type: 'bevel-edges', + edgeIds, width: latestWidth, segments: activeSegments, profile: 0.5, @@ -3087,8 +3088,10 @@ function BlockEditor({ displayTopology, extent, gl.domElement, + mode, node.id, ownsEditSession, + selectedIds, ], ) @@ -3329,12 +3332,12 @@ function BlockEditor({ if ( !ownsEditSession() || mode !== 'face' || - selectedIds.length !== 1 || + selectedIds.length === 0 || cancelDragRef.current ) return false - const faceId = selectedIds[0]! - if (!displayTopology.faces.some((face) => face.id === faceId)) return false + const faceIds = [...selectedIds] + if (faceIds.some((id) => !displayTopology.faces.some((face) => face.id === id))) return false const origin = selectionCentroid(displayTopology, selection) if (!origin) return false const pivotClient = localPointToClient(origin, target, camera, gl.domElement) @@ -3409,7 +3412,7 @@ function BlockEditor({ } const result = applyBlockCommand( baseTopology, - blockFaceOperationCommand(operation, faceId, value), + blockFaceOperationCommand(operation, faceIds, value), ) if (!result.ok) { setError(result.error) @@ -3445,7 +3448,7 @@ function BlockEditor({ if (commit && latestTopology && latestSelection && Math.abs(latestValue) > 1e-6) { commitAdjustableOperation( baseTopology, - blockFaceOperationCommand(operation, faceId, latestValue), + blockFaceOperationCommand(operation, faceIds, latestValue), operation === 'extrude' ? 'Extrude' : 'Inset', ) playBlockSfx('operation-commit') @@ -3580,8 +3583,11 @@ function BlockEditor({ } const dissolveSelection = () => { - if (mode !== 'edge' || selectedIds.length !== 1) return - commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + if (mode === 'edge' && selectedIds.length > 0) { + commitCommand({ type: 'dissolve-edges', edgeIds: selectedIds }, 'dissolve') + } else if (mode === 'face' && selectedIds.length > 1) { + commitCommand({ type: 'dissolve-faces', faceIds: selectedIds }, 'dissolve') + } } const adjustLastOperation = (command: BlockCommand) => { @@ -3730,7 +3736,7 @@ function BlockEditor({ actions.mergeSelection() } else if (key === 'd') { actions.dissolveSelection() - } else if (event.key === 'Delete' || event.key === 'Backspace' || key === 'x') { + } else if (event.key === 'Delete' || key === 'x') { actions.deleteSelection() } else { handled = false @@ -4000,7 +4006,7 @@ function BlockEditor({ @@ -4008,7 +4014,7 @@ function BlockEditor({ @@ -4052,7 +4058,7 @@ function BlockEditor({ @@ -4061,7 +4067,7 @@ function BlockEditor({ { playBlockSfx('tool-select') setBevelSegments(DEFAULT_BEVEL_SEGMENTS) diff --git a/packages/nodes/src/block/toolbar-state.test.ts b/packages/nodes/src/block/toolbar-state.test.ts index 14dde23260..ed3d21bb3c 100644 --- a/packages/nodes/src/block/toolbar-state.test.ts +++ b/packages/nodes/src/block/toolbar-state.test.ts @@ -11,7 +11,7 @@ import { } from './toolbar-state' describe('block toolbar state', () => { - test('enables face operations only for one selected face', () => { + test('enables face operations for one or more selected faces', () => { expect(blockOperationAvailability('face', 1)).toEqual({ extrude: true, inset: true, @@ -19,7 +19,7 @@ describe('block toolbar state', () => { dissolve: false, bevel: false, }) - expect(blockOperationAvailability('face', 2).extrude).toBe(false) + expect(blockOperationAvailability('face', 2)).toMatchObject({ extrude: true, inset: true }) }) test('enables component-specific vertex and edge operations', () => { @@ -29,6 +29,8 @@ describe('block toolbar state', () => { dissolve: true, bevel: true, }) + expect(blockOperationAvailability('edge', 2).dissolve).toBe(true) + expect(blockOperationAvailability('face', 2).dissolve).toBe(true) expect(blockOperationAvailability('edge', 0).bevel).toBe(true) }) diff --git a/packages/nodes/src/block/toolbar-state.ts b/packages/nodes/src/block/toolbar-state.ts index 23a0e60b76..adecf81b33 100644 --- a/packages/nodes/src/block/toolbar-state.ts +++ b/packages/nodes/src/block/toolbar-state.ts @@ -31,10 +31,10 @@ export function blockOperationAvailability( selectedCount: number, ): BlockOperationAvailability { return { - extrude: mode === 'face' && selectedCount === 1, - inset: mode === 'face' && selectedCount === 1, + extrude: mode === 'face' && selectedCount >= 1, + inset: mode === 'face' && selectedCount >= 1, merge: mode === 'vertex' && selectedCount >= 2, - dissolve: mode === 'edge' && selectedCount === 1, + dissolve: (mode === 'edge' && selectedCount >= 1) || (mode === 'face' && selectedCount >= 2), bevel: mode === 'edge', } } From bc87ca228250a1a9b86b5ffe91019f1d8402fc2d Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 13:13:49 +0530 Subject: [PATCH 07/12] feat(block): assign accent material to new slots --- .../nodes/src/block/material-slots.test.ts | 20 +++++-- packages/nodes/src/block/material-slots.ts | 4 +- packages/nodes/src/block/panel.tsx | 55 +++++++++++++++++-- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/nodes/src/block/material-slots.test.ts b/packages/nodes/src/block/material-slots.test.ts index f1caf3b596..a4f4627ff9 100644 --- a/packages/nodes/src/block/material-slots.test.ts +++ b/packages/nodes/src/block/material-slots.test.ts @@ -40,14 +40,18 @@ describe('block material slots', () => { test('creates a slot and assigns it to the selected faces in one operation', () => { const topology = createBoxBlockTopology() - const result = createAssignedBlockMaterialSlot(topology, undefined, { body: 'Body' }, [ - 'f-top', - 'f-front', - ]) + const result = createAssignedBlockMaterialSlot( + topology, + undefined, + { body: 'Body' }, + ['f-top', 'f-front'], + 'scene:block-accent', + ) expect(result.changed).toBe(true) expect(result.slotId).toBe('slot-1') expect(result.slotNames).toEqual({ body: 'Body', 'slot-1': 'Slot 1' }) + expect(result.slots).toEqual({ 'slot-1': 'scene:block-accent' }) expect(result.topology.faces.map((face) => face.materialSlot)).toEqual([ 'body', 'slot-1', @@ -61,7 +65,13 @@ describe('block material slots', () => { test('does not create an empty slot when no faces are selected', () => { const topology = createBoxBlockTopology() const slotNames = { body: 'Body' } - const result = createAssignedBlockMaterialSlot(topology, undefined, slotNames, []) + const result = createAssignedBlockMaterialSlot( + topology, + undefined, + slotNames, + [], + 'scene:block-accent', + ) expect(result.changed).toBe(false) expect(result.topology).toBe(topology) diff --git a/packages/nodes/src/block/material-slots.ts b/packages/nodes/src/block/material-slots.ts index 55d1d3169b..761acfe685 100644 --- a/packages/nodes/src/block/material-slots.ts +++ b/packages/nodes/src/block/material-slots.ts @@ -93,6 +93,7 @@ export function createAssignedBlockMaterialSlot( slots: BlockMaterialSlots, slotNames: BlockMaterialSlotNames, selectedFaceIds: readonly string[], + materialRef: MaterialRef, ): BlockAssignedMaterialSlotCreationResult { const selected = new Set(selectedFaceIds) if (!topology.faces.some((face) => selected.has(face.id))) { @@ -106,9 +107,10 @@ export function createAssignedBlockMaterialSlot( { kind: 'slot', slotId: created.slotId }, created.slotNames, ) + const bound = setBlockMaterialSlot(assigned.slots, created.slotId, materialRef) return { topology: assigned.topology, - slots: assigned.slots, + slots: bound.slots, slotId: created.slotId, slotNames: created.slotNames, changed: true, diff --git a/packages/nodes/src/block/panel.tsx b/packages/nodes/src/block/panel.tsx index 2f305e0eaf..2f9438f341 100644 --- a/packages/nodes/src/block/panel.tsx +++ b/packages/nodes/src/block/panel.tsx @@ -1,10 +1,13 @@ 'use client' import { + type AnyNode, type AnyNodeId, type BlockNode, getCatalogMaterialById, + type MaterialSchema, parseMaterialRef, + type SceneMaterialId, useScene, } from '@pascal-app/core' import { @@ -20,6 +23,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Check, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useRef, useState } from 'react' +import { resolveSlotPaintMaterialRef } from '../shared/slot-paint' import useBlockEditSession from './edit-session' import { assignBlockMaterial, @@ -35,6 +39,17 @@ const SLOT_TRAILING_ACTION_CLASS = 'm-2 ml-0 flex w-8 shrink-0 items-center justify-center rounded-md' const SLOT_DISABLED_ACTION_CLASS = 'disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-[#2C2C2E] disabled:active:bg-[#2C2C2E]' +const NEW_BLOCK_SLOT_MATERIAL = { + preset: 'custom', + properties: { + color: '#7768d8', + roughness: 0.75, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, +} satisfies MaterialSchema function materialRefLabel( ref: string | undefined, @@ -145,22 +160,52 @@ export default function BlockPanel() { } const addMaterialSlot = () => { + const scene = useScene.getState() + const resolution = resolveSlotPaintMaterialRef( + scene.materials, + NEW_BLOCK_SLOT_MATERIAL, + undefined, + ) + if (!resolution?.ref) return const result = createAssignedBlockMaterialSlot( node.topology, node.slots, node.slotNames, selectedFaceIds, + resolution.ref, ) if (!result.changed) return - useScene.getState().updateNode(node.id, { - topology: result.topology, - slots: result.slots, - slotNames: result.slotNames, + let committed = false + useScene.setState((current) => { + if (current.readOnly || current.nodes[node.id]?.type !== 'block') return current + committed = true + return { + materials: resolution.newSceneMaterial + ? { + ...current.materials, + [resolution.newSceneMaterial.id as SceneMaterialId]: { + ...resolution.newSceneMaterial, + name: 'Block Accent', + }, + } + : current.materials, + nodes: { + ...current.nodes, + [node.id]: { + ...current.nodes[node.id], + topology: result.topology, + slots: result.slots, + slotNames: result.slotNames, + } as AnyNode, + }, + } }) + if (!committed) return + useScene.getState().markDirty(node.id) const faceLabel = selectedFaceIds.length === 1 ? 'face' : 'faces' setSlotNotice({ nodeId: node.id, - text: `${result.slotNames[result.slotId] ?? result.slotId} applied to ${selectedFaceIds.length} ${faceLabel}. Use Paint (P) to choose its material.`, + text: `${result.slotNames[result.slotId] ?? result.slotId} applied to ${selectedFaceIds.length} ${faceLabel} with an accent material. Use Paint (P) to replace it.`, }) triggerSFX('sfx:menu-click') } From df066c8456c63fe045c907ff1b9c375df063776b Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 14:15:43 +0530 Subject: [PATCH 08/12] refactor(block): isolate edit mode services --- .../systems/selection-affordance-manager.tsx | 50 +- .../systems/selection-affordance-services.ts | 13 + packages/editor/src/index.tsx | 4 + .../nodes/src/block/last-operation.test.ts | 33 +- packages/nodes/src/block/last-operation.ts | 52 +- packages/nodes/src/block/modal-session.ts | 58 +++ .../nodes/src/block/selection-geometry.ts | 38 ++ packages/nodes/src/block/selection.tsx | 489 +++++------------- .../src/block/use-block-face-operation.ts | 290 +++++++++++ wiki/architecture/materials-and-themes.md | 6 +- 10 files changed, 628 insertions(+), 405 deletions(-) create mode 100644 packages/editor/src/components/systems/selection-affordance-services.ts create mode 100644 packages/nodes/src/block/modal-session.ts create mode 100644 packages/nodes/src/block/selection-geometry.ts create mode 100644 packages/nodes/src/block/use-block-face-operation.ts diff --git a/packages/editor/src/components/systems/selection-affordance-manager.tsx b/packages/editor/src/components/systems/selection-affordance-manager.tsx index 61ad52241a..0ae886f4b0 100644 --- a/packages/editor/src/components/systems/selection-affordance-manager.tsx +++ b/packages/editor/src/components/systems/selection-affordance-manager.tsx @@ -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. @@ -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( + () => ({ + 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 + }, + }), + [], + ) - const Component = useMemo(() => { - if (!selectedKind) return null - return getRegistryAffordanceTool(selectedKind, 'selection') - }, [selectedKind]) + const Component = useMemo | null>(() => { + if (!selectedNode) return null + return getRegistryAffordanceTool(selectedNode.type, 'selection') + }, [selectedNode]) - if (!Component) return null + if (!(Component && selectedNode)) return null return ( - + ) } diff --git a/packages/editor/src/components/systems/selection-affordance-services.ts b/packages/editor/src/components/systems/selection-affordance-services.ts new file mode 100644 index 0000000000..a74b731a0e --- /dev/null +++ b/packages/editor/src/components/systems/selection-affordance-services.ts @@ -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 +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 84ddb9f193..b9d528e32c 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -99,6 +99,10 @@ export { buildSvgArrowHeadPoints, getArcPlanPoint, } from './components/editor-2d/svg-paths' +export type { + SelectionAffordanceHistoryApi, + SelectionAffordanceProps, +} from './components/systems/selection-affordance-services' // Phase 5 Stage D transitional exports — pure drafting / angle helpers // consumed by kind-owned drag actions in @pascal-app/nodes. Stage F // cleanup moves these into @pascal-app/nodes (fence/drafting.ts + diff --git a/packages/nodes/src/block/last-operation.test.ts b/packages/nodes/src/block/last-operation.test.ts index 22111ad469..3568d5a4dc 100644 --- a/packages/nodes/src/block/last-operation.test.ts +++ b/packages/nodes/src/block/last-operation.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { BlockNode, useScene } from '@pascal-app/core' +import { BlockNode, createSceneApi, runAsSingleSceneHistoryStep, useScene } from '@pascal-app/core' import { applyBlockCommand } from './commands' import { recordCommittedBlockOperation, @@ -15,6 +15,23 @@ globalThis.cancelAnimationFrame ??= () => {} describe('block last operation history transaction', () => { const node = BlockNode.parse({ name: 'Adjustable block' }) + const services = { + historyApi: { + depth: () => useScene.temporal.getState().pastStates.length, + replaceLatest: (expectedDepth: number, replace: () => boolean) => { + 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 + }, + }, + readOnly: false, + sceneApi: createSceneApi(useScene), + } beforeEach(() => { useScene.setState({ nodes: { [node.id]: node }, dirtyNodes: new Set(), readOnly: false }) @@ -33,6 +50,7 @@ describe('block last operation history transaction', () => { if (!first.ok) return useScene.getState().updateNode(node.id, { topology: first.topology }) const record = recordCommittedBlockOperation( + services, node.id, 'Extrude', node.topology, @@ -40,7 +58,7 @@ describe('block last operation history transaction', () => { first, ) - const adjusted = replaceCommittedBlockOperation(record, { + const adjusted = replaceCommittedBlockOperation(services, record, { ...firstCommand, distance: 0.5, }) @@ -66,9 +84,16 @@ describe('block last operation history transaction', () => { expect(first.ok).toBe(true) if (!first.ok) return useScene.getState().updateNode(node.id, { topology: first.topology }) - const record = recordCommittedBlockOperation(node.id, 'Extrude', node.topology, command, first) + const record = recordCommittedBlockOperation( + services, + node.id, + 'Extrude', + node.topology, + command, + first, + ) - const repeated = repeatCommittedBlockOperation(record, { + const repeated = repeatCommittedBlockOperation(services, record, { mode: 'face', ids: ['f-top'], activeId: 'f-top', diff --git a/packages/nodes/src/block/last-operation.ts b/packages/nodes/src/block/last-operation.ts index 5a358718e4..30b296d8cf 100644 --- a/packages/nodes/src/block/last-operation.ts +++ b/packages/nodes/src/block/last-operation.ts @@ -1,9 +1,5 @@ -import { - type AnyNodeId, - type BlockTopology, - runAsSingleSceneHistoryStep, - useScene, -} from '@pascal-app/core' +import type { AnyNodeId, BlockNode, BlockTopology, SceneApi } from '@pascal-app/core' +import type { SelectionAffordanceHistoryApi } from '@pascal-app/editor' import { applyBlockCommand, type BlockCommand, @@ -14,6 +10,12 @@ import { type SuccessfulBlockCommandResult = Extract +export type BlockOperationServices = { + historyApi: SelectionAffordanceHistoryApi + readOnly: boolean + sceneApi: Pick +} + export type BlockLastOperation = { baseTopology: BlockTopology command: BlockCommand @@ -83,6 +85,7 @@ function commandForRepeat( } export function recordCommittedBlockOperation( + services: BlockOperationServices, nodeId: AnyNodeId, label: string, baseTopology: BlockTopology, @@ -92,7 +95,7 @@ export function recordCommittedBlockOperation( return { baseTopology, command, - historyDepth: useScene.temporal.getState().pastStates.length, + historyDepth: services.historyApi.depth(), label, nodeId, resultSelection: result.selection, @@ -101,38 +104,36 @@ export function recordCommittedBlockOperation( } export function replaceCommittedBlockOperation( + services: BlockOperationServices, operation: BlockLastOperation, command: BlockCommand, ): BlockLastOperationReplacement { - const scene = useScene.getState() - const current = scene.nodes[operation.nodeId] - if (scene.readOnly) return { ok: false, error: 'Scene is read-only' } + if (services.readOnly) return { ok: false, error: 'Scene is read-only' } + const current = services.sceneApi.get(operation.nodeId) if (current?.type !== 'block' || !sameTopology(current.topology, operation.resultTopology)) { return { ok: false, error: 'The last operation is no longer the latest scene change' } } - if (useScene.temporal.getState().pastStates.length !== operation.historyDepth) { + if (services.historyApi.depth() !== operation.historyDepth) { return { ok: false, error: 'Scene history changed after the last operation' } } const result = applyBlockCommand(operation.baseTopology, command) if (!result.ok) return result - let restored = false - runAsSingleSceneHistoryStep(useScene, () => { - useScene.temporal.getState().undo() - const baseline = useScene.getState().nodes[operation.nodeId] - restored = baseline?.type === 'block' && sameTopology(baseline.topology, operation.baseTopology) - if (!restored) { - useScene.temporal.getState().redo() - return + const restored = services.historyApi.replaceLatest(operation.historyDepth, () => { + const baseline = services.sceneApi.get(operation.nodeId) + if (baseline?.type !== 'block' || !sameTopology(baseline.topology, operation.baseTopology)) { + return false } - useScene.getState().updateNode(operation.nodeId, { topology: result.topology }) + services.sceneApi.update(operation.nodeId, { topology: result.topology }) + return true }) if (!restored) return { ok: false, error: 'Could not restore the operation baseline' } return { ok: true, operation: recordCommittedBlockOperation( + services, operation.nodeId, operation.label, operation.baseTopology, @@ -143,26 +144,27 @@ export function replaceCommittedBlockOperation( } export function repeatCommittedBlockOperation( + services: BlockOperationServices, operation: BlockLastOperation, selection: RepeatSelection, ): BlockLastOperationReplacement { - const scene = useScene.getState() - const current = scene.nodes[operation.nodeId] - if (scene.readOnly) return { ok: false, error: 'Scene is read-only' } + if (services.readOnly) return { ok: false, error: 'Scene is read-only' } + const current = services.sceneApi.get(operation.nodeId) if (current?.type !== 'block' || !sameTopology(current.topology, operation.resultTopology)) { return { ok: false, error: 'The last operation is no longer the latest scene change' } } - if (useScene.temporal.getState().pastStates.length !== operation.historyDepth) { + if (services.historyApi.depth() !== operation.historyDepth) { return { ok: false, error: 'Scene history changed after the last operation' } } const command = commandForRepeat(operation.command, current.topology, selection) if (!command) return { ok: false, error: 'The current selection cannot repeat this operation' } const result = applyBlockCommand(current.topology, command) if (!result.ok) return result - useScene.getState().updateNode(operation.nodeId, { topology: result.topology }) + services.sceneApi.update(operation.nodeId, { topology: result.topology }) return { ok: true, operation: recordCommittedBlockOperation( + services, operation.nodeId, operation.label, current.topology, diff --git a/packages/nodes/src/block/modal-session.ts b/packages/nodes/src/block/modal-session.ts new file mode 100644 index 0000000000..ae0ca21641 --- /dev/null +++ b/packages/nodes/src/block/modal-session.ts @@ -0,0 +1,58 @@ +import { useViewer } from '@pascal-app/viewer' +import type { MutableRefObject } from 'react' + +type FinishModal = (commit: boolean) => void + +export type BlockModalSessionOptions = { + cancelRef: MutableRefObject<(() => void) | null> + cursor: string + onFinish: (commit: boolean) => void + onKeyDown?: (event: KeyboardEvent, finish: FinishModal) => void + onPointerDown?: (event: PointerEvent, finish: FinishModal) => void + onPointerMove?: (event: PointerEvent) => void +} + +export function beginBlockModalSession({ + cancelRef, + cursor, + onFinish, + onKeyDown, + onPointerDown, + onPointerMove, +}: BlockModalSessionOptions): FinishModal { + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let finished = false + + const onContextMenu = (event: Event) => { + event.preventDefault() + event.stopImmediatePropagation() + } + const onCancel = () => finish(false) + const pointerDown = (event: PointerEvent) => onPointerDown?.(event, finish) + const keyDown = (event: KeyboardEvent) => onKeyDown?.(event, finish) + + function finish(commit: boolean) { + if (finished) return + finished = true + if (onPointerMove) window.removeEventListener('pointermove', onPointerMove, true) + if (onPointerDown) window.removeEventListener('pointerdown', pointerDown, true) + if (onKeyDown) window.removeEventListener('keydown', keyDown, true) + window.removeEventListener('contextmenu', onContextMenu, true) + window.removeEventListener('blur', onCancel) + cancelRef.current = null + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + onFinish(commit) + } + + useViewer.getState().setInputDragging(true) + document.body.style.cursor = cursor + cancelRef.current = onCancel + if (onPointerMove) window.addEventListener('pointermove', onPointerMove, true) + if (onPointerDown) window.addEventListener('pointerdown', pointerDown, true) + if (onKeyDown) window.addEventListener('keydown', keyDown, true) + window.addEventListener('contextmenu', onContextMenu, true) + window.addEventListener('blur', onCancel, { once: true }) + return finish +} diff --git a/packages/nodes/src/block/selection-geometry.ts b/packages/nodes/src/block/selection-geometry.ts new file mode 100644 index 0000000000..01ec988f82 --- /dev/null +++ b/packages/nodes/src/block/selection-geometry.ts @@ -0,0 +1,38 @@ +import type { BlockTopology } from '@pascal-app/core' +import type { Camera, Object3D } from 'three' +import { Vector2, Vector3 } from 'three' +import { type BlockSelection, blockSelectionVertexIds } from './commands' + +export type BlockPoint = [number, number, number] + +export function blockSelectionCentroid( + topology: BlockTopology, + selection: BlockSelection, +): BlockPoint | null { + const ids = blockSelectionVertexIds(topology, selection) + const positions = topology.vertices + .filter((vertex) => ids.has(vertex.id)) + .map((vertex) => vertex.position) + if (positions.length === 0) return null + const total = positions.reduce( + (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]], + [0, 0, 0], + ) + return [total[0] / positions.length, total[1] / positions.length, total[2] / positions.length] +} + +export function blockLocalPointToClient( + point: BlockPoint, + target: Object3D, + camera: Camera, + canvas: HTMLCanvasElement, +): Vector2 | null { + target.updateWorldMatrix(true, false) + const projected = target.localToWorld(new Vector3(...point)).project(camera) + if (![projected.x, projected.y, projected.z].every(Number.isFinite)) return null + const rect = canvas.getBoundingClientRect() + return new Vector2( + rect.left + ((projected.x + 1) / 2) * rect.width, + rect.top + ((1 - projected.y) / 2) * rect.height, + ) +} diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index fd81985b5d..6d1fd23475 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -1,14 +1,12 @@ 'use client' import { - type AnyNodeId, type BlockFace, type BlockNode, type BlockTopology, emitter, sceneRegistry, useLiveNodeOverrides, - useScene, } from '@pascal-app/core' import { cn, @@ -20,6 +18,7 @@ import { markToolCancelConsumed, meshEditScope, NodeActionMenu, + type SelectionAffordanceProps, swallowNextClick, triggerSFX, useEditor, @@ -84,7 +83,6 @@ import { blockFaceCentroid, blockFaceNormal, blockLoopCutSegments, - blockSelectionVertexIds, } from './commands' import useBlockEditSession from './edit-session' import { triangulateBlockFace } from './geometry' @@ -99,12 +97,8 @@ import { } from './last-operation' import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' import { BLOCK_BODY_SLOT_ID, unpaintedBlockMaterialSlotIds } from './material-slots' -import { - type BlockModalFaceOperation, - blockFaceOperationCommand, - blockFaceOperationValueFromPointer, - blockModalFaceOperationStatus, -} from './modal-face-operation' +import { type BlockModalFaceOperation, blockModalFaceOperationStatus } from './modal-face-operation' +import { beginBlockModalSession } from './modal-session' import { type BlockActiveTransform, type BlockAxisVisualState, @@ -132,6 +126,11 @@ import { signedAngleAroundAxis, unwrapRotationDelta, } from './rotation-drag' +import { + blockLocalPointToClient as localPointToClient, + type BlockPoint as Point, + blockSelectionCentroid as selectionCentroid, +} from './selection-geometry' import { type BlockSelectionState, blockSelectionChanged, @@ -151,9 +150,9 @@ import { blockToolbarOffset, formatBlockSelectionStatus, } from './toolbar-state' +import { useBlockFaceOperation } from './use-block-face-operation' type ComponentMode = BlockSelection['mode'] -type Point = [number, number, number] type Axis = BlockTransformAxis type PlaneAxes = BlockTransformPlane type TransformOperation = BlockTransformOperation @@ -223,19 +222,6 @@ function topologyVertexMap(topology: BlockTopology): Map { return new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) } -function selectionCentroid(topology: BlockTopology, selection: BlockSelection): Point | null { - const ids = blockSelectionVertexIds(topology, selection) - const positions = topology.vertices - .filter((vertex) => ids.has(vertex.id)) - .map((vertex) => vertex.position) - if (positions.length === 0) return null - const total = positions.reduce( - (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]], - [0, 0, 0], - ) - return [total[0] / positions.length, total[1] / positions.length, total[2] / positions.length] -} - function topologyExtent(topology: BlockTopology): number { const axes = [0, 1, 2] as const return Math.max( @@ -262,22 +248,6 @@ function closestAxisParameterToRay( return e + b * axisParameter < 0 ? -d : axisParameter } -function localPointToClient( - point: Point, - target: Object3D, - camera: Camera, - canvas: HTMLCanvasElement, -): Vector2 | null { - target.updateWorldMatrix(true, false) - const projected = target.localToWorld(new Vector3(...point)).project(camera) - if (![projected.x, projected.y, projected.z].every(Number.isFinite)) return null - const rect = canvas.getBoundingClientRect() - return new Vector2( - rect.left + ((projected.x + 1) / 2) * rect.width, - rect.top + ((1 - projected.y) / 2) * rect.height, - ) -} - function geometrySnapThreshold( camera: Camera, worldPoint: Vector3, @@ -1448,11 +1418,17 @@ function LastOperationControls({ } function BlockEditor({ + historyApi, node, + readOnly, + sceneApi, target, mirrorTarget, }: { + historyApi: SelectionAffordanceProps['historyApi'] node: BlockNode + readOnly: boolean + sceneApi: SelectionAffordanceProps['sceneApi'] target: Object3D mirrorTarget: boolean }) { @@ -1497,6 +1473,10 @@ function BlockEditor({ const [error, setError] = useState(null) const cancelDragRef = useRef<(() => void) | null>(null) const lastPointerClientRef = useRef(null) + const operationServices = useMemo( + () => ({ historyApi, readOnly, sceneApi }), + [historyApi, readOnly, sceneApi], + ) const displayTopology = previewTopology ?? node.topology const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selection = useMemo(() => ({ mode, ids: selectedIds }), [mode, selectedIds]) @@ -1554,7 +1534,7 @@ function BlockEditor({ cancelDragRef.current?.() cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) endOwnedScope() useBlockEditSession.getState().end(node.id) setPreviewTopology(null) @@ -1570,18 +1550,18 @@ function BlockEditor({ setToolbarPanel(null) setError(null) playBlockSfx('finish') - }, [endOwnedScope, node.id]) + }, [endOwnedScope, node.id, sceneApi.markDirty]) useEffect( () => () => { cancelDragRef.current?.() useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) endOwnedScope() useBlockEditSession.getState().end(node.id) if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' }, - [endOwnedScope, node.id], + [endOwnedScope, node.id, sceneApi.markDirty], ) useEffect(() => { @@ -1589,7 +1569,7 @@ function BlockEditor({ cancelDragRef.current?.() cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setPreviewTopology(null) setToolbarPanel(null) setLoopCutSegments(null) @@ -1601,7 +1581,7 @@ function BlockEditor({ setActiveFaceOperation(null) setFaceOperationValue('') useBlockEditSession.getState().end(node.id) - }, [editing, node.id]) + }, [editing, node.id, sceneApi.markDirty]) useEffect(() => { if (!editing) return @@ -1855,7 +1835,7 @@ function BlockEditor({ setError(result.error) return false } - useScene.getState().updateNode(node.id, { topology: result.topology }) + sceneApi.update(node.id, { topology: result.topology }) const session = useBlockEditSession.getState() session.setSelection(node.id, { ...result.selection, @@ -1863,13 +1843,20 @@ function BlockEditor({ }) session.setLastOperation( node.id, - recordCommittedBlockOperation(node.id, label, baseTopology, command, result), + recordCommittedBlockOperation( + operationServices, + node.id, + label, + baseTopology, + command, + result, + ), ) setLastOperationPanelOpen(true) setError(null) return true }, - [node.id], + [node.id, operationServices, sceneApi], ) const beginKeyboardTransformModal = useCallback( @@ -1896,8 +1883,6 @@ function BlockEditor({ .normalize() const baseTopology = displayTopology const baseSelection = selection - const previousInputDragging = useViewer.getState().inputDragging - const previousCursor = document.body.style.cursor let activeConstraint: Axis | PlaneAxes | null = null let latestTopology: BlockTopology | null = null let latestCommand: BlockCommand | null = null @@ -1917,7 +1902,6 @@ function BlockEditor({ let previousRawPointer = { x: startPointer.x, y: startPointer.y } let lastSnapValue: string | number | null = null let typedInput = '' - let finished = false const worldAxisFor = (axis: Axis) => target @@ -2093,23 +2077,13 @@ function BlockEditor({ latestCommand = command setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) } - const finish = (commit: boolean) => { - if (finished) return - finished = true - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerdown', onPointerDown, true) - window.removeEventListener('keydown', onKeyDown, true) - window.removeEventListener('contextmenu', onContextMenu, true) - window.removeEventListener('blur', onCancel) - cancelDragRef.current = null + const complete = (commit: boolean) => { useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - useViewer.getState().setInputDragging(previousInputDragging) - document.body.style.cursor = previousCursor + sceneApi.markDirty(node.id) setPreviewTopology(null) setActiveTransform(null) setTransformNumericInput('') @@ -2144,13 +2118,13 @@ function BlockEditor({ pointerEvent.shiftKey, ) } - const onPointerDown = (pointerEvent: PointerEvent) => { + const onPointerDown = (pointerEvent: PointerEvent, finish: (commit: boolean) => void) => { if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return pointerEvent.preventDefault() pointerEvent.stopImmediatePropagation() finish(pointerEvent.button === 0) } - const onKeyDown = (keyboardEvent: KeyboardEvent) => { + const onKeyDown = (keyboardEvent: KeyboardEvent, finish: (commit: boolean) => void) => { const element = keyboardEvent.target as HTMLElement | null if ( element?.tagName === 'INPUT' || @@ -2216,28 +2190,22 @@ function BlockEditor({ } } } - const onContextMenu = (event: Event) => { - event.preventDefault() - event.stopImmediatePropagation() - } - const onCancel = () => finish(false) - useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operation)) playBlockSfx('drag-start') - useViewer.getState().setInputDragging(true) setTransformTool('transform') setToolbarPanel(null) setActiveTransform({ operation, constraint: 'free' }) setTransformNumericInput('') setModalFeedbackMode('free') setError(null) - document.body.style.cursor = operation === 'translate' ? 'move' : 'crosshair' - cancelDragRef.current = onCancel - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerdown', onPointerDown, true) - window.addEventListener('keydown', onKeyDown, true) - window.addEventListener('contextmenu', onContextMenu, true) - window.addEventListener('blur', onCancel, { once: true }) + beginBlockModalSession({ + cancelRef: cancelDragRef, + cursor: operation === 'translate' ? 'move' : 'crosshair', + onFinish: complete, + onKeyDown, + onPointerDown, + onPointerMove: onMove, + }) return true }, [ @@ -2252,6 +2220,7 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ], ) @@ -2385,7 +2354,7 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) } const finish = (commit: boolean) => { @@ -2397,7 +2366,7 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) @@ -2439,6 +2408,7 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ], ) @@ -2533,7 +2503,7 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) } const finish = (commit: boolean) => { @@ -2545,7 +2515,7 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) @@ -2590,6 +2560,7 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ], ) @@ -2672,7 +2643,7 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) } @@ -2685,7 +2656,7 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) @@ -2730,6 +2701,7 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ], ) @@ -2747,8 +2719,6 @@ function BlockEditor({ const initialDistance = Math.max(24, pivotClient.distanceTo(startPointer)) const baseTopology = displayTopology const baseSelection = selection - const previousInputDragging = useViewer.getState().inputDragging - const previousCursor = document.body.style.cursor let latestFactor = 1 let lastSnapFactor: number | null = null let latestTopology: BlockTopology | null = null @@ -2760,7 +2730,6 @@ function BlockEditor({ let lastShiftKey = false let effectivePointer = { x: startPointer.x, y: startPointer.y } let previousRawPointer = { ...effectivePointer } - let finished = false const updatePreview = ( clientX: number, @@ -2804,23 +2773,13 @@ function BlockEditor({ latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) } - const finish = (commit: boolean) => { - if (finished) return - finished = true - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerdown', onPointerDown, true) - window.removeEventListener('keydown', onKeyDown, true) - window.removeEventListener('contextmenu', onContextMenu, true) - window.removeEventListener('blur', onCancel) - cancelDragRef.current = null + const complete = (commit: boolean) => { useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - useViewer.getState().setInputDragging(previousInputDragging) - document.body.style.cursor = previousCursor + sceneApi.markDirty(node.id) setPreviewTopology(null) setActiveTransform(null) setTransformNumericInput('') @@ -2862,22 +2821,12 @@ function BlockEditor({ pointerEvent.shiftKey, ) } - const onPointerDown = (pointerEvent: PointerEvent) => { + const onPointerDown = (pointerEvent: PointerEvent, finish: (commit: boolean) => void) => { pointerEvent.preventDefault() pointerEvent.stopImmediatePropagation() - if (pointerEvent.button === 2) { - window.addEventListener( - 'contextmenu', - (event) => { - event.preventDefault() - event.stopImmediatePropagation() - }, - { capture: true, once: true }, - ) - } finish(pointerEvent.button !== 2) } - const onKeyDown = (keyboardEvent: KeyboardEvent) => { + const onKeyDown = (keyboardEvent: KeyboardEvent, finish: (commit: boolean) => void) => { const element = keyboardEvent.target as HTMLElement | null if ( element?.tagName === 'INPUT' || @@ -2911,28 +2860,22 @@ function BlockEditor({ finish(false) } } - const onContextMenu = (event: Event) => { - event.preventDefault() - event.stopImmediatePropagation() - } - const onCancel = () => finish(false) - useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) playBlockSfx('drag-start') - useViewer.getState().setInputDragging(true) setTransformTool('transform') setToolbarPanel(null) setActiveTransform({ operation: 'scale', constraint: 'uniform' }) setTransformNumericInput('') setModalFeedbackMode('free') setError(null) - document.body.style.cursor = 'nwse-resize' - cancelDragRef.current = onCancel - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerdown', onPointerDown, true) - window.addEventListener('keydown', onKeyDown, true) - window.addEventListener('contextmenu', onContextMenu, true) - window.addEventListener('blur', onCancel, { once: true }) + beginBlockModalSession({ + cancelRef: cancelDragRef, + cursor: 'nwse-resize', + onFinish: complete, + onKeyDown, + onPointerDown, + onPointerMove: onMove, + }) return true }, [ camera, @@ -2945,6 +2888,7 @@ function BlockEditor({ selectedIds.length, selection, target, + sceneApi.markDirty, ]) const beginBevelDrag = useCallback( @@ -3001,7 +2945,7 @@ function BlockEditor({ latestSelection = result.selection setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) return true } @@ -3047,7 +2991,7 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) @@ -3092,6 +3036,7 @@ function BlockEditor({ node.id, ownsEditSession, selectedIds, + sceneApi.markDirty, ], ) @@ -3204,7 +3149,7 @@ function BlockEditor({ setLoopCutSegments(segments) setLoopCutFactor(effectiveFactor) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) setError(null) return true } @@ -3266,7 +3211,7 @@ function BlockEditor({ window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) + sceneApi.markDirty(node.id) useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) @@ -3324,227 +3269,34 @@ function BlockEditor({ node.topology, ownsEditSession, target, + sceneApi.markDirty, ], ) - const beginFaceOperationModal = useCallback( - (operation: BlockModalFaceOperation) => { - if ( - !ownsEditSession() || - mode !== 'face' || - selectedIds.length === 0 || - cancelDragRef.current - ) - return false - const faceIds = [...selectedIds] - if (faceIds.some((id) => !displayTopology.faces.some((face) => face.id === id))) return false - const origin = selectionCentroid(displayTopology, selection) - if (!origin) return false - const pivotClient = localPointToClient(origin, target, camera, gl.domElement) - if (!pivotClient) return false - - const startPointer = - lastPointerClientRef.current?.clone() ?? pivotClient.clone().add(new Vector2(80, 0)) - const baseTopology = displayTopology - const previousInputDragging = useViewer.getState().inputDragging - const previousCursor = document.body.style.cursor - let latestTopology: BlockTopology | null = null - let latestSelection: BlockSelection | null = null - let latestValue = 0 - let typedInput = '' - let lastClientX = startPointer.x - let lastClientY = startPointer.y - let lastAltKey = false - let lastShiftKey = false - let lastSnapValue: number | null = null - let effectivePointer = { x: startPointer.x, y: startPointer.y } - let previousRawPointer = { ...effectivePointer } - let finished = false - - const displayValue = (value: number) => String(Math.round(value * 1000) / 1000) - - const updatePreview = ( - clientX: number, - clientY: number, - altKey: boolean, - precision: boolean, - ) => { - lastClientX = clientX - lastClientY = clientY - lastAltKey = altKey - lastShiftKey = precision - const typedValue = blockTransformNumericValue( - typedInput, - operation === 'extrude' ? 'translate' : 'scale', - ) - let value = - typedValue ?? - blockFaceOperationValueFromPointer( - operation, - clientX - startPointer.x, - clientY - startPointer.y, - extent, - ) - const snapping = - operation === 'extrude' && typedValue === null && isGridSnapActive() && !altKey - if (snapping) { - const step = blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) - if (step > 0) value = Math.round(value / step) * step - } - setFaceOperationValue(typedInput || displayValue(value)) - setModalFeedbackMode( - typedInput ? 'exact' : precision ? 'precision' : snapping ? 'grid' : 'free', - ) - if (Math.abs(value) <= 1e-6) { - latestTopology = null - latestSelection = null - latestValue = 0 - setPreviewTopology(null) - useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - return - } - if (snapping && value !== lastSnapValue) { - lastSnapValue = value - playBlockSfx('move-step') - } else if (!snapping) { - lastSnapValue = null - } - const result = applyBlockCommand( - baseTopology, - blockFaceOperationCommand(operation, faceIds, value), - ) - if (!result.ok) { - setError(result.error) - return - } - latestTopology = result.topology - latestSelection = result.selection - latestValue = value - setPreviewTopology(result.topology) - useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) - setError(null) - } - - const finish = (commit: boolean) => { - if (finished) return - finished = true - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerdown', onPointerDown, true) - window.removeEventListener('keydown', onKeyDown, true) - window.removeEventListener('contextmenu', onContextMenu, true) - window.removeEventListener('blur', onCancel) - cancelDragRef.current = null - useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - useViewer.getState().setInputDragging(previousInputDragging) - document.body.style.cursor = previousCursor - setPreviewTopology(null) - setActiveFaceOperation(null) - setFaceOperationValue('') - setTransformNumericInput('') - setModalFeedbackMode('free') - if (commit && latestTopology && latestSelection && Math.abs(latestValue) > 1e-6) { - commitAdjustableOperation( - baseTopology, - blockFaceOperationCommand(operation, faceIds, latestValue), - operation === 'extrude' ? 'Extrude' : 'Inset', - ) - playBlockSfx('operation-commit') - } else if (!commit) { - playBlockSfx('cancel') - } - if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) - swallowNextClick() - } - - const onMove = (pointerEvent: PointerEvent) => { - lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) - effectivePointer = blockAccumulatePrecisionPointer( - effectivePointer, - previousRawPointer, - pointerEvent, - pointerEvent.shiftKey, - ) - previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } - updatePreview( - effectivePointer.x, - effectivePointer.y, - pointerEvent.altKey, - pointerEvent.shiftKey, - ) - } - const onPointerDown = (pointerEvent: PointerEvent) => { - if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return - pointerEvent.preventDefault() - pointerEvent.stopImmediatePropagation() - finish(pointerEvent.button === 0) - } - const onKeyDown = (keyboardEvent: KeyboardEvent) => { - const element = keyboardEvent.target as HTMLElement | null - if ( - element?.tagName === 'INPUT' || - element?.tagName === 'TEXTAREA' || - element?.isContentEditable - ) - return - const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) - if (nextInput !== null) { - keyboardEvent.preventDefault() - keyboardEvent.stopImmediatePropagation() - typedInput = nextInput - setTransformNumericInput(nextInput) - updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) - } else if (keyboardEvent.key === 'Enter') { - keyboardEvent.preventDefault() - keyboardEvent.stopImmediatePropagation() - finish(true) - } else if (keyboardEvent.key === 'Escape') { - keyboardEvent.preventDefault() - keyboardEvent.stopImmediatePropagation() - finish(false) - } - } - const onContextMenu = (event: Event) => { - event.preventDefault() - event.stopImmediatePropagation() - } - const onCancel = () => finish(false) - - useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operation)) - playBlockSfx('operation-start') - useViewer.getState().setInputDragging(true) - setToolbarPanel(null) - setActiveFaceOperation(operation) - setFaceOperationValue('0') - setTransformNumericInput('') - setModalFeedbackMode('free') - setError(null) - document.body.style.cursor = operation === 'extrude' ? 'ns-resize' : 'nwse-resize' - cancelDragRef.current = onCancel - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerdown', onPointerDown, true) - window.addEventListener('keydown', onKeyDown, true) - window.addEventListener('contextmenu', onContextMenu, true) - window.addEventListener('blur', onCancel, { once: true }) - return true - }, - [ - camera, - commitAdjustableOperation, - displayTopology, - extent, - gl.domElement, - mode, - node.id, - ownsEditSession, - selectedIds, - selection, - target, - ], - ) - + const beginFaceOperationModal = useBlockFaceOperation({ + camera, + cancelRef: cancelDragRef, + canvas: gl.domElement, + closeToolbar: () => setToolbarPanel(null), + commit: commitAdjustableOperation, + displayTopology, + extent, + lastPointerClientRef, + mode, + nodeId: node.id, + ownsEditSession, + playSfx: playBlockSfx, + sceneApi, + selectedIds, + selection, + setActiveFaceOperation, + setError, + setFaceOperationValue, + setModalFeedbackMode, + setPreviewTopology, + setTransformNumericInput, + target, + }) const commitCommand = (command: BlockCommand, operator: TopologyOperator) => { if (cancelDragRef.current) return useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operator)) @@ -3554,7 +3306,7 @@ function BlockEditor({ setError(result.error) return } - useScene.getState().updateNode(node.id, { topology: result.topology }) + sceneApi.update(node.id, { topology: result.topology }) const session = useBlockEditSession.getState() session.setSelection(node.id, { ...result.selection, @@ -3592,7 +3344,7 @@ function BlockEditor({ const adjustLastOperation = (command: BlockCommand) => { if (!lastOperation || cancelDragRef.current) return - const replacement = replaceCommittedBlockOperation(lastOperation, command) + const replacement = replaceCommittedBlockOperation(operationServices, lastOperation, command) if (!replacement.ok) { setError(replacement.error) setLastOperationPanelOpen(false) @@ -3610,7 +3362,7 @@ function BlockEditor({ const repeatLastOperation = () => { if (!lastOperation || cancelDragRef.current) return - const repeated = repeatCommittedBlockOperation(lastOperation, { + const repeated = repeatCommittedBlockOperation(operationServices, lastOperation, { mode, ids: selectedIds, activeId, @@ -3758,7 +3510,7 @@ function BlockEditor({ const deleteNode = (event: ReactMouseEvent) => { event.stopPropagation() useViewer.getState().setSelection({ selectedIds: [] }) - useScene.getState().deleteNode(node.id) + sceneApi.delete(node.id) playBlockSfx('delete') } @@ -4214,15 +3966,15 @@ function BlockEditor({ ) } -const BlockSelectionAffordance = () => { - const selectedIds = useViewer((state) => state.selection.selectedIds) - const node = useScene((state) => { - if (selectedIds.length !== 1) return null - const selected = state.nodes[selectedIds[0] as AnyNodeId] - return selected?.type === 'block' ? (selected as BlockNode) : null - }) +const BlockSelectionAffordance = ({ + historyApi, + node, + readOnly, + sceneApi, +}: SelectionAffordanceProps) => { + const blockNode = node.type === 'block' ? node : null const [target, setTarget] = useState(null) - const nodeId = node?.id ?? null + const nodeId = blockNode?.id ?? null const scopeAllowsAffordance = useInteractionScope( (state) => state.scope.kind === 'idle' || @@ -4236,7 +3988,7 @@ const BlockSelectionAffordance = () => { } let frameId = 0 const resolve = () => { - const next = sceneRegistry.nodes.get(nodeId as AnyNodeId) ?? null + const next = sceneRegistry.nodes.get(nodeId) ?? null setTarget((current) => (current === next ? current : next)) if (!next) frameId = window.requestAnimationFrame(resolve) } @@ -4244,10 +3996,17 @@ const BlockSelectionAffordance = () => { return () => window.cancelAnimationFrame(frameId) }, [nodeId]) - if (!node || !target || !scopeAllowsAffordance) return null + if (!blockNode || !target || !scopeAllowsAffordance) return null const mount = target.parent ?? target return createPortal( - , + , mount, undefined, ) diff --git a/packages/nodes/src/block/use-block-face-operation.ts b/packages/nodes/src/block/use-block-face-operation.ts new file mode 100644 index 0000000000..e0d810b139 --- /dev/null +++ b/packages/nodes/src/block/use-block-face-operation.ts @@ -0,0 +1,290 @@ +import { + type AnyNodeId, + type BlockTopology, + type SceneApi, + useLiveNodeOverrides, +} from '@pascal-app/core' +import { + isGridSnapActive, + meshEditScope, + swallowNextClick, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from 'react' +import type { Camera, Object3D } from 'three' +import { Vector2 } from 'three' +import { applyBlockCommand, type BlockCommand, type BlockSelection } from './commands' +import type { BlockSfxAction } from './interaction-sfx' +import { + type BlockModalFaceOperation, + blockFaceOperationCommand, + blockFaceOperationValueFromPointer, +} from './modal-face-operation' +import { beginBlockModalSession } from './modal-session' +import { + type BlockModalFeedbackMode, + blockAccumulatePrecisionPointer, + blockPrecisionSnapStep, + blockTransformNumericInputFromKey, + blockTransformNumericValue, +} from './modal-transform' +import { blockLocalPointToClient, blockSelectionCentroid } from './selection-geometry' + +type StateSetter = Dispatch> + +export type UseBlockFaceOperationOptions = { + camera: Camera + cancelRef: MutableRefObject<(() => void) | null> + canvas: HTMLCanvasElement + closeToolbar: () => void + commit: (baseTopology: BlockTopology, command: BlockCommand, label: string) => boolean + displayTopology: BlockTopology + extent: number + lastPointerClientRef: MutableRefObject + mode: BlockSelection['mode'] + nodeId: AnyNodeId + ownsEditSession: () => boolean + playSfx: (action: BlockSfxAction) => void + sceneApi: Pick + selectedIds: string[] + selection: BlockSelection + setActiveFaceOperation: StateSetter + setError: StateSetter + setFaceOperationValue: StateSetter + setModalFeedbackMode: StateSetter + setPreviewTopology: StateSetter + setTransformNumericInput: StateSetter + target: Object3D +} + +export function useBlockFaceOperation({ + camera, + cancelRef, + canvas, + closeToolbar, + commit, + displayTopology, + extent, + lastPointerClientRef, + mode, + nodeId, + ownsEditSession, + playSfx, + sceneApi, + selectedIds, + selection, + setActiveFaceOperation, + setError, + setFaceOperationValue, + setModalFeedbackMode, + setPreviewTopology, + setTransformNumericInput, + target, +}: UseBlockFaceOperationOptions) { + return useCallback( + (operation: BlockModalFaceOperation) => { + if (!ownsEditSession() || mode !== 'face' || selectedIds.length === 0 || cancelRef.current) { + return false + } + const faceIds = [...selectedIds] + if (faceIds.some((id) => !displayTopology.faces.some((face) => face.id === id))) return false + const origin = blockSelectionCentroid(displayTopology, selection) + if (!origin) return false + const pivotClient = blockLocalPointToClient(origin, target, camera, canvas) + if (!pivotClient) return false + + const startPointer = + lastPointerClientRef.current?.clone() ?? pivotClient.clone().add(new Vector2(80, 0)) + const baseTopology = displayTopology + let latestTopology: BlockTopology | null = null + let latestSelection: BlockSelection | null = null + let latestValue = 0 + let typedInput = '' + let lastClientX = startPointer.x + let lastClientY = startPointer.y + let lastAltKey = false + let lastShiftKey = false + let lastSnapValue: number | null = null + let effectivePointer = { x: startPointer.x, y: startPointer.y } + let previousRawPointer = { ...effectivePointer } + + const updatePreview = ( + clientX: number, + clientY: number, + altKey: boolean, + precision: boolean, + ) => { + lastClientX = clientX + lastClientY = clientY + lastAltKey = altKey + lastShiftKey = precision + const typedValue = blockTransformNumericValue( + typedInput, + operation === 'extrude' ? 'translate' : 'scale', + ) + let value = + typedValue ?? + blockFaceOperationValueFromPointer( + operation, + clientX - startPointer.x, + clientY - startPointer.y, + extent, + ) + const snapping = + operation === 'extrude' && typedValue === null && isGridSnapActive() && !altKey + if (snapping) { + const step = blockPrecisionSnapStep(useEditor.getState().gridSnapStep, precision) + if (step > 0) value = Math.round(value / step) * step + } + setFaceOperationValue(typedInput || String(Math.round(value * 1000) / 1000)) + setModalFeedbackMode( + typedInput ? 'exact' : precision ? 'precision' : snapping ? 'grid' : 'free', + ) + if (Math.abs(value) <= 1e-6) { + latestTopology = null + latestSelection = null + latestValue = 0 + setPreviewTopology(null) + useLiveNodeOverrides.getState().clear(nodeId) + sceneApi.markDirty(nodeId) + return + } + if (snapping && value !== lastSnapValue) { + lastSnapValue = value + playSfx('move-step') + } else if (!snapping) { + lastSnapValue = null + } + const result = applyBlockCommand( + baseTopology, + blockFaceOperationCommand(operation, faceIds, value), + ) + if (!result.ok) { + setError(result.error) + return + } + latestTopology = result.topology + latestSelection = result.selection + latestValue = value + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(nodeId, { topology: result.topology }) + sceneApi.markDirty(nodeId) + setError(null) + } + + const complete = (commitOperation: boolean) => { + useLiveNodeOverrides.getState().clear(nodeId) + sceneApi.markDirty(nodeId) + setPreviewTopology(null) + setActiveFaceOperation(null) + setFaceOperationValue('') + setTransformNumericInput('') + setModalFeedbackMode('free') + if (commitOperation && latestTopology && latestSelection && Math.abs(latestValue) > 1e-6) { + commit( + baseTopology, + blockFaceOperationCommand(operation, faceIds, latestValue), + operation === 'extrude' ? 'Extrude' : 'Inset', + ) + playSfx('operation-commit') + } else if (!commitOperation) { + playSfx('cancel') + } + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(nodeId)) + swallowNextClick() + } + + const onMove = (pointerEvent: PointerEvent) => { + lastPointerClientRef.current = new Vector2(pointerEvent.clientX, pointerEvent.clientY) + effectivePointer = blockAccumulatePrecisionPointer( + effectivePointer, + previousRawPointer, + pointerEvent, + pointerEvent.shiftKey, + ) + previousRawPointer = { x: pointerEvent.clientX, y: pointerEvent.clientY } + updatePreview( + effectivePointer.x, + effectivePointer.y, + pointerEvent.altKey, + pointerEvent.shiftKey, + ) + } + const onPointerDown = (pointerEvent: PointerEvent, finish: (commit: boolean) => void) => { + if (pointerEvent.button !== 0 && pointerEvent.button !== 2) return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(pointerEvent.button === 0) + } + const onKeyDown = (keyboardEvent: KeyboardEvent, finish: (commit: boolean) => void) => { + const element = keyboardEvent.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) { + return + } + const nextInput = blockTransformNumericInputFromKey(typedInput, keyboardEvent.key) + if (nextInput !== null) { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + typedInput = nextInput + setTransformNumericInput(nextInput) + updatePreview(lastClientX, lastClientY, lastAltKey, lastShiftKey) + } else if (keyboardEvent.key === 'Enter') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(true) + } else if (keyboardEvent.key === 'Escape') { + keyboardEvent.preventDefault() + keyboardEvent.stopImmediatePropagation() + finish(false) + } + } + + useInteractionScope.getState().begin(meshEditScope(nodeId, 'operating', operation)) + playSfx('operation-start') + closeToolbar() + setActiveFaceOperation(operation) + setFaceOperationValue('0') + setTransformNumericInput('') + setModalFeedbackMode('free') + setError(null) + beginBlockModalSession({ + cancelRef, + cursor: operation === 'extrude' ? 'ns-resize' : 'nwse-resize', + onFinish: complete, + onKeyDown, + onPointerDown, + onPointerMove: onMove, + }) + return true + }, + [ + camera, + cancelRef, + canvas, + closeToolbar, + commit, + displayTopology, + extent, + lastPointerClientRef, + mode, + nodeId, + ownsEditSession, + playSfx, + sceneApi, + selectedIds, + selection, + setActiveFaceOperation, + setError, + setFaceOperationValue, + setModalFeedbackMode, + setPreviewTopology, + setTransformNumericInput, + target, + ], + ) +} diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index 2f4f5d0d10..18856ba9fe 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -64,11 +64,13 @@ Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useVie ## Custom-mesh face materials -blockes use the reusable `MaterialRef` model through stable, user-named object slots. `BlockNode.slots` maps slot IDs to `scene:` or `library:` references, `slotNames` stores their editable labels, and each `BlockFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved slots. +Blocks use the reusable `MaterialRef` model through stable, user-named object slots. `BlockNode.slots` maps slot IDs to `scene:` or `library:` references, `slotNames` stores their editable labels, and each `BlockFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved slots. The geometry builder emits one Three.js group per topology face and a material array ordered by the node's stable slot IDs. It publishes that render-material order as `userData.slotIds` and records each face's vertex range in `geometry.userData.blockFaces`. The paint capability re-raycasts the mesh and maps the hit triangle through those ranges to a stable topology face ID, so preview and commit affect only that face. Face UVs retain the world-scale projection contract below. -The block inspector calls this collection **Slots**. Users add and rename slots independently from their material binding. A compact dropdown changes the active slot's material using deduplicated `MaterialRef`s already used by scene node slots plus reusable scene-material datablocks. Choosing a slot changes only the transient assignment source; **Assign** applies that slot to the selected faces. **Select** and **Deselect** only add or subtract faces using the active slot from transient component selection. +The block inspector calls this collection **Slots**. Users can rename slots and change a slot's material through a compact dropdown using deduplicated `MaterialRef`s already used by scene node slots plus reusable scene-material datablocks. While one or more faces are selected in edit mode, clicking a slot binds those faces to it immediately; there is no separate Assign / Select / Deselect button row. + +Adding a slot while faces are selected creates the slot, binds those faces to it, and assigns a distinct generated accent material in the same scene update. This makes the new surface visibly different in both edit mode and the rendered model before the user chooses a final paint material. With no selected faces, Add Slot is a no-op so it cannot create an invisible, unused slot. Deleting a non-body slot remaps every assigned face to `body` in the same node update, and `body` becomes the active assignment source. The reusable scene or library material remains available to other nodes. From 6c7452d461ffaa2f2e948cfc102944b37c9ced5c Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 14:30:54 +0530 Subject: [PATCH 09/12] fix(block): preserve constrained transform intent --- .../nodes/src/block/geometry-snap.test.ts | 45 ++++++++++++++++++- packages/nodes/src/block/geometry-snap.ts | 5 ++- .../nodes/src/block/modal-transform.test.ts | 6 +++ packages/nodes/src/block/modal-transform.ts | 11 +++++ packages/nodes/src/block/selection.tsx | 10 +++-- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/nodes/src/block/geometry-snap.test.ts b/packages/nodes/src/block/geometry-snap.test.ts index 14e733b893..77930733b1 100644 --- a/packages/nodes/src/block/geometry-snap.test.ts +++ b/packages/nodes/src/block/geometry-snap.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { createBoxBlockTopology } from '@pascal-app/core' +import { type BlockTopology, createBoxBlockTopology } from '@pascal-app/core' import { PerspectiveCamera, Vector3 } from 'three' import { blockGeometrySnapThreshold, resolveBlockGeometrySnap } from './geometry-snap' @@ -56,6 +56,49 @@ describe('block geometry snapping', () => { expect(snap?.delta[2]).toBe(0) }) + test('ranks nearby targets by their legal correction under an axis constraint', () => { + const topology: BlockTopology = { + vertices: [ + { id: 'source', position: [0, 0, 0] }, + { id: 'closer-in-3d', position: [0.08, 0.01, 0] }, + { id: 'closer-on-axis', position: [0.02, 0.09, 0] }, + ], + edges: [], + faces: [], + } + const snap = resolveBlockGeometrySnap( + topology, + { mode: 'vertex', ids: ['source'], activeId: 'source' }, + [0, 0, 0], + 'x', + 0.1, + ) + + expect(snap?.targetId).toBe('closer-on-axis') + expect(snap?.delta).toEqual([0.02, 0, 0]) + }) + + test('does not report geometry snap when a target requires no legal movement', () => { + const topology: BlockTopology = { + vertices: [ + { id: 'source', position: [0, 0, 0] }, + { id: 'off-axis', position: [0, 0.05, 0] }, + ], + edges: [], + faces: [], + } + + expect( + resolveBlockGeometrySnap( + topology, + { mode: 'vertex', ids: ['source'], activeId: 'source' }, + [0, 0, 0], + 'x', + 0.1, + ), + ).toBeNull() + }) + test('snaps an active face center onto another face surface', () => { const snap = resolveBlockGeometrySnap( createBoxBlockTopology(), diff --git a/packages/nodes/src/block/geometry-snap.ts b/packages/nodes/src/block/geometry-snap.ts index 0381fa505e..d287546de5 100644 --- a/packages/nodes/src/block/geometry-snap.ts +++ b/packages/nodes/src/block/geometry-snap.ts @@ -123,9 +123,10 @@ export function resolveBlockGeometrySnap( ) => { const movedSource = source.map((value, index) => value + proposedDelta[index]!) as Point const correction = target.map((value, index) => value - movedSource[index]!) as Point - const distance = Math.hypot(...correction) - if (distance > threshold || (best && distance >= best.distance)) return + if (Math.hypot(...correction) > threshold) return const allowedCorrection = constrainedCorrection(correction, constraint) + const distance = Math.hypot(...allowedCorrection) + if (distance <= 1e-8 || (best && distance >= best.distance)) return best = { delta: proposedDelta.map((value, index) => value + allowedCorrection[index]!) as Point, distance, diff --git a/packages/nodes/src/block/modal-transform.test.ts b/packages/nodes/src/block/modal-transform.test.ts index 56e0978a82..d0b39fd707 100644 --- a/packages/nodes/src/block/modal-transform.test.ts +++ b/packages/nodes/src/block/modal-transform.test.ts @@ -3,6 +3,7 @@ import { blockAccumulatePrecisionPointer, blockAxisDelta, blockAxisVisualState, + blockConstrainTranslationDelta, blockModalTransformStatus, blockNumericDeltaForConstraint, blockPlaneVisualState, @@ -68,6 +69,11 @@ describe('block modal transform', () => { expect(blockTransformConstraintFromKey('x', false)).toBe('x') }) + test('keeps accumulated movement when it is projected onto a plane lock', () => { + expect(blockConstrainTranslationDelta([0.6, 0.4, 0.2], 'xy')).toEqual([0.6, 0.4, 0]) + expect(blockConstrainTranslationDelta([0.6, 0.4, 0.2], 'yz')).toEqual([0, 0.4, 0.2]) + }) + test('keeps the constrained plane axes and plane handle colorful', () => { const active = { operation: 'translate', constraint: 'xz' } as const expect(blockAxisVisualState(active, 'translate', 'x')).toBe('active') diff --git a/packages/nodes/src/block/modal-transform.ts b/packages/nodes/src/block/modal-transform.ts index ea234d0368..27125f656c 100644 --- a/packages/nodes/src/block/modal-transform.ts +++ b/packages/nodes/src/block/modal-transform.ts @@ -111,6 +111,17 @@ export function blockAxisDelta( return [axis === 'x' ? distance : 0, axis === 'y' ? distance : 0, axis === 'z' ? distance : 0] } +export function blockConstrainTranslationDelta( + delta: [number, number, number], + constraint: BlockTransformConstraint, +): [number, number, number] { + if (constraint === 'free' || constraint === 'uniform') return delta + return delta.map((value, index) => { + const axis = index === 0 ? 'x' : index === 1 ? 'y' : 'z' + return constraint.includes(axis) ? value : 0 + }) as [number, number, number] +} + export function blockNumericDeltaForConstraint( constraint: BlockTransformConstraint, pointerDelta: [number, number, number], diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 6d1fd23475..366c11131e 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -110,6 +110,7 @@ import { blockAccumulatePrecisionPointer, blockAxisDelta, blockAxisVisualState, + blockConstrainTranslationDelta, blockModalTransformStatus, blockNumericDeltaForConstraint, blockPlaneVisualState, @@ -1949,9 +1950,10 @@ function BlockEditor({ const localPoint = target.worldToLocal( worldOrigin.clone().add(currentHit.sub(lockedTranslationInitialHit)), ) - delta = [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]] - const excludedAxis = PLANE_NORMAL[activeConstraint] - delta[excludedAxis === 'x' ? 0 : excludedAxis === 'y' ? 1 : 2] = 0 + delta = blockConstrainTranslationDelta( + [localPoint.x - origin[0], localPoint.y - origin[1], localPoint.z - origin[2]], + activeConstraint, + ) } else { const currentHit = ray.intersectPlane(viewPlane, new Vector3()) if (!currentHit) return @@ -2159,7 +2161,7 @@ function BlockEditor({ worldAxisFor(normalAxis), worldOrigin, ) - lockedTranslationInitialHit = makeRay(lastClientX, lastClientY).intersectPlane( + lockedTranslationInitialHit = startRay.intersectPlane( lockedTranslationPlane, new Vector3(), ) From b15178bfec17865883d6c79291134c9def48ca9c Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 20 Aug 2026 14:47:22 +0530 Subject: [PATCH 10/12] fix(block): key last operation controls --- packages/nodes/src/block/selection.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nodes/src/block/selection.tsx b/packages/nodes/src/block/selection.tsx index 366c11131e..12654e2048 100644 --- a/packages/nodes/src/block/selection.tsx +++ b/packages/nodes/src/block/selection.tsx @@ -1330,7 +1330,10 @@ function LastOperationControls({ update: (value: number) => BlockCommand, options: { min?: number; max?: number; step?: number } = {}, ) => ( -
{lastOperation ? ( -
- setLastOperationPanelOpen((open) => !open)} - > - - - {lastOperationPanelOpen ? ( - -
-
-
{lastOperation.label}
-
- Adjust Last Operation · F9 -
-
- -
- - -
- ) : null} -
+ setLastOperationPanelOpen((open) => !open)} + > + + ) : null} @@ -3967,6 +3976,21 @@ function BlockEditor({ ) : null} + {editing && lastOperation && lastOperationPanelOpen ? ( + [size.width / 2, size.height / 2]} + fullscreen + style={{ pointerEvents: 'none' }} + zIndexRange={[80, 0]} + > + setLastOperationPanelOpen(false)} + onRepeat={repeatLastOperation} + operation={lastOperation} + /> + + ) : null} ) }