From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/10] 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/10] 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 ea19e80d97906fbfdcf6f18ab910f9a00cb08200 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 10 Aug 2026 16:39:04 +0530 Subject: [PATCH 03/10] feat: add Blender-style custom mesh edit mode --- apps/editor/components/build-tab.tsx | 14 +- packages/core/src/events/bus.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 10 + .../core/src/schema/nodes/custom-mesh.test.ts | 25 + packages/core/src/schema/nodes/custom-mesh.ts | 168 ++ packages/core/src/schema/nodes/level.ts | 2 + packages/core/src/schema/types.ts | 2 + .../components/editor/selection-manager.tsx | 8 +- packages/editor/src/index.tsx | 1 + packages/editor/src/lib/interaction/scope.ts | 29 + .../src/store/use-interaction-scope.test.ts | 18 + .../nodes/src/custom-mesh/commands.test.ts | 253 +++ packages/nodes/src/custom-mesh/commands.ts | 855 ++++++++ .../nodes/src/custom-mesh/definition.test.ts | 37 + packages/nodes/src/custom-mesh/definition.ts | 111 + packages/nodes/src/custom-mesh/floorplan.ts | 53 + .../nodes/src/custom-mesh/geometry.test.ts | 38 + packages/nodes/src/custom-mesh/geometry.ts | 118 ++ packages/nodes/src/custom-mesh/preview.tsx | 38 + .../src/custom-mesh/rotation-drag.test.ts | 21 + .../nodes/src/custom-mesh/rotation-drag.ts | 12 + packages/nodes/src/custom-mesh/schema.ts | 1 + .../src/custom-mesh/selection-model.test.ts | 45 + .../nodes/src/custom-mesh/selection-model.ts | 114 + packages/nodes/src/custom-mesh/selection.tsx | 1887 +++++++++++++++++ packages/nodes/src/custom-mesh/tool.tsx | 136 ++ packages/nodes/src/index.ts | 11 + wiki/architecture/interaction-scope.md | 60 +- wiki/blender-edit-mode-research.md | 406 ++++ wiki/blender-loop-cut-research.md | 165 ++ 31 files changed, 4610 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/schema/nodes/custom-mesh.test.ts create mode 100644 packages/core/src/schema/nodes/custom-mesh.ts create mode 100644 packages/nodes/src/custom-mesh/commands.test.ts create mode 100644 packages/nodes/src/custom-mesh/commands.ts create mode 100644 packages/nodes/src/custom-mesh/definition.test.ts create mode 100644 packages/nodes/src/custom-mesh/definition.ts create mode 100644 packages/nodes/src/custom-mesh/floorplan.ts create mode 100644 packages/nodes/src/custom-mesh/geometry.test.ts create mode 100644 packages/nodes/src/custom-mesh/geometry.ts create mode 100644 packages/nodes/src/custom-mesh/preview.tsx create mode 100644 packages/nodes/src/custom-mesh/rotation-drag.test.ts create mode 100644 packages/nodes/src/custom-mesh/rotation-drag.ts create mode 100644 packages/nodes/src/custom-mesh/schema.ts create mode 100644 packages/nodes/src/custom-mesh/selection-model.test.ts create mode 100644 packages/nodes/src/custom-mesh/selection-model.ts create mode 100644 packages/nodes/src/custom-mesh/selection.tsx create mode 100644 packages/nodes/src/custom-mesh/tool.tsx create mode 100644 wiki/blender-edit-mode-research.md create mode 100644 wiki/blender-loop-cut-research.md diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0440e1d1ea..b42f82b065 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -13,7 +13,7 @@ import { } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { Tooltip, TooltipContent, @@ -78,6 +78,8 @@ const BASE_BUILD_TYPES: BuildType[] = [ { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, ] +const subscribeToClientMount = () => () => {} + function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ @@ -209,7 +211,15 @@ export function BuildTab() { const floorplanMode = useFloorplanMode((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) - const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode]) + const registryReady = useSyncExternalStore( + subscribeToClientMount, + () => true, + () => false, + ) + const buildTypes = useMemo( + () => (registryReady ? collectBuildTypes(floorplanMode) : BASE_BUILD_TYPES), + [floorplanMode, registryReady], + ) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 55fdcfd9ba..8783fa2937 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -11,6 +11,7 @@ import type { ColumnNode, ConstructionDimensionNode, CupolaNode, + CustomMeshNode, DoorNode, DormerNode, DownspoutNode, @@ -104,6 +105,7 @@ export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type ColumnEvent = NodeEvent export type ConstructionDimensionEvent = NodeEvent +export type CustomMeshEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent @@ -305,6 +307,7 @@ type EditorEvents = GridEvents & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & NodeEvents<'construction-dimension', ConstructionDimensionEvent> & + NodeEvents<'custom-mesh', CustomMeshEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9bf1a6b741..72014e5b9b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export type { ChimneyEvent, ColumnEvent, ConstructionDimensionEvent, + CustomMeshEvent, DoorEvent, DormerEvent, ElevatorEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index d11bdec7d1..7747659309 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -74,6 +74,16 @@ export { setConstructionDimensionDrawingSuppressedSegments, } from './nodes/construction-dimension' export { CupolaNode } from './nodes/cupola' +export { + CustomMeshEdge, + CustomMeshFace, + CustomMeshNode, + CustomMeshTopology, + type CustomMeshTopologyIssue, + CustomMeshVertex, + createBoxCustomMeshTopology, + inspectCustomMeshTopology, +} from './nodes/custom-mesh' export { DoorNode, DoorSegment, diff --git a/packages/core/src/schema/nodes/custom-mesh.test.ts b/packages/core/src/schema/nodes/custom-mesh.test.ts new file mode 100644 index 0000000000..7aa4175850 --- /dev/null +++ b/packages/core/src/schema/nodes/custom-mesh.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' +import { + CustomMeshNode, + CustomMeshTopology, + createBoxCustomMeshTopology, + inspectCustomMeshTopology, +} from './custom-mesh' + +describe('CustomMeshNode', () => { + test('creates a valid topology-backed box by default', () => { + const node = CustomMeshNode.parse({ name: 'Editable box' }) + + expect(node.topology.vertices).toHaveLength(8) + expect(node.topology.edges).toHaveLength(12) + expect(node.topology.faces).toHaveLength(6) + expect(inspectCustomMeshTopology(node.topology)).toEqual([]) + }) + + test('rejects a face loop without a persisted boundary edge', () => { + const topology = createBoxCustomMeshTopology() + topology.edges = topology.edges.filter((edge) => edge.id !== 'e4') + + expect(CustomMeshTopology.safeParse(topology).success).toBe(false) + }) +}) diff --git a/packages/core/src/schema/nodes/custom-mesh.ts b/packages/core/src/schema/nodes/custom-mesh.ts new file mode 100644 index 0000000000..b662bb1c2a --- /dev/null +++ b/packages/core/src/schema/nodes/custom-mesh.ts @@ -0,0 +1,168 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const CustomMeshVertex = z.object({ + id: z.string().min(1), + position: z.tuple([z.number(), z.number(), z.number()]), +}) + +export const CustomMeshEdge = z.object({ + id: z.string().min(1), + vertexIds: z.tuple([z.string().min(1), z.string().min(1)]), +}) + +export const CustomMeshFace = z.object({ + id: z.string().min(1), + vertexIds: z.array(z.string().min(1)).min(3), + materialSlot: z.string().min(1).default('body'), +}) + +const CustomMeshTopologyShape = z.object({ + vertices: z.array(CustomMeshVertex), + edges: z.array(CustomMeshEdge), + faces: z.array(CustomMeshFace), +}) + +export type CustomMeshVertex = z.infer +export type CustomMeshEdge = z.infer +export type CustomMeshFace = z.infer +export type CustomMeshTopology = z.infer + +export type CustomMeshTopologyIssue = { + path: (string | number)[] + message: string +} + +const edgeKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`) + +export function inspectCustomMeshTopology(topology: CustomMeshTopology): CustomMeshTopologyIssue[] { + const issues: CustomMeshTopologyIssue[] = [] + const vertexIds = new Set() + const edgeIds = new Set() + const faceIds = new Set() + const edgeKeys = new Set() + + topology.vertices.forEach((vertex, index) => { + if (vertexIds.has(vertex.id)) { + issues.push({ path: ['vertices', index, 'id'], message: `Duplicate vertex id: ${vertex.id}` }) + } + vertexIds.add(vertex.id) + }) + + topology.edges.forEach((edge, index) => { + if (edgeIds.has(edge.id)) { + issues.push({ path: ['edges', index, 'id'], message: `Duplicate edge id: ${edge.id}` }) + } + edgeIds.add(edge.id) + const [a, b] = edge.vertexIds + if (a === b) { + issues.push({ path: ['edges', index, 'vertexIds'], message: 'An edge needs two vertices' }) + } + edge.vertexIds.forEach((vertexId, vertexIndex) => { + if (!vertexIds.has(vertexId)) { + issues.push({ + path: ['edges', index, 'vertexIds', vertexIndex], + message: `Unknown vertex id: ${vertexId}`, + }) + } + }) + const key = edgeKey(a, b) + if (edgeKeys.has(key)) { + issues.push({ path: ['edges', index], message: `Duplicate edge: ${a}–${b}` }) + } + edgeKeys.add(key) + }) + + topology.faces.forEach((face, index) => { + if (faceIds.has(face.id)) { + issues.push({ path: ['faces', index, 'id'], message: `Duplicate face id: ${face.id}` }) + } + faceIds.add(face.id) + if (new Set(face.vertexIds).size < 3) { + issues.push({ path: ['faces', index, 'vertexIds'], message: 'A face needs three vertices' }) + } + face.vertexIds.forEach((vertexId, vertexIndex) => { + if (!vertexIds.has(vertexId)) { + issues.push({ + path: ['faces', index, 'vertexIds', vertexIndex], + message: `Unknown vertex id: ${vertexId}`, + }) + } + const nextVertexId = face.vertexIds[(vertexIndex + 1) % face.vertexIds.length] + if (nextVertexId && !edgeKeys.has(edgeKey(vertexId, nextVertexId))) { + issues.push({ + path: ['faces', index, 'vertexIds', vertexIndex], + message: `Missing edge for face boundary: ${vertexId}–${nextVertexId}`, + }) + } + }) + }) + + return issues +} + +export const CustomMeshTopology = CustomMeshTopologyShape.superRefine((topology, context) => { + for (const issue of inspectCustomMeshTopology(topology)) { + context.addIssue({ code: 'custom', path: issue.path, message: issue.message }) + } +}) + +export function createBoxCustomMeshTopology( + width = 2, + height = 2.4, + depth = 2, +): CustomMeshTopology { + const halfWidth = width / 2 + const halfDepth = depth / 2 + return { + vertices: [ + { id: 'v0', position: [-halfWidth, 0, -halfDepth] }, + { id: 'v1', position: [halfWidth, 0, -halfDepth] }, + { id: 'v2', position: [halfWidth, 0, halfDepth] }, + { id: 'v3', position: [-halfWidth, 0, halfDepth] }, + { id: 'v4', position: [-halfWidth, height, -halfDepth] }, + { id: 'v5', position: [halfWidth, height, -halfDepth] }, + { id: 'v6', position: [halfWidth, height, halfDepth] }, + { id: 'v7', position: [-halfWidth, height, halfDepth] }, + ], + edges: [ + { id: 'e0', vertexIds: ['v0', 'v1'] }, + { id: 'e1', vertexIds: ['v1', 'v2'] }, + { id: 'e2', vertexIds: ['v2', 'v3'] }, + { id: 'e3', vertexIds: ['v3', 'v0'] }, + { id: 'e4', vertexIds: ['v4', 'v5'] }, + { id: 'e5', vertexIds: ['v5', 'v6'] }, + { id: 'e6', vertexIds: ['v6', 'v7'] }, + { id: 'e7', vertexIds: ['v7', 'v4'] }, + { id: 'e8', vertexIds: ['v0', 'v4'] }, + { id: 'e9', vertexIds: ['v1', 'v5'] }, + { id: 'e10', vertexIds: ['v2', 'v6'] }, + { id: 'e11', vertexIds: ['v3', 'v7'] }, + ], + faces: [ + { id: 'f-bottom', vertexIds: ['v0', 'v1', 'v2', 'v3'], materialSlot: 'body' }, + { id: 'f-top', vertexIds: ['v4', 'v7', 'v6', 'v5'], materialSlot: 'body' }, + { id: 'f-front', vertexIds: ['v0', 'v4', 'v5', 'v1'], materialSlot: 'body' }, + { id: 'f-right', vertexIds: ['v1', 'v5', 'v6', 'v2'], materialSlot: 'body' }, + { id: 'f-back', vertexIds: ['v2', 'v6', 'v7', 'v3'], materialSlot: 'body' }, + { id: 'f-left', vertexIds: ['v3', 'v7', 'v4', 'v0'], materialSlot: 'body' }, + ], + } +} + +export const CustomMeshNode = BaseNode.extend({ + id: objectId('custom-mesh'), + type: nodeType('custom-mesh'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + topology: CustomMeshTopology.default(createBoxCustomMeshTopology), + slots: z.record(z.string(), z.string()).optional(), +}).describe(dedent` + Custom mesh node - a topology-backed editable solid. + - topology: persistent vertices, edges, and ordered face loops with stable IDs + - position/rotation: level-local placement transform + - slots: optional material references keyed by face materialSlot +`) + +export type CustomMeshNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 2032c61a7e..3dbe3e0010 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -4,6 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base' import type { CeilingNode } from './ceiling' import type { ColumnNode } from './column' import type { ConstructionDimensionNode } from './construction-dimension' +import type { CustomMeshNode } from './custom-mesh' import type { DuctFittingNode } from './duct-fitting' import type { DuctSegmentNode } from './duct-segment' import type { DuctTerminalNode } from './duct-terminal' @@ -32,6 +33,7 @@ type CoreLevelChildId = | FenceNode['id'] | ColumnNode['id'] | ConstructionDimensionNode['id'] + | CustomMeshNode['id'] | StructuralGridNode['id'] | ItemNode['id'] | ZoneNode['id'] diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 70575bcfb9..f52b5e05cc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -7,6 +7,7 @@ import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' import { ConstructionDimensionNode } from './nodes/construction-dimension' import { CupolaNode } from './nodes/cupola' +import { CustomMeshNode } from './nodes/custom-mesh' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' import { DownspoutNode } from './nodes/downspout' @@ -52,6 +53,7 @@ export const AnyNode = z.discriminatedUnion('type', [ LevelNode, ColumnNode, ConstructionDimensionNode, + CustomMeshNode, StructuralGridNode, WallNode, FenceNode, diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 2adde5b2a7..8a6938d8af 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -42,6 +42,7 @@ import { resolveDirectRotationPatch, } from '../../lib/direct-manipulation' import { createEditorApi } from '../../lib/editor-api' +import { selectionEnabled } from '../../lib/interaction/scope' import { type ActivePaintMaterial, buildRoofSegmentSurfaceMaterialPatch, @@ -1228,6 +1229,7 @@ export const SelectionManager = () => { if (movingNode || isCurveReshape) return const onPointerDown = (event: NodeEvent) => { + if (!selectionEnabled(useInteractionScope.getState().scope)) return const pointer = pointerEventFromNodeEvent(event) if (pointer.button !== 0) return @@ -1381,7 +1383,7 @@ export const SelectionManager = () => { if (key === prevKey) return prevKey = key let wantsMove = false - if (hoveredId && !getMovingNode()) { + if (hoveredId && !getMovingNode() && selectionEnabled(useInteractionScope.getState().scope)) { if (sole === hoveredId) { const node = useScene.getState().nodes[sole as AnyNodeId] wantsMove = !!node && canDirectMoveNode(node) @@ -1544,6 +1546,7 @@ export const SelectionManager = () => { // body click so only the reshape tool handles the release. (Scoped to // `endpoint`: hole-edit relies on node clicks to exit, just below.) const activeScope = useInteractionScope.getState().scope + if (activeScope.kind === 'mesh-editing') return if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return if (dispatchSceneAction(event.node, getEventObject(event))) { @@ -1760,6 +1763,7 @@ export const SelectionManager = () => { const onGridClick = (event: GridEvent) => { if (clickHandledRef.current) return if (boxSelectHandled) return + if (useInteractionScope.getState().scope.kind === 'mesh-editing') return const nativeEvent = event.nativeEvent if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return const { phase, structureLayer } = useEditor.getState() @@ -1789,6 +1793,7 @@ export const SelectionManager = () => { if (movingNode || isCurveReshape) return const onEnter = (event: NodeEvent) => { + if (useInteractionScope.getState().scope.kind === 'mesh-editing') return // A host-driven drag (handle resize/rotate, box-select) sets // `inputDragging`. useNodeEvents still emits hover events during it so // surface move tools keep tracking — but the select-hover outline must @@ -1837,6 +1842,7 @@ export const SelectionManager = () => { } const onDoubleClick = (event: NodeEvent) => { + if (useInteractionScope.getState().scope.kind === 'mesh-editing') return let node = resolveCanvasSelectionNode({ node: resolveSelectModeNodeTarget(event), nodes: useScene.getState().nodes, diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 212478ab49..b15bb0b950 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -397,6 +397,7 @@ export { curveReshapeScope, endpointReshapeScope, holeEditScope, + meshEditScope, movingNodeOf, scopeNodeId, } from './lib/interaction/scope' diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 03e0db7b1a..da694d38fb 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -40,6 +40,24 @@ export type InteractionScope = | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView } // Dragging a resize/translate/rotate handle of a selected node. | { kind: 'handle-drag'; nodeId: string; handle: string } + // Editing the internal topology of one custom mesh. This scope remains + // active for the whole edit-mode session so scene selection and whole-node + // movement cannot claim the same pointer stream. + | { + kind: 'mesh-editing' + nodeId: string + phase: 'selecting' | 'operating' + operator?: + | 'translate' + | 'rotate' + | 'scale' + | 'extrude' + | 'inset' + | 'merge' + | 'dissolve' + | 'loop-cut' + | 'delete' + } // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | { kind: 'drafting'; tool: string } // Reshaping a selected node's geometry (see ReshapeKind). `holeIndex` is set @@ -88,6 +106,7 @@ export function scopeNodeId(scope: InteractionScope): string | null { case 'placing': case 'moving': case 'handle-drag': + case 'mesh-editing': case 'reshaping': return scope.nodeId default: @@ -111,6 +130,16 @@ export function selectionEnabled(scope: InteractionScope): boolean { return scope.kind === 'idle' } +export function meshEditScope( + nodeId: string, + phase: 'selecting' | 'operating' = 'selecting', + operator?: Extract['operator'], +): ActiveInteractionScope { + return operator + ? { kind: 'mesh-editing', nodeId, phase, operator } + : { kind: 'mesh-editing', nodeId, phase } +} + // Derived views of the scope that mirror the legacy `useEditor` flags they // replaced. Each returns null unless that exact interaction is active, so a // stale payload is unrepresentable: the value is a pure function of the single diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index c3b9a10353..3f6568f6e7 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -8,6 +8,7 @@ import { isActive, isIdle, isToolDrivenReshape, + meshEditScope, scopeNodeId, selectionEnabled, } from '../lib/interaction/scope' @@ -116,6 +117,22 @@ describe('use-interaction-scope state machine', () => { expect(isActive(useInteractionScope.getState().scope)).toBe(true) }) + test('mesh edit mode owns its node and disables scene selection for the full session', () => { + const s = useInteractionScope.getState() + s.begin(meshEditScope('custom-mesh_1')) + expect(scopeNodeId(useInteractionScope.getState().scope)).toBe('custom-mesh_1') + expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(false) + + s.begin(meshEditScope('custom-mesh_1', 'operating', 'translate')) + expect(useInteractionScope.getState().scope).toEqual({ + kind: 'mesh-editing', + nodeId: 'custom-mesh_1', + phase: 'operating', + operator: 'translate', + }) + expect(selectionEnabled(useInteractionScope.getState().scope)).toBe(false) + }) + test('end is idempotent', () => { const s = useInteractionScope.getState() s.end() @@ -191,6 +208,7 @@ describe('derived flag views are leak-free (no parallel flags)', () => { }, { kind: 'moving', node: mockNode('i', 'item'), nodeId: 'i', nodeType: 'item', view: '3d' }, { kind: 'drafting', tool: 'wall' }, + { kind: 'mesh-editing', nodeId: 'mesh_1', phase: 'selecting' }, { kind: 'box-select' }, { kind: 'painting' }, { kind: 'sculpting' }, diff --git a/packages/nodes/src/custom-mesh/commands.test.ts b/packages/nodes/src/custom-mesh/commands.test.ts new file mode 100644 index 0000000000..f7d6ae0035 --- /dev/null +++ b/packages/nodes/src/custom-mesh/commands.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test' +import { createBoxCustomMeshTopology, inspectCustomMeshTopology } from '@pascal-app/core' +import { applyCustomMeshCommand } from './commands' + +describe('applyCustomMeshCommand', () => { + test('extrudes a face while retaining valid stable topology', () => { + const topology = createBoxCustomMeshTopology() + const result = applyCustomMeshCommand(topology, { + type: 'extrude-face', + faceId: 'f-top', + distance: 0.25, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(topology.vertices).toHaveLength(8) + expect(result.topology.vertices).toHaveLength(12) + expect(result.topology.edges).toHaveLength(20) + expect(result.topology.faces).toHaveLength(10) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + const cap = result.topology.faces.find((face) => face.id === 'f-top')! + const capVertices = cap.vertexIds.map( + (id) => result.topology.vertices.find((vertex) => vertex.id === id)!, + ) + expect(capVertices.every((vertex) => vertex.position[1] === 2.65)).toBe(true) + }) + + test('can extrude the resulting cap again without colliding IDs', () => { + const first = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'extrude-face', + faceId: 'f-top', + distance: 0.25, + }) + expect(first.ok).toBe(true) + if (!first.ok) return + + const second = applyCustomMeshCommand(first.topology, { + type: 'extrude-face', + faceId: 'f-top', + distance: 0.25, + }) + expect(second.ok).toBe(true) + if (!second.ok) return + expect(new Set(second.topology.vertices.map((vertex) => vertex.id)).size).toBe( + second.topology.vertices.length, + ) + expect(new Set(second.topology.edges.map((edge) => edge.id)).size).toBe( + second.topology.edges.length, + ) + expect(new Set(second.topology.faces.map((face) => face.id)).size).toBe( + second.topology.faces.length, + ) + expect(inspectCustomMeshTopology(second.topology)).toEqual([]) + }) + + test('reports an invalid face selection without changing topology', () => { + const topology = createBoxCustomMeshTopology() + expect( + applyCustomMeshCommand(topology, { + type: 'extrude-face', + faceId: 'missing', + distance: 0.25, + }), + ).toEqual({ ok: false, error: 'Face not found: missing' }) + }) + + test('moves vertices selected directly or through edges and faces', () => { + const topology = createBoxCustomMeshTopology() + const vertexResult = applyCustomMeshCommand(topology, { + type: 'translate-components', + selection: { mode: 'vertex', ids: ['v6'] }, + delta: [0.5, 0.25, -0.25], + }) + expect(vertexResult.ok).toBe(true) + if (!vertexResult.ok) return + expect(vertexResult.topology.vertices.find((vertex) => vertex.id === 'v6')?.position).toEqual([ + 1.5, 2.65, 0.75, + ]) + + const edgeResult = applyCustomMeshCommand(topology, { + type: 'translate-components', + selection: { mode: 'edge', ids: ['e4'] }, + delta: [0, 0.5, 0], + }) + expect(edgeResult.ok).toBe(true) + if (!edgeResult.ok) return + expect(edgeResult.topology.vertices.find((vertex) => vertex.id === 'v4')?.position[1]).toBe(2.9) + expect(edgeResult.topology.vertices.find((vertex) => vertex.id === 'v5')?.position[1]).toBe(2.9) + + const faceResult = applyCustomMeshCommand(topology, { + type: 'translate-components', + selection: { mode: 'face', ids: ['f-top'] }, + delta: [0, 0.5, 0], + }) + expect(faceResult.ok).toBe(true) + if (!faceResult.ok) return + expect( + faceResult.topology.vertices + .filter((vertex) => ['v4', 'v5', 'v6', 'v7'].includes(vertex.id)) + .every((vertex) => vertex.position[1] === 2.9), + ).toBe(true) + expect(inspectCustomMeshTopology(faceResult.topology)).toEqual([]) + }) + + test('rotates and scales selected components around an explicit pivot', () => { + const topology = createBoxCustomMeshTopology() + const rotated = applyCustomMeshCommand(topology, { + type: 'rotate-components', + selection: { mode: 'vertex', ids: ['v6'] }, + pivot: [0, 0, 0], + axis: [0, 1, 0], + angle: Math.PI / 2, + }) + expect(rotated.ok).toBe(true) + if (!rotated.ok) return + const rotatedPosition = rotated.topology.vertices.find((vertex) => vertex.id === 'v6')!.position + expect(rotatedPosition[0]).toBeCloseTo(1) + expect(rotatedPosition[1]).toBeCloseTo(2.4) + expect(rotatedPosition[2]).toBeCloseTo(-1) + + const scaled = applyCustomMeshCommand(topology, { + type: 'scale-components', + selection: { mode: 'face', ids: ['f-top'] }, + pivot: [0, 2.4, 0], + factors: [0.5, 1, 0.5], + }) + expect(scaled.ok).toBe(true) + if (!scaled.ok) return + expect(scaled.topology.vertices.find((vertex) => vertex.id === 'v6')?.position).toEqual([ + 0.5, 2.4, 0.5, + ]) + expect(inspectCustomMeshTopology(scaled.topology)).toEqual([]) + }) + + test('insets a face into a valid inner face and surrounding ring', () => { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'inset-face', + faceId: 'f-top', + amount: 0.2, + depth: 0, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.vertices).toHaveLength(12) + expect(result.topology.edges).toHaveLength(20) + expect(result.topology.faces).toHaveLength(10) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + }) + + test('deletes selected faces, edges, or vertices without invalid references', () => { + for (const selection of [ + { mode: 'face' as const, ids: ['f-top'] }, + { mode: 'edge' as const, ids: ['e4'] }, + { mode: 'vertex' as const, ids: ['v4'] }, + ]) { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'delete-components', + selection, + }) + expect(result.ok).toBe(true) + if (!result.ok) continue + expect(result.selection.ids).toEqual([]) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + } + }) + + test('merges selected vertices at their center and collapses duplicate boundaries', () => { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'merge-vertices', + vertexIds: ['v4', 'v5'], + }) + + expect(result.ok).toBe(true) + 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([ + 0, 2.4, -1, + ]) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + }) + + test('dissolves a shared edge into one valid face loop', () => { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'dissolve-edge', + edgeId: 'e4', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.edges).toHaveLength(11) + expect(result.topology.faces).toHaveLength(5) + expect(result.selection).toEqual({ mode: 'face', ids: ['f-top'] }) + expect(result.topology.faces.find((face) => face.id === 'f-top')?.vertexIds).toEqual([ + 'v4', + 'v7', + 'v6', + 'v5', + 'v1', + 'v0', + ]) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + }) + + test('cuts a connected quad ring and selects the inserted loop', () => { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'loop-cut', + edgeId: 'e8', + factor: 0.25, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.vertices).toHaveLength(12) + expect(result.topology.edges).toHaveLength(20) + expect(result.topology.faces).toHaveLength(10) + expect(result.selection.mode).toBe('edge') + expect(result.selection.ids).toHaveLength(4) + const selectedEdges = result.topology.edges.filter((edge) => + result.selection.ids.includes(edge.id), + ) + const vertices = new Map( + result.topology.vertices.map((vertex) => [vertex.id, vertex.position] as const), + ) + expect(selectedEdges).toHaveLength(4) + expect( + selectedEdges.every((edge) => + edge.vertexIds.every((vertexId) => Math.abs(vertices.get(vertexId)![1] - 0.6) < 1e-8), + ), + ).toBe(true) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + }) + + test('rejects a loop cut when the hovered edge does not lead through quads', () => { + const dissolved = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'dissolve-edge', + edgeId: 'e4', + }) + expect(dissolved.ok).toBe(true) + if (!dissolved.ok) return + + expect( + applyCustomMeshCommand(dissolved.topology, { + type: 'loop-cut', + edgeId: 'e0', + factor: 0.5, + }), + ).toEqual({ ok: false, error: 'Loop cut requires a connected ring of quad faces' }) + }) +}) diff --git a/packages/nodes/src/custom-mesh/commands.ts b/packages/nodes/src/custom-mesh/commands.ts new file mode 100644 index 0000000000..21e3f22d5a --- /dev/null +++ b/packages/nodes/src/custom-mesh/commands.ts @@ -0,0 +1,855 @@ +import { + type CustomMeshEdge, + type CustomMeshFace, + type CustomMeshTopology, + type CustomMeshVertex, + inspectCustomMeshTopology, +} from '@pascal-app/core' +import type { CustomMeshSelection } from './selection-model' + +export type { CustomMeshSelection } from './selection-model' + +type Point = [number, number, number] + +export type CustomMeshCommand = + | { + type: 'extrude-face' + faceId: string + distance: number + } + | { + type: 'translate-components' + selection: CustomMeshSelection + delta: Point + } + | { + type: 'rotate-components' + selection: CustomMeshSelection + pivot: Point + axis: Point + angle: number + } + | { + type: 'scale-components' + selection: CustomMeshSelection + pivot: Point + factors: Point + } + | { + type: 'inset-face' + faceId: string + amount: number + depth: number + } + | { + type: 'delete-components' + selection: CustomMeshSelection + } + | { + type: 'merge-vertices' + vertexIds: string[] + } + | { + type: 'dissolve-edge' + edgeId: string + } + | { + type: 'loop-cut' + edgeId: string + factor: number + } + +export type CustomMeshCommandResult = + | { ok: true; topology: CustomMeshTopology; selection: CustomMeshSelection } + | { ok: false; error: string } + +function normalize(point: Point): Point | null { + const length = Math.hypot(point[0], point[1], point[2]) + if (length < 1e-8) return null + return [point[0] / length, point[1] / length, point[2] / length] +} + +export function customMeshFaceNormal( + topology: CustomMeshTopology, + face: CustomMeshFace, +): Point | null { + const vertices = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) + const positions = face.vertexIds + .map((id) => vertices.get(id)) + .filter((value): value is Point => !!value) + if (positions.length < 3) return null + + const normal: Point = [0, 0, 0] + for (let index = 0; index < positions.length; index += 1) { + const current = positions[index]! + const next = positions[(index + 1) % positions.length]! + normal[0] += (current[1] - next[1]) * (current[2] + next[2]) + normal[1] += (current[2] - next[2]) * (current[0] + next[0]) + normal[2] += (current[0] - next[0]) * (current[1] + next[1]) + } + return normalize(normal) +} + +export function customMeshFaceCentroid( + topology: CustomMeshTopology, + face: CustomMeshFace, +): Point | null { + const vertices = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) + const positions = face.vertexIds + .map((id) => vertices.get(id)) + .filter((value): value is Point => !!value) + if (positions.length !== face.vertexIds.length || positions.length === 0) return null + const total = positions.reduce( + (sum, position) => [sum[0] + position[0], sum[1] + position[1], sum[2] + position[2]], + [0, 0, 0], + ) + return [total[0] / positions.length, total[1] / positions.length, total[2] / positions.length] +} + +function nextNumericId(prefix: string, ids: readonly string[]): () => string { + const pattern = new RegExp(`^${prefix}(\\d+)$`) + let next = ids.reduce((highest, id) => { + const match = pattern.exec(id) + return match ? Math.max(highest, Number(match[1]) + 1) : highest + }, 0) + const occupied = new Set(ids) + return () => { + let candidate = `${prefix}${next++}` + while (occupied.has(candidate)) candidate = `${prefix}${next++}` + occupied.add(candidate) + return candidate + } +} + +const topologyEdgeKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`) + +type LoopCutStep = { + faceId: string + fromEdgeId: string + toEdgeId: string +} + +type LoopCutRing = { + steps: LoopCutStep[] + orientedEdgeVertices: Map +} + +function oppositeOrientedEdgeVertices( + face: CustomMeshFace, + orientedVertices: [string, string], +): [string, string] | null { + if (face.vertexIds.length !== 4) return null + const [from, to] = orientedVertices + const index = face.vertexIds.indexOf(from) + if (index < 0) return null + if (face.vertexIds[(index + 1) % 4] === to) { + return [face.vertexIds[(index + 3) % 4]!, face.vertexIds[(index + 2) % 4]!] + } + if (face.vertexIds[(index + 3) % 4] === to) { + return [face.vertexIds[(index + 1) % 4]!, face.vertexIds[(index + 2) % 4]!] + } + return null +} + +function resolveLoopCutRing(topology: CustomMeshTopology, edgeId: string): LoopCutRing | null { + const startEdge = topology.edges.find((edge) => edge.id === edgeId) + if (!startEdge) return null + const edgeByKey = new Map( + topology.edges.map((edge) => [topologyEdgeKey(...edge.vertexIds), edge] as const), + ) + const facesByEdgeId = new Map() + for (const face of topology.faces) { + for (let index = 0; index < face.vertexIds.length; index += 1) { + const edge = edgeByKey.get( + topologyEdgeKey( + face.vertexIds[index]!, + face.vertexIds[(index + 1) % face.vertexIds.length]!, + ), + ) + if (!edge) return null + const faces = facesByEdgeId.get(edge.id) ?? [] + faces.push(face) + facesByEdgeId.set(edge.id, faces) + } + } + const startFaces = facesByEdgeId.get(startEdge.id) ?? [] + if ( + startFaces.length === 0 || + startFaces.length > 2 || + startFaces.some((face) => face.vertexIds.length !== 4) + ) { + return null + } + + const orientedEdgeVertices = new Map([ + [startEdge.id, startEdge.vertexIds], + ]) + const queue = startFaces.map((face) => ({ edgeId: startEdge.id, faceId: face.id })) + const visitedFaces = new Set() + const steps: LoopCutStep[] = [] + + while (queue.length > 0) { + const current = queue.shift()! + if (visitedFaces.has(current.faceId)) continue + const face = topology.faces.find((entry) => entry.id === current.faceId) + const orientedVertices = orientedEdgeVertices.get(current.edgeId) + if (!(face && orientedVertices) || face.vertexIds.length !== 4) return null + const oppositeVertices = oppositeOrientedEdgeVertices(face, orientedVertices) + if (!oppositeVertices) return null + const oppositeEdge = edgeByKey.get(topologyEdgeKey(...oppositeVertices)) + if (!oppositeEdge) return null + const existingOrientation = orientedEdgeVertices.get(oppositeEdge.id) + if ( + existingOrientation && + (existingOrientation[0] !== oppositeVertices[0] || + existingOrientation[1] !== oppositeVertices[1]) + ) { + return null + } + orientedEdgeVertices.set(oppositeEdge.id, oppositeVertices) + visitedFaces.add(face.id) + steps.push({ + faceId: face.id, + fromEdgeId: current.edgeId, + toEdgeId: oppositeEdge.id, + }) + + const adjacentFaces = facesByEdgeId.get(oppositeEdge.id) ?? [] + if (adjacentFaces.length > 2) return null + for (const adjacentFace of adjacentFaces) { + if (adjacentFace.id === face.id || visitedFaces.has(adjacentFace.id)) continue + if (adjacentFace.vertexIds.length !== 4) return null + queue.push({ edgeId: oppositeEdge.id, faceId: adjacentFace.id }) + } + } + + return steps.length > 0 ? { steps, orientedEdgeVertices } : null +} + +function interpolatePoint(from: Point, to: Point, factor: number): Point { + return [ + from[0] + (to[0] - from[0]) * factor, + from[1] + (to[1] - from[1]) * factor, + from[2] + (to[2] - from[2]) * factor, + ] +} + +export function customMeshLoopCutSegments( + topology: CustomMeshTopology, + edgeId: string, + factor: number, +): [Point, Point][] | null { + const ring = resolveLoopCutRing(topology, edgeId) + if (!ring || !Number.isFinite(factor) || factor <= 0 || factor >= 1) return null + const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) + const pointByEdgeId = new Map() + for (const [ringEdgeId, [fromId, toId]] of ring.orientedEdgeVertices) { + const from = vertexById.get(fromId) + const to = vertexById.get(toId) + if (!(from && to)) return null + pointByEdgeId.set(ringEdgeId, interpolatePoint(from, to, factor)) + } + return ring.steps.map((step) => [ + pointByEdgeId.get(step.fromEdgeId)!, + pointByEdgeId.get(step.toEdgeId)!, + ]) +} + +function splitFaceLoop( + face: CustomMeshFace, + cutVertexByEdgeKey: ReadonlyMap, + firstCutId: string, + secondCutId: string, +): [string[], string[]] | null { + const augmented: string[] = [] + for (let index = 0; index < face.vertexIds.length; index += 1) { + const current = face.vertexIds[index]! + const next = face.vertexIds[(index + 1) % face.vertexIds.length]! + augmented.push(current) + const cutId = cutVertexByEdgeKey.get(topologyEdgeKey(current, next)) + if (cutId) augmented.push(cutId) + } + const firstIndex = augmented.indexOf(firstCutId) + const secondIndex = augmented.indexOf(secondCutId) + if (firstIndex < 0 || secondIndex < 0) return null + const walk = (start: number, end: number) => { + const loop: string[] = [] + for (let index = start; ; index = (index + 1) % augmented.length) { + loop.push(augmented[index]!) + if (index === end) return loop + } + } + const first = walk(firstIndex, secondIndex) + const second = walk(secondIndex, firstIndex) + return first.length >= 3 && second.length >= 3 ? [first, second] : null +} + +function loopCut( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + if (!Number.isFinite(command.factor) || command.factor <= 0 || command.factor >= 1) { + return { ok: false, error: 'Loop cut factor must be greater than 0 and less than 1' } + } + const ring = resolveLoopCutRing(topology, command.edgeId) + if (!ring) return { ok: false, error: 'Loop cut requires a connected ring of quad faces' } + const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) + const edgeById = new Map(topology.edges.map((edge) => [edge.id, edge])) + const allocateVertexId = nextNumericId( + 'v', + topology.vertices.map((vertex) => vertex.id), + ) + const allocateEdgeId = nextNumericId( + 'e', + topology.edges.map((edge) => edge.id), + ) + const allocateFaceId = nextNumericId( + 'f', + topology.faces.map((face) => face.id), + ) + const cutVertexByEdgeId = new Map() + const cutVertexByEdgeKey = new Map() + const newVertices: CustomMeshVertex[] = [] + + for (const [ringEdgeId, [fromId, toId]] of ring.orientedEdgeVertices) { + const from = vertexById.get(fromId) + const to = vertexById.get(toId) + const edge = edgeById.get(ringEdgeId) + if (!(from && to && edge)) return { ok: false, error: 'Loop cut references missing topology' } + const id = allocateVertexId() + cutVertexByEdgeId.set(ringEdgeId, id) + cutVertexByEdgeKey.set(topologyEdgeKey(...edge.vertexIds), id) + newVertices.push({ id, position: interpolatePoint(from.position, to.position, command.factor) }) + } + + const splitBoundaryEdges = topology.edges.flatMap((edge) => { + const cutId = cutVertexByEdgeId.get(edge.id) + if (!cutId) return [edge] + return [ + { ...edge, vertexIds: [edge.vertexIds[0], cutId] }, + { id: allocateEdgeId(), vertexIds: [cutId, edge.vertexIds[1]] }, + ] + }) + const stepByFaceId = new Map(ring.steps.map((step) => [step.faceId, step] as const)) + const cutEdgeIds: string[] = [] + const cutEdges: CustomMeshEdge[] = [] + const faces: CustomMeshFace[] = [] + for (const face of topology.faces) { + const step = stepByFaceId.get(face.id) + if (!step) { + faces.push(face) + continue + } + const fromCutId = cutVertexByEdgeId.get(step.fromEdgeId) + const toCutId = cutVertexByEdgeId.get(step.toEdgeId) + if (!(fromCutId && toCutId)) return { ok: false, error: 'Loop cut references missing topology' } + const loops = splitFaceLoop(face, cutVertexByEdgeKey, fromCutId, toCutId) + if (!loops) return { ok: false, error: `Could not split quad face: ${face.id}` } + const cutEdgeId = allocateEdgeId() + cutEdgeIds.push(cutEdgeId) + cutEdges.push({ id: cutEdgeId, vertexIds: [fromCutId, toCutId] }) + faces.push( + { ...face, vertexIds: loops[0] }, + { ...face, id: allocateFaceId(), vertexIds: loops[1] }, + ) + } + + const nextTopology: CustomMeshTopology = { + vertices: [...topology.vertices, ...newVertices], + edges: [...splitBoundaryEdges, ...cutEdges], + faces, + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { + ok: true, + topology: nextTopology, + selection: { mode: 'edge', ids: cutEdgeIds }, + } +} + +function extrudeFace( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + 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}` } + 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 = customMeshFaceNormal(topology, face) + if (!normal) return { ok: false, error: `Face has no usable normal: ${face.id}` } + + const verticesById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) + const allocateVertexId = nextNumericId( + 'v', + topology.vertices.map((vertex) => vertex.id), + ) + const allocateEdgeId = nextNumericId( + 'e', + topology.edges.map((edge) => edge.id), + ) + const allocateFaceId = nextNumericId( + 'f', + topology.faces.map((entry) => entry.id), + ) + const duplicateIds = new Map() + const newVertices: CustomMeshVertex[] = [] + + for (const vertexId of face.vertexIds) { + const vertex = verticesById.get(vertexId) + if (!vertex) return { ok: false, error: `Face references missing vertex: ${vertexId}` } + const id = allocateVertexId() + duplicateIds.set(vertexId, id) + newVertices.push({ + id, + position: [ + vertex.position[0] + normal[0] * command.distance, + vertex.position[1] + normal[1] * command.distance, + vertex.position[2] + normal[2] * command.distance, + ], + }) + } + + const capVertexIds = face.vertexIds.map((vertexId) => duplicateIds.get(vertexId)!) + const newEdges: CustomMeshEdge[] = [] + const sideFaces: CustomMeshFace[] = [] + 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 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, + }) + } + + const faces = topology.faces.slice() + faces[faceIndex] = { ...face, vertexIds: capVertexIds } + const nextTopology: CustomMeshTopology = { + vertices: [...topology.vertices, ...newVertices], + edges: [...topology.edges, ...newEdges], + faces: [...faces, ...sideFaces], + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { + ok: true, + topology: nextTopology, + selection: { mode: 'face', ids: [face.id] }, + } +} + +export function customMeshSelectionVertexIds( + topology: CustomMeshTopology, + selection: CustomMeshSelection, +): Set { + const selectedIds = new Set(selection.ids) + switch (selection.mode) { + case 'vertex': + return new Set( + topology.vertices.filter((vertex) => selectedIds.has(vertex.id)).map((v) => v.id), + ) + case 'edge': { + const vertices = new Set() + for (const edge of topology.edges) { + if (!selectedIds.has(edge.id)) continue + vertices.add(edge.vertexIds[0]) + vertices.add(edge.vertexIds[1]) + } + return vertices + } + case 'face': { + const vertices = new Set() + for (const face of topology.faces) { + if (!selectedIds.has(face.id)) continue + for (const vertexId of face.vertexIds) vertices.add(vertexId) + } + return vertices + } + } +} + +function translateComponents( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + if (command.delta.some((value) => !Number.isFinite(value))) { + return { ok: false, error: 'Translation delta must contain finite numbers' } + } + const vertexIds = customMeshSelectionVertexIds(topology, command.selection) + if (vertexIds.size === 0) return { ok: false, error: 'Select a component to move' } + + const nextTopology: CustomMeshTopology = { + ...topology, + vertices: topology.vertices.map((vertex) => + vertexIds.has(vertex.id) + ? { + ...vertex, + position: [ + vertex.position[0] + command.delta[0], + vertex.position[1] + command.delta[1], + vertex.position[2] + command.delta[2], + ], + } + : vertex, + ), + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { ok: true, topology: nextTopology, selection: command.selection } +} + +function transformComponents( + topology: CustomMeshTopology, + selection: CustomMeshSelection, + transform: (position: Point) => Point, +): CustomMeshCommandResult { + const vertexIds = customMeshSelectionVertexIds(topology, selection) + if (vertexIds.size === 0) return { ok: false, error: 'Select a component to transform' } + const nextTopology: CustomMeshTopology = { + ...topology, + vertices: topology.vertices.map((vertex) => + vertexIds.has(vertex.id) ? { ...vertex, position: transform(vertex.position) } : vertex, + ), + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { ok: true, topology: nextTopology, selection } +} + +function rotateComponents( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + if (!Number.isFinite(command.angle) || command.pivot.some((value) => !Number.isFinite(value))) { + return { ok: false, error: 'Rotation requires a finite angle and pivot' } + } + const axis = normalize(command.axis) + if (!axis) return { ok: false, error: 'Rotation axis must be non-zero' } + const cosine = Math.cos(command.angle) + const sine = Math.sin(command.angle) + return transformComponents(topology, command.selection, (position) => { + const x = position[0] - command.pivot[0] + const y = position[1] - command.pivot[1] + const z = position[2] - command.pivot[2] + const dot = axis[0] * x + axis[1] * y + axis[2] * z + const cross: Point = [ + axis[1] * z - axis[2] * y, + axis[2] * x - axis[0] * z, + axis[0] * y - axis[1] * x, + ] + return [ + command.pivot[0] + x * cosine + cross[0] * sine + axis[0] * dot * (1 - cosine), + command.pivot[1] + y * cosine + cross[1] * sine + axis[1] * dot * (1 - cosine), + command.pivot[2] + z * cosine + cross[2] * sine + axis[2] * dot * (1 - cosine), + ] + }) +} + +function scaleComponents( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + if ( + command.pivot.some((value) => !Number.isFinite(value)) || + command.factors.some((value) => !Number.isFinite(value) || Math.abs(value) < 1e-6) + ) { + return { ok: false, error: 'Scale requires finite, non-zero factors and a finite pivot' } + } + return transformComponents(topology, command.selection, (position) => [ + command.pivot[0] + (position[0] - command.pivot[0]) * command.factors[0], + command.pivot[1] + (position[1] - command.pivot[1]) * command.factors[1], + command.pivot[2] + (position[2] - command.pivot[2]) * command.factors[2], + ]) +} + +function insetFace( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + 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}` } + if (!Number.isFinite(command.amount) || command.amount <= 0 || command.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' } + const centroid = customMeshFaceCentroid(topology, face) + const normal = customMeshFaceNormal(topology, face) + if (!(centroid && normal)) return { ok: false, error: `Face cannot be inset: ${face.id}` } + + const verticesById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) + const allocateVertexId = nextNumericId( + 'v', + topology.vertices.map((vertex) => vertex.id), + ) + const allocateEdgeId = nextNumericId( + 'e', + topology.edges.map((edge) => edge.id), + ) + const allocateFaceId = nextNumericId( + 'f', + topology.faces.map((entry) => entry.id), + ) + const insetIds: string[] = [] + const newVertices: CustomMeshVertex[] = [] + for (const vertexId of face.vertexIds) { + const vertex = verticesById.get(vertexId) + if (!vertex) return { ok: false, error: `Face references missing vertex: ${vertexId}` } + const id = allocateVertexId() + insetIds.push(id) + 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, + ], + }) + } + + const newEdges: CustomMeshEdge[] = [] + const ringFaces: CustomMeshFace[] = [] + for (let index = 0; index < face.vertexIds.length; index += 1) { + const oldA = face.vertexIds[index]! + const oldB = face.vertexIds[(index + 1) % face.vertexIds.length]! + const insetA = insetIds[index]! + const insetB = insetIds[(index + 1) % insetIds.length]! + newEdges.push({ id: allocateEdgeId(), vertexIds: [insetA, insetB] }) + newEdges.push({ id: allocateEdgeId(), vertexIds: [oldA, insetA] }) + ringFaces.push({ + id: allocateFaceId(), + vertexIds: [oldA, oldB, insetB, insetA], + materialSlot: face.materialSlot, + }) + } + const faces = topology.faces.slice() + faces[faceIndex] = { ...face, vertexIds: insetIds } + const nextTopology: CustomMeshTopology = { + vertices: [...topology.vertices, ...newVertices], + edges: [...topology.edges, ...newEdges], + faces: [...faces, ...ringFaces], + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { ok: true, topology: nextTopology, selection: { mode: 'face', ids: [face.id] } } +} + +function deleteComponents( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + const selected = new Set(command.selection.ids) + if (selected.size === 0) return { ok: false, error: 'Select a component to delete' } + let vertices = topology.vertices + let edges = topology.edges + let faces = topology.faces + + if (command.selection.mode === 'face') { + faces = faces.filter((face) => !selected.has(face.id)) + } else if (command.selection.mode === 'edge') { + const removedKeys = new Set( + edges + .filter((edge) => selected.has(edge.id)) + .map((edge) => + edge.vertexIds[0] < edge.vertexIds[1] + ? `${edge.vertexIds[0]}\u0000${edge.vertexIds[1]}` + : `${edge.vertexIds[1]}\u0000${edge.vertexIds[0]}`, + ), + ) + edges = edges.filter((edge) => !selected.has(edge.id)) + faces = faces.filter((face) => + face.vertexIds.every((vertexId, index) => { + const next = face.vertexIds[(index + 1) % face.vertexIds.length]! + const key = vertexId < next ? `${vertexId}\u0000${next}` : `${next}\u0000${vertexId}` + return !removedKeys.has(key) + }), + ) + } else { + vertices = vertices.filter((vertex) => !selected.has(vertex.id)) + edges = edges.filter( + (edge) => !selected.has(edge.vertexIds[0]) && !selected.has(edge.vertexIds[1]), + ) + faces = faces.filter((face) => face.vertexIds.every((vertexId) => !selected.has(vertexId))) + } + + const nextTopology = { vertices, edges, faces } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { + ok: true, + topology: nextTopology, + selection: { mode: command.selection.mode, ids: [] }, + } +} + +function mergeVertices( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + const selected = new Set(command.vertexIds) + 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 center = selectedVertices.reduce( + (sum, vertex) => [ + sum[0] + vertex.position[0], + sum[1] + vertex.position[1], + sum[2] + vertex.position[2], + ], + [0, 0, 0], + ) + center[0] /= selectedVertices.length + center[1] /= selectedVertices.length + center[2] /= selectedVertices.length + const mapVertexId = (id: string) => (selected.has(id) ? keepId : id) + + const edgeKeys = new Set() + const edges = topology.edges.flatMap((edge) => { + const a = mapVertexId(edge.vertexIds[0]) + const b = mapVertexId(edge.vertexIds[1]) + if (a === b) return [] + const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}` + if (edgeKeys.has(key)) return [] + edgeKeys.add(key) + return [{ ...edge, vertexIds: [a, b] }] + }) + + const faces: CustomMeshFace[] = [] + for (const face of topology.faces) { + const mapped = face.vertexIds.map(mapVertexId) + const loop: string[] = [] + for (const id of mapped) { + if (loop.at(-1) !== id) loop.push(id) + } + if (loop.length > 1 && loop[0] === loop.at(-1)) loop.pop() + if (loop.length < 3 || new Set(loop).size < 3) continue + if (new Set(loop).size !== loop.length) { + return { ok: false, error: 'The selected vertices would create a repeated face vertex' } + } + faces.push({ ...face, vertexIds: loop }) + } + + const nextTopology: CustomMeshTopology = { + vertices: topology.vertices + .filter((vertex) => vertex.id === keepId || !selected.has(vertex.id)) + .map((vertex) => (vertex.id === keepId ? { ...vertex, position: center } : vertex)), + edges, + faces, + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { + ok: true, + topology: nextTopology, + selection: { mode: 'vertex', ids: [keepId] }, + } +} + +function faceContainsEdge(face: CustomMeshFace, a: string, b: string): boolean { + return face.vertexIds.some((vertexId, index) => { + const next = face.vertexIds[(index + 1) % face.vertexIds.length] + return (vertexId === a && next === b) || (vertexId === b && next === a) + }) +} + +function longFacePath(face: CustomMeshFace, start: string, end: string): string[] | null { + const startIndex = face.vertexIds.indexOf(start) + if (startIndex < 0) return null + const forward: string[] = [start] + for (let offset = 1; offset <= face.vertexIds.length; offset += 1) { + const id = face.vertexIds[(startIndex + offset) % face.vertexIds.length]! + forward.push(id) + if (id === end) break + } + if (forward.at(-1) !== end) return null + if (forward.length > 2) return forward + + const backward: string[] = [start] + for (let offset = 1; offset <= face.vertexIds.length; offset += 1) { + const index = (startIndex - offset + face.vertexIds.length) % face.vertexIds.length + const id = face.vertexIds[index]! + backward.push(id) + if (id === end) break + } + return backward.at(-1) === end && backward.length > 2 ? backward : null +} + +function dissolveEdge( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + const edge = topology.edges.find((entry) => entry.id === command.edgeId) + if (!edge) return { ok: false, error: `Edge not found: ${command.edgeId}` } + const [a, b] = edge.vertexIds + const adjacentFaces = topology.faces.filter((face) => faceContainsEdge(face, a, b)) + if (adjacentFaces.length !== 2) { + return { ok: false, error: 'Dissolve requires an edge shared by exactly two faces' } + } + const firstPath = longFacePath(adjacentFaces[0]!, a, b) + const secondPath = longFacePath(adjacentFaces[1]!, b, a) + if (!(firstPath && secondPath)) + return { ok: false, error: 'Could not resolve adjacent face loops' } + const mergedLoop = [...firstPath, ...secondPath.slice(1, -1)] + if (new Set(mergedLoop).size !== mergedLoop.length) { + return { ok: false, error: 'Dissolving this edge would create a repeated face vertex' } + } + const removedFaceId = adjacentFaces[1]!.id + const nextTopology: CustomMeshTopology = { + vertices: topology.vertices, + edges: topology.edges.filter((entry) => entry.id !== edge.id), + faces: topology.faces + .filter((face) => face.id !== removedFaceId) + .map((face) => + face.id === adjacentFaces[0]!.id ? { ...face, vertexIds: mergedLoop } : face, + ), + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + return { + ok: true, + topology: nextTopology, + selection: { mode: 'face', ids: [adjacentFaces[0]!.id] }, + } +} + +export function applyCustomMeshCommand( + topology: CustomMeshTopology, + command: CustomMeshCommand, +): CustomMeshCommandResult { + const issues = inspectCustomMeshTopology(topology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + switch (command.type) { + case 'extrude-face': + return extrudeFace(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 'delete-components': + return deleteComponents(topology, command) + case 'merge-vertices': + return mergeVertices(topology, command) + case 'dissolve-edge': + return dissolveEdge(topology, command) + case 'loop-cut': + return loopCut(topology, command) + } +} diff --git a/packages/nodes/src/custom-mesh/definition.test.ts b/packages/nodes/src/custom-mesh/definition.test.ts new file mode 100644 index 0000000000..5f07cf43f2 --- /dev/null +++ b/packages/nodes/src/custom-mesh/definition.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { CustomMeshNode } from '@pascal-app/core' +import { customMeshDefinition } from './definition' + +describe('custom mesh placement bounds', () => { + test('keeps asymmetric edited topology centered during a rotated drag', () => { + const base = CustomMeshNode.parse({ + name: 'Asymmetric mesh', + position: [10, 2, 20], + rotation: Math.PI / 2, + }) + const node = { + ...base, + topology: { + ...base.topology, + vertices: base.topology.vertices.map((vertex) => ({ + ...vertex, + position: [ + vertex.position[0] < 0 ? vertex.position[0] - 4 : vertex.position[0], + vertex.position[1] > 0 ? vertex.position[1] + 1 : vertex.position[1], + vertex.position[2] > 0 ? vertex.position[2] + 2 : vertex.position[2], + ] as [number, number, number], + })), + }, + } + + expect(customMeshDefinition.capabilities.dragBounds?.(node, {})).toEqual({ + size: [6, 3.4, 4], + center: [-2, 1.7, 1], + }) + expect(customMeshDefinition.capabilities.floorPlaced?.footprint?.(node)).toEqual({ + dimensions: [6, 3.4, 4], + position: [11, 2, 22], + rotation: [0, Math.PI / 2, 0], + }) + }) +}) diff --git a/packages/nodes/src/custom-mesh/definition.ts b/packages/nodes/src/custom-mesh/definition.ts new file mode 100644 index 0000000000..bb94fa6403 --- /dev/null +++ b/packages/nodes/src/custom-mesh/definition.ts @@ -0,0 +1,111 @@ +import { + type CustomMeshNode as CustomMeshNodeType, + createBoxCustomMeshTopology, + type NodeDefinition, +} from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { buildCustomMeshFloorplan } from './floorplan' +import { buildCustomMeshGeometry } from './geometry' +import { CustomMeshNode } from './schema' + +function bounds(node: CustomMeshNodeType) { + const xs = node.topology.vertices.map((vertex) => vertex.position[0]) + const ys = node.topology.vertices.map((vertex) => vertex.position[1]) + const zs = node.topology.vertices.map((vertex) => vertex.position[2]) + if (xs.length === 0) { + return { + size: [0, 0, 0] as [number, number, number], + center: [0, 0, 0] as [number, number, number], + } + } + const minX = Math.min(...xs) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + const maxY = Math.max(...ys) + const minZ = Math.min(...zs) + const maxZ = Math.max(...zs) + return { + size: [maxX - minX, maxY - minY, maxZ - minZ] as [number, number, number], + center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2] as [number, number, number], + } +} + +function footprintPosition(node: CustomMeshNodeType, center: [number, number, number]) { + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + return [ + node.position[0] + center[0] * cos + center[2] * sin, + node.position[1], + node.position[2] - center[0] * sin + center[2] * cos, + ] as [number, number, number] +} + +export const customMeshDefinition: NodeDefinition = { + kind: 'custom-mesh', + schemaVersion: 1, + schema: CustomMeshNode, + category: 'structure', + surfaceRole: 'wall', + snapProfile: 'item', + extensions: { + 'pascal:editor/floorplan': { + tool: () => import('./tool'), + preferredView: '3d', + } satisfies FloorplanNodeExtension, + }, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + topology: createBoxCustomMeshTopology(), + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'z'], gridSnap: true }, + duplicable: true, + deletable: true, + dragBounds: (rawNode) => bounds(rawNode as CustomMeshNodeType), + floorPlaced: { + footprint: (rawNode) => { + const node = rawNode as CustomMeshNodeType + const { size, center } = bounds(node) + return { + dimensions: size, + position: footprintPosition(node, center), + rotation: [0, node.rotation, 0] as [number, number, number], + } + }, + collides: true, + }, + }, + + geometry: buildCustomMeshGeometry, + geometryKey: (node) => JSON.stringify([node.topology, node.slots]), + floorplan: buildCustomMeshFloorplan, + affordanceTools: { + selection: () => import('./selection'), + }, + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Place custom mesh' }, + { key: 'Esc', label: 'Cancel' }, + ], + presentation: { + label: 'Custom Mesh', + description: 'A topology-backed solid edited directly in the canvas.', + icon: { kind: 'iconify', name: 'lucide:box-select' }, + paletteSection: 'structure', + paletteOrder: 75, + actionMenu: false, + }, + mcp: { + description: + 'A custom editable solid with persistent vertex, edge, and face topology. Positions are level-local meters.', + }, +} diff --git a/packages/nodes/src/custom-mesh/floorplan.ts b/packages/nodes/src/custom-mesh/floorplan.ts new file mode 100644 index 0000000000..bfad5728fe --- /dev/null +++ b/packages/nodes/src/custom-mesh/floorplan.ts @@ -0,0 +1,53 @@ +import type { + CustomMeshNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' + +function cross(origin: FloorplanPoint, a: FloorplanPoint, b: FloorplanPoint) { + return (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]) +} + +function convexHull(points: FloorplanPoint[]): FloorplanPoint[] { + const unique = [...new Map(points.map((point) => [`${point[0]}:${point[1]}`, point])).values()] + if (unique.length <= 3) return unique + unique.sort((a, b) => a[0] - b[0] || a[1] - b[1]) + const lower: FloorplanPoint[] = [] + for (const point of unique) { + while (lower.length >= 2 && cross(lower.at(-2)!, lower.at(-1)!, point) <= 0) lower.pop() + lower.push(point) + } + const upper: FloorplanPoint[] = [] + for (const point of [...unique].reverse()) { + while (upper.length >= 2 && cross(upper.at(-2)!, upper.at(-1)!, point) <= 0) upper.pop() + upper.push(point) + } + return [...lower.slice(0, -1), ...upper.slice(0, -1)] +} + +export function buildCustomMeshFloorplan( + node: CustomMeshNode, + ctx?: GeometryContext, +): FloorplanGeometry | null { + const points = convexHull( + node.topology.vertices.map((vertex) => [vertex.position[0], vertex.position[2]]), + ) + if (points.length < 3) return null + const selected = ctx?.viewState?.selected ?? false + return { + kind: 'group', + transform: { translate: [node.position[0], node.position[2]], rotate: -node.rotation }, + children: [ + { + kind: 'polygon', + points, + fill: selected ? '#fed7aa' : '#cbd5e1', + fillOpacity: selected ? 0.55 : 0.72, + stroke: selected ? (ctx?.viewState?.palette?.selectedStroke ?? '#f97316') : '#475569', + strokeWidth: selected ? 0.03 : 0.018, + pointerEvents: 'all', + }, + ], + } +} diff --git a/packages/nodes/src/custom-mesh/geometry.test.ts b/packages/nodes/src/custom-mesh/geometry.test.ts new file mode 100644 index 0000000000..173ed0269d --- /dev/null +++ b/packages/nodes/src/custom-mesh/geometry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { CustomMeshNode } from '@pascal-app/core' +import { Mesh } from 'three' +import { applyCustomMeshCommand } from './commands' +import { buildCustomMeshGeometry } from './geometry' + +describe('buildCustomMeshGeometry', () => { + test('derives a render mesh from persistent topology', () => { + const node = CustomMeshNode.parse({ name: 'Box' }) + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh)) return + expect(mesh.geometry.getAttribute('position').count).toBe(36) + expect(mesh.geometry.getAttribute('normal').count).toBe(36) + expect(mesh.geometry.getAttribute('uv').count).toBe(36) + expect(mesh.geometry.userData.customMeshFaces).toHaveLength(6) + }) + + test('rebuilds the extruded topology into additional face triangles', () => { + const node = CustomMeshNode.parse({ name: 'Box' }) + const result = applyCustomMeshCommand(node.topology, { + type: 'extrude-face', + faceId: 'f-top', + distance: 0.25, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const group = buildCustomMeshGeometry({ ...node, topology: result.topology }) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh)) return + expect(mesh.geometry.getAttribute('position').count).toBe(60) + expect(mesh.geometry.userData.customMeshFaces).toHaveLength(10) + }) +}) diff --git a/packages/nodes/src/custom-mesh/geometry.ts b/packages/nodes/src/custom-mesh/geometry.ts new file mode 100644 index 0000000000..df5c459def --- /dev/null +++ b/packages/nodes/src/custom-mesh/geometry.ts @@ -0,0 +1,118 @@ +import type { + CustomMeshFace, + CustomMeshNode, + CustomMeshTopology, + GeometryContext, +} from '@pascal-app/core' +import { createDefaultMaterial, type RenderShading, resolveMaterialRef } from '@pascal-app/viewer' +import { + BufferGeometry, + Float32BufferAttribute, + Group, + Mesh, + ShapeUtils, + Vector2, + Vector3, +} from 'three' +import { customMeshFaceNormal } from './commands' + +type Point = [number, number, number] + +function projectedPoint(point: Point, normal: Point): Vector2 { + const ax = Math.abs(normal[0]) + const ay = Math.abs(normal[1]) + const az = Math.abs(normal[2]) + if (ax >= ay && ax >= az) return new Vector2(point[1], point[2]) + if (ay >= az) return new Vector2(point[0], point[2]) + return new Vector2(point[0], point[1]) +} + +export function triangulateCustomMeshFace( + topology: CustomMeshTopology, + face: CustomMeshFace, +): { triangles: [Point, Point, Point][]; normal: Point } | null { + const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) + const contour = face.vertexIds + .map((id) => vertexById.get(id)) + .filter((point): point is Point => !!point) + const normal = customMeshFaceNormal(topology, face) + if (!normal || contour.length !== face.vertexIds.length) return null + + const triangleIndices = ShapeUtils.triangulateShape( + contour.map((point) => projectedPoint(point, normal)), + [], + ) + const targetNormal = new Vector3(...normal) + const triangles: [Point, Point, Point][] = [] + for (const indices of triangleIndices) { + const aIndex = indices[0] + const bIndex = indices[1] + const cIndex = indices[2] + if (aIndex === undefined || bIndex === undefined || cIndex === undefined) continue + const a = contour[aIndex] + let b = contour[bIndex] + let c = contour[cIndex] + if (!(a && b && c)) continue + const triangleNormal = new Vector3(...b) + .sub(new Vector3(...a)) + .cross(new Vector3(...c).sub(new Vector3(...a))) + if (triangleNormal.dot(targetNormal) < 0) [b, c] = [c, b] + triangles.push([a, b, c]) + } + return { triangles, normal } +} + +export function buildCustomMeshGeometry( + node: CustomMeshNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', +): Group { + const group = new Group() + group.name = 'custom-mesh-geometry' + const geometry = new BufferGeometry() + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + const faceRanges: { faceId: string; start: number; count: number }[] = [] + const slotIds = [...new Set(node.topology.faces.map((face) => face.materialSlot))] + const materialIndex = new Map(slotIds.map((slotId, index) => [slotId, index])) + + for (const face of node.topology.faces) { + const triangulated = triangulateCustomMeshFace(node.topology, face) + if (!triangulated) continue + const start = positions.length / 3 + for (const triangle of triangulated.triangles) { + for (const point of triangle) { + positions.push(...point) + normals.push(...triangulated.normal) + const uv = projectedPoint(point, triangulated.normal) + uvs.push(uv.x, uv.y) + } + } + const count = positions.length / 3 - start + geometry.addGroup(start, count, materialIndex.get(face.materialSlot) ?? 0) + faceRanges.push({ faceId: face.id, start, count }) + } + + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) + geometry.computeBoundingBox() + geometry.computeBoundingSphere() + geometry.userData.customMeshFaces = faceRanges + + const materials = slotIds.map((slotId) => { + const ref = node.slots?.[slotId] + return ( + (ref ? resolveMaterialRef(ref, ctx?.materials, shading) : null) ?? + createDefaultMaterial('#b8c5d1', 0.72, shading) + ) + }) + const mesh = new Mesh(geometry, materials.length === 1 ? materials[0] : materials) + mesh.name = 'custom-mesh-body' + mesh.castShadow = true + mesh.receiveShadow = true + mesh.userData.customMesh = true + group.add(mesh) + return group +} diff --git a/packages/nodes/src/custom-mesh/preview.tsx b/packages/nodes/src/custom-mesh/preview.tsx new file mode 100644 index 0000000000..9e703716a6 --- /dev/null +++ b/packages/nodes/src/custom-mesh/preview.tsx @@ -0,0 +1,38 @@ +'use client' + +import type { CustomMeshNode } from '@pascal-app/core' +import { useEffect, useMemo } from 'react' +import { type Material, Mesh } from 'three' +import { buildCustomMeshGeometry } from './geometry' + +export default function CustomMeshPreview({ node }: { node: CustomMeshNode }) { + const object = useMemo(() => { + const next = buildCustomMeshGeometry(node) + next.traverse((child) => { + if (!(child instanceof Mesh)) return + const materials = Array.isArray(child.material) ? child.material : [child.material] + for (const material of materials) { + material.transparent = true + material.opacity = 0.52 + material.depthWrite = false + } + }) + return next + }, [node]) + + useEffect( + () => () => { + object.traverse((child) => { + if (!(child instanceof Mesh)) return + child.geometry.dispose() + const materials = Array.isArray(child.material) ? child.material : [child.material] + materials.forEach((material: Material) => { + material.dispose() + }) + }) + }, + [object], + ) + + return +} diff --git a/packages/nodes/src/custom-mesh/rotation-drag.test.ts b/packages/nodes/src/custom-mesh/rotation-drag.test.ts new file mode 100644 index 0000000000..e10d3055a2 --- /dev/null +++ b/packages/nodes/src/custom-mesh/rotation-drag.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { Vector3 } from 'three' +import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' + +describe('custom mesh rotation drag', () => { + test('derives rotation direction around the chosen axis', () => { + const from = new Vector3(1, 0, 0) + const to = new Vector3(0, 0, -1) + + expect(signedAngleAroundAxis(from, to, new Vector3(0, 1, 0))).toBeCloseTo(Math.PI / 2) + expect(signedAngleAroundAxis(from, to, new Vector3(0, -1, 0))).toBeCloseTo(-Math.PI / 2) + }) + + test('continues smoothly when the pointer crosses the angle seam', () => { + const previous = (179 * Math.PI) / 180 + const current = (-179 * Math.PI) / 180 + + expect(unwrapRotationDelta(previous, current)).toBeCloseTo((2 * Math.PI) / 180) + expect(unwrapRotationDelta(current, previous)).toBeCloseTo((-2 * Math.PI) / 180) + }) +}) diff --git a/packages/nodes/src/custom-mesh/rotation-drag.ts b/packages/nodes/src/custom-mesh/rotation-drag.ts new file mode 100644 index 0000000000..b171a66f6e --- /dev/null +++ b/packages/nodes/src/custom-mesh/rotation-drag.ts @@ -0,0 +1,12 @@ +import type { Vector3 } from 'three' + +export function signedAngleAroundAxis(from: Vector3, to: Vector3, axis: Vector3): number { + return Math.atan2(axis.dot(from.clone().cross(to)), from.dot(to)) +} + +export function unwrapRotationDelta(previous: number, current: number): number { + let delta = current - previous + if (delta > Math.PI) delta -= Math.PI * 2 + if (delta < -Math.PI) delta += Math.PI * 2 + return delta +} diff --git a/packages/nodes/src/custom-mesh/schema.ts b/packages/nodes/src/custom-mesh/schema.ts new file mode 100644 index 0000000000..bf4e810434 --- /dev/null +++ b/packages/nodes/src/custom-mesh/schema.ts @@ -0,0 +1 @@ +export { CustomMeshNode } from '@pascal-app/core' diff --git a/packages/nodes/src/custom-mesh/selection-model.test.ts b/packages/nodes/src/custom-mesh/selection-model.test.ts new file mode 100644 index 0000000000..ee5cbfb240 --- /dev/null +++ b/packages/nodes/src/custom-mesh/selection-model.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { createBoxCustomMeshTopology } from '@pascal-app/core' +import { + convertCustomMeshSelection, + createCustomMeshSelection, + invertCustomMeshSelection, + selectAllCustomMeshComponents, + selectCustomMeshComponent, +} from './selection-model' + +describe('custom mesh component selection', () => { + const topology = createBoxCustomMeshTopology() + + test('tracks the last selected component as active and supports toggling', () => { + let selection = createCustomMeshSelection('vertex') + selection = selectCustomMeshComponent(selection, 'v0', false) + selection = selectCustomMeshComponent(selection, 'v1', true) + expect(selection).toEqual({ mode: 'vertex', ids: ['v0', 'v1'], activeId: 'v1' }) + + selection = selectCustomMeshComponent(selection, 'v1', true) + expect(selection).toEqual({ mode: 'vertex', ids: ['v0'], activeId: 'v0' }) + }) + + test('converts face selection through topology instead of discarding it', () => { + const face = createCustomMeshSelection('face', ['f-top']) + const vertices = convertCustomMeshSelection(topology, face, 'vertex') + expect(vertices.ids).toEqual(['v4', 'v5', 'v6', 'v7']) + + const edges = convertCustomMeshSelection(topology, face, 'edge') + expect(edges.ids).toEqual(['e4', 'e5', 'e6', 'e7']) + }) + + test('select all and invert operate on the active component domain', () => { + const all = selectAllCustomMeshComponents(topology, createCustomMeshSelection('face')) + expect(all.ids).toHaveLength(6) + expect(invertCustomMeshSelection(topology, all).ids).toEqual([]) + + const inverse = invertCustomMeshSelection( + topology, + createCustomMeshSelection('edge', ['e0', 'e1']), + ) + expect(inverse.ids).toHaveLength(10) + expect(inverse.ids).not.toContain('e0') + }) +}) diff --git a/packages/nodes/src/custom-mesh/selection-model.ts b/packages/nodes/src/custom-mesh/selection-model.ts new file mode 100644 index 0000000000..6ecb080ade --- /dev/null +++ b/packages/nodes/src/custom-mesh/selection-model.ts @@ -0,0 +1,114 @@ +import type { CustomMeshTopology } from '@pascal-app/core' + +export type CustomMeshComponentMode = 'vertex' | 'edge' | 'face' + +export type CustomMeshSelection = { + mode: CustomMeshComponentMode + ids: string[] +} + +export type CustomMeshSelectionState = CustomMeshSelection & { + activeId: string | null +} + +function idsForMode(topology: CustomMeshTopology, mode: CustomMeshComponentMode): string[] { + switch (mode) { + case 'vertex': + return topology.vertices.map((vertex) => vertex.id) + case 'edge': + return topology.edges.map((edge) => edge.id) + case 'face': + return topology.faces.map((face) => face.id) + } +} + +function selectedVertexIds( + topology: CustomMeshTopology, + selection: CustomMeshSelection, +): Set { + const selected = new Set(selection.ids) + if (selection.mode === 'vertex') return selected + const vertices = new Set() + if (selection.mode === 'edge') { + for (const edge of topology.edges) { + if (!selected.has(edge.id)) continue + vertices.add(edge.vertexIds[0]) + vertices.add(edge.vertexIds[1]) + } + return vertices + } + for (const face of topology.faces) { + if (!selected.has(face.id)) continue + for (const vertexId of face.vertexIds) vertices.add(vertexId) + } + return vertices +} + +export function createCustomMeshSelection( + mode: CustomMeshComponentMode, + ids: string[] = [], +): CustomMeshSelectionState { + return { mode, ids, activeId: ids.at(-1) ?? null } +} + +export function selectCustomMeshComponent( + selection: CustomMeshSelectionState, + id: string, + additive: boolean, +): CustomMeshSelectionState { + if (!additive) return { ...selection, ids: [id], activeId: id } + if (!selection.ids.includes(id)) { + return { ...selection, ids: [...selection.ids, id], activeId: id } + } + const ids = selection.ids.filter((entry) => entry !== id) + return { ...selection, ids, activeId: ids.at(-1) ?? null } +} + +export function convertCustomMeshSelection( + topology: CustomMeshTopology, + selection: CustomMeshSelectionState, + nextMode: CustomMeshComponentMode, +): CustomMeshSelectionState { + if (selection.mode === nextMode) return selection + const vertices = selectedVertexIds(topology, selection) + let ids: string[] + switch (nextMode) { + case 'vertex': + ids = topology.vertices.filter((vertex) => vertices.has(vertex.id)).map((vertex) => vertex.id) + break + case 'edge': + ids = topology.edges + .filter((edge) => vertices.has(edge.vertexIds[0]) && vertices.has(edge.vertexIds[1])) + .map((edge) => edge.id) + break + case 'face': + ids = topology.faces + .filter((face) => face.vertexIds.every((vertexId) => vertices.has(vertexId))) + .map((face) => face.id) + break + } + return { mode: nextMode, ids, activeId: ids.at(-1) ?? null } +} + +export function selectAllCustomMeshComponents( + topology: CustomMeshTopology, + selection: CustomMeshSelectionState, +): CustomMeshSelectionState { + const ids = idsForMode(topology, selection.mode) + return { ...selection, ids, activeId: ids.at(-1) ?? null } +} + +export function invertCustomMeshSelection( + topology: CustomMeshTopology, + selection: CustomMeshSelectionState, +): CustomMeshSelectionState { + const selected = new Set(selection.ids) + const ids = idsForMode(topology, selection.mode).filter((id) => !selected.has(id)) + return { ...selection, ids, activeId: ids.at(-1) ?? null } +} + +export function clearCustomMeshSelection( + selection: CustomMeshSelectionState, +): CustomMeshSelectionState { + return { ...selection, ids: [], activeId: null } +} diff --git a/packages/nodes/src/custom-mesh/selection.tsx b/packages/nodes/src/custom-mesh/selection.tsx new file mode 100644 index 0000000000..1e3ea225bf --- /dev/null +++ b/packages/nodes/src/custom-mesh/selection.tsx @@ -0,0 +1,1887 @@ +'use client' + +import { + type AnyNodeId, + type CustomMeshFace, + type CustomMeshNode, + type CustomMeshTopology, + emitter, + sceneRegistry, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + cn, + EDITOR_LAYER, + isGridSnapActive, + markToolCancelConsumed, + meshEditScope, + swallowNextClick, + triggerSFX, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' +import { + ArrowUpFromLine, + Check, + CircleDot, + Eye, + EyeOff, + MousePointer2, + Move3D, + PencilRuler, + Rotate3D, + Rows3, + Scaling, + ScanLine, + Square, + Trash2, +} from 'lucide-react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + BufferGeometry, + ConeGeometry, + CylinderGeometry, + DoubleSide, + Float32BufferAttribute, + type Group, + LineSegments, + type Object3D, + Plane, + Quaternion, + Raycaster, + SphereGeometry, + TorusGeometry, + Vector2, + Vector3, +} from 'three' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { + applyCustomMeshCommand, + type CustomMeshCommand, + type CustomMeshSelection, + customMeshFaceCentroid, + customMeshFaceNormal, + customMeshLoopCutSegments, + customMeshSelectionVertexIds, +} from './commands' +import { triangulateCustomMeshFace } from './geometry' +import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' +import { + type CustomMeshSelectionState, + clearCustomMeshSelection, + convertCustomMeshSelection, + invertCustomMeshSelection, + selectAllCustomMeshComponents, + selectCustomMeshComponent, +} from './selection-model' + +type ComponentMode = CustomMeshSelection['mode'] +type Point = [number, number, number] +type Axis = 'x' | 'y' | 'z' +type TransformTool = 'select' | 'move' | 'rotate' | 'loop-cut' + +const AXIS_VECTORS: Record = { + x: [1, 0, 0], + y: [0, 1, 0], + z: [0, 0, 1], +} +const AXIS_COLORS: Record = { + x: '#ef4444', + y: '#22c55e', + z: '#3b82f6', +} + +const FLOATING_PANEL_CLASS = + 'corner-smooth pointer-events-auto flex rounded-xl border border-border/40 bg-background/95 p-1.5 shadow-elevation-4 backdrop-blur-xl' +const TOOLBAR_INPUT_CLASS = + 'h-7 w-12 rounded-md border border-border/50 bg-accent/30 px-1.5 font-mono text-xs text-foreground tabular-nums outline-none transition-[border-color,box-shadow,background-color] hover:border-border/80 focus:border-ring focus:ring-1 focus:ring-ring/40' + +function preferredFace(topology: CustomMeshTopology): CustomMeshFace | null { + return ( + topology.faces + .map((face) => ({ + face, + normal: customMeshFaceNormal(topology, face), + centroid: customMeshFaceCentroid(topology, face), + })) + .filter((entry) => entry.normal && entry.centroid) + .sort((a, b) => b.normal![1] - a.normal![1] || b.centroid![1] - a.centroid![1])[0]?.face ?? + null + ) +} + +function topologyVertexMap(topology: CustomMeshTopology): Map { + return new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) +} + +function selectionCentroid( + topology: CustomMeshTopology, + selection: CustomMeshSelection, +): Point | null { + const ids = customMeshSelectionVertexIds(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: CustomMeshTopology): number { + const axes = [0, 1, 2] as const + return Math.max( + 0.5, + ...axes.map((axis) => { + const values = topology.vertices.map((vertex) => vertex.position[axis]) + return Math.max(...values) - Math.min(...values) + }), + ) +} + +function closestAxisParameterToRay( + axisOrigin: Vector3, + axisDirection: Vector3, + ray: Raycaster['ray'], +): number { + const originToRay = axisOrigin.clone().sub(ray.origin) + const b = axisDirection.dot(ray.direction) + const d = axisDirection.dot(originToRay) + const e = ray.direction.dot(originToRay) + const denominator = 1 - b * b + if (Math.abs(denominator) < 1e-6) return -d + const axisParameter = (b * e - d) / denominator + return e + b * axisParameter < 0 ? -d : axisParameter +} + +function VertexHandle({ + id, + position, + radius, + selected, + active, + xray, + onSelect, +}: { + id: string + position: Point + radius: number + selected: boolean + active: boolean + xray: boolean + onSelect: (id: string, additive: boolean) => void +}) { + const [hovered, setHovered] = useState(false) + const visibleGeometry = useMemo(() => new SphereGeometry(radius, 16, 12), [radius]) + const hitGeometry = useMemo(() => new SphereGeometry(radius * 4.2, 12, 8), [radius]) + const visibleMaterial = useMemo( + () => new MeshBasicNodeMaterial({ depthTest: !xray, depthWrite: false }), + [xray], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [], + ) + useEffect(() => { + visibleMaterial.color.set( + active ? '#ffffff' : selected ? '#fb923c' : hovered ? '#fcd34d' : '#6b7280', + ) + }, [active, hovered, selected, visibleMaterial]) + useEffect( + () => () => { + visibleGeometry.dispose() + hitGeometry.dispose() + visibleMaterial.dispose() + hitMaterial.dispose() + }, + [hitGeometry, hitMaterial, visibleGeometry, visibleMaterial], + ) + + return ( + + {}} + renderOrder={1200} + /> + { + event.stopPropagation() + onSelect(id, event.nativeEvent.shiftKey) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' + }} + renderOrder={1201} + /> + + ) +} + +function EdgeHandle({ + id, + start, + end, + radius, + selected, + active, + xray, + onSelect, +}: { + id: string + start: Point + end: Point + radius: number + selected: boolean + active: boolean + xray: boolean + onSelect: (id: string, additive: boolean) => void +}) { + const [hovered, setHovered] = useState(false) + const placement = useMemo(() => { + const a = new Vector3(...start) + const b = new Vector3(...end) + const direction = b.clone().sub(a) + const length = direction.length() + return { + length, + position: a.add(b).multiplyScalar(0.5), + quaternion: new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction.normalize()), + } + }, [end, start]) + const visibleGeometry = useMemo(() => { + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute([...start, ...end], 3)) + return geometry + }, [end, start]) + const hitGeometry = useMemo( + () => new CylinderGeometry(radius * 3.2, radius * 3.2, placement.length, 8), + [placement.length, radius], + ) + const visibleMaterial = useMemo( + () => + new LineBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + }), + [xray], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [], + ) + useEffect(() => { + visibleMaterial.color.set( + active ? '#ffffff' : selected ? '#fb923c' : hovered ? '#fcd34d' : '#6b7280', + ) + visibleMaterial.opacity = active || selected || hovered ? 1 : 0.65 + }, [active, hovered, selected, visibleMaterial]) + const visibleLine = useMemo(() => { + const line = new LineSegments(visibleGeometry, visibleMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.raycast = () => {} + line.renderOrder = 1200 + return line + }, [visibleGeometry, visibleMaterial]) + useEffect( + () => () => { + visibleGeometry.dispose() + hitGeometry.dispose() + visibleMaterial.dispose() + hitMaterial.dispose() + }, + [hitGeometry, hitMaterial, visibleGeometry, visibleMaterial], + ) + + return ( + <> + + + { + event.stopPropagation() + onSelect(id, event.nativeEvent.shiftKey) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' + }} + renderOrder={1201} + /> + + + ) +} + +function FaceHandle({ + face, + topology, + selected, + active, + xray, + onSelect, +}: { + face: CustomMeshFace + topology: CustomMeshTopology + selected: boolean + active: boolean + xray: boolean + onSelect: (id: string, additive: boolean) => void +}) { + const [hovered, setHovered] = useState(false) + const geometries = useMemo(() => { + const triangulated = triangulateCustomMeshFace(topology, face) + if (!triangulated) return null + const fill = new BufferGeometry() + fill.setAttribute( + 'position', + new Float32BufferAttribute( + triangulated.triangles.flatMap((triangle) => triangle.flat()), + 3, + ), + ) + const vertexById = topologyVertexMap(topology) + const outlinePositions: number[] = [] + for (let index = 0; index < face.vertexIds.length; index += 1) { + const start = vertexById.get(face.vertexIds[index]!) + const end = vertexById.get(face.vertexIds[(index + 1) % face.vertexIds.length]!) + if (start && end) outlinePositions.push(...start, ...end) + } + const outline = new BufferGeometry() + outline.setAttribute('position', new Float32BufferAttribute(outlinePositions, 3)) + return { fill, outline } + }, [face, topology]) + const fillMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -4, + side: DoubleSide, + }), + [xray], + ) + const outlineMaterial = useMemo( + () => + new LineBasicNodeMaterial({ + transparent: true, + depthTest: !xray, + depthWrite: false, + }), + [xray], + ) + useEffect(() => { + fillMaterial.color.set(active ? '#ffffff' : selected ? '#fb923c' : '#fbbf24') + fillMaterial.opacity = selected ? 0.26 : hovered ? 0.14 : 0.001 + outlineMaterial.color.set(active ? '#ffffff' : selected ? '#fb923c' : '#fcd34d') + outlineMaterial.opacity = selected || hovered ? 1 : 0 + }, [active, fillMaterial, hovered, outlineMaterial, selected]) + const outline = useMemo(() => { + if (!geometries) return null + const line = new LineSegments(geometries.outline, outlineMaterial) + line.raycast = () => {} + line.renderOrder = 1201 + return line + }, [geometries, outlineMaterial]) + useEffect( + () => () => { + geometries?.fill.dispose() + geometries?.outline.dispose() + fillMaterial.dispose() + outlineMaterial.dispose() + }, + [fillMaterial, geometries, outlineMaterial], + ) + if (!geometries) return null + + return ( + + { + event.stopPropagation() + onSelect(face.id, event.nativeEvent.shiftKey) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' + }} + renderOrder={1200} + /> + {outline ? : null} + + ) +} + +function AxisHandle({ + axis, + length, + radius, + active, + onPointerDown, +}: { + axis: Axis + length: number + radius: number + active: boolean + onPointerDown: (axis: Axis, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const shaftGeometry = useMemo( + () => new CylinderGeometry(radius, radius, length * 0.72, 10), + [length, radius], + ) + const tipGeometry = useMemo( + () => new ConeGeometry(radius * 2.5, length * 0.28, 12), + [length, radius], + ) + const hitGeometry = useMemo( + () => new CylinderGeometry(radius * 4.5, radius * 4.5, length, 8), + [length, radius], + ) + const material = useMemo( + () => new MeshBasicNodeMaterial({ depthTest: false, depthWrite: false }), + [], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: AXIS_COLORS[axis], + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [axis], + ) + useEffect(() => { + material.color.set(active || hovered ? '#fef3c7' : AXIS_COLORS[axis]) + }, [active, axis, hovered, material]) + useEffect( + () => () => { + shaftGeometry.dispose() + tipGeometry.dispose() + hitGeometry.dispose() + material.dispose() + hitMaterial.dispose() + }, + [hitGeometry, hitMaterial, material, shaftGeometry, tipGeometry], + ) + const rotation: Point = + axis === 'x' ? [0, 0, -Math.PI / 2] : axis === 'z' ? [Math.PI / 2, 0, 0] : [0, 0, 0] + + return ( + + {}} + renderOrder={1210} + /> + {}} + renderOrder={1210} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + position={[0, length * 0.5, 0]} + renderOrder={1211} + /> + + ) +} + +function RotationHandle({ + axis, + radius, + tube, + active, + onPointerDown, +}: { + axis: Axis + radius: number + tube: number + active: boolean + onPointerDown: (axis: Axis, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const ringGeometry = useMemo(() => new TorusGeometry(radius, tube, 8, 64), [radius, tube]) + const hitGeometry = useMemo(() => new TorusGeometry(radius, tube * 4.5, 8, 64), [radius, tube]) + const arrowGeometry = useMemo(() => new ConeGeometry(tube * 2.8, tube * 7, 12), [tube]) + const material = useMemo( + () => new MeshBasicNodeMaterial({ depthTest: false, depthWrite: false }), + [], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: AXIS_COLORS[axis], + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [axis], + ) + useEffect(() => { + material.color.set(active || hovered ? '#fef3c7' : AXIS_COLORS[axis]) + }, [active, axis, hovered, material]) + useEffect( + () => () => { + ringGeometry.dispose() + hitGeometry.dispose() + arrowGeometry.dispose() + material.dispose() + hitMaterial.dispose() + }, + [arrowGeometry, hitGeometry, hitMaterial, material, ringGeometry], + ) + const rotation: Point = + axis === 'x' ? [0, Math.PI / 2, 0] : axis === 'y' ? [-Math.PI / 2, 0, 0] : [0, 0, 0] + + return ( + + {}} + renderOrder={1210} + /> + {}} + renderOrder={1210} + /> + {}} + renderOrder={1210} + rotation={[0, 0, Math.PI]} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(false) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + renderOrder={1211} + /> + + ) +} + +function LoopCutTarget({ + edgeId, + start, + end, + radius, + onHover, + onPointerDown, +}: { + edgeId: string + start: Point + end: Point + radius: number + onHover: (edgeId: string | null) => void + onPointerDown: (edgeId: string, event: ThreeEvent) => void +}) { + const placement = useMemo(() => { + const from = new Vector3(...start) + const to = new Vector3(...end) + const direction = to.clone().sub(from) + return { + length: direction.length(), + position: from.add(to).multiplyScalar(0.5), + quaternion: new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction.normalize()), + } + }, [end, start]) + const geometry = useMemo( + () => new CylinderGeometry(radius, radius, placement.length, 8), + [placement.length, radius], + ) + const material = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + transparent: true, + opacity: 0, + depthTest: false, + depthWrite: false, + }), + [], + ) + useEffect( + () => () => { + geometry.dispose() + material.dispose() + if (document.body.style.cursor === 'crosshair') document.body.style.cursor = '' + }, + [geometry, material], + ) + + return ( + + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onPointerDown(edgeId, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + onHover(edgeId) + document.body.style.cursor = 'crosshair' + }} + onPointerLeave={() => { + onHover(null) + if (document.body.style.cursor === 'crosshair') document.body.style.cursor = '' + }} + renderOrder={1221} + /> + + ) +} + +function LoopCutPreview({ segments }: { segments: [Point, Point][] }) { + const geometry = useMemo(() => { + const next = new BufferGeometry() + next.setAttribute( + 'position', + new Float32BufferAttribute( + segments.flatMap(([from, to]) => [...from, ...to]), + 3, + ), + ) + return next + }, [segments]) + const material = useMemo( + () => + new LineBasicNodeMaterial({ + color: '#facc15', + depthTest: false, + depthWrite: false, + }), + [], + ) + const line = useMemo(() => { + const next = new LineSegments(geometry, material) + next.frustumCulled = false + next.layers.set(EDITOR_LAYER) + next.raycast = () => {} + next.renderOrder = 1220 + return next + }, [geometry, material]) + useEffect( + () => () => { + geometry.dispose() + material.dispose() + }, + [geometry, material], + ) + return +} + +function ToolbarButton({ + label, + active = false, + disabled = false, + destructive = false, + onClick, + children, +}: { + label: string + active?: boolean + disabled?: boolean + destructive?: boolean + onClick?: () => void + children: ReactNode +}) { + return ( + + + + + ) +} + +function CustomMeshEditor({ + node, + target, + mirrorTarget, +}: { + node: CustomMeshNode + target: Object3D + mirrorTarget: boolean +}) { + const { camera, gl } = useThree() + const outerRef = useRef(null) + const [editing, setEditing] = useState(false) + const editingRef = useRef(false) + const [mode, setMode] = useState('face') + const [selectedIds, setSelectedIds] = useState([]) + const [activeId, setActiveId] = useState(null) + const [transformTool, setTransformTool] = useState('select') + const [xray, setXray] = useState(false) + const [previewTopology, setPreviewTopology] = useState(null) + const [dragAxis, setDragAxis] = useState(null) + const [loopCutSegments, setLoopCutSegments] = useState<[Point, Point][] | null>(null) + const [extrudeDistance, setExtrudeDistance] = useState('0.25') + const [insetAmount, setInsetAmount] = useState('0.15') + const [rotationSnapAngle, setRotationSnapAngle] = useState('15') + const [scaleFactor, setScaleFactor] = useState('1.1') + const [error, setError] = useState(null) + const cancelDragRef = useRef<(() => void) | null>(null) + const displayTopology = previewTopology ?? node.topology + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const selection = useMemo( + () => ({ mode, ids: selectedIds }), + [mode, selectedIds], + ) + const extent = topologyExtent(displayTopology) + const componentRadius = Math.min(0.055, Math.max(0.022, extent * 0.011)) + const gizmoOrigin = selectionCentroid(displayTopology, selection) + const gizmoLength = Math.min(0.72, Math.max(0.26, extent * 0.18)) + const gizmoRadius = Math.min(0.026, Math.max(0.009, extent * 0.006)) + const rotationGizmoRadius = Math.min(1, Math.max(0.45, extent * 0.28)) + const vertexById = useMemo(() => topologyVertexMap(displayTopology), [displayTopology]) + const menuAnchor = useMemo(() => { + const xs = displayTopology.vertices.map((vertex) => vertex.position[0]) + const ys = displayTopology.vertices.map((vertex) => vertex.position[1]) + const zs = displayTopology.vertices.map((vertex) => vertex.position[2]) + return [ + (Math.min(...xs) + Math.max(...xs)) / 2, + Math.max(...ys) + Math.min(1.4, Math.max(0.9, extent * 0.25)), + (Math.min(...zs) + Math.max(...zs)) / 2, + ] + }, [displayTopology, extent]) + + useFrame(() => { + const outer = outerRef.current + if (!(outer && mirrorTarget)) return + outer.position.copy(target.position) + outer.quaternion.copy(target.quaternion) + outer.scale.copy(target.scale) + }) + + useEffect(() => { + editingRef.current = editing + }, [editing]) + + const endOwnedScope = useCallback(() => { + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'mesh-editing' && scope.nodeId === node.id) + }, [node.id]) + + const exitEditMode = useCallback(() => { + cancelDragRef.current?.() + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + endOwnedScope() + editingRef.current = false + setEditing(false) + setPreviewTopology(null) + setSelectedIds([]) + setActiveId(null) + setTransformTool('select') + setDragAxis(null) + setLoopCutSegments(null) + setError(null) + }, [endOwnedScope, node.id]) + + useEffect( + () => () => { + cancelDragRef.current?.() + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + endOwnedScope() + if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' + }, + [endOwnedScope, node.id], + ) + + useEffect(() => { + if (!editing) return + const onToolCancel = () => { + markToolCancelConsumed() + if (cancelDragRef.current) cancelDragRef.current() + else exitEditMode() + } + emitter.on('tool:cancel', onToolCancel) + return () => emitter.off('tool:cancel', onToolCancel) + }, [editing, exitEditMode]) + + useEffect(() => { + if (!editing) return + const onGridClick = () => { + const scope = useInteractionScope.getState().scope + if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return + setSelectedIds([]) + setActiveId(null) + setError(null) + } + emitter.on('grid:click', onGridClick) + return () => emitter.off('grid:click', onGridClick) + }, [editing, node.id]) + + useEffect(() => { + if (!editing) return + const onKeyDown = (event: KeyboardEvent) => { + const element = event.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable + ) + return + if (event.key === 'Tab') { + event.preventDefault() + event.stopImmediatePropagation() + exitEditMode() + return + } + const nextMode = + event.key === '1' + ? 'vertex' + : event.key === '2' + ? 'edge' + : event.key === '3' + ? 'face' + : null + if (!nextMode || cancelDragRef.current) return + event.preventDefault() + event.stopImmediatePropagation() + const converted = convertCustomMeshSelection( + node.topology, + { + mode, + ids: selectedIds, + activeId, + }, + nextMode, + ) + setMode(converted.mode) + setSelectedIds(converted.ids) + setActiveId(converted.activeId) + setError(null) + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [activeId, editing, exitEditMode, mode, node.topology, selectedIds]) + + useEffect(() => { + const validIds = new Set( + mode === 'vertex' + ? node.topology.vertices.map((vertex) => vertex.id) + : mode === 'edge' + ? node.topology.edges.map((edge) => edge.id) + : node.topology.faces.map((face) => face.id), + ) + setSelectedIds((current) => current.filter((id) => validIds.has(id))) + setActiveId((current) => (current && validIds.has(current) ? current : null)) + }, [mode, node.topology]) + + const enterEditMode = () => { + const face = preferredFace(node.topology) + setMode('face') + setSelectedIds(face ? [face.id] : []) + setActiveId(face?.id ?? null) + setTransformTool('select') + setError(null) + editingRef.current = true + setEditing(true) + useInteractionScope.getState().begin(meshEditScope(node.id)) + triggerSFX('sfx:item-pick') + } + + const selectComponent = useCallback( + (id: string, additive: boolean) => { + const next = selectCustomMeshComponent({ mode, ids: selectedIds, activeId }, id, additive) + setSelectedIds(next.ids) + setActiveId(next.activeId) + setError(null) + }, + [activeId, mode, selectedIds], + ) + + const switchMode = (nextMode: ComponentMode) => { + if (cancelDragRef.current) return + const converted = convertCustomMeshSelection( + displayTopology, + { mode, ids: selectedIds, activeId }, + nextMode, + ) + setMode(converted.mode) + setSelectedIds(converted.ids) + setActiveId(converted.activeId) + setError(null) + } + + const makeRay = useCallback( + (clientX: number, clientY: number) => { + const rect = gl.domElement.getBoundingClientRect() + const pointer = new Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + const raycaster = new Raycaster() + raycaster.setFromCamera(pointer, camera) + return raycaster.ray + }, + [camera, gl.domElement], + ) + + const beginAxisDrag = useCallback( + (axis: Axis, event: ThreeEvent) => { + if (!editingRef.current || 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 worldTip = target.localToWorld(originLocal.clone().add(localAxis)) + const worldAxis = worldTip.sub(worldOrigin).normalize() + const initialParameter = closestAxisParameterToRay(worldOrigin, worldAxis, event.ray) + const baseTopology = displayTopology + const baseSelection = selection + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let latestTopology: CustomMeshTopology | null = null + let latestDistance = 0 + let finished = false + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'translate')) + useViewer.getState().setInputDragging(true) + setDragAxis(axis) + document.body.style.cursor = 'grabbing' + + const onMove = (pointerEvent: PointerEvent) => { + const parameter = closestAxisParameterToRay( + worldOrigin, + worldAxis, + makeRay(pointerEvent.clientX, pointerEvent.clientY), + ) + const worldPoint = worldOrigin + .clone() + .addScaledVector(worldAxis, parameter - initialParameter) + const localPoint = target.worldToLocal(worldPoint) + const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 + let distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + if (isGridSnapActive() && !pointerEvent.altKey) { + const step = useEditor.getState().gridSnapStep + if (step > 0) distance = Math.round(distance / step) * step + } + const delta: Point = [0, 0, 0] + delta[axisIndex] = distance + const result = applyCustomMeshCommand(baseTopology, { + type: 'translate-components', + selection: baseSelection, + delta, + }) + if (!result.ok) { + setError(result.error) + return + } + latestDistance = distance + latestTopology = result.topology + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setDragAxis(null) + if (commit && latestTopology && Math.abs(latestDistance) > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + triggerSFX('sfx:item-pick') + } + if (editingRef.current) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [displayTopology, makeRay, node.id, selectedIds.length, selection, target], + ) + + const beginRotationDrag = useCallback( + (axis: Axis, event: ThreeEvent) => { + if (!editingRef.current || 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 worldAxis = target + .localToWorld(originLocal.clone().add(localAxis)) + .sub(worldOrigin) + .normalize() + const initialVector = event.point + .clone() + .sub(worldOrigin) + .projectOnPlane(worldAxis) + .normalize() + if (initialVector.lengthSq() < 1e-6) return + const rotationPlane = new Plane().setFromNormalAndCoplanarPoint(worldAxis, worldOrigin) + const baseTopology = displayTopology + const baseSelection = selection + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let previousWrappedAngle = 0 + let accumulatedAngle = 0 + let latestAngle = 0 + let latestTopology: CustomMeshTopology | null = null + let finished = false + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'rotate')) + useViewer.getState().setInputDragging(true) + setDragAxis(axis) + document.body.style.cursor = 'grabbing' + + const onMove = (pointerEvent: PointerEvent) => { + const hit = makeRay(pointerEvent.clientX, pointerEvent.clientY).intersectPlane( + rotationPlane, + new Vector3(), + ) + if (!hit) return + const currentVector = hit.sub(worldOrigin).projectOnPlane(worldAxis) + if (currentVector.lengthSq() < 1e-6) return + currentVector.normalize() + const wrappedAngle = signedAngleAroundAxis(initialVector, currentVector, worldAxis) + accumulatedAngle += unwrapRotationDelta(previousWrappedAngle, wrappedAngle) + previousWrappedAngle = wrappedAngle + let angle = accumulatedAngle + const snapDegrees = Math.abs(Number(rotationSnapAngle)) + if (!pointerEvent.altKey && Number.isFinite(snapDegrees) && snapDegrees > 0) { + const step = (snapDegrees * Math.PI) / 180 + angle = Math.round(angle / step) * step + } + const result = applyCustomMeshCommand(baseTopology, { + type: 'rotate-components', + selection: baseSelection, + pivot: origin, + axis: AXIS_VECTORS[axis], + angle, + }) + if (!result.ok) { + setError(result.error) + return + } + latestAngle = angle + latestTopology = result.topology + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setDragAxis(null) + if (commit && latestTopology && Math.abs(latestAngle) > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + triggerSFX('sfx:item-pick') + } + if (editingRef.current) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [displayTopology, makeRay, node.id, rotationSnapAngle, selectedIds.length, selection, target], + ) + + const previewLoopCut = useCallback( + (edgeId: string | null) => { + if (cancelDragRef.current) return + if (!edgeId) { + setLoopCutSegments(null) + setError(null) + return + } + const segments = customMeshLoopCutSegments(displayTopology, edgeId, 0.5) + setLoopCutSegments(segments) + setError(segments ? null : 'Loop cut requires a connected ring of quad faces') + }, + [displayTopology], + ) + + const beginLoopCutDrag = useCallback( + (edgeId: string, event: ThreeEvent) => { + if (!editingRef.current || cancelDragRef.current) return + const edge = displayTopology.edges.find((entry) => entry.id === edgeId) + const vertices = topologyVertexMap(displayTopology) + const start = edge ? vertices.get(edge.vertexIds[0]) : null + const end = edge ? vertices.get(edge.vertexIds[1]) : null + if (!(edge && start && end)) return + target.updateWorldMatrix(true, false) + const worldStart = target.localToWorld(new Vector3(...start)) + const worldEnd = target.localToWorld(new Vector3(...end)) + const worldDirection = worldEnd.clone().sub(worldStart) + const worldLength = worldDirection.length() + if (worldLength < 1e-6) return + const worldAxis = worldDirection.normalize() + const initialParameter = closestAxisParameterToRay(worldStart, worldAxis, event.ray) + const baseTopology = displayTopology + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let latestTopology: CustomMeshTopology | null = null + let latestSelection: CustomMeshSelection | null = null + let latestFactor = 0.5 + let finished = false + + const updatePreview = (factor: number) => { + const result = applyCustomMeshCommand(baseTopology, { type: 'loop-cut', edgeId, factor }) + const segments = customMeshLoopCutSegments(baseTopology, edgeId, factor) + if (!result.ok || !segments) { + setError(result.ok ? 'Could not preview loop cut' : result.error) + return false + } + latestFactor = factor + latestTopology = result.topology + latestSelection = result.selection + setPreviewTopology(result.topology) + setLoopCutSegments(segments) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + return true + } + if (!updatePreview(0.5)) return + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'loop-cut')) + useViewer.getState().setInputDragging(true) + document.body.style.cursor = 'ew-resize' + + const onMove = (pointerEvent: PointerEvent) => { + const parameter = closestAxisParameterToRay( + worldStart, + worldAxis, + makeRay(pointerEvent.clientX, pointerEvent.clientY), + ) + const factor = Math.min( + 0.98, + Math.max(0.02, 0.5 + (parameter - initialParameter) / worldLength), + ) + updatePreview(factor) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setLoopCutSegments(null) + if (commit && latestTopology && latestSelection && latestFactor > 0) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + setMode(latestSelection.mode) + setSelectedIds(latestSelection.ids) + setActiveId(latestSelection.ids.at(-1) ?? null) + triggerSFX('sfx:item-pick') + } + if (editingRef.current) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [displayTopology, makeRay, node.id, target], + ) + + const commitCommand = ( + command: CustomMeshCommand, + operator: 'rotate' | 'scale' | 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete', + ) => { + if (cancelDragRef.current) return + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operator)) + const result = applyCustomMeshCommand(node.topology, command) + useInteractionScope.getState().begin(meshEditScope(node.id)) + if (!result.ok) { + setError(result.error) + return + } + useScene.getState().updateNode(node.id, { topology: result.topology }) + setMode(result.selection.mode) + setSelectedIds(result.selection.ids) + setActiveId(result.selection.ids.at(-1) ?? null) + setError(null) + triggerSFX('sfx:item-pick') + } + + const extrudeSelectedFace = () => { + if (mode !== 'face' || selectedIds.length !== 1) return + commitCommand( + { type: 'extrude-face', faceId: selectedIds[0]!, distance: Number(extrudeDistance) }, + 'extrude', + ) + } + + const insetSelectedFace = () => { + if (mode !== 'face' || selectedIds.length !== 1) return + commitCommand( + { + type: 'inset-face', + faceId: selectedIds[0]!, + amount: Number(insetAmount), + depth: 0, + }, + 'inset', + ) + } + + const scaleSelection = () => { + if (!gizmoOrigin) return + const factor = Number(scaleFactor) + commitCommand( + { + type: 'scale-components', + selection, + pivot: gizmoOrigin, + factors: [factor, factor, factor], + }, + 'scale', + ) + } + + const deleteSelection = () => { + if (selectedIds.length === 0) return + commitCommand({ type: 'delete-components', selection }, 'delete') + } + + const mergeSelection = () => { + if (mode !== 'vertex' || selectedIds.length < 2) return + commitCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') + } + + const dissolveSelection = () => { + if (mode !== 'edge' || selectedIds.length !== 1) return + commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + } + + const updateSelection = (next: CustomMeshSelectionState) => { + setMode(next.mode) + setSelectedIds(next.ids) + setActiveId(next.activeId) + setError(null) + } + + const selectAll = () => + updateSelection( + selectAllCustomMeshComponents(displayTopology, { mode, ids: selectedIds, activeId }), + ) + const invertSelection = () => + updateSelection( + invertCustomMeshSelection(displayTopology, { mode, ids: selectedIds, activeId }), + ) + const clearSelection = () => + updateSelection(clearCustomMeshSelection({ mode, ids: selectedIds, activeId })) + + const keyboardActionsRef = useRef({ + clearSelection, + deleteSelection, + dissolveSelection, + extrudeSelectedFace, + hasSelection: selectedIds.length > 0, + insetSelectedFace, + invertSelection, + mergeSelection, + scaleSelection, + selectAll, + }) + keyboardActionsRef.current = { + clearSelection, + deleteSelection, + dissolveSelection, + extrudeSelectedFace, + hasSelection: selectedIds.length > 0, + insetSelectedFace, + invertSelection, + mergeSelection, + scaleSelection, + selectAll, + } + + useEffect(() => { + if (!editing) return + const onKeyDown = (event: KeyboardEvent) => { + const element = event.target as HTMLElement | null + if ( + element?.tagName === 'INPUT' || + element?.tagName === 'TEXTAREA' || + element?.isContentEditable || + cancelDragRef.current + ) + return + const key = event.key.toLowerCase() + const actions = keyboardActionsRef.current + let handled = true + if (key === 'a') { + if (event.altKey) actions.clearSelection() + else actions.selectAll() + } else if (key === 'i' && (event.ctrlKey || event.metaKey)) { + actions.invertSelection() + } else if (key === 'g') { + if (actions.hasSelection) setTransformTool('move') + } else if (key === 'e') { + actions.extrudeSelectedFace() + } else if (key === 'i') { + actions.insetSelectedFace() + } else if (key === 'r') { + if (event.ctrlKey || event.metaKey) setTransformTool('loop-cut') + else if (actions.hasSelection) setTransformTool('rotate') + } else if (key === 's') { + actions.scaleSelection() + } else if (key === 'm') { + actions.mergeSelection() + } else if (key === 'd') { + actions.dissolveSelection() + } else if (event.key === 'Delete' || event.key === 'Backspace' || key === 'x') { + actions.deleteSelection() + } else { + handled = false + } + if (!handled) return + event.preventDefault() + event.stopImmediatePropagation() + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [editing]) + + const moveNode = () => { + useEditor.getState().setMovingNode(node as never) + useViewer.getState().setSelection({ selectedIds: [] }) + triggerSFX('sfx:item-pick') + } + const deleteNode = () => { + useViewer.getState().setSelection({ selectedIds: [] }) + useScene.getState().deleteNode(node.id) + } + + const componentLabel = + selectedIds.length === 1 ? mode : mode === 'vertex' ? 'vertices' : `${mode}s` + const componentStatus = + transformTool === 'loop-cut' + ? 'Loop Cut · hover an edge to preview · click or drag to cut and slide · Ctrl+R' + : selectedIds.length === 0 + ? `Click a ${mode} to select it` + : transformTool === 'move' + ? `${selectedIds.length} ${componentLabel} selected · drag an axis to move · Alt for free movement` + : transformTool === 'rotate' + ? `${selectedIds.length} ${componentLabel} selected · drag a rotation ring · Alt for free rotation` + : `${selectedIds.length} ${componentLabel} selected · choose a transform or mesh operator` + + return ( + + {editing ? ( + <> + {mode === 'vertex' + ? displayTopology.vertices.map((vertex) => ( + + )) + : null} + {mode === 'edge' + ? displayTopology.edges.map((edge) => { + const start = vertexById.get(edge.vertexIds[0]) + const end = vertexById.get(edge.vertexIds[1]) + return start && end ? ( + + ) : null + }) + : null} + {mode === 'face' + ? displayTopology.faces.map((face) => ( + + )) + : null} + {gizmoOrigin && transformTool === 'move' ? ( + + {(['x', 'y', 'z'] as const).map((axis) => ( + + ))} + + ) : null} + {gizmoOrigin && transformTool === 'rotate' ? ( + + {(['x', 'y', 'z'] as const).map((axis) => ( + + ))} + + ) : null} + {transformTool === 'loop-cut' + ? displayTopology.edges.map((edge) => { + const start = vertexById.get(edge.vertexIds[0]) + const end = vertexById.get(edge.vertexIds[1]) + return start && end ? ( + + ) : null + }) + : null} + {loopCutSegments ? : null} + + ) : null} + + +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + > + {editing ? ( +
+
+ + Edit mode + + + switchMode('vertex')} + > + + + switchMode('edge')} + > + + + switchMode('face')} + > + + + + + All + + + Invert + + + Clear + + setXray((value) => !value)} + > + {xray ? : } + + + + + +
+
+ setTransformTool('select')} + > + + + setTransformTool('move')} + > + + + + setRotationSnapAngle(event.target.value)} + step="5" + type="number" + value={rotationSnapAngle} + /> + setTransformTool('rotate')} + > + + + setTransformTool('loop-cut')} + > + + + setScaleFactor(event.target.value)} + step="0.1" + type="number" + value={scaleFactor} + /> + + + + + setExtrudeDistance(event.target.value)} + step="0.05" + type="number" + value={extrudeDistance} + /> + + + + setInsetAmount(event.target.value)} + step="0.05" + type="number" + value={insetAmount} + /> + + Inset + + + Merge + + + Dissolve + + + + +
+
+ ) : ( +
+ + + + + + + + + +
+ )} + {editing ? ( +
+ {error ?? componentStatus} +
+ ) : null} +
+ +
+ ) +} + +const CustomMeshSelectionAffordance = () => { + 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 === 'custom-mesh' ? (selected as CustomMeshNode) : null + }) + const [target, setTarget] = useState(null) + const nodeId = node?.id ?? null + + useEffect(() => { + if (!nodeId) { + setTarget(null) + return + } + let frameId = 0 + const resolve = () => { + const next = sceneRegistry.nodes.get(nodeId as AnyNodeId) ?? null + setTarget((current) => (current === next ? current : next)) + if (!next) frameId = window.requestAnimationFrame(resolve) + } + resolve() + return () => window.cancelAnimationFrame(frameId) + }, [nodeId]) + + if (!node || !target) return null + const mount = target.parent ?? target + return createPortal( + , + mount, + undefined, + ) +} + +export default CustomMeshSelectionAffordance diff --git a/packages/nodes/src/custom-mesh/tool.tsx b/packages/nodes/src/custom-mesh/tool.tsx new file mode 100644 index 0000000000..8eb0b953c9 --- /dev/null +++ b/packages/nodes/src/custom-mesh/tool.tsx @@ -0,0 +1,136 @@ +'use client' + +import { + CustomMeshNode, + collectAlignmentAnchors, + emitter, + type GridEvent, + useScene, +} from '@pascal-app/core' +import { + getFloorStackPreviewPosition, + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, + movementSfxStepKey, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { Group } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + resolveAlignedFloorPlacement, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' +import { customMeshDefinition } from './definition' +import CustomMeshPreview from './preview' + +const CustomMeshTool = () => { + const activeLevelId = useViewer((state) => state.selection.levelId) + const cursorRef = useRef(null) + const previousSnapRef = useRef(null) + const cursorVisibleRef = useRef(false) + const [cursorVisible, setCursorVisible] = useState(false) + const previewNode = useMemo( + () => + CustomMeshNode.parse({ + ...customMeshDefinition.defaults(), + name: 'Custom Mesh', + position: [0, 0, 0], + }), + [], + ) + + useEffect(() => { + if (!activeLevelId) return + let lastPosition: [number, number, number] | null = null + let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + + const onGridMove = (event: GridEvent) => { + if (!cursorVisibleRef.current) { + cursorVisibleRef.current = true + setCursorVisible(true) + } + const { position, guides } = resolveAlignedFloorPlacement({ + node: previewNode, + rawX: event.localPosition[0], + rawZ: event.localPosition[2], + gridStep: useEditor.getState().gridSnapStep, + candidates: alignmentCandidates, + showAlignment: isAlignmentGuideActive(), + applyAlignmentSnap: isMagneticSnapActive(), + bypassGrid: !isGridSnapActive(), + }) + useAlignmentGuides.getState().set(guides) + const visualPosition = getFloorStackPreviewPosition({ + node: previewNode, + position, + rotation: previewNode.rotation, + levelId: activeLevelId, + }) + cursorRef.current?.position.set(...visualPosition) + lastPosition = position + + const snapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) + if (snapKey !== previousSnapRef.current) { + triggerSFX('sfx:grid-snap') + previousSnapRef.current = snapKey + } + } + + const commit = (event: FloorPlacementClickTriggerEvent) => { + const position = + lastPosition ?? + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + !isGridSnapActive(), + ) + const node = CustomMeshNode.parse({ + ...customMeshDefinition.defaults(), + name: 'Custom Mesh', + parentId: activeLevelId, + position, + }) + useScene.getState().createNode(node, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + triggerSFX('sfx:structure-build') + useAlignmentGuides.getState().clear() + if (useEditor.getState().getContinuation('point') === 'repeat') { + alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + } else { + cursorVisibleRef.current = false + setCursorVisible(false) + useEditor.getState().setTool(null) + } + stopPlacementCommitPropagation(event) + } + + emitter.on('grid:move', onGridMove) + const unsubscribe = subscribeFloorPlacementClicks(commit) + return () => { + emitter.off('grid:move', onGridMove) + unsubscribe() + useAlignmentGuides.getState().clear() + } + }, [activeLevelId, previewNode]) + + if (!activeLevelId) return null + return ( + + + + ) +} + +export default CustomMeshTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index b9040fba74..36ce9e4364 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -7,6 +7,7 @@ import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' import { constructionDimensionDefinition } from './construction-dimension' import { cupolaDefinition } from './cupola' +import { customMeshDefinition } from './custom-mesh/definition' import { doorDefinition } from './door' import { dormerDefinition } from './dormer' import { downspoutDefinition } from './downspout' @@ -67,6 +68,7 @@ export const builtinPlugin: Plugin = { nodes: [ // Stage E-complete (full registry path) shelfDefinition as unknown as AnyNodeDefinition, + customMeshDefinition as unknown as AnyNodeDefinition, spawnDefinition as unknown as AnyNodeDefinition, wallDefinition as unknown as AnyNodeDefinition, fenceDefinition as unknown as AnyNodeDefinition, @@ -136,6 +138,15 @@ export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' export { constructionDimensionDefinition } from './construction-dimension' export { cupolaDefinition } from './cupola' +export { + applyCustomMeshCommand, + type CustomMeshCommand, + type CustomMeshCommandResult, + type CustomMeshSelection, + customMeshFaceCentroid, + customMeshFaceNormal, +} from './custom-mesh/commands' +export { customMeshDefinition } from './custom-mesh/definition' export { doorDefinition } from './door' export { dormerDefinition } from './dormer' export { downspoutDefinition } from './downspout' diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 09b66aa97c..145708cd6b 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -1,6 +1,6 @@ # Interaction Scope -*The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing".* +_The authoritative interaction state machine ("the spine") — one scope describes "what the user is currently doing"._ Applies to: `packages/editor/src/lib/interaction/**`, `packages/editor/src/store/use-interaction-scope.ts`. @@ -19,16 +19,17 @@ scope is exactly one interaction at a time, and `idle` carries no payload. `InteractionScope` (`lib/interaction/scope.ts`) is a discriminated union on `kind`: -| `kind` | Payload | What | -|---|---|---| -| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. | -| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. | -| `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. | -| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. | -| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | -| `reshaping` | `nodeId`, `reshape`, `driver`, `holeIndex?`, `endpoint?`, `index?`, `side?` | Reshaping a selected node's geometry. `driver` identifies the interaction body that owns preview and commit. | -| `box-select` | — | Marquee selection drag. | -| `painting` | — | Material paint application. | +| `kind` | Payload | What | +| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. | +| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. | +| `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. | +| `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. | +| `mesh-editing` | `nodeId`, `phase`, `operator?` | Editing one node's internal mesh components. Held for the complete edit-mode session; `phase` distinguishes component selection from an in-flight operator. | +| `drafting` | `tool` | Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). | +| `reshaping` | `nodeId`, `reshape`, `driver`, `holeIndex?`, `endpoint?`, `index?`, `side?` | Reshaping a selected node's geometry. `driver` identifies the interaction body that owns preview and commit. | +| `box-select` | — | Marquee selection drag. | +| `painting` | — | Material paint application. | `reshaping` groups endpoint/curve/hole/boundary/control-point/tangent edits as sub-states of one scope — there is one node and one in-flight reshape, so @@ -41,7 +42,7 @@ mounting for the same gesture. Placing and moving use `view: '2d' | '3d'`. ### Helpers - `isIdle(scope)` / `isActive(scope)` — `idle` vs anything else (`ActiveInteractionScope`). -- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node. +- `scopeNodeId(scope)` — the node a scope acts on, or `null`. `drafting`/`box-select`/`painting`/`idle` target no single existing node. A `mesh-editing` scope targets the custom mesh whose topology owns the session. - `isToolDrivenReshape(scope)` / `isFloorplanDrivenReshape(scope)` — narrow reshape ownership so only the matching interaction body mounts. - `selectionEnabled(scope)` — true only while `idle`. During any active interaction the pointer belongs to that interaction's body, not to selecting a different object; the picking choke point must not route a hover/click to selection while this is false. @@ -54,12 +55,12 @@ mounting for the same gesture. Placing and moving use `view: '2d' | '3d'`. single owner. Exactly one scope at a time; the only writable shape is `InteractionScope`, so there is no setter that can leave a half-state. -| Method | Behaviour | -|---|---| -| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). | -| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). | -| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. | -| `endIf(match)` | Return to idle only if the active scope satisfies `match`. | +| Method | Behaviour | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `begin(scope: ActiveInteractionScope)` | Enter an interaction. If one is already active it is replaced (single owner, no producer races). | +| `update(patch)` | Patch the current scope's payload. **Ignored when idle, and ignored when the patch's `kind` differs from the active kind** — payload updates must not change which interaction is running (use `begin` for that). | +| `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. | +| `endIf(match)` | Return to idle only if the active scope satisfies `match`. | **Atomic-end invariant.** `end()` sets the scope back to `IDLE_SCOPE` in one write — no interaction payload can leak past the end of its interaction (no stale @@ -80,7 +81,7 @@ the node's `asset.attachTo` plus whether a candidate exposes a top surface. - `wall` — `attachTo` of `wall` or `wall-side`. - `ceiling` — `attachTo` of `ceiling`. -- `surface` — everything else ("floor item" really means *surface-resting*: rests on the floor **or** any host's top surface). +- `surface` — everything else ("floor item" really means _surface-resting_: rests on the floor **or** any host's top surface). `isPickableForAttach(placed, candidate)` decides, for a node of attach class `placed`, whether a `HotSetCandidate` is a valid host/surface: @@ -91,7 +92,7 @@ the node's `asset.attachTo` plus whether a candidate exposes a top surface. `isCandidateInHotSet(scope, placedAttachClass, candidate)` lifts this to a whole scope: -- `idle` → `true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an *active* interaction can target). +- `idle` → `true` (selection/phase filtering stays in the selection manager; the hot-set only narrows what an _active_ interaction can target). - `placing` / `moving` → `isPickableForAttach`, or `true` when `placedAttachClass` is `null`. - every other active scope → `false`: nothing in the scene is a placement target, so the interaction body's own raycast owns the pointer. @@ -108,14 +109,14 @@ module pure and unit-testable without the scene or registry. any non-idle scope, scene objects stay visible but non-pickable, and DOM/HUD overlays step back differentiated by how distracting they are. -| Overlay | Idle | Any active scope | -|---|---|---| -| Zone labels | shown | hidden (not a primary editing concern) | -| Context badges (hover name pills) | shown | faded + `pointer-events: none` | -| Conflicting controls (other objects' handles, floating action menu) | shown | hidden | -| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) | -| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown | -| Contextual control HUD interactive | yes | yes (it *is* the active interaction's own controls — exempt from the pointer-events step-back) | +| Overlay | Idle | Any active scope | +| ---------------------------------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------- | +| Zone labels | shown | hidden (not a primary editing concern) | +| Context badges (hover name pills) | shown | faded + `pointer-events: none` | +| Conflicting controls (other objects' handles, floating action menu) | shown | hidden | +| Scene objects pickable | yes | no (the hot-set owns targeting; context preserved, can't grab the wrong thing) | +| Active affordances (ghost, snap guides, dimension labels, the active handle) | shown | shown | +| Contextual control HUD interactive | yes | yes (it _is_ the active interaction's own controls — exempt from the pointer-events step-back) | The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`. @@ -124,7 +125,7 @@ The policy is binary (`IDLE_POLICY` vs `ACTIVE_POLICY`) keyed on `isActive`. ## Snapping mode & modifiers (the unified model) Snapping is a persistent, **per-context**, always-visible mode — not a held-Shift bypass. -The active scope selects the *context*; the context's current mode selects the *behaviour*. +The active scope selects the _context_; the context's current mode selects the _behaviour_. There is no per-kind snapping switch. - **Contexts** (`lib/snapping-mode.ts`, `SNAP_PROFILES`): `wall` (grid/lines/angles/off, default grid), @@ -147,6 +148,7 @@ There is no per-kind snapping switch. **Known-legacy (migrate on touch).** Two legacy modifier patterns predate this model and survive in spots not yet touched; both are tracked in `plans/editor-placement-interaction-overhaul.md`. A PR that **touches** one must migrate it to the model above, not extend the legacy path: + 1. **`event.shiftKey` as a snap bypass with hardcoded steps** — the MEP move/endpoint tools (`packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx`). Opening a `moving` scope from a bespoke mover is **not** the migration — `useMovingNode()` reads the scope, diff --git a/wiki/blender-edit-mode-research.md b/wiki/blender-edit-mode-research.md new file mode 100644 index 0000000000..2fbe6a23c5 --- /dev/null +++ b/wiki/blender-edit-mode-research.md @@ -0,0 +1,406 @@ +# Blender-Style Custom Mesh Edit Mode Research + +## Purpose and conclusion + +This brief describes the Edit Mode interaction shown by the linked X post, how Blender's mesh Edit Mode actually behaves, what the current Pascal custom-mesh slice already implements, and the staged work required for a credible Blender-like experience. External behavior claims use only current first-party Blender manuals, developer documentation, and API documentation. Repository claims come from the current worktree, audited on 2026-08-10. + +The central recommendation is unchanged but now concrete: + +1. Keep persistent, adjacency-rich topology with stable component IDs as the source of truth. `THREE.BufferGeometry` remains a derived render and picking artifact. +2. Treat Edit Mode as a persistent editor session containing component selection and display state. +3. Run every transform or topology command through one modal preview → confirm/cancel lifecycle, regardless of whether it starts from a gizmo, keyboard shortcut, or toolbar button. +4. Deliver in dependency order. The current box, component selection, axis translation, and single-face extrusion are a useful vertical slice, not Blender parity. + +## What the linked video suggests + +The [37-second X video](https://x.com/00namazu86_7/status/2079180451521200550) primarily demonstrates architectural massing: a rectangular footprint becomes a shallow solid, a top face is raised with a measured handle, and contextual surface actions lead into higher-level building and material workflows. The closest first product target is therefore **face Push/Pull on an editable architectural solid**, built on a topology model capable of growing toward Blender-style component editing. + +That does not mean the custom mesh should absorb Pascal's semantic model. Walls, slabs, roofs, openings, balconies, and hosted items should remain semantic nodes or explicit semantic commands. Materials and day/night presentation remain orthogonal to topology. Stable hosting on a custom-mesh face would require a later face-host contract that survives topology remapping. + +## Current Pascal implementation + +The worktree already contains a coherent first vertical slice. + +| Area | Current implementation | Current boundary | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Persistent schema | [`CustomMeshNode`](../packages/core/src/schema/nodes/custom-mesh.ts) stores level-local position/rotation plus stable-ID vertices, undirected edges, ordered face vertex loops, per-face `materialSlot`, and optional slots. A box is the default topology. | No face-corner/loop attributes, holes, explicit adjacency, or topology version/revision. | +| Validation | `inspectCustomMeshTopology` rejects duplicate component IDs, self-edges, missing vertex references, duplicate undirected edges, faces with fewer than three distinct vertices, and missing face-boundary edges. | It does not yet define a manifold policy or reject zero-length edges, repeated vertices in a longer face loop, duplicate faces, non-planar/zero-area/self-intersecting faces, inconsistent winding, or failed triangulation. | +| Pure commands | [`commands.ts`](../packages/nodes/src/custom-mesh/commands.ts) exposes component translate/rotate/scale/delete plus single-face extrude/inset, returns a new topology plus selection, preserves input immutability, allocates stable IDs, and validates the result. | Selection supports one mode at a time, has no identity remap, and extrude/inset handle exactly one face with immediate numeric parameters rather than modal region operations. | +| Derived geometry | [`geometry.ts`](../packages/nodes/src/custom-mesh/geometry.ts) projects each face to 2D, triangulates it, generates flat normals/UVs/material groups, and stores triangle ranges keyed by face ID. | Triangulation assumes a usable planar simple loop; rebuilding replaces the whole `BufferGeometry`; the face-range metadata is not yet the main component picker. | +| Registry integration | [`definition.ts`](../packages/nodes/src/custom-mesh/definition.ts) registers geometry, floor-plan output, placement preview/tool, selection affordance, item-style snapping, move/delete/duplicate, floor placement, collision, and materials. The node is registered in the built-in plugin and appears through registry-driven build UI. | Object rotation is stored but no rotatable capability is declared. The floor plan uses the XZ convex hull, so concavity and overhang distinctions are lost. | +| Edit UI | [`selection.tsx`](../packages/nodes/src/custom-mesh/selection.tsx) mounts only for a sole selected custom mesh. It has a dedicated `mesh-editing` interaction scope, active-white/selected-orange components, topology-aware `1`/`2`/`3` conversion, All/Invert/Clear, X-Ray, tool-gated compact move handles, numeric rotate/scale, Extrude, Inset, Merge at Center, Dissolve Edge, component Delete, shortcuts, and Done/Tab. | Detailed session state is still component-local; there is no mixed-mode or box selection, face-center picking, plane/free transform handles, orientation/pivot controls, proportional editing, or full modal operator engine. | +| Preview/history | The complete Edit Mode session owns the `mesh-editing` scope, suppressing object selection and whole-node movement. Axis drag snapshots topology, previews through `useLiveNodeOverrides`, clears on cancel/unmount, and performs one `updateNode` on release. Numeric operators perform one validated scene update. | Numeric operators do not yet provide pointer preview/cancel, typed modal input, or a durable last-operator record for parameter replay. | +| Tests | Core schema tests cover the default box and a missing boundary edge. Command tests cover extrusion, ID allocation, translation, rotation, scale, inset, deletion, and topology validity. Selection tests cover active identity, toggling, conversion, All, and Invert; scope tests cover persistent mesh-edit ownership. | No tests yet cover pointer picking/occlusion, component visuals, preview cancellation/history, save/reload, floor-plan updates, degeneracies, performance, or end-to-end user workflows. | + +This implementation already follows two important repository precedents: + +- Polygon editing already provides direct vertex/edge affordances through the shared [`PolygonEditor`](../packages/editor/src/components/tools/shared/polygon-editor.tsx). +- The slab boundary editor already demonstrates the desired one-undo transaction: preview through `useLiveNodeOverrides`, mark dirty at pointer rate, clear on cancel/unmount, and perform one scene update on release. [Slab boundary editor](../packages/nodes/src/slab/boundary-editor.tsx), [Pascal tool rules](architecture/tools.md) + +## Blender's actual Edit Mode UX + +### Entry, exit, and mode ownership + +Blender uses `Tab` to toggle Edit Mode for supported objects. Entering a mode changes viewport appearance, header, toolbar, menus, and the shortcut map; Object Mode transforms the object while Edit Mode changes its components. Blender also supports multiple objects in Edit Mode, but cannot connect geometry across different objects. [Blender object modes](https://docs.blender.org/manual/en/latest/editors/3dview/modes.html) + +For Pascal v1, the transferable behavior is explicit mode ownership, not multi-object editing. Tab and an Edit Mesh action should enter a sole selected custom mesh; Tab should exit when no modal command is active. Escape should first cancel the current command. The canvas, contextual HUD, shortcut routing, and component overlays must all derive from the same session. + +### Component modes, visuals, and active element + +Blender's `1`, `2`, and `3` modes have a precise visual vocabulary: + +- Vertex mode shows vertices as points: unselected black, selected orange, active/last-selected white. +- Edge mode hides vertex points: unselected edges black, selected edges yellow/orange, active edge white. +- Face mode shades selected faces orange and gives the active face a white border. + +Shift allows multiple component modes. Ascending mode conversion keeps only complete higher-order elements; descending conversion selects every constituent. Ctrl changes switching into expand/contract behavior. [Blender mesh selection](https://docs.blender.org/manual/en/latest/modeling/meshes/selecting/introduction.html) + +Blender's overlays can additionally show face orientation, selected-face fill, face centers, indices, edge lengths/angles, face areas/angles, and normals; measurements update while components transform. [Blender viewport overlays](https://docs.blender.org/manual/en/latest/editors/3dview/display/overlays.html) + +Pascal now tracks an active ID and ordered selected IDs, and mode switching normalizes one coherent topology selection instead of clearing it. That ordering contract must be preserved when Merge at First/Last and Active Element pivot are added. + +### Occlusion and X-Ray + +With X-Ray off, occluded geometry is not selectable. X-Ray enables through-selection; in Face mode, selection uses face-center dots rather than clicking anywhere on the filled surface. Blender notes that dense overlapping components can still cause region-selection misses and that a concave n-gon's center dot can fall somewhere visually misleading. [Blender mesh selection](https://docs.blender.org/manual/en/latest/modeling/meshes/selecting/introduction.html) + +Pascal now exposes a visual X-Ray toggle and depth-tests component overlays by default. Full picking parity still requires: + +- Default: depth-tested, frontmost component picking and visuals. +- X-Ray toggle: through-picking plus visually muted occluded components. +- Face mode in X-Ray: explicit face-center pick targets. +- Region selection: screen-space point/edge/face tests with a documented frontmost-versus-through policy. + +### Transform gizmos versus modal `G`/`R`/`S` + +Blender supports two input surfaces over one transform model. Object gizmos also apply to mesh components: red/green/blue axes constrain to one axis; Move and Scale include two-axis plane squares; white handles provide view-plane/free movement, view rotation/trackball behavior, or uniform scale. Gizmos can be shown or hidden independently. [Blender viewport gizmos](https://docs.blender.org/manual/en/latest/editors/3dview/display/gizmo.html) + +`G`, `R`, and `S` start keyboard modal Move, Rotate, and Scale. Moving Edit Mode components changes their coordinates but does not move the object's origin. Pivot and transform orientation are independent state. [Move](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/move.html), [Rotate](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/rotate.html), [Scale](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/scale.html), [pivot points](https://docs.blender.org/manual/en/latest/editors/3dview/controls/pivot_point/index.html) + +The first implementation now hides transform arrows while the Select tool is active and uses a smaller Move-only overlay. The complete compact, screen-size-stable transform overlay should add: + +- Thin X/Y/Z stems, small terminal handles, two-axis plane squares, and a small neutral center/view-plane handle. +- The gizmo appears only when components are selected and never visually dominates the mesh. +- The constrained axis brightens during a command; the other axes fade. +- A small value readout near the pivot or contextual HUD shows live distance/angle/scale and typed input. +- `G`/`R`/`S` and pointer-drag gizmos invoke the same pure transform command and preview transaction. + +This is a visual replacement, not a second transform implementation. + +### Axis constraints, numeric input, and modal lifecycle + +During Move/Rotate/Scale and extrusion, `X`, `Y`, or `Z` constrain to one axis; `Shift-X/Y/Z` constrain Move/Scale to the other two axes. Repeating an axis key cycles orientation spaces and then clears the constraint, while the constrained axis is shown brighter. [Blender axis locking](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/control/axis_locking.html) + +Typing during a modal transform supplies an exact value. Blender displays the value in the viewport footer and supports decimal, negative, reciprocal, per-axis, unit, and expression input. The essential v1 subset is signed decimal values with the project unit system; multi-axis and expressions can follow. [Blender numeric input](https://docs.blender.org/manual/en/latest/scene_layout/object/editing/transform/control/numeric_input.html) + +Ordinary modal transforms preview continuously, confirm with click/Return, and cancel back to their original state with right-click/Escape. After confirmation, Adjust Last Operation (`F9`) can reparameterize the result; a new edit after undo truncates redo history. [Blender operators](https://docs.blender.org/manual/en/latest/interface/operators.html), [Undo & Redo](https://docs.blender.org/manual/en/latest/interface/undo_redo.html) + +The implementation rule is: every preview recomputes from an immutable pre-operation topology and parameter object. Pointer movement must never compound the prior preview. + +### Snapping + +Blender separates the **snap base** being moved (Closest, Center/pivot, Median, Active) from the **snap target** (increment/grid, vertex, edge, face, volume, edge center, edge perpendicular, and others). Increment snapping is relative to the starting position unless absolute grid snap is enabled. Face Project and Face Nearest can move vertices individually rather than transform the selection rigidly. [Blender snapping](https://docs.blender.org/manual/en/latest/editors/3dview/controls/snapping.html) + +Pascal should copy this separation without copying Blender's modifier map. The repository already defines visible per-context snap modes, Shift-to-cycle, Ctrl-to-cycle grid step, and Alt force/free. Mesh commands must resolve through that path. Add component targets and snap-base policies behind the same mode UI rather than reading new hidden modifiers. [Pascal interaction scope](architecture/interaction-scope.md), [Pascal tools](architecture/tools.md) + +### Proportional editing + +Proportional Editing affects nearby unselected vertices with a falloff while a selected transform runs. Wheel/PageUp/PageDown adjusts the influence radius live. Connected Only measures distance through topology rather than Euclidean space; Projected from View ignores depth. [Blender proportional editing](https://docs.blender.org/manual/en/latest/editors/3dview/controls/proportional_editing.html) + +This belongs in the shared transform engine as vertex weights and a radius overlay. It is not a brush and does not change topology. + +## Operator behavior and Pascal implications + +### Extrude Region and Individual Faces + +Extrude duplicates selected geometry while keeping it connected. Region extrusion identifies the selection boundary, creates side faces only there, and moves the selected interior patch unchanged; faces initially move along their average normal and can be axis-constrained. Closed and open selections have different connectivity behavior. [Blender Extrude Region](https://docs.blender.org/manual/en/latest/modeling/meshes/tools/extrude_region.html) + +Individual Faces extrudes each face separately rather than treating connected faces as one region. [Blender Extrude Individual Faces](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/face/extrude_individual_faces.html) + +The current `extrude-face` command is a good Push/Pull seed, but the target needs: + +- `extrude-region` over vertex/edge/face selections, with connected-region boundary extraction. +- `extrude-individual-faces` with separate caps and side walls. +- A modal distance preview using average/individual normals, axis constraints, typed input, and snapping. +- Selection/remapping that selects new caps and preserves surviving IDs. + +Blender has a hazardous quirk where cancelling the movement portion of some face extrusions can leave coincident new topology. [Blender Extrude Faces](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/face/extrude_faces.html) Pascal should deliberately diverge: Escape cancels the entire uncommitted extrusion, matching the repository's preview/cancel convention and avoiding invisible duplicate faces. + +### Inset Faces + +Inset creates border faces around selected patches; pointer distance controls thickness, Ctrl adjusts depth, and the command can switch between connected regions and individual faces. Boundary, even/relative offset, edge rail, outset, selection side, and attribute interpolation alter topology or output data. Confirm applies the result; right-click/Escape cancels it. [Blender Inset Faces](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/face/inset_faces.html) + +Pascal should first support planar connected face patches, thickness, optional depth, even offset, and outset. Each disconnected selected patch is a separate inset region inside one command. Preview must rebuild from the pre-inset snapshot whenever any parameter changes. + +### Bevel + +Bevel is a modal topology operator. Pointer movement controls width, Wheel controls segments, typed input is supported, Shift gives fine control, and options change width interpretation, edge/vertex affect, profile, overlap clamping, loop slide, miters, intersections, materials, and normals. Click/Return confirms; right-click/Escape cancels. [Blender Bevel](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/edge/bevel.html) + +For Pascal, v1 edge bevel should require exactly two incident faces, support width, segments, profile, and clamp overlap, and reject unsupported non-manifold junctions explicitly. It is an adjacency-driven topology command, not screen-space line thickening. + +### Loop Cut and Slide + +Loop Cut is explicitly two-stage. Hovering a perpendicular edge previews a yellow topology-derived loop; first click chooses the loop, then pointer movement slides the new loop. Right-click in stage one aborts, but right-click in stage two **commits a centered cut**. Wheel or typed input changes cut count; Even, Flipped, Clamp, smoothing, and UV correction remain parameters. [Blender Loop Cut and Slide](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/edge/loopcut_slide.html) + +Pascal needs quad-loop traversal and a staged modal state. It cannot substitute an arbitrary world plane cut. Loop/ring selection should land first because it proves the required adjacency traversal and pole/branch termination. + +### Subdivide + +Subdivide applies immediately to selected edges/faces, then exposes Number of Cuts, smoothing, n-gon policy, quad-corner pattern, and optional displacement in Adjust Last Operation. Results depend on the selected edge pattern and incident triangle/quad/n-gon topology; subdividing an n-gon's boundary does not necessarily split its face. [Blender Subdivide](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/edge/subdivide.html) + +Pascal needs deterministic pattern handlers and a replayable command record. A simple “split every face into four” implementation would not match Blender's selection semantics. + +### Merge + +Merge supports Center, Cursor, First, Last, per-connected-island Collapse, and By Distance. First/Last depends on selection order, while Collapse requires connected-component grouping. By Distance adds a threshold and optional unselected participation. [Blender Merge](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/mesh/merge.html) + +The command result must choose survivors, resolve positions/attributes, remove degenerate edges/faces, and return a complete remap from removed IDs to survivors. This is why active element and selection order must precede Merge. + +### Delete and Dissolve + +Delete exposes explicit vertex, edge, face, only-edge-and-face, and only-face variants with different dependent-topology cleanup. Dissolve preserves the surrounding surface: vertex dissolve joins surrounding faces, edge dissolve requires exactly two neighboring faces, face dissolve merges connected patches, and Limited Dissolve removes sufficiently flat detail under an angle threshold. [Blender Deleting & Dissolving](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/mesh/delete.html) + +Pascal should not expose one ambiguous `deleteSelected()` command. The UI may default based on component mode, but the pure command must encode the exact delete/dissolve variant and return removed/surviving selection mappings. + +### Knife + +Knife changes the cursor, lets successive clicks or a drag define visible cut paths, previews yellow segments and aqua points, supports multiple paths, measurements, midpoint/geometry snapping, angle/axis constraints, selected-only and visible-only/cut-through policies, internal segment undo, and one final apply or cancel. It is view-dependent and commits the resulting edge chains atomically. [Blender Knife Topology Tool](https://docs.blender.org/manual/en/latest/modeling/meshes/editing/mesh/knife_topology_tool.html) + +Pascal therefore needs a screen-space overlay plus a geometry kernel: raycast the path to faces, sort crossings, split edges/faces, reject unrepresentable paths, and commit once. Knife should come after stable picking, face splitting, adjacency, and command-local undo exist. + +## Target Pascal architecture + +### Persistent topology, not render triangles + +The serialized node should remain one scene node with internal components. Vertices/edges/faces must not become scene nodes. The existing schema is a sound starting point, but a runtime adjacency index should be derived once per topology revision: + +- Vertex → incident edges/faces. +- Edge → endpoint vertices and ordered incident faces. +- Face → ordered boundary edges/vertices. +- Connected components, boundary edges, and loop/ring traversal helpers. + +Future face-corner records must carry UVs, split normals, and other per-corner attributes. Blender's editable BMesh is connectivity-aware, provides split/collapse/dissolve operators and custom-data layers, and explicitly refreshes tessellation after destructive edits. [Blender BMesh API](https://docs.blender.org/api/current/bmesh.html), [BMesh operators](https://docs.blender.org/api/current/bmesh.ops.html) + +`BufferGeometry`, triangle ranges, normals, UVs, bounds, and floor-plan projection are derived caches. Never infer authoritative edges or persistent face identity back from triangulated render positions. + +### Topology invariants and remapping + +Keep the current checks and add, in dependency order: + +1. Finite coordinates; distinct face-loop entries; nonzero edge length; no duplicate face boundary. +2. Nonzero-area, planar, simple face loops and deterministic triangulation success. +3. Consistent winding across shared edges and explicit normal direction. +4. Explicit manifold policy: whether loose vertices/edges and edges with 0, 1, 2, or more incident faces are supported by each command. +5. Attribute validity and interpolation once face-corner data exists. + +Blender documents corresponding editable-mesh invariants: selected edges imply endpoint selection, selected faces imply their edges/vertices, hidden elements are unselected, duplicate edges/faces are invalid, and faces have at least three vertices. [Blender BMesh state](https://docs.blender.org/api/current/bmesh.html) + +Every command result should include more than `topology` and one selection: + +```ts +type MeshCommandResult = { + topology: CustomMeshTopology; + selection: MeshComponentSelection; + active: MeshComponentRef | null; + remap: { + retained: ReadonlySet; + created: ReadonlySet; + removed: ReadonlySet; + replacedBy: ReadonlyMap; + }; + warnings: readonly MeshCommandWarning[]; +}; +``` + +The shape is illustrative. The contract matters: operators must report identity changes so selection, host references, material assignments, and future measurements can follow edits deterministically. + +### Session and modal state + +Move the current local React state into a dedicated editor-owned session controlled through semantic methods, not independent setters: + +```ts +type MeshEditSession = { + nodeId: CustomMeshNodeId; + enabledModes: ReadonlySet<"vertex" | "edge" | "face">; + selected: { + vertices: ReadonlySet; + edges: ReadonlySet; + faces: ReadonlySet; + }; + active: MeshComponentRef | null; + selectionOrder: readonly MeshComponentRef[]; + xray: boolean; + pivot: MeshPivotMode; + orientation: MeshTransformOrientation; + proportional: MeshProportionalSettings; + operation: MeshModalOperation | null; +}; +``` + +The existing global editor `Mode` already contains `'edit'` for property-boundary editing, so it cannot silently become mesh Edit Mode. The current `drafting/custom-mesh-edit` scope also misnames a persistent editing session. + +Recommended seam: + +- `useMeshEditSession` owns the detailed session and immutable operation snapshot. +- Add an explicit `mesh-editing` summary to `InteractionScope` with `nodeId`, `phase` (`selecting` or `operating`), operator, and stage. This keeps selection gating, hot-set, overlays, HUD, and Escape routing on the interaction spine without putting large topology snapshots into it. +- One controller owns enter, switch mode, select, begin operation, preview, confirm, cancel, and exit so the stores cannot drift. +- Viewer selection retains the custom-mesh node; component selection remains editor-only. `packages/viewer` stays unaware of Edit Mode. [Viewer isolation](architecture/viewer-isolation.md), [selection managers](architecture/selection-managers.md), [interaction scope](architecture/interaction-scope.md) + +### Pure command and modal interfaces + +Extend the existing pure `applyCustomMeshCommand` model instead of embedding algorithms in `selection.tsx`: + +```ts +type MeshCommand

= { + kind: string; + execute(input: { + topology: CustomMeshTopology; + selection: MeshComponentSelection; + active: MeshComponentRef | null; + parameters: P; + }): MeshCommandResult; +}; + +type MeshModalOperation

= { + command: MeshCommand

; + baseTopology: CustomMeshTopology; + baseSelection: MeshComponentSelection; + baseActive: MeshComponentRef | null; + parameters: P; + constraint: MeshTransformConstraint; + typedInput: string; + stage: string; +}; +``` + +Commands remain pure, deterministic, Three-free, React-free, and store-free. Gizmos, `G/R/S`, toolbar actions, numeric input, and future touch controls only translate user input into parameters. Each preview calls `execute` against `baseTopology`; it never uses the prior preview as input. + +### Preview, confirm, cancel, history, and redo + +Use the current axis-drag and PolygonEditor pattern for every operator: + +1. `begin`: capture immutable topology/selection/active state and enter the operation scope. +2. `preview`: execute from that snapshot, publish `{ topology }` through `useLiveNodeOverrides`, update component overlays, and `markDirty`; do not call `useScene.updateNode`. +3. `confirm`: validate, clear the override, perform one `updateNode`, apply the returned selection/remap, and return to mesh-selection phase. +4. `cancel`: clear the override and return to the exact pre-operation topology/selection without history. +5. `unmount/blur/tool switch`: run the same cancellation path. + +For Adjust Last Operation, retain `{ baseTopology, baseSelection, commandKind, parameters }` after commit. Parameter changes re-execute from the base and replace the last semantic history entry rather than append incremental edits. This requires explicit history integration and should not be faked with repeated `updateNode` calls. Blender exposes equivalent post-operation parameter editing through the lower-left panel and `F9`. [Blender Undo & Redo](https://docs.blender.org/manual/en/latest/interface/undo_redo.html) + +### Package seams + +| Concern | Home | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Serialized schema, validation shared by every consumer, migrations | `packages/core/src/schema/nodes/custom-mesh.ts` | +| Kind-specific pure adjacency and command kernel | `packages/nodes/src/custom-mesh/`, kept independent of React/stores/Three | +| Derived Three.js geometry and triangle-to-face metadata | `packages/nodes/src/custom-mesh/geometry.ts` through `def.geometry` | +| Kind-owned selection/edit contribution | `packages/nodes/src/custom-mesh/selection.tsx`, progressively reduced to composition over shared editor controllers/components | +| Reusable mesh-edit session, modal input, compact gizmo, numeric HUD, picking | `packages/editor`, injected into Viewer as editor-only children/contributions | +| Read-only rendering/scene registration | Existing generic viewer path; no edit-mode state in `packages/viewer` | + +This follows the registry composition model and viewer isolation. [Node definitions](architecture/node-definitions.md), [viewer isolation](architecture/viewer-isolation.md), [Three.js layers](architecture/layers.md) + +## Phased delivery plan + +### Phase 0 — stabilize the existing slice + +- Keep the current schema, placement, box, derived geometry, materials, floor-plan footprint, translation command, extrusion command, and one-undo axis preview. +- Add the missing topology degeneracy checks and command remap contract before adding more destructive operations. +- Add save/reload, duplicate/delete, floor-plan live-update, cancel/unmount, and history tests. +- Set an initial interactive budget, for example representative fixtures at 100, 1,000, and 10,000 components, and measure validation, triangulation, rebuild, and picking. + +**Exit criteria:** current features survive reload and undo/redo; invalid topology never reaches rendering; repeated preview/cancel leaves no override or history entry. + +### Phase 1 — Blender-like Edit Mode shell and selection + +- Introduce the persistent mesh edit session and explicit interaction-scope summary. +- Support Edit button and Tab entry/exit, with modal Escape precedence. +- Replace always-visible spheres/cylinders with screen-stable Blender-like point/line/face overlays, selected orange and active white. +- Replace the large arrow trio with the compact transform overlay described above. +- Add active element, selection order, topology-aware `1`/`2`/`3` conversion, Shift mixed modes, Select All/None/Invert, box select, and X-Ray. +- Route picking through face-range metadata plus screen-space vertex/edge hit testing; keep editor visuals on `EDITOR_LAYER`. + +**Exit criteria:** component selection is stable across camera angles and render rebuilds; occluded components cannot be picked unless X-Ray is on; active and selected visuals are unambiguous; entering/exiting never changes topology. + +### Phase 2 — shared transform grammar + +- Add one Move/Rotate/Scale engine invoked by compact gizmo and `G/R/S`. +- Add free/view-plane movement, axis and plane constraints, local/global/normal orientations, median and active pivots, signed decimal/unit input, confirm/cancel, and visible values. +- Integrate Pascal snap modes with component targets and explicit snap-base selection. +- Keep one live override and one history commit per gesture. + +**Exit criteria:** equivalent gizmo and keyboard inputs produce byte-identical topology; numeric and pointer previews recompute from the same snapshot; cancel restores exact coordinates and selection. + +### Phase 3 — architectural Push/Pull, region extrusion, and inset + +- Convert the current immediate single-face extrusion into the modal engine. +- Add multi-face Extrude Region, Individual Faces, normal and axis constraints, exact distance, repeated cap extrusion, and full Escape rollback. +- Add planar region Inset with thickness, depth, outset, even offset, and disconnected patches. +- Preserve material slots and define interpolation for all created faces/components. + +**Exit criteria:** the video's measured face Push/Pull flow works without a large arrow; connected regions have no internal duplicate side walls; repeated extrusion keeps stable IDs and valid winding. + +### Phase 4 — cleanup and resolution operators + +- Add explicit Delete variants, Dissolve Vertex/Edge/Face, Merge Center/First/Last/Collapse/By Distance, and Subdivide pattern cases. +- Add selection remap, connected-component utilities, n-gon tests, and last-operator replay for Subdivide parameters. +- Define command-by-command behavior for boundaries and non-manifold inputs. + +**Exit criteria:** every removed component is represented in remap output; no dangling selection remains; delete and dissolve visibly differ; merge/subdivide replay deterministically. + +### Phase 5 — bevel, loops, and knife + +- Add loop/ring traversal and selection first. +- Add edge/vertex Bevel with width, segments, profile, and clamp overlap. +- Add staged Loop Cut & Slide with topology hover preview, cut count, centered right-click commit, and slide. +- Add Knife screen overlay, snapping/constraints, command-local undo, face/edge splitting, and one atomic commit. + +**Exit criteria:** traversal stops predictably at unsupported junctions; bevel rejects or handles degeneracy without corrupting topology; Knife cancel is history-free and confirmed paths survive validation/triangulation. + +### Phase 6 — proportional editing, attributes, and operator redo + +- Add proportional falloffs, live radius, Connected Only graph distance, and Projected from View to the shared transform engine. +- Add face-corner UVs, custom/split normals, attribute interpolation, normal flip/recalculate, and material preservation across every operator. +- Add a real Adjust Last Operation surface and deterministic replacement of the most recent command. +- Revisit multi-object Edit Mode only after single-node semantics and performance are proven. + +## Test strategy + +### Pure topology tests + +- Table-driven fixtures for triangle, quad, concave n-gon, disconnected patches, boundaries, holes when supported, and non-manifold junctions. +- Invariant checks after every command and randomized command sequences. +- Stable-ID/remap assertions: retained IDs stay retained, created IDs never collide, removed IDs never remain selected, replacement maps resolve. +- Determinism: same base topology + selection + parameters produces structurally identical output. +- Attribute/winding/normal assertions as those layers land. + +### Transaction and state tests + +- Enter/select/begin/preview/confirm/cancel/exit transition tests for the mesh session controller and interaction scope. +- Assert pointer-rate preview performs zero scene writes; confirm performs exactly one; cancel performs zero and clears overrides. +- Undo/redo restores topology, component selection policy, materials, and redo truncation correctly. +- Adjust Last Operation re-executes from its original base instead of compounding the prior result. + +### Rendering and picking tests + +- Triangle-to-face mapping for convex and concave faces. +- Depth-tested versus X-Ray picking, face-center targets, screen-space tolerances, and camera-scale stability. +- Geometry/floor-plan parity after live and committed edits. +- Visual regression captures for vertex/edge/face, hovered/selected/active, constrained axes, numeric HUD, proportional radius, loop preview, and knife paths. + +### End-to-end acceptance tests + +- Place a custom mesh, Tab into Edit Mode, select a face, `G Z 1.5`, confirm, undo, redo, and exit. +- Push/Pull a face to an exact height, cancel a second extrusion with no duplicate topology, then repeat and commit. +- Select through with X-Ray, switch component modes with topology-aware preservation, and merge at active/last. +- Inset, bevel, loop cut, dissolve, subdivide, and knife representative meshes as their phases land. +- Blur, Escape, selection changes, route changes, and unmount never strand interaction scope, cursor state, or live overrides. + +## Risks and decisions + +1. **Scope:** “exactly like Blender” is open-ended. Promise the documented phase behaviors, not the full mature Blender surface. +2. **Topology degeneracy:** Inset, bevel, dissolve, n-gon triangulation, and knife have hard numerical cases. Unsupported inputs must fail visibly and atomically. +3. **Performance:** `geometryKey` currently serializes full topology and previews rebuild the full mesh. Benchmark before raising mesh-size promises; coordinate-only preview may later update buffers incrementally while retaining one canonical preview state. +4. **Picking:** Always-on overlay hit volumes become noisy on dense meshes. Screen-space acceleration and depth policy are required, not optional polish. +5. **State:** The current local session can disappear on remount. Centralizing it must not create a second interaction truth beside `useInteractionScope`. +6. **2D/3D parity:** Mesh component editing can be an explicit 3D-only exception because depth, normals, and view-projected cuts are essential. The floor plan must still update live/committed projection; any future 2D component editor must share commands and snapping. [Pascal 2D/3D parity](architecture/tools.md) +7. **Licensing:** Blender is GPL-licensed. Its behavior and manuals can guide an independent implementation, but copying Blender source into this MIT repository requires license review. [Blender license](https://developer.blender.org/docs/license/) diff --git a/wiki/blender-loop-cut-research.md b/wiki/blender-loop-cut-research.md new file mode 100644 index 0000000000..f4f0403ad4 --- /dev/null +++ b/wiki/blender-loop-cut-research.md @@ -0,0 +1,165 @@ +# Blender Loop Cut and Slide Research + +## Purpose and conclusion + +This brief records Blender's Loop Cut and Slide behavior for the Pascal custom-mesh implementation. Behavior claims use only first-party Blender documentation and the official Blender source mirror. The source links are pinned to commit `1663a95e78e36c5a792c63fc10bcd4e1d09b7585`; the research was completed on 2026-08-10. + +The important architectural fact is that Blender does not implement this as one opaque action. `MESH_OT_loopcut_slide` is a macro that runs topology insertion (`MESH_OT_loopcut`) and then the existing edge-slide transform (`TRANSFORM_OT_edge_slide`). [Blender mesh operator registration](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/mesh_ops.cc#L217-L228), [Blender operator API](https://docs.blender.org/api/current/bpy.ops.mesh.html#bpy.ops.mesh.loopcut_slide) + +Pascal should preserve the same conceptual split: + +1. Resolve and preview a valid quad edge ring without mutating topology. +2. Insert a centered loop into an operation-local draft. +3. Slide that draft from the immutable pre-cut topology. +4. Commit the final topology once, or restore the original topology on Pascal cancellation. + +## Blender interaction contract + +### Stage 1: choose the face loop + +Loop Cut and Slide is available in Mesh Edit Mode through **Edge → Loop Cut and Slide** with `Ctrl-R`. After activation, the pointer chooses an edge perpendicular to the desired cut direction. Blender previews the resulting cut across the face loop. `LMB` accepts that ring and advances to slide; `RMB` aborts before inserting geometry. [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html) + +The implementation finds the nearest visible edit-mesh edge under the pointer and refreshes its edge-ring preselection on mouse movement. The preview is not a selection side effect. [Blender loop-cut targeting](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L322-L366), [Blender modal update](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L552-L717) + +Blender draws this preview using the theme's primary gizmo color, a one-pixel line, alpha blending, and disabled depth testing. The manuals call it yellow in the operator and magenta in the toolbar tool, so the transferable behavior is a thin, theme-aware, always-legible preview rather than a hard-coded color. [Blender edge-ring preview rendering](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_preselect_edgering.cc#L151-L203), [Blender Loop Cut tool manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/tools/loop.html) + +### Ring traversal and eligible topology + +Loop Cut traverses an **edge ring**, not an edge loop. Starting from the hovered edge, it crosses each quad to the opposite edge and continues in both directions. The loop preview is formed perpendicular to the crossed ring edges. Blender invokes the edge-ring walker with the `BMW_DELIMIT_EDGE_RING_NGONS` delimiter. [Blender loop-cut ring selection](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L99-L154) + +With that delimiter, the walker: + +- traverses only four-sided faces; +- steps from a face edge to its opposite edge; +- walks outward in both directions from the starting edge; +- accepts boundary and manifold edges while traversing; +- stops at non-quads, hidden faces, already visited edges, and non-manifold ambiguity. + +These rules are explicit in the official BMesh walker. The manual presents triangles and n-gons as poles where the face loop terminates. [Blender BMesh edge-ring walker](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/bmesh/intern/bmesh_walkers_impl.cc#L1399-L1578), [Blender Loop Cut tool manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/tools/loop.html) + +Blender contains a rare fallback that can subdivide only the hovered edge when no quad ring is available; its source notes that edge slide then breaks for that case. This is not a good Pascal MVP behavior because it looks like a valid loop preview but cannot provide the promised slide interaction. [Blender single-edge fallback](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L158-L224) + +### Preview and number of cuts + +For `N` cuts, Blender places preview points at fractions `i / (N + 1)` along every crossed ring edge and connects corresponding points across each quad. This yields uniformly spaced parallel previews without mutating the mesh. Vertex ordering is corrected as the ring is traversed so neighboring preview segments do not cross. [Blender edge-ring preview construction](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_preselect_edgering.cc#L205-L347) + +During stage 1, the wheel, numeric input, and `PageUp`/`PageDown` change the number of cuts. `Alt-Wheel` changes smoothness, although the manual warns that smoothness is not previewed at this stage. [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html), [Blender loop-cut modal controls](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L560-L697) + +### Stage 2: slide the inserted loop + +After the first confirmation, pointer movement slides the new loop. `LMB` confirms its current location. `RMB` keeps the cut but resets it to the center; it is not an undo of the whole loop cut. [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html) + +The ordinary slide is proportional: every new vertex uses the same factor along its crossed edge, regardless of that edge's absolute length. A negative or positive factor moves the loop toward the two opposite neighboring loops. [Blender Edge Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/edge_slide.html) + +The slide options are: + +| Option | Blender behavior | Shortcut | +| ------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| Factor | Relative slide position between the two neighboring loops. | Pointer or numeric input | +| Even | Keeps an even absolute distance from one adjacent loop rather than using the same percentage on every crossed edge. | `E` | +| Flipped | In Even mode, changes which adjacent loop provides the reference side. | `F` | +| Clamp | Keeps the result inside the surrounding edge extents. Disabling it permits movement outside the face-loop boundary. | `C` or `Alt` | +| Control edge | Changes the edge whose length/reference drives Even mode. | `Alt-Wheel` | + +The manual defines these semantics, and Blender's transform operator exposes `value`, `use_even`, `flipped`, `use_clamp`, mirror editing, geometry snapping, and UV correction as distinct properties. [Blender Edge Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/edge_slide.html), [Blender edge-slide operator source](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/transform/transform_ops.cc#L1215-L1253) + +Edge Slide participates in Blender's transform snapping. Transform operations use the current scene snap settings, and `Ctrl` temporarily inverts snapping by default. Therefore Blender does not define one special Loop Cut snap target; it inherits the active transform snap configuration. [Blender transform modal map](https://docs.blender.org/manual/en/5.0/modeling/transform/modal_map.html), [Blender edge-slide application](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/transform/transform_mode_edge_slide.cc#L781-L850) + +### Confirmation and cancellation details + +| State | Confirm | Cancel/reset | +| --------------- | ---------------------------------- | ------------------------------------ | +| Choosing a ring | `LMB` or Enter advances to slide. | `RMB` or Escape exits without a cut. | +| Sliding | `LMB` confirms the current factor. | `RMB` keeps the cut centered. | + +Stage-1 behavior is explicit in Blender's modal source. Stage-2 right-click behavior is explicit in the manual. [Blender loop-cut modal handling](https://github.com/blender/blender/blob/1663a95e78e36c5a792c63fc10bcd4e1d09b7585/source/blender/editors/mesh/editmesh_loopcut.cc#L572-L609), [Blender Loop Cut and Slide manual](https://docs.blender.org/manual/en/5.0/modeling/meshes/editing/edge/loopcut_slide.html) + +Pascal should deliberately provide an additional unambiguous full-operation cancel during stage 2: Escape restores the immutable pre-cut topology. This is a product inference, not a claim about Blender, and it matches Pascal's existing preview/cancel and single-undo conventions. + +## Safe Pascal MVP + +The current `CustomMeshTopology` stores stable-ID vertices, undirected edges, and ordered face vertex loops, but validation does not establish manifoldness, face planarity, or geometric self-intersection. The MVP should therefore be narrower than Blender's complete BMesh behavior. + +### Supported topology + +- Accept one hovered edge only when it resolves to one deterministic open or closed ring of quads. +- Require each traversed edge to have no more than two incident faces. +- Stop at boundaries and non-quad faces. +- Reject a non-manifold starting edge, branching traversal, repeated face, missing opposite edge, degenerate edge, or any preview that cannot preserve face winding. +- Show no valid cut preview for an unsupported target; do not partially subdivide only the hovered edge. + +This is an implementation inference based on Blender's quad-ring traversal and Pascal's stricter need to preserve a simple persistent topology. + +### Operation state + +Use an explicit modal state machine: + +```text +inactive + -> hovering { edgeId, orderedRing, cutCount } + -> sliding { baseTopology, draftRing, factor } + -> commit | cancel +``` + +- `hovering` is read-only and changes as the pointer crosses eligible edges. +- First `LMB` snapshots the original topology and creates only an operation-local centered draft. +- Pointer movement always recomputes from that snapshot; it never compounds the prior preview. +- Second `LMB` writes one scene update and one undo entry. +- Stage-1 `RMB`/Escape and stage-2 Escape clear preview state without a scene update. +- Stage-2 `RMB` commits the centered draft, matching Blender's visible behavior. +- Whole-node dragging and component transforms remain disabled while this operator owns the mesh-editing interaction scope. + +### Pure topology rewrite + +Represent the resolved ring as ordered crossed edges plus ordered quad faces and consistent per-face orientation. For one centered cut: + +1. Insert one stable-ID vertex on every crossed edge. +2. Replace each crossed edge with two edge segments. +3. Connect the new vertices across every traversed quad. +4. Replace every traversed quad with two winding-preserving quads that retain its material slot. +5. Reuse each inserted vertex and edge between neighboring faces. +6. Reconnect the last segment to the first for a closed ring; terminate at boundary edges for an open ring. +7. Validate the completed draft before exposing or committing it. + +For `N` centered cuts, interpolate at `i / (N + 1)`, split each crossed edge into `N + 1` segments, and replace each crossed quad with `N + 1` ordered quads. Multi-cut should follow the single-cut implementation because its ID remapping, selection, and slide constraints increase failure modes. + +### MVP interaction and visual treatment + +- Add a persistent Loop Cut tool to the existing floating Edit Mode UI and `Ctrl-R` shortcut routing. +- Hover the existing generous edge hit targets; render only the exact prospective loop as a thin project-theme line. +- Default to one cut. Let the wheel change count with a conservative Pascal cap, such as 32, to prevent accidental topology explosions. +- Start slide at the center and support proportional factor with mandatory clamp. +- Snap the factor predictably through Pascal's existing interaction/snap model; do not introduce a second hidden modifier convention solely for this node. +- Display the cut count in stage 1 and the slide factor in stage 2. +- Select the newly created edge loop after commit. + +### Parity deferred until the kernel is proven + +Implement these only after single-cut traversal, winding, preview cancellation, and undo are correct on skewed geometry: + +1. Multi-cut sliding. +2. Even distance, control-edge choice, and Flip. +3. Unclamped slide; it can create self-intersection that the current validator cannot detect. +4. Smoothness and falloff. +5. UV correction and mirror editing, once custom-mesh topology stores the required attributes and symmetry contract. +6. Blender's single-edge fallback on non-quad topology. + +## Acceptance matrix + +| Case | Expected result | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| Default box, hover a vertical edge | Preview one closed horizontal loop across the four side quads. | +| Default box, hover a top/bottom edge | Preview the perpendicular closed ring selected by that edge. | +| Extruded or inset shape containing n-gon poles | Preview traverses deterministic quads and stops before the non-quad. | +| Skewed quads | Preview segments preserve correspondence and do not cross; proportional slide remains on crossed edges. | +| Open quad strip | Preview terminates at both boundaries and commit creates an open new loop. | +| Triangle, isolated edge, or non-manifold branch | No valid preview and no mutation. | +| Wheel in stage 1 | Parallel previews update uniformly; topology remains unchanged. | +| First-stage Escape/RMB | Preview disappears; topology and history are unchanged. | +| Stage-2 pointer movement | Live draft updates from the original topology without accumulating error. | +| Stage-2 Escape | Pascal restores the original topology and adds no history entry. | +| Stage-2 RMB | One centered cut is committed. | +| Stage-2 LMB | Current clamped factor is committed in one undoable update. | +| Rotate/move whole node while tool is active | Node drag does not start. | + +Unit tests should separately cover ring discovery, orientation, open/closed termination, ID uniqueness, material retention, one-cut and multi-cut rewrites, invalid-topology rejection, factor clamping, and validation of every result. Interaction tests should cover hover-without-mutation, both confirmation stages, both cancellation stages, scroll count, selection of created edges, and exactly one history update. From 438875f084c1754fd913198eaa77582b2e90dfef Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 10 Aug 2026 17:02:15 +0530 Subject: [PATCH 04/10] fix: complete custom mesh edit mode --- .../tools/registry-tool-context.tsx | 28 + .../src/components/tools/tool-manager.tsx | 15 +- packages/editor/src/index.tsx | 1 + packages/editor/src/lib/interaction/scope.ts | 1 + packages/editor/src/lib/snapping-mode.test.ts | 13 +- packages/editor/src/lib/snapping-mode.ts | 2 + .../nodes/src/custom-mesh/commands.test.ts | 51 +- packages/nodes/src/custom-mesh/commands.ts | 486 +++++++++++++++--- packages/nodes/src/custom-mesh/definition.ts | 8 +- packages/nodes/src/custom-mesh/preview.tsx | 39 +- packages/nodes/src/custom-mesh/selection.tsx | 465 +++++++++++++---- packages/nodes/src/custom-mesh/tool.tsx | 31 +- 12 files changed, 950 insertions(+), 190 deletions(-) create mode 100644 packages/editor/src/components/tools/registry-tool-context.tsx diff --git a/packages/editor/src/components/tools/registry-tool-context.tsx b/packages/editor/src/components/tools/registry-tool-context.tsx new file mode 100644 index 0000000000..f66dac7724 --- /dev/null +++ b/packages/editor/src/components/tools/registry-tool-context.tsx @@ -0,0 +1,28 @@ +'use client' + +import type { AnyNodeId, LevelNode, SceneApi } from '@pascal-app/core' +import { createContext, type ReactNode, useContext } from 'react' + +export type RegistryToolContextValue = { + activeLevelId: LevelNode['id'] | null + sceneApi: SceneApi + selectNode: (nodeId: AnyNodeId) => void +} + +const RegistryToolContext = createContext(null) + +export function RegistryToolProvider({ + children, + value, +}: { + children: ReactNode + value: RegistryToolContextValue +}) { + return {children} +} + +export function useRegistryToolContext(): RegistryToolContextValue { + const value = useContext(RegistryToolContext) + if (!value) throw new Error('Registry tools must be mounted by ToolManager') + return value +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 08259b6f4a..f70203fa18 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, type BuildingNode, type CeilingNode, + createSceneApi, type FenceNode, nodeRegistry, type SlabNode, @@ -30,6 +31,7 @@ import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' +import { RegistryToolProvider } from './registry-tool-context' import { RoofTool } from './roof/roof-tool' import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { FacingPoseIndicator } from './shared/facing-pose-indicator' @@ -141,6 +143,15 @@ export const ToolManager: React.FC = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const setSelection = useViewer((state) => state.setSelection) const nodes = useScene((state) => state.nodes) + const registrySceneApi = useMemo(() => createSceneApi(useScene), []) + const registryToolContext = useMemo( + () => ({ + activeLevelId: activeLevelId ?? null, + sceneApi: registrySceneApi, + selectNode: (nodeId: AnyNodeId) => setSelection({ selectedIds: [nodeId] }), + }), + [activeLevelId, registrySceneApi, setSelection], + ) // Building transform for the local group — all building-relative tools live inside this group // so their cursor positions and committed data are naturally in building-local space. @@ -377,7 +388,9 @@ export const ToolManager: React.FC = () => { NodeDefinition with a tool contribution, mount it here. */} {!movingNode && useRegistryTool && RegistryToolComponent && ( - + + + )} {!movingNode && !useRegistryTool && showBuildTool && tool === 'elevator' && ( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b15bb0b950..50c3ab4211 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -132,6 +132,7 @@ export { type PlacementCoordinatorConfig, usePlacementCoordinator, } from './components/tools/item/use-placement-coordinator' +export { useRegistryToolContext } from './components/tools/registry-tool-context' export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index da694d38fb..52022082a8 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -56,6 +56,7 @@ export type InteractionScope = | 'merge' | 'dissolve' | 'loop-cut' + | 'bevel' | 'delete' } // Click-to-click drafting of a polyline/polygon kind (wall/fence/slab/…). diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index e91fb09319..cd343c2dbf 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -83,10 +83,17 @@ describe('snapContextOf (profile-driven, node-declared)', () => { ceiling: 'structural', roof: 'structural', zone: 'structural', + 'custom-mesh': 'structural', } const profileOf = (t: string) => declared[t] const profileOfNode = (id: string) => - id === 'cabinet-module_1' ? declared.item : id === 'wall_1' ? declared.wall : undefined + id === 'cabinet-module_1' + ? declared.item + : id === 'wall_1' + ? declared.wall + : id === 'custom-mesh_1' + ? declared['custom-mesh'] + : undefined const ctx = ( scope: { kind: string @@ -115,6 +122,10 @@ describe('snapContextOf (profile-driven, node-declared)', () => { ).toBeNull() }) + it('gives custom-mesh edit mode the shared grid and angle context', () => { + expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1' })).toBe('wall') + }) + it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => { expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall') expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon') diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 368ffe3174..3c97260ce0 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -166,6 +166,8 @@ export function snapContextOf(args: { return 'item' } switch (scope.kind) { + case 'mesh-editing': + return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), true) : null case 'handle-drag': if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null diff --git a/packages/nodes/src/custom-mesh/commands.test.ts b/packages/nodes/src/custom-mesh/commands.test.ts index f7d6ae0035..072dd53fc1 100644 --- a/packages/nodes/src/custom-mesh/commands.test.ts +++ b/packages/nodes/src/custom-mesh/commands.test.ts @@ -234,7 +234,7 @@ describe('applyCustomMeshCommand', () => { expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) - test('rejects a loop cut when the hovered edge does not lead through quads', () => { + test('stops a loop cut cleanly before a non-quad face', () => { const dissolved = applyCustomMeshCommand(createBoxCustomMeshTopology(), { type: 'dissolve-edge', edgeId: 'e4', @@ -242,12 +242,47 @@ describe('applyCustomMeshCommand', () => { expect(dissolved.ok).toBe(true) if (!dissolved.ok) return - expect( - applyCustomMeshCommand(dissolved.topology, { - type: 'loop-cut', - edgeId: 'e0', - factor: 0.5, - }), - ).toEqual({ ok: false, error: 'Loop cut requires a connected ring of quad faces' }) + const result = applyCustomMeshCommand(dissolved.topology, { + type: 'loop-cut', + edgeId: 'e0', + factor: 0.5, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.selection.ids).toHaveLength(2) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + expect(result.topology.faces.find((face) => face.id === 'f-top')?.vertexIds.length).toBe(8) + }) + + test('creates multiple evenly spaced loop cuts in one valid transaction', () => { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'loop-cut', + edgeId: 'e8', + factor: 0.5, + cuts: 3, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.vertices).toHaveLength(20) + expect(result.topology.faces).toHaveLength(18) + expect(result.selection.ids).toHaveLength(12) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) + }) + + test('bevels a manifold box edge with width, segments, profile, and overlap clamping', () => { + const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'bevel-edge', + edgeId: 'e0', + width: 0.2, + segments: 3, + profile: 0.5, + clampOverlap: true, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.selection.mode).toBe('edge') + expect(result.selection.ids).toHaveLength(4) + expect(result.topology.faces).toHaveLength(9) + expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) }) diff --git a/packages/nodes/src/custom-mesh/commands.ts b/packages/nodes/src/custom-mesh/commands.ts index 21e3f22d5a..8ae0ac1174 100644 --- a/packages/nodes/src/custom-mesh/commands.ts +++ b/packages/nodes/src/custom-mesh/commands.ts @@ -57,6 +57,15 @@ export type CustomMeshCommand = type: 'loop-cut' edgeId: string factor: number + cuts?: number + } + | { + type: 'bevel-edge' + edgeId: string + width: number + segments: number + profile: number + clampOverlap: boolean } export type CustomMeshCommandResult = @@ -172,19 +181,18 @@ function resolveLoopCutRing(topology: CustomMeshTopology, edgeId: string): LoopC facesByEdgeId.set(edge.id, faces) } } - const startFaces = facesByEdgeId.get(startEdge.id) ?? [] - if ( - startFaces.length === 0 || - startFaces.length > 2 || - startFaces.some((face) => face.vertexIds.length !== 4) - ) { - return null - } + const incidentStartFaces = facesByEdgeId.get(startEdge.id) ?? [] + if (incidentStartFaces.length === 0 || incidentStartFaces.length > 2) return null + const startFaces = incidentStartFaces.filter((face) => face.vertexIds.length === 4) + if (startFaces.length === 0) return null const orientedEdgeVertices = new Map([ [startEdge.id, startEdge.vertexIds], ]) - const queue = startFaces.map((face) => ({ edgeId: startEdge.id, faceId: face.id })) + const queue = startFaces.map((face) => ({ + edgeId: startEdge.id, + faceId: face.id, + })) const visitedFaces = new Set() const steps: LoopCutStep[] = [] @@ -218,7 +226,7 @@ function resolveLoopCutRing(topology: CustomMeshTopology, edgeId: string): LoopC if (adjacentFaces.length > 2) return null for (const adjacentFace of adjacentFaces) { if (adjacentFace.id === face.id || visitedFaces.has(adjacentFace.id)) continue - if (adjacentFace.vertexIds.length !== 4) return null + if (adjacentFace.vertexIds.length !== 4) continue queue.push({ edgeId: oppositeEdge.id, faceId: adjacentFace.id }) } } @@ -238,45 +246,72 @@ export function customMeshLoopCutSegments( topology: CustomMeshTopology, edgeId: string, factor: number, + cuts = 1, ): [Point, Point][] | null { const ring = resolveLoopCutRing(topology, edgeId) - if (!ring || !Number.isFinite(factor) || factor <= 0 || factor >= 1) return null + const fractions = loopCutFractions(factor, cuts) + if (!ring || !fractions) return null const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex.position])) - const pointByEdgeId = new Map() + const pointByEdgeId = new Map() for (const [ringEdgeId, [fromId, toId]] of ring.orientedEdgeVertices) { const from = vertexById.get(fromId) const to = vertexById.get(toId) if (!(from && to)) return null - pointByEdgeId.set(ringEdgeId, interpolatePoint(from, to, factor)) + pointByEdgeId.set( + ringEdgeId, + fractions.map((fraction) => interpolatePoint(from, to, fraction)), + ) } - return ring.steps.map((step) => [ - pointByEdgeId.get(step.fromEdgeId)!, - pointByEdgeId.get(step.toEdgeId)!, - ]) + return ring.steps.flatMap((step) => + fractions.map((_, index) => [ + pointByEdgeId.get(step.fromEdgeId)![index]!, + pointByEdgeId.get(step.toEdgeId)![index]!, + ]), + ) +} + +function loopCutFractions(factor: number, cuts: number): number[] | null { + const count = Math.floor(cuts) + if ( + !Number.isFinite(factor) || + factor <= 0 || + factor >= 1 || + !Number.isFinite(cuts) || + count < 1 || + count > 32 + ) + return null + if (count === 1) return [factor] + const spacing = 1 / (count + 1) + const offset = (factor - 0.5) * spacing * 1.96 + return Array.from({ length: count }, (_, index) => (index + 1) * spacing + offset) } -function splitFaceLoop( +function augmentFaceLoop( face: CustomMeshFace, - cutVertexByEdgeKey: ReadonlyMap, - firstCutId: string, - secondCutId: string, -): [string[], string[]] | null { + cutVerticesByEdgeKey: ReadonlyMap, +): string[] { const augmented: string[] = [] for (let index = 0; index < face.vertexIds.length; index += 1) { const current = face.vertexIds[index]! const next = face.vertexIds[(index + 1) % face.vertexIds.length]! augmented.push(current) - const cutId = cutVertexByEdgeKey.get(topologyEdgeKey(current, next)) - if (cutId) augmented.push(cutId) + const cuts = cutVerticesByEdgeKey.get(topologyEdgeKey(current, next)) + if (!cuts) continue + augmented.push(...(cuts.edgeOrder[0] === current ? cuts.ids : [...cuts.ids].reverse())) } - const firstIndex = augmented.indexOf(firstCutId) - const secondIndex = augmented.indexOf(secondCutId) + return augmented +} + +function splitLoopByChord(loop: string[], firstCutId: string, secondCutId: string) { + const firstIndex = loop.indexOf(firstCutId) + const secondIndex = loop.indexOf(secondCutId) if (firstIndex < 0 || secondIndex < 0) return null const walk = (start: number, end: number) => { - const loop: string[] = [] - for (let index = start; ; index = (index + 1) % augmented.length) { - loop.push(augmented[index]!) - if (index === end) return loop + const result: string[] = [] + for (let index = start; ; index = (index + 1) % loop.length) { + result.push(loop[index]!) + if (index === end) return result } } const first = walk(firstIndex, secondIndex) @@ -288,11 +323,18 @@ function loopCut( topology: CustomMeshTopology, command: Extract, ): CustomMeshCommandResult { - if (!Number.isFinite(command.factor) || command.factor <= 0 || command.factor >= 1) { - return { ok: false, error: 'Loop cut factor must be greater than 0 and less than 1' } - } + const fractions = loopCutFractions(command.factor, command.cuts ?? 1) + if (!fractions) + return { + ok: false, + error: 'Loop cut requires 1–32 cuts and a factor between 0 and 1', + } const ring = resolveLoopCutRing(topology, command.edgeId) - if (!ring) return { ok: false, error: 'Loop cut requires a connected ring of quad faces' } + if (!ring) + return { + ok: false, + error: 'Loop cut requires a connected ring of quad faces', + } const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) const edgeById = new Map(topology.edges.map((edge) => [edge.id, edge])) const allocateVertexId = nextNumericId( @@ -307,8 +349,8 @@ function loopCut( 'f', topology.faces.map((face) => face.id), ) - const cutVertexByEdgeId = new Map() - const cutVertexByEdgeKey = new Map() + const cutVerticesByEdgeId = new Map() + const cutVerticesByEdgeKey = new Map() const newVertices: CustomMeshVertex[] = [] for (const [ringEdgeId, [fromId, toId]] of ring.orientedEdgeVertices) { @@ -316,19 +358,29 @@ function loopCut( const to = vertexById.get(toId) const edge = edgeById.get(ringEdgeId) if (!(from && to && edge)) return { ok: false, error: 'Loop cut references missing topology' } - const id = allocateVertexId() - cutVertexByEdgeId.set(ringEdgeId, id) - cutVertexByEdgeKey.set(topologyEdgeKey(...edge.vertexIds), id) - newVertices.push({ id, position: interpolatePoint(from.position, to.position, command.factor) }) + const ids = fractions.map(() => allocateVertexId()) + cutVerticesByEdgeId.set(ringEdgeId, ids) + const edgeOrderIds = edge.vertexIds[0] === fromId ? ids : [...ids].reverse() + cutVerticesByEdgeKey.set(topologyEdgeKey(...edge.vertexIds), { + edgeOrder: edge.vertexIds, + ids: edgeOrderIds, + }) + newVertices.push( + ...ids.map((id, index) => ({ + id, + position: interpolatePoint(from.position, to.position, fractions[index]!), + })), + ) } const splitBoundaryEdges = topology.edges.flatMap((edge) => { - const cutId = cutVertexByEdgeId.get(edge.id) - if (!cutId) return [edge] - return [ - { ...edge, vertexIds: [edge.vertexIds[0], cutId] }, - { id: allocateEdgeId(), vertexIds: [cutId, edge.vertexIds[1]] }, - ] + const cutIds = cutVerticesByEdgeKey.get(topologyEdgeKey(...edge.vertexIds))?.ids + if (!cutIds) return [edge] + const chain = [edge.vertexIds[0], ...cutIds, edge.vertexIds[1]] + return chain.slice(0, -1).map((vertexId, index) => ({ + id: index === 0 ? edge.id : allocateEdgeId(), + vertexIds: [vertexId, chain[index + 1]!], + })) }) const stepByFaceId = new Map(ring.steps.map((step) => [step.faceId, step] as const)) const cutEdgeIds: string[] = [] @@ -337,20 +389,47 @@ function loopCut( for (const face of topology.faces) { const step = stepByFaceId.get(face.id) if (!step) { - faces.push(face) + const vertexIds = augmentFaceLoop(face, cutVerticesByEdgeKey) + faces.push(vertexIds.length === face.vertexIds.length ? face : { ...face, vertexIds }) continue } - const fromCutId = cutVertexByEdgeId.get(step.fromEdgeId) - const toCutId = cutVertexByEdgeId.get(step.toEdgeId) - if (!(fromCutId && toCutId)) return { ok: false, error: 'Loop cut references missing topology' } - const loops = splitFaceLoop(face, cutVertexByEdgeKey, fromCutId, toCutId) - if (!loops) return { ok: false, error: `Could not split quad face: ${face.id}` } - const cutEdgeId = allocateEdgeId() - cutEdgeIds.push(cutEdgeId) - cutEdges.push({ id: cutEdgeId, vertexIds: [fromCutId, toCutId] }) + const fromCutIds = cutVerticesByEdgeId.get(step.fromEdgeId) + const toCutIds = cutVerticesByEdgeId.get(step.toEdgeId) + if (!(fromCutIds && toCutIds)) + return { ok: false, error: 'Loop cut references missing topology' } + let remaining = augmentFaceLoop(face, cutVerticesByEdgeKey) + const splitLoops: string[][] = [] + for (let index = 0; index < fromCutIds.length; index += 1) { + const fromCutId = fromCutIds[index]! + const toCutId = toCutIds[index]! + const pair = splitLoopByChord(remaining, fromCutId, toCutId) + if (!pair) return { ok: false, error: `Could not split quad face: ${face.id}` } + const cutEdgeId = allocateEdgeId() + cutEdgeIds.push(cutEdgeId) + cutEdges.push({ id: cutEdgeId, vertexIds: [fromCutId, toCutId] }) + if (index === fromCutIds.length - 1) { + splitLoops.push(...pair) + } else { + const nextFrom = fromCutIds[index + 1]! + const nextTo = toCutIds[index + 1]! + const remainingIndex = pair.findIndex( + (candidate) => candidate.includes(nextFrom) && candidate.includes(nextTo), + ) + if (remainingIndex < 0) + return { + ok: false, + error: `Could not order cuts on quad face: ${face.id}`, + } + splitLoops.push(pair[1 - remainingIndex]!) + remaining = pair[remainingIndex]! + } + } faces.push( - { ...face, vertexIds: loops[0] }, - { ...face, id: allocateFaceId(), vertexIds: loops[1] }, + ...splitLoops.map((vertexIds, index) => ({ + ...face, + id: index === 0 ? face.id : allocateFaceId(), + vertexIds, + })), ) } @@ -368,6 +447,244 @@ function loopCut( } } +function bevelProfileFactor(value: number, profile: number): number { + const exponent = 2 ** ((0.5 - profile) * 4) + const a = value ** exponent + const b = (1 - value) ** exponent + return a / (a + b) +} + +function rebuildEdgesFromFaces( + topology: CustomMeshTopology, + faces: CustomMeshFace[], + allocateEdgeId: () => string, +): CustomMeshEdge[] { + const oldByKey = new Map( + topology.edges.map((edge) => [topologyEdgeKey(...edge.vertexIds), edge] as const), + ) + const seen = new Set() + const edges: CustomMeshEdge[] = [] + for (const face of faces) { + for (let index = 0; index < face.vertexIds.length; index += 1) { + const vertexIds = [ + face.vertexIds[index]!, + face.vertexIds[(index + 1) % face.vertexIds.length]!, + ] as [string, string] + const key = topologyEdgeKey(...vertexIds) + if (seen.has(key)) continue + seen.add(key) + const old = oldByKey.get(key) + edges.push(old ?? { id: allocateEdgeId(), vertexIds }) + } + } + return edges +} + +function bevelEdge( + topology: CustomMeshTopology, + command: Extract, +): CustomMeshCommandResult { + const edge = topology.edges.find((entry) => entry.id === command.edgeId) + if (!edge) return { ok: false, error: `Edge not found: ${command.edgeId}` } + const segments = Math.floor(command.segments) + if (!Number.isFinite(command.width) || command.width <= 0) + return { ok: false, error: 'Bevel width must be positive' } + if (!Number.isFinite(command.segments) || segments < 1 || segments > 12) + return { ok: false, error: 'Bevel segments must be between 1 and 12' } + if (!Number.isFinite(command.profile) || command.profile < 0 || command.profile > 1) + return { ok: false, error: 'Bevel profile must be between 0 and 1' } + + const [aId, bId] = edge.vertexIds + const adjacentFaces = topology.faces.filter((face) => faceContainsEdge(face, aId, bId)) + if (adjacentFaces.length !== 2) + return { + ok: false, + error: 'Bevel requires an edge shared by exactly two faces', + } + const incidentAt = (id: string) => topology.faces.filter((face) => face.vertexIds.includes(id)) + const caps = [aId, bId].map((id) => + incidentAt(id).filter((face) => !adjacentFaces.some((adjacent) => adjacent.id === face.id)), + ) + if (caps.some((faces) => faces.length !== 1)) + return { + ok: false, + error: 'Bevel currently requires three-face corner endpoints', + } + const vertexById = new Map(topology.vertices.map((vertex) => [vertex.id, vertex])) + const vertexA = vertexById.get(aId) + const vertexB = vertexById.get(bId) + if (!(vertexA && vertexB)) return { ok: false, error: 'Bevel edge references missing vertices' } + + const neighborInFace = (face: CustomMeshFace, id: string, other: string) => { + const index = face.vertexIds.indexOf(id) + const previous = face.vertexIds[(index - 1 + face.vertexIds.length) % face.vertexIds.length]! + const next = face.vertexIds[(index + 1) % face.vertexIds.length]! + return previous === other ? next : next === other ? previous : null + } + const neighbors = adjacentFaces.map((face) => ({ + a: neighborInFace(face, aId, bId), + b: neighborInFace(face, bId, aId), + })) + if (neighbors.some((entry) => !(entry.a && entry.b))) + return { ok: false, error: 'Could not resolve bevel corner neighbors' } + const points = neighbors.flatMap((entry, faceIndex) => + ( + [ + ['a', aId], + ['b', bId], + ] as const + ).map(([endpoint, endpointId]) => { + const neighborId = entry[endpoint]! + const origin = vertexById.get(endpointId)!.position + const neighbor = vertexById.get(neighborId)?.position + return neighbor ? { faceIndex, endpoint, origin, neighbor } : null + }), + ) + if (points.some((point) => !point)) + return { ok: false, error: 'Bevel references missing vertices' } + const safeMaximum = + Math.min( + ...points.map((point) => + Math.hypot( + point!.neighbor[0] - point!.origin[0], + point!.neighbor[1] - point!.origin[1], + point!.neighbor[2] - point!.origin[2], + ), + ), + ) * 0.49 + const width = command.clampOverlap ? Math.min(command.width, safeMaximum) : command.width + if (!command.clampOverlap && width >= safeMaximum * 2) + return { + ok: false, + error: 'Bevel width overlaps adjacent edges; enable Clamp', + } + + const offset = (origin: Point, neighbor: Point) => { + const direction = normalize([ + neighbor[0] - origin[0], + neighbor[1] - origin[1], + neighbor[2] - origin[2], + ])! + return [ + origin[0] + direction[0] * width, + origin[1] + direction[1] * width, + origin[2] + direction[2] * width, + ] as Point + } + const outerA = neighbors.map((entry) => + offset(vertexA.position, vertexById.get(entry.a!)!.position), + ) + const outerB = neighbors.map((entry) => + offset(vertexB.position, vertexById.get(entry.b!)!.position), + ) + const allocateVertexId = nextNumericId( + 'v', + topology.vertices.map((vertex) => vertex.id), + ) + const allocateEdgeId = nextNumericId( + 'e', + topology.edges.map((entry) => entry.id), + ) + const allocateFaceId = nextNumericId( + 'f', + topology.faces.map((face) => face.id), + ) + const railsA: string[] = [] + const railsB: string[] = [] + const newVertices: CustomMeshVertex[] = [] + for (let index = 0; index <= segments; index += 1) { + const factor = bevelProfileFactor(index / segments, command.profile) + const aVertexId = allocateVertexId() + const bVertexId = allocateVertexId() + railsA.push(aVertexId) + railsB.push(bVertexId) + newVertices.push( + { + id: aVertexId, + position: interpolatePoint(outerA[0]!, outerA[1]!, factor), + }, + { + id: bVertexId, + position: interpolatePoint(outerB[0]!, outerB[1]!, factor), + }, + ) + } + + const replaceVertex = (loop: string[], id: string, replacement: string[]) => + loop.flatMap((vertexId) => (vertexId === id ? replacement : [vertexId])) + const faces = topology.faces + .filter((face) => !adjacentFaces.some((adjacent) => adjacent.id === face.id)) + .map((face) => { + if (face.id === caps[0]![0]!.id) { + const index = face.vertexIds.indexOf(aId) + const previous = face.vertexIds[(index - 1 + face.vertexIds.length) % face.vertexIds.length] + const replacement = previous === neighbors[0]!.a ? railsA : [...railsA].reverse() + return { + ...face, + vertexIds: replaceVertex(face.vertexIds, aId, replacement), + } + } + if (face.id === caps[1]![0]!.id) { + const index = face.vertexIds.indexOf(bId) + const previous = face.vertexIds[(index - 1 + face.vertexIds.length) % face.vertexIds.length] + const replacement = previous === neighbors[0]!.b ? railsB : [...railsB].reverse() + return { + ...face, + vertexIds: replaceVertex(face.vertexIds, bId, replacement), + } + } + return face + }) + for (let faceIndex = 0; faceIndex < 2; faceIndex += 1) { + const source = adjacentFaces[faceIndex]! + faces.push({ + ...source, + vertexIds: source.vertexIds.map((id) => + id === aId + ? railsA[faceIndex === 0 ? 0 : segments]! + : id === bId + ? railsB[faceIndex === 0 ? 0 : segments]! + : id, + ), + }) + } + const firstFaceForward = adjacentFaces[0]!.vertexIds.some( + (id, index) => + id === aId && + adjacentFaces[0]!.vertexIds[(index + 1) % adjacentFaces[0]!.vertexIds.length] === bId, + ) + for (let index = 0; index < segments; index += 1) { + const vertexIds = firstFaceForward + ? [railsB[index]!, railsA[index]!, railsA[index + 1]!, railsB[index + 1]!] + : [railsA[index]!, railsB[index]!, railsB[index + 1]!, railsA[index + 1]!] + faces.push({ + id: allocateFaceId(), + vertexIds, + materialSlot: adjacentFaces[0]!.materialSlot, + }) + } + const nextTopology: CustomMeshTopology = { + vertices: [ + ...topology.vertices.filter((vertex) => vertex.id !== aId && vertex.id !== bId), + ...newVertices, + ], + edges: rebuildEdgesFromFaces(topology, faces, allocateEdgeId), + faces, + } + const issues = inspectCustomMeshTopology(nextTopology) + if (issues.length > 0) return { ok: false, error: issues[0]!.message } + const selected = nextTopology.edges.filter((entry) => { + const aRail = railsA.includes(entry.vertexIds[0]) && railsB.includes(entry.vertexIds[1]) + const bRail = railsB.includes(entry.vertexIds[0]) && railsA.includes(entry.vertexIds[1]) + return aRail || bRail + }) + return { + ok: true, + topology: nextTopology, + selection: { mode: 'edge', ids: selected.map((entry) => entry.id) }, + } +} + function extrudeFace( topology: CustomMeshTopology, command: Extract, @@ -376,7 +693,10 @@ function extrudeFace( const face = topology.faces[faceIndex] if (!face) return { ok: false, error: `Face not found: ${command.faceId}` } if (!Number.isFinite(command.distance) || Math.abs(command.distance) < 1e-6) { - return { ok: false, error: 'Extrude distance must be a non-zero finite number' } + return { + ok: false, + error: 'Extrude distance must be a non-zero finite number', + } } const normal = customMeshFaceNormal(topology, face) if (!normal) return { ok: false, error: `Face has no usable normal: ${face.id}` } @@ -399,7 +719,11 @@ function extrudeFace( for (const vertexId of face.vertexIds) { const vertex = verticesById.get(vertexId) - if (!vertex) return { ok: false, error: `Face references missing vertex: ${vertexId}` } + if (!vertex) + return { + ok: false, + error: `Face references missing vertex: ${vertexId}`, + } const id = allocateVertexId() duplicateIds.set(vertexId, id) newVertices.push({ @@ -480,7 +804,10 @@ function translateComponents( command: Extract, ): CustomMeshCommandResult { if (command.delta.some((value) => !Number.isFinite(value))) { - return { ok: false, error: 'Translation delta must contain finite numbers' } + return { + ok: false, + error: 'Translation delta must contain finite numbers', + } } const vertexIds = customMeshSelectionVertexIds(topology, command.selection) if (vertexIds.size === 0) return { ok: false, error: 'Select a component to move' } @@ -560,7 +887,10 @@ function scaleComponents( command.pivot.some((value) => !Number.isFinite(value)) || command.factors.some((value) => !Number.isFinite(value) || Math.abs(value) < 1e-6) ) { - return { ok: false, error: 'Scale requires finite, non-zero factors and a finite pivot' } + return { + ok: false, + error: 'Scale requires finite, non-zero factors and a finite pivot', + } } return transformComponents(topology, command.selection, (position) => [ command.pivot[0] + (position[0] - command.pivot[0]) * command.factors[0], @@ -577,7 +907,10 @@ function insetFace( 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) { - return { ok: false, error: 'Inset amount must be greater than 0 and less than 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' } const centroid = customMeshFaceCentroid(topology, face) @@ -601,7 +934,11 @@ function insetFace( const newVertices: CustomMeshVertex[] = [] for (const vertexId of face.vertexIds) { const vertex = verticesById.get(vertexId) - if (!vertex) return { ok: false, error: `Face references missing vertex: ${vertexId}` } + if (!vertex) + return { + ok: false, + error: `Face references missing vertex: ${vertexId}`, + } const id = allocateVertexId() insetIds.push(id) newVertices.push({ @@ -644,7 +981,11 @@ function insetFace( } const issues = inspectCustomMeshTopology(nextTopology) if (issues.length > 0) return { ok: false, error: issues[0]!.message } - return { ok: true, topology: nextTopology, selection: { mode: 'face', ids: [face.id] } } + return { + ok: true, + topology: nextTopology, + selection: { mode: 'face', ids: [face.id] }, + } } function deleteComponents( @@ -738,7 +1079,10 @@ function mergeVertices( if (loop.length > 1 && loop[0] === loop.at(-1)) loop.pop() if (loop.length < 3 || new Set(loop).size < 3) continue if (new Set(loop).size !== loop.length) { - return { ok: false, error: 'The selected vertices would create a repeated face vertex' } + return { + ok: false, + error: 'The selected vertices would create a repeated face vertex', + } } faces.push({ ...face, vertexIds: loop }) } @@ -797,7 +1141,10 @@ function dissolveEdge( const [a, b] = edge.vertexIds const adjacentFaces = topology.faces.filter((face) => faceContainsEdge(face, a, b)) if (adjacentFaces.length !== 2) { - return { ok: false, error: 'Dissolve requires an edge shared by exactly two faces' } + return { + ok: false, + error: 'Dissolve requires an edge shared by exactly two faces', + } } const firstPath = longFacePath(adjacentFaces[0]!, a, b) const secondPath = longFacePath(adjacentFaces[1]!, b, a) @@ -805,7 +1152,10 @@ function dissolveEdge( return { ok: false, error: 'Could not resolve adjacent face loops' } const mergedLoop = [...firstPath, ...secondPath.slice(1, -1)] if (new Set(mergedLoop).size !== mergedLoop.length) { - return { ok: false, error: 'Dissolving this edge would create a repeated face vertex' } + return { + ok: false, + error: 'Dissolving this edge would create a repeated face vertex', + } } const removedFaceId = adjacentFaces[1]!.id const nextTopology: CustomMeshTopology = { @@ -851,5 +1201,7 @@ export function applyCustomMeshCommand( return dissolveEdge(topology, command) case 'loop-cut': return loopCut(topology, command) + case 'bevel-edge': + return bevelEdge(topology, command) } } diff --git a/packages/nodes/src/custom-mesh/definition.ts b/packages/nodes/src/custom-mesh/definition.ts index bb94fa6403..82e4b9d9cb 100644 --- a/packages/nodes/src/custom-mesh/definition.ts +++ b/packages/nodes/src/custom-mesh/definition.ts @@ -8,7 +8,7 @@ import { buildCustomMeshFloorplan } from './floorplan' import { buildCustomMeshGeometry } from './geometry' import { CustomMeshNode } from './schema' -function bounds(node: CustomMeshNodeType) { +export function customMeshBounds(node: CustomMeshNodeType) { const xs = node.topology.vertices.map((vertex) => vertex.position[0]) const ys = node.topology.vertices.map((vertex) => vertex.position[1]) const zs = node.topology.vertices.map((vertex) => vertex.position[2]) @@ -46,7 +46,7 @@ export const customMeshDefinition: NodeDefinition = { schema: CustomMeshNode, category: 'structure', surfaceRole: 'wall', - snapProfile: 'item', + snapProfile: 'structural', extensions: { 'pascal:editor/floorplan': { tool: () => import('./tool'), @@ -69,11 +69,11 @@ export const customMeshDefinition: NodeDefinition = { movable: { axes: ['x', 'z'], gridSnap: true }, duplicable: true, deletable: true, - dragBounds: (rawNode) => bounds(rawNode as CustomMeshNodeType), + dragBounds: (rawNode) => customMeshBounds(rawNode as CustomMeshNodeType), floorPlaced: { footprint: (rawNode) => { const node = rawNode as CustomMeshNodeType - const { size, center } = bounds(node) + const { size, center } = customMeshBounds(node) return { dimensions: size, position: footprintPosition(node, center), diff --git a/packages/nodes/src/custom-mesh/preview.tsx b/packages/nodes/src/custom-mesh/preview.tsx index 9e703716a6..f19b552773 100644 --- a/packages/nodes/src/custom-mesh/preview.tsx +++ b/packages/nodes/src/custom-mesh/preview.tsx @@ -1,38 +1,51 @@ 'use client' import type { CustomMeshNode } from '@pascal-app/core' +import { EDITOR_LAYER } from '@pascal-app/editor' import { useEffect, useMemo } from 'react' -import { type Material, Mesh } from 'three' +import { Color, type Material, Mesh } from 'three' import { buildCustomMeshGeometry } from './geometry' -export default function CustomMeshPreview({ node }: { node: CustomMeshNode }) { - const object = useMemo(() => { +export default function CustomMeshPreview({ + node, + valid = true, +}: { + node: CustomMeshNode + valid?: boolean +}) { + const preview = useMemo(() => { const next = buildCustomMeshGeometry(node) + const ownedMaterials: Material[] = [] next.traverse((child) => { + child.layers.set(EDITOR_LAYER) + child.raycast = () => {} if (!(child instanceof Mesh)) return - const materials = Array.isArray(child.material) ? child.material : [child.material] + const sourceMaterials = Array.isArray(child.material) ? child.material : [child.material] + const materials = sourceMaterials.map((material) => material.clone()) for (const material of materials) { material.transparent = true material.opacity = 0.52 material.depthWrite = false + if (!valid && 'color' in material && material.color instanceof Color) { + material.color.set('#ef4444') + } } + ownedMaterials.push(...materials) + child.material = Array.isArray(child.material) ? materials : materials[0]! }) - return next - }, [node]) + return { object: next, ownedMaterials } + }, [node, valid]) useEffect( () => () => { - object.traverse((child) => { + preview.object.traverse((child) => { if (!(child instanceof Mesh)) return child.geometry.dispose() - const materials = Array.isArray(child.material) ? child.material : [child.material] - materials.forEach((material: Material) => { - material.dispose() - }) }) + for (const material of preview.ownedMaterials) material.dispose() }, - [object], + [preview], ) - return + return } diff --git a/packages/nodes/src/custom-mesh/selection.tsx b/packages/nodes/src/custom-mesh/selection.tsx index 1e3ea225bf..6dad3bfc55 100644 --- a/packages/nodes/src/custom-mesh/selection.tsx +++ b/packages/nodes/src/custom-mesh/selection.tsx @@ -13,6 +13,7 @@ import { import { cn, EDITOR_LAYER, + isAngleSnapActive, isGridSnapActive, markToolCancelConsumed, meshEditScope, @@ -39,6 +40,7 @@ import { ScanLine, Square, Trash2, + X as XIcon, } from 'lucide-react' import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -83,6 +85,12 @@ type ComponentMode = CustomMeshSelection['mode'] type Point = [number, number, number] type Axis = 'x' | 'y' | 'z' type TransformTool = 'select' | 'move' | 'rotate' | 'loop-cut' +type ModalOperator = 'scale' | 'extrude' | 'inset' | 'merge' | 'dissolve' | 'bevel' | 'delete' +type ModalDraft = { + topology: CustomMeshTopology + selection: CustomMeshSelection + operator: ModalOperator +} const AXIS_VECTORS: Record = { x: [1, 0, 0], @@ -175,7 +183,7 @@ function VertexHandle({ selected: boolean active: boolean xray: boolean - onSelect: (id: string, additive: boolean) => void + onSelect: (id: string, additive: boolean, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) const visibleGeometry = useMemo(() => new SphereGeometry(radius, 16, 12), [radius]) @@ -227,7 +235,7 @@ function VertexHandle({ material={hitMaterial} onClick={(event) => { event.stopPropagation() - onSelect(id, event.nativeEvent.shiftKey) + onSelect(id, event.nativeEvent.shiftKey, event) }} onPointerEnter={(event) => { event.stopPropagation() @@ -261,7 +269,7 @@ function EdgeHandle({ selected: boolean active: boolean xray: boolean - onSelect: (id: string, additive: boolean) => void + onSelect: (id: string, additive: boolean, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) const placement = useMemo(() => { @@ -339,7 +347,7 @@ function EdgeHandle({ material={hitMaterial} onClick={(event) => { event.stopPropagation() - onSelect(id, event.nativeEvent.shiftKey) + onSelect(id, event.nativeEvent.shiftKey, event) }} onPointerEnter={(event) => { event.stopPropagation() @@ -363,6 +371,7 @@ function FaceHandle({ selected, active, xray, + interactive = true, onSelect, }: { face: CustomMeshFace @@ -370,7 +379,8 @@ function FaceHandle({ selected: boolean active: boolean xray: boolean - onSelect: (id: string, additive: boolean) => void + interactive?: boolean + onSelect: (id: string, additive: boolean, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) const geometries = useMemo(() => { @@ -425,6 +435,7 @@ function FaceHandle({ const outline = useMemo(() => { if (!geometries) return null const line = new LineSegments(geometries.outline, outlineMaterial) + line.layers.set(EDITOR_LAYER) line.raycast = () => {} line.renderOrder = 1201 return line @@ -445,20 +456,34 @@ function FaceHandle({ { - event.stopPropagation() - onSelect(face.id, event.nativeEvent.shiftKey) - }} - onPointerEnter={(event) => { - event.stopPropagation() - setHovered(true) - document.body.style.cursor = 'pointer' - }} - onPointerLeave={() => { - setHovered(false) - if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' - }} + onClick={ + interactive + ? (event) => { + event.stopPropagation() + onSelect(face.id, event.nativeEvent.shiftKey, event) + } + : undefined + } + onPointerEnter={ + interactive + ? (event) => { + event.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + } + : undefined + } + onPointerLeave={ + interactive + ? () => { + setHovered(false) + if (document.body.style.cursor === 'pointer') document.body.style.cursor = '' + } + : undefined + } + raycast={interactive ? undefined : () => {}} renderOrder={1200} /> {outline ? : null} @@ -527,6 +552,7 @@ function AxisHandle({ {}} @@ -534,6 +560,7 @@ function AxisHandle({ /> {}} @@ -837,8 +864,9 @@ function CustomMeshEditor({ }) { const { camera, gl } = useThree() const outerRef = useRef(null) - const [editing, setEditing] = useState(false) - const editingRef = useRef(false) + const editing = useInteractionScope( + (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === node.id, + ) const [mode, setMode] = useState('face') const [selectedIds, setSelectedIds] = useState([]) const [activeId, setActiveId] = useState(null) @@ -847,12 +875,20 @@ function CustomMeshEditor({ const [previewTopology, setPreviewTopology] = useState(null) const [dragAxis, setDragAxis] = useState(null) const [loopCutSegments, setLoopCutSegments] = useState<[Point, Point][] | null>(null) + const [loopCutCount, setLoopCutCount] = useState(1) + const [loopCutFactor, setLoopCutFactor] = useState(0.5) const [extrudeDistance, setExtrudeDistance] = useState('0.25') const [insetAmount, setInsetAmount] = useState('0.15') const [rotationSnapAngle, setRotationSnapAngle] = useState('15') const [scaleFactor, setScaleFactor] = useState('1.1') + const [bevelWidth, setBevelWidth] = useState('0.12') + const [bevelSegments, setBevelSegments] = useState('1') + const [bevelProfile, setBevelProfile] = useState('0.5') + const [bevelClamp, setBevelClamp] = useState(true) + const [modalDraft, setModalDraft] = useState(null) const [error, setError] = useState(null) const cancelDragRef = useRef<(() => void) | null>(null) + const modalDraftRef = useRef(null) const displayTopology = previewTopology ?? node.topology const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selection = useMemo( @@ -885,9 +921,12 @@ function CustomMeshEditor({ outer.scale.copy(target.scale) }) - useEffect(() => { - editingRef.current = editing - }, [editing]) + modalDraftRef.current = modalDraft + + const ownsEditSession = useCallback(() => { + const scope = useInteractionScope.getState().scope + return scope.kind === 'mesh-editing' && scope.nodeId === node.id + }, [node.id]) const endOwnedScope = useCallback(() => { useInteractionScope @@ -901,9 +940,8 @@ function CustomMeshEditor({ useLiveNodeOverrides.getState().clear(node.id) useScene.getState().markDirty(node.id) endOwnedScope() - editingRef.current = false - setEditing(false) setPreviewTopology(null) + setModalDraft(null) setSelectedIds([]) setActiveId(null) setTransformTool('select') @@ -912,6 +950,31 @@ function CustomMeshEditor({ setError(null) }, [endOwnedScope, node.id]) + const cancelModalDraft = useCallback(() => { + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + setPreviewTopology(null) + setModalDraft(null) + setError(null) + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + }, [node.id, ownsEditSession]) + + const confirmModalDraft = useCallback(() => { + const draft = modalDraftRef.current + if (!draft) return + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useScene.getState().updateNode(node.id, { topology: draft.topology }) + setPreviewTopology(null) + setModalDraft(null) + setMode(draft.selection.mode) + setSelectedIds(draft.selection.ids) + setActiveId(draft.selection.ids.at(-1) ?? null) + setError(null) + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + triggerSFX('sfx:item-pick') + }, [node.id, ownsEditSession]) + useEffect( () => () => { cancelDragRef.current?.() @@ -923,22 +986,41 @@ function CustomMeshEditor({ [endOwnedScope, node.id], ) + useEffect(() => { + if (editing) return + cancelDragRef.current?.() + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + setPreviewTopology(null) + setModalDraft(null) + setLoopCutSegments(null) + setDragAxis(null) + }, [editing, node.id]) + useEffect(() => { if (!editing) return const onToolCancel = () => { markToolCancelConsumed() if (cancelDragRef.current) cancelDragRef.current() + else if (modalDraftRef.current) cancelModalDraft() else exitEditMode() } emitter.on('tool:cancel', onToolCancel) return () => emitter.off('tool:cancel', onToolCancel) - }, [editing, exitEditMode]) + }, [cancelModalDraft, editing, exitEditMode]) useEffect(() => { if (!editing) return const onGridClick = () => { const scope = useInteractionScope.getState().scope - if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return + if ( + scope.kind !== 'mesh-editing' || + scope.nodeId !== node.id || + cancelDragRef.current || + modalDraftRef.current + ) + return setSelectedIds([]) setActiveId(null) setError(null) @@ -948,7 +1030,6 @@ function CustomMeshEditor({ }, [editing, node.id]) useEffect(() => { - if (!editing) return const onKeyDown = (event: KeyboardEvent) => { const element = event.target as HTMLElement | null if ( @@ -960,7 +1041,26 @@ function CustomMeshEditor({ if (event.key === 'Tab') { event.preventDefault() event.stopImmediatePropagation() - exitEditMode() + if (cancelDragRef.current || modalDraftRef.current) return + if (editing) { + exitEditMode() + } else if (useInteractionScope.getState().scope.kind === 'idle') { + const face = preferredFace(node.topology) + setMode('face') + setSelectedIds(face ? [face.id] : []) + setActiveId(face?.id ?? null) + setTransformTool('select') + setError(null) + useInteractionScope.getState().begin(meshEditScope(node.id)) + triggerSFX('sfx:item-pick') + } + return + } + if (!editing) return + if (event.key === 'Enter' && modalDraftRef.current) { + event.preventDefault() + event.stopImmediatePropagation() + confirmModalDraft() return } const nextMode = @@ -990,7 +1090,16 @@ function CustomMeshEditor({ } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) - }, [activeId, editing, exitEditMode, mode, node.topology, selectedIds]) + }, [ + activeId, + confirmModalDraft, + editing, + exitEditMode, + mode, + node.id, + node.topology, + selectedIds, + ]) useEffect(() => { const validIds = new Set( @@ -1011,24 +1120,58 @@ function CustomMeshEditor({ setActiveId(face?.id ?? null) setTransformTool('select') setError(null) - editingRef.current = true - setEditing(true) useInteractionScope.getState().begin(meshEditScope(node.id)) triggerSFX('sfx:item-pick') } + const componentIsVisible = useCallback( + (id: string, event: ThreeEvent) => { + if (xray) return true + target.updateWorldMatrix(true, true) + const raycaster = new Raycaster() + raycaster.ray.copy(event.ray) + const nearestSurface = raycaster.intersectObject(target, true)[0] + if (!nearestSurface) return true + let worldPoint: Vector3 | null = null + if (mode === 'vertex') { + const vertex = displayTopology.vertices.find((entry) => entry.id === id) + if (vertex) worldPoint = target.localToWorld(new Vector3(...vertex.position)) + } else if (mode === 'edge') { + const edge = displayTopology.edges.find((entry) => entry.id === id) + const vertices = topologyVertexMap(displayTopology) + const start = edge ? vertices.get(edge.vertexIds[0]) : null + const end = edge ? vertices.get(edge.vertexIds[1]) : null + if (start && end) { + const worldStart = target.localToWorld(new Vector3(...start)) + const worldEnd = target.localToWorld(new Vector3(...end)) + worldPoint = new Vector3() + event.ray.distanceSqToSegment(worldStart, worldEnd, undefined, worldPoint) + } + } else { + worldPoint = event.point.clone() + } + if (!worldPoint) return false + const scale = target.getWorldScale(new Vector3()) + const tolerance = componentRadius * Math.max(scale.x, scale.y, scale.z) * 1.5 + return event.ray.origin.distanceTo(worldPoint) <= nearestSurface.distance + tolerance + }, + [componentRadius, displayTopology, mode, target, xray], + ) + const selectComponent = useCallback( - (id: string, additive: boolean) => { + (id: string, additive: boolean, event: ThreeEvent) => { + if (modalDraftRef.current) return + if (!componentIsVisible(id, event)) return const next = selectCustomMeshComponent({ mode, ids: selectedIds, activeId }, id, additive) setSelectedIds(next.ids) setActiveId(next.activeId) setError(null) }, - [activeId, mode, selectedIds], + [activeId, componentIsVisible, mode, selectedIds], ) const switchMode = (nextMode: ComponentMode) => { - if (cancelDragRef.current) return + if (cancelDragRef.current || modalDraftRef.current) return const converted = convertCustomMeshSelection( displayTopology, { mode, ids: selectedIds, activeId }, @@ -1056,7 +1199,7 @@ function CustomMeshEditor({ const beginAxisDrag = useCallback( (axis: Axis, event: ThreeEvent) => { - if (!editingRef.current || selectedIds.length === 0 || cancelDragRef.current) return + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return const origin = selectionCentroid(displayTopology, selection) if (!origin) return target.updateWorldMatrix(true, false) @@ -1131,7 +1274,7 @@ function CustomMeshEditor({ useScene.getState().updateNode(node.id, { topology: latestTopology }) triggerSFX('sfx:item-pick') } - if (editingRef.current) { + if (ownsEditSession()) { useInteractionScope.getState().begin(meshEditScope(node.id)) } swallowNextClick() @@ -1144,12 +1287,12 @@ function CustomMeshEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, selectedIds.length, selection, target], + [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], ) const beginRotationDrag = useCallback( (axis: Axis, event: ThreeEvent) => { - if (!editingRef.current || selectedIds.length === 0 || cancelDragRef.current) return + if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return const origin = selectionCentroid(displayTopology, selection) if (!origin) return target.updateWorldMatrix(true, false) @@ -1196,7 +1339,12 @@ function CustomMeshEditor({ previousWrappedAngle = wrappedAngle let angle = accumulatedAngle const snapDegrees = Math.abs(Number(rotationSnapAngle)) - if (!pointerEvent.altKey && Number.isFinite(snapDegrees) && snapDegrees > 0) { + if ( + !pointerEvent.altKey && + isAngleSnapActive() && + Number.isFinite(snapDegrees) && + snapDegrees > 0 + ) { const step = (snapDegrees * Math.PI) / 180 angle = Math.round(angle / step) * step } @@ -1236,7 +1384,7 @@ function CustomMeshEditor({ useScene.getState().updateNode(node.id, { topology: latestTopology }) triggerSFX('sfx:item-pick') } - if (editingRef.current) { + if (ownsEditSession()) { useInteractionScope.getState().begin(meshEditScope(node.id)) } swallowNextClick() @@ -1249,7 +1397,16 @@ function CustomMeshEditor({ window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, rotationSnapAngle, selectedIds.length, selection, target], + [ + displayTopology, + makeRay, + node.id, + ownsEditSession, + rotationSnapAngle, + selectedIds.length, + selection, + target, + ], ) const previewLoopCut = useCallback( @@ -1260,16 +1417,16 @@ function CustomMeshEditor({ setError(null) return } - const segments = customMeshLoopCutSegments(displayTopology, edgeId, 0.5) + const segments = customMeshLoopCutSegments(displayTopology, edgeId, 0.5, loopCutCount) setLoopCutSegments(segments) setError(segments ? null : 'Loop cut requires a connected ring of quad faces') }, - [displayTopology], + [displayTopology, loopCutCount], ) const beginLoopCutDrag = useCallback( (edgeId: string, event: ThreeEvent) => { - if (!editingRef.current || cancelDragRef.current) return + if (event.nativeEvent.button !== 0 || !ownsEditSession() || cancelDragRef.current) return const edge = displayTopology.edges.find((entry) => entry.id === edgeId) const vertices = topologyVertexMap(displayTopology) const start = edge ? vertices.get(edge.vertexIds[0]) : null @@ -1289,20 +1446,30 @@ function CustomMeshEditor({ let latestTopology: CustomMeshTopology | null = null let latestSelection: CustomMeshSelection | null = null let latestFactor = 0.5 + let activeCuts = loopCutCount + let firstStageConfirmed = false let finished = false - const updatePreview = (factor: number) => { - const result = applyCustomMeshCommand(baseTopology, { type: 'loop-cut', edgeId, factor }) - const segments = customMeshLoopCutSegments(baseTopology, edgeId, factor) + const updatePreview = (factor: number, cuts = activeCuts) => { + const result = applyCustomMeshCommand(baseTopology, { + type: 'loop-cut', + edgeId, + factor, + cuts, + }) + const segments = customMeshLoopCutSegments(baseTopology, edgeId, factor, cuts) if (!result.ok || !segments) { setError(result.ok ? 'Could not preview loop cut' : result.error) return false } latestFactor = factor + activeCuts = cuts latestTopology = result.topology latestSelection = result.selection setPreviewTopology(result.topology) setLoopCutSegments(segments) + setLoopCutCount(cuts) + setLoopCutFactor(factor) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) useScene.getState().markDirty(node.id) setError(null) @@ -1315,23 +1482,40 @@ function CustomMeshEditor({ document.body.style.cursor = 'ew-resize' const onMove = (pointerEvent: PointerEvent) => { + if (!firstStageConfirmed) return const parameter = closestAxisParameterToRay( worldStart, worldAxis, makeRay(pointerEvent.clientX, pointerEvent.clientY), ) - const factor = Math.min( + let factor = Math.min( 0.98, Math.max(0.02, 0.5 + (parameter - initialParameter) / worldLength), ) + if (isGridSnapActive() && !pointerEvent.altKey) { + const step = useEditor.getState().gridSnapStep + if (step > 0) + factor = Math.min( + 0.98, + Math.max(0.02, (Math.round((factor * worldLength) / step) * step) / worldLength), + ) + } updatePreview(factor) } + const onWheel = (wheelEvent: WheelEvent) => { + wheelEvent.preventDefault() + const cuts = Math.min(32, Math.max(1, activeCuts + (wheelEvent.deltaY < 0 ? 1 : -1))) + updatePreview(latestFactor, cuts) + } + const finish = (commit: boolean) => { if (finished) return finished = true window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointerup', onFirstPointerUp) + window.removeEventListener('pointerdown', onSecondPointerDown, true) + window.removeEventListener('wheel', onWheel) window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null @@ -1348,45 +1532,53 @@ function CustomMeshEditor({ setActiveId(latestSelection.ids.at(-1) ?? null) triggerSFX('sfx:item-pick') } - if (editingRef.current) { + if (ownsEditSession()) { useInteractionScope.getState().begin(meshEditScope(node.id)) } swallowNextClick() } - const onPointerUp = () => finish(true) + const onFirstPointerUp = () => { + firstStageConfirmed = true + } + const onSecondPointerDown = (pointerEvent: PointerEvent) => { + if (!firstStageConfirmed || (pointerEvent.button !== 0 && pointerEvent.button !== 2)) return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + if (pointerEvent.button === 2) updatePreview(0.5) + finish(true) + } const onPointerCancel = () => finish(false) cancelDragRef.current = onPointerCancel window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointerup', onFirstPointerUp, { once: true }) + window.addEventListener('pointerdown', onSecondPointerDown, true) + window.addEventListener('wheel', onWheel, { passive: false }) window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, - [displayTopology, makeRay, node.id, target], + [displayTopology, loopCutCount, makeRay, node.id, ownsEditSession, target], ) - const commitCommand = ( - command: CustomMeshCommand, - operator: 'rotate' | 'scale' | 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete', - ) => { - if (cancelDragRef.current) return + const previewCommand = (command: CustomMeshCommand, operator: ModalOperator) => { + if (cancelDragRef.current || modalDraftRef.current) return useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operator)) const result = applyCustomMeshCommand(node.topology, command) - useInteractionScope.getState().begin(meshEditScope(node.id)) if (!result.ok) { + useInteractionScope.getState().begin(meshEditScope(node.id)) setError(result.error) return } - useScene.getState().updateNode(node.id, { topology: result.topology }) - setMode(result.selection.mode) - setSelectedIds(result.selection.ids) - setActiveId(result.selection.ids.at(-1) ?? null) + const draft = { topology: result.topology, selection: result.selection, operator } + setModalDraft(draft) + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) setError(null) - triggerSFX('sfx:item-pick') } const extrudeSelectedFace = () => { if (mode !== 'face' || selectedIds.length !== 1) return - commitCommand( + previewCommand( { type: 'extrude-face', faceId: selectedIds[0]!, distance: Number(extrudeDistance) }, 'extrude', ) @@ -1394,7 +1586,7 @@ function CustomMeshEditor({ const insetSelectedFace = () => { if (mode !== 'face' || selectedIds.length !== 1) return - commitCommand( + previewCommand( { type: 'inset-face', faceId: selectedIds[0]!, @@ -1408,7 +1600,7 @@ function CustomMeshEditor({ const scaleSelection = () => { if (!gizmoOrigin) return const factor = Number(scaleFactor) - commitCommand( + previewCommand( { type: 'scale-components', selection, @@ -1421,17 +1613,32 @@ function CustomMeshEditor({ const deleteSelection = () => { if (selectedIds.length === 0) return - commitCommand({ type: 'delete-components', selection }, 'delete') + previewCommand({ type: 'delete-components', selection }, 'delete') } const mergeSelection = () => { if (mode !== 'vertex' || selectedIds.length < 2) return - commitCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') + previewCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') } const dissolveSelection = () => { if (mode !== 'edge' || selectedIds.length !== 1) return - commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + previewCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + } + + const bevelSelection = () => { + if (mode !== 'edge' || selectedIds.length !== 1) return + previewCommand( + { + type: 'bevel-edge', + edgeId: selectedIds[0]!, + width: Number(bevelWidth), + segments: Number(bevelSegments), + profile: Number(bevelProfile), + clampOverlap: bevelClamp, + }, + 'bevel', + ) } const updateSelection = (next: CustomMeshSelectionState) => { @@ -1453,6 +1660,7 @@ function CustomMeshEditor({ updateSelection(clearCustomMeshSelection({ mode, ids: selectedIds, activeId })) const keyboardActionsRef = useRef({ + bevelSelection, clearSelection, deleteSelection, dissolveSelection, @@ -1465,6 +1673,7 @@ function CustomMeshEditor({ selectAll, }) keyboardActionsRef.current = { + bevelSelection, clearSelection, deleteSelection, dissolveSelection, @@ -1491,7 +1700,9 @@ function CustomMeshEditor({ const key = event.key.toLowerCase() const actions = keyboardActionsRef.current let handled = true - if (key === 'a') { + if (key === 'b' && (event.ctrlKey || event.metaKey)) { + actions.bevelSelection() + } else if (key === 'a') { if (event.altKey) actions.clearSelection() else actions.selectAll() } else if (key === 'i' && (event.ctrlKey || event.metaKey)) { @@ -1536,9 +1747,10 @@ function CustomMeshEditor({ const componentLabel = selectedIds.length === 1 ? mode : mode === 'vertex' ? 'vertices' : `${mode}s` - const componentStatus = - transformTool === 'loop-cut' - ? 'Loop Cut · hover an edge to preview · click or drag to cut and slide · Ctrl+R' + const componentStatus = modalDraft + ? `${modalDraft.operator} preview · Enter to confirm · Esc to cancel` + : transformTool === 'loop-cut' + ? `Loop Cut · ${loopCutCount} cut${loopCutCount === 1 ? '' : 's'} · factor ${loopCutFactor.toFixed(2)} · first click chooses ring, second click confirms · wheel changes count` : selectedIds.length === 0 ? `Click a ${mode} to select it` : transformTool === 'move' @@ -1585,17 +1797,33 @@ function CustomMeshEditor({ }) : null} {mode === 'face' - ? displayTopology.faces.map((face) => ( - - )) + ? displayTopology.faces.map((face) => { + const center = customMeshFaceCentroid(displayTopology, face) + return ( + + + {xray && center ? ( + + ) : null} + + ) + }) : null} {gizmoOrigin && transformTool === 'move' ? ( @@ -1703,6 +1931,16 @@ function CustomMeshEditor({ > {xray ? : } + {modalDraft ? ( + <> + + + + + + + + ) : null} @@ -1749,6 +1987,18 @@ function CustomMeshEditor({ > + + setLoopCutCount(Math.min(32, Math.max(1, Number(event.target.value) || 1))) + } + step="1" + type="number" + value={loopCutCount} + /> Dissolve + setBevelWidth(event.target.value)} + step="0.02" + type="number" + value={bevelWidth} + /> + setBevelSegments(event.target.value)} + step="1" + type="number" + value={bevelSegments} + /> + setBevelProfile(event.target.value)} + step="0.1" + type="number" + value={bevelProfile} + /> + setBevelClamp((value) => !value)} + > + Clamp + + + Bevel + { - const activeLevelId = useViewer((state) => state.selection.levelId) + const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() + const { canPlaceOnFloor } = useSpatialQuery() const cursorRef = useRef(null) const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) + const [validPlacement, setValidPlacement] = useState(true) const previewNode = useMemo( () => CustomMeshNode.parse({ @@ -49,7 +51,8 @@ const CustomMeshTool = () => { useEffect(() => { if (!activeLevelId) return let lastPosition: [number, number, number] | null = null - let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + let alignmentCandidates = collectAlignmentAnchors(sceneApi.nodes(), previewNode.id) + const { size } = customMeshBounds(previewNode) const onGridMove = (event: GridEvent) => { if (!cursorVisibleRef.current) { @@ -75,6 +78,8 @@ const CustomMeshTool = () => { }) cursorRef.current?.position.set(...visualPosition) lastPosition = position + const placement = canPlaceOnFloor(activeLevelId, position, size, [0, previewNode.rotation, 0]) + setValidPlacement(placement.valid) const snapKey = movementSfxStepKey({ coords: [position[0], position[2]], @@ -102,12 +107,18 @@ const CustomMeshTool = () => { parentId: activeLevelId, position, }) - useScene.getState().createNode(node, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [node.id] }) + const placement = canPlaceOnFloor(activeLevelId, position, size, [0, node.rotation, 0]) + setValidPlacement(placement.valid) + if (!placement.valid) { + stopPlacementCommitPropagation(event) + return + } + sceneApi.upsert(node, activeLevelId) + selectNode(node.id) triggerSFX('sfx:structure-build') useAlignmentGuides.getState().clear() if (useEditor.getState().getContinuation('point') === 'repeat') { - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + alignmentCandidates = collectAlignmentAnchors(sceneApi.nodes(), previewNode.id) } else { cursorVisibleRef.current = false setCursorVisible(false) @@ -123,12 +134,12 @@ const CustomMeshTool = () => { unsubscribe() useAlignmentGuides.getState().clear() } - }, [activeLevelId, previewNode]) + }, [activeLevelId, canPlaceOnFloor, previewNode, sceneApi, selectNode]) if (!activeLevelId) return null return ( - + ) } From 23235318345e5d38d57d6e173ef2d8592bd2e62a Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 11 Aug 2026 13:20:06 +0530 Subject: [PATCH 05/10] feat: refine custom mesh editing experience --- .../editor/floating-action-menu.tsx | 19 +- .../components/editor/node-action-menu.tsx | 15 +- packages/editor/src/index.tsx | 2 + .../src/lib/floating-menu-scale.test.ts | 29 + .../editor/src/lib/floating-menu-scale.ts | 14 + .../nodes/src/custom-mesh/commands.test.ts | 9 + packages/nodes/src/custom-mesh/commands.ts | 56 +- .../nodes/src/custom-mesh/definition.test.ts | 7 + packages/nodes/src/custom-mesh/definition.ts | 2 +- .../nodes/src/custom-mesh/geometry.test.ts | 50 +- packages/nodes/src/custom-mesh/geometry.ts | 44 +- .../src/custom-mesh/gesture-wheel.test.ts | 29 + .../nodes/src/custom-mesh/gesture-wheel.ts | 13 + .../src/custom-mesh/interaction-sfx.test.ts | 18 + .../nodes/src/custom-mesh/interaction-sfx.ts | 30 + packages/nodes/src/custom-mesh/selection.tsx | 1202 ++++++++++++----- .../src/custom-mesh/toolbar-state.test.ts | 56 + .../nodes/src/custom-mesh/toolbar-state.ts | 63 + 18 files changed, 1332 insertions(+), 326 deletions(-) create mode 100644 packages/editor/src/lib/floating-menu-scale.test.ts create mode 100644 packages/editor/src/lib/floating-menu-scale.ts create mode 100644 packages/nodes/src/custom-mesh/gesture-wheel.test.ts create mode 100644 packages/nodes/src/custom-mesh/gesture-wheel.ts create mode 100644 packages/nodes/src/custom-mesh/interaction-sfx.test.ts create mode 100644 packages/nodes/src/custom-mesh/interaction-sfx.ts create mode 100644 packages/nodes/src/custom-mesh/toolbar-state.test.ts create mode 100644 packages/nodes/src/custom-mesh/toolbar-state.ts diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 1091ced9e5..01af884e01 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -43,6 +43,7 @@ import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' import { useReducedMotion } from '../../hooks/use-reduced-motion' import { resolveMoveActionNode } from '../../lib/direct-manipulation' +import { getFloatingMenuScale } from '../../lib/floating-menu-scale' import { createFreshPlacementSubtree, duplicatesAsFreshSubtree, @@ -99,17 +100,6 @@ const ALLOWED_TYPES = [ const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] -// Menu scales with camera zoom so it feels anchored to the object, but is -// clamped on both ends so it stays readable when zoomed way out and doesn't -// dominate the screen when zoomed in close. Reference values are picked so -// scale = 1 lands near the editor's default framing. -const MIN_MENU_SCALE = 0.5 -// Cap at 1 so zooming in doesn't grow the menu past its default pixel size — -// only zoom-out shrinks it (down to MIN_MENU_SCALE). -const MAX_MENU_SCALE = 1 -const REF_ORTHO_ZOOM = 20 -const REF_CAMERA_DISTANCE = 12 - // World-space Y distance from a node's bbox top to the floating menu anchor. // Per-type because in-world chrome above the node (height-resize arrows, // measurement labels) varies in vertical reach. @@ -392,12 +382,7 @@ export function FloatingActionMenu() { // so it stays readable at extreme zoom-out and doesn't fill the screen // when zoomed in close. if (menuScaleRef.current) { - const raw = - state.camera instanceof THREE.OrthographicCamera - ? state.camera.zoom / REF_ORTHO_ZOOM - : REF_CAMERA_DISTANCE / - Math.max(state.camera.position.distanceTo(groupRef.current.position), 0.001) - const scale = Math.min(MAX_MENU_SCALE, Math.max(MIN_MENU_SCALE, raw)) + const scale = getFloatingMenuScale(state.camera, groupRef.current.position) menuScaleRef.current.style.transform = `scale(${scale})` } diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index a503d24ba0..bb1ca78dad 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,7 +1,7 @@ 'use client' import { Icon } from '@iconify/react' -import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react' +import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { @@ -10,6 +10,7 @@ type NodeActionMenuProps = { onDelete?: MouseEventHandler onDuplicate?: MouseEventHandler onMove?: MouseEventHandler + onEditMesh?: MouseEventHandler onCurve?: MouseEventHandler /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */ onGroup?: MouseEventHandler @@ -27,6 +28,7 @@ export function NodeActionMenu({ onDelete, onDuplicate, onMove, + onEditMesh, onCurve, onGroup, onUngroup, @@ -65,6 +67,17 @@ export function NodeActionMenu({ )} + {onEditMesh && ( + + )} {onGroup && ( + ) +} + +function ToolbarOperationItem({ + label, + shortcut, + active = false, + disabled = false, + controls, + onClick, + children, +}: { + label: string + shortcut?: string + active?: boolean + disabled?: boolean + controls?: ReactNode + onClick: () => void + children: ReactNode +}) { + return ( +

+ + {controls ?
{controls}
: null} +
+ ) +} + +function ToolbarPanelFrame({ + label, + className, + children, +}: { + label: string + className?: string + children: ReactNode +}) { + return ( +
+ {children} +
+ ) +} + function CustomMeshEditor({ node, target, @@ -864,6 +1087,8 @@ function CustomMeshEditor({ }) { const { camera, gl } = useThree() const outerRef = useRef(null) + const menuScaleRef = useRef(null) + const menuWorldPositionRef = useRef(new Vector3()) const editing = useInteractionScope( (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === node.id, ) @@ -879,13 +1104,9 @@ function CustomMeshEditor({ const [loopCutFactor, setLoopCutFactor] = useState(0.5) const [extrudeDistance, setExtrudeDistance] = useState('0.25') const [insetAmount, setInsetAmount] = useState('0.15') - const [rotationSnapAngle, setRotationSnapAngle] = useState('15') - const [scaleFactor, setScaleFactor] = useState('1.1') - const [bevelWidth, setBevelWidth] = useState('0.12') - const [bevelSegments, setBevelSegments] = useState('1') - const [bevelProfile, setBevelProfile] = useState('0.5') - const [bevelClamp, setBevelClamp] = useState(true) + const [bevelSegments, setBevelSegments] = useState(DEFAULT_BEVEL_SEGMENTS) const [modalDraft, setModalDraft] = useState(null) + const [toolbarPanel, setToolbarPanel] = useState(null) const [error, setError] = useState(null) const cancelDragRef = useRef<(() => void) | null>(null) const modalDraftRef = useRef(null) @@ -913,12 +1134,22 @@ function CustomMeshEditor({ ] }, [displayTopology, extent]) - useFrame(() => { + useFrame((state) => { const outer = outerRef.current - if (!(outer && mirrorTarget)) return - outer.position.copy(target.position) - outer.quaternion.copy(target.quaternion) - outer.scale.copy(target.scale) + if (!outer) return + if (mirrorTarget) { + outer.position.copy(target.position) + outer.quaternion.copy(target.quaternion) + outer.scale.copy(target.scale) + } + if (menuScaleRef.current) { + const menuWorldPosition = menuWorldPositionRef.current.set(...menuAnchor) + outer.localToWorld(menuWorldPosition) + menuScaleRef.current.style.transform = `scale(${getFloatingMenuScale( + state.camera, + menuWorldPosition, + )})` + } }) modalDraftRef.current = modalDraft @@ -947,7 +1178,9 @@ function CustomMeshEditor({ setTransformTool('select') setDragAxis(null) setLoopCutSegments(null) + setToolbarPanel(null) setError(null) + playCustomMeshSfx('finish') }, [endOwnedScope, node.id]) const cancelModalDraft = useCallback(() => { @@ -955,8 +1188,10 @@ function CustomMeshEditor({ useScene.getState().markDirty(node.id) setPreviewTopology(null) setModalDraft(null) + setToolbarPanel(null) setError(null) if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + playCustomMeshSfx('cancel') }, [node.id, ownsEditSession]) const confirmModalDraft = useCallback(() => { @@ -972,7 +1207,7 @@ function CustomMeshEditor({ setActiveId(draft.selection.ids.at(-1) ?? null) setError(null) if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) - triggerSFX('sfx:item-pick') + playCustomMeshSfx(draft.operator === 'delete' ? 'delete' : 'operation-commit') }, [node.id, ownsEditSession]) useEffect( @@ -994,6 +1229,7 @@ function CustomMeshEditor({ useScene.getState().markDirty(node.id) setPreviewTopology(null) setModalDraft(null) + setToolbarPanel(null) setLoopCutSegments(null) setDragAxis(null) }, [editing, node.id]) @@ -1002,13 +1238,27 @@ function CustomMeshEditor({ if (!editing) return const onToolCancel = () => { markToolCancelConsumed() - if (cancelDragRef.current) cancelDragRef.current() + if (toolbarPanel) { + setToolbarPanel(null) + playCustomMeshSfx('cancel') + } else if (cancelDragRef.current) cancelDragRef.current() else if (modalDraftRef.current) cancelModalDraft() else exitEditMode() } emitter.on('tool:cancel', onToolCancel) return () => emitter.off('tool:cancel', onToolCancel) - }, [cancelModalDraft, editing, exitEditMode]) + }, [cancelModalDraft, editing, exitEditMode, toolbarPanel]) + + useEffect(() => { + if (!(editing && toolbarPanel)) return + const closePanel = (event: PointerEvent) => { + const targetElement = event.target + if (targetElement instanceof Node && menuScaleRef.current?.contains(targetElement)) return + setToolbarPanel(null) + } + window.addEventListener('pointerdown', closePanel, true) + return () => window.removeEventListener('pointerdown', closePanel, true) + }, [editing, toolbarPanel]) useEffect(() => { if (!editing) return @@ -1024,6 +1274,7 @@ function CustomMeshEditor({ setSelectedIds([]) setActiveId(null) setError(null) + playCustomMeshSfx('component-select') } emitter.on('grid:click', onGridClick) return () => emitter.off('grid:click', onGridClick) @@ -1050,6 +1301,7 @@ function CustomMeshEditor({ setSelectedIds(face ? [face.id] : []) setActiveId(face?.id ?? null) setTransformTool('select') + setToolbarPanel(null) setError(null) useInteractionScope.getState().begin(meshEditScope(node.id)) triggerSFX('sfx:item-pick') @@ -1087,6 +1339,7 @@ function CustomMeshEditor({ setSelectedIds(converted.ids) setActiveId(converted.activeId) setError(null) + playCustomMeshSfx('tool-select') } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) @@ -1113,12 +1366,14 @@ function CustomMeshEditor({ setActiveId((current) => (current && validIds.has(current) ? current : null)) }, [mode, node.topology]) - const enterEditMode = () => { + const enterEditMode = (event: ReactMouseEvent) => { + event.stopPropagation() const face = preferredFace(node.topology) setMode('face') setSelectedIds(face ? [face.id] : []) setActiveId(face?.id ?? null) setTransformTool('select') + setToolbarPanel(null) setError(null) useInteractionScope.getState().begin(meshEditScope(node.id)) triggerSFX('sfx:item-pick') @@ -1166,6 +1421,7 @@ function CustomMeshEditor({ setSelectedIds(next.ids) setActiveId(next.activeId) setError(null) + playCustomMeshSfx('component-select') }, [activeId, componentIsVisible, mode, selectedIds], ) @@ -1180,6 +1436,7 @@ function CustomMeshEditor({ setMode(converted.mode) setSelectedIds(converted.ids) setActiveId(converted.activeId) + setToolbarPanel(null) setError(null) } @@ -1215,9 +1472,11 @@ function CustomMeshEditor({ const previousCursor = document.body.style.cursor let latestTopology: CustomMeshTopology | null = null let latestDistance = 0 + let lastSnapDistance: number | null = null let finished = false useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'translate')) + playCustomMeshSfx('drag-start') useViewer.getState().setInputDragging(true) setDragAxis(axis) document.body.style.cursor = 'grabbing' @@ -1234,10 +1493,17 @@ function CustomMeshEditor({ const localPoint = target.worldToLocal(worldPoint) const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 let distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) - if (isGridSnapActive() && !pointerEvent.altKey) { + const snapping = isGridSnapActive() && !pointerEvent.altKey + if (snapping) { const step = useEditor.getState().gridSnapStep if (step > 0) distance = Math.round(distance / step) * step } + if (snapping && Math.abs(distance) > 1e-6 && distance !== lastSnapDistance) { + lastSnapDistance = distance + playCustomMeshSfx('move-step') + } else if (!snapping) { + lastSnapDistance = null + } const delta: Point = [0, 0, 0] delta[axisIndex] = distance const result = applyCustomMeshCommand(baseTopology, { @@ -1272,7 +1538,9 @@ function CustomMeshEditor({ setDragAxis(null) if (commit && latestTopology && Math.abs(latestDistance) > 1e-6) { useScene.getState().updateNode(node.id, { topology: latestTopology }) - triggerSFX('sfx:item-pick') + playCustomMeshSfx('finish') + } else if (!commit) { + playCustomMeshSfx('cancel') } if (ownsEditSession()) { useInteractionScope.getState().begin(meshEditScope(node.id)) @@ -1317,10 +1585,12 @@ function CustomMeshEditor({ let previousWrappedAngle = 0 let accumulatedAngle = 0 let latestAngle = 0 + let lastSnapAngle: number | null = null let latestTopology: CustomMeshTopology | null = null let finished = false useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'rotate')) + playCustomMeshSfx('drag-start') useViewer.getState().setInputDragging(true) setDragAxis(axis) document.body.style.cursor = 'grabbing' @@ -1338,16 +1608,17 @@ function CustomMeshEditor({ accumulatedAngle += unwrapRotationDelta(previousWrappedAngle, wrappedAngle) previousWrappedAngle = wrappedAngle let angle = accumulatedAngle - const snapDegrees = Math.abs(Number(rotationSnapAngle)) - if ( - !pointerEvent.altKey && - isAngleSnapActive() && - Number.isFinite(snapDegrees) && - snapDegrees > 0 - ) { - const step = (snapDegrees * Math.PI) / 180 + const snapping = !pointerEvent.altKey && isAngleSnapActive() + if (snapping) { + const step = (ROTATION_SNAP_ANGLE_DEGREES * Math.PI) / 180 angle = Math.round(angle / step) * step } + if (snapping && Math.abs(angle) > 1e-6 && angle !== lastSnapAngle) { + lastSnapAngle = angle + playCustomMeshSfx('rotate-step') + } else if (!snapping) { + lastSnapAngle = null + } const result = applyCustomMeshCommand(baseTopology, { type: 'rotate-components', selection: baseSelection, @@ -1382,7 +1653,112 @@ function CustomMeshEditor({ setDragAxis(null) if (commit && latestTopology && Math.abs(latestAngle) > 1e-6) { useScene.getState().updateNode(node.id, { topology: latestTopology }) - triggerSFX('sfx:item-pick') + playCustomMeshSfx('finish') + } else if (!commit) { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [displayTopology, makeRay, node.id, ownsEditSession, selectedIds.length, selection, target], + ) + + const beginScaleDrag = useCallback( + (axis: Axis, 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 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 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: CustomMeshTopology | null = null + let finished = false + + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) + playCustomMeshSfx('drag-start') + useViewer.getState().setInputDragging(true) + setDragAxis(axis) + document.body.style.cursor = 'grabbing' + + const onMove = (pointerEvent: PointerEvent) => { + const parameter = closestAxisParameterToRay( + worldOrigin, + worldAxis, + makeRay(pointerEvent.clientX, pointerEvent.clientY), + ) + const worldPoint = worldOrigin + .clone() + .addScaledVector(worldAxis, parameter - initialParameter) + const localPoint = target.worldToLocal(worldPoint) + const distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + const snapStep = !pointerEvent.altKey && isGridSnapActive() ? 0.1 : 0 + const factor = customMeshScaleFactorFromDrag(distance, gizmoLength, snapStep) + if (snapStep > 0 && Math.abs(factor - 1) > 1e-6 && factor !== lastSnapFactor) { + lastSnapFactor = factor + playCustomMeshSfx('resize-step') + } else if (snapStep === 0) { + lastSnapFactor = null + } + const result = applyCustomMeshCommand(baseTopology, { + type: 'scale-components', + selection: baseSelection, + pivot: origin, + factors: customMeshScaleFactors(axis, factor), + }) + if (!result.ok) { + setError(result.error) + return + } + latestFactor = factor + 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) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + setDragAxis(null) + if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + playCustomMeshSfx('finish') + } else if (!commit) { + playCustomMeshSfx('cancel') } if (ownsEditSession()) { useInteractionScope.getState().begin(meshEditScope(node.id)) @@ -1399,16 +1775,132 @@ function CustomMeshEditor({ }, [ displayTopology, + gizmoLength, makeRay, node.id, ownsEditSession, - rotationSnapAngle, selectedIds.length, selection, target, ], ) + const beginBevelDrag = useCallback( + (edgeId: string, event: ThreeEvent) => { + if (event.nativeEvent.button !== 0 || !ownsEditSession() || cancelDragRef.current) return + if (!displayTopology.edges.some((edge) => edge.id === edgeId)) return + const baseTopology = displayTopology + const startClientX = event.nativeEvent.clientX + const startClientY = event.nativeEvent.clientY + const viewportHeight = gl.domElement.clientHeight + const previousInputDragging = useViewer.getState().inputDragging + const previousCursor = document.body.style.cursor + let activeSegments = bevelSegments + let latestWidth = 0 + let lastWidthStep = 0 + let latestTopology: CustomMeshTopology | null = null + let latestSelection: CustomMeshSelection | null = null + let finished = false + + setMode('edge') + setSelectedIds([edgeId]) + setActiveId(edgeId) + setToolbarPanel(null) + setError(null) + useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'bevel')) + playCustomMeshSfx('operation-start') + useViewer.getState().setInputDragging(true) + document.body.style.cursor = 'ew-resize' + + const updatePreview = (width: number, segments = activeSegments) => { + if (width <= 1e-6) return false + const result = applyCustomMeshCommand(baseTopology, { + type: 'bevel-edge', + edgeId, + width, + segments, + profile: 0.5, + clampOverlap: true, + }) + if (!result.ok) { + setError(result.error) + return false + } + activeSegments = segments + latestWidth = width + latestTopology = result.topology + latestSelection = result.selection + setPreviewTopology(result.topology) + useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) + useScene.getState().markDirty(node.id) + setError(null) + return true + } + + const onMove = (pointerEvent: PointerEvent) => { + const deltaX = pointerEvent.clientX - startClientX + const deltaY = pointerEvent.clientY - startClientY + if (Math.hypot(deltaX, deltaY) < 2) return + const width = customMeshBevelWidthFromDrag(deltaX, deltaY, extent, viewportHeight) + const widthStep = Math.floor(width / Math.max(0.01, extent * 0.025)) + if (widthStep > 0 && widthStep !== lastWidthStep) { + lastWidthStep = widthStep + playCustomMeshSfx('resize-step') + } + updatePreview(width, activeSegments) + } + + const onWheel = (wheelEvent: WheelEvent) => { + const direction = consumeCustomMeshGestureWheel(wheelEvent) + if (direction === 0) return + const segments = Math.min(12, Math.max(1, activeSegments + direction)) + if (segments === activeSegments) return + activeSegments = segments + setBevelSegments(segments) + playCustomMeshSfx('resize-step') + if (latestWidth > 0) updatePreview(latestWidth, segments) + } + + const finish = (commit: boolean) => { + if (finished) return + finished = true + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onPointerUp) + window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.removeEventListener('pointercancel', onPointerCancel) + window.removeEventListener('blur', onPointerCancel) + cancelDragRef.current = null + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(node.id) + useViewer.getState().setInputDragging(previousInputDragging) + document.body.style.cursor = previousCursor + setPreviewTopology(null) + if (commit && latestTopology && latestSelection && latestWidth > 1e-6) { + useScene.getState().updateNode(node.id, { topology: latestTopology }) + setMode(latestSelection.mode) + setSelectedIds(latestSelection.ids) + setActiveId(latestSelection.ids.at(-1) ?? null) + playCustomMeshSfx('operation-commit') + } else if (!commit) { + playCustomMeshSfx('cancel') + } + if (ownsEditSession()) { + useInteractionScope.getState().begin(meshEditScope(node.id)) + } + swallowNextClick() + } + const onPointerUp = () => finish(true) + const onPointerCancel = () => finish(false) + cancelDragRef.current = onPointerCancel + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onPointerUp, { once: true }) + window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.addEventListener('pointercancel', onPointerCancel, { once: true }) + window.addEventListener('blur', onPointerCancel, { once: true }) + }, + [bevelSegments, displayTopology, extent, gl.domElement, node.id, ownsEditSession], + ) + const previewLoopCut = useCallback( (edgeId: string | null) => { if (cancelDragRef.current) return @@ -1447,6 +1939,7 @@ function CustomMeshEditor({ let latestSelection: CustomMeshSelection | null = null let latestFactor = 0.5 let activeCuts = loopCutCount + let lastSnapFactor: number | null = null let firstStageConfirmed = false let finished = false @@ -1478,6 +1971,7 @@ function CustomMeshEditor({ if (!updatePreview(0.5)) return useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'loop-cut')) + playCustomMeshSfx('operation-start') useViewer.getState().setInputDragging(true) document.body.style.cursor = 'ew-resize' @@ -1492,7 +1986,8 @@ function CustomMeshEditor({ 0.98, Math.max(0.02, 0.5 + (parameter - initialParameter) / worldLength), ) - if (isGridSnapActive() && !pointerEvent.altKey) { + const snapping = isGridSnapActive() && !pointerEvent.altKey + if (snapping) { const step = useEditor.getState().gridSnapStep if (step > 0) factor = Math.min( @@ -1500,12 +1995,20 @@ function CustomMeshEditor({ Math.max(0.02, (Math.round((factor * worldLength) / step) * step) / worldLength), ) } + if (snapping && factor !== lastSnapFactor) { + lastSnapFactor = factor + playCustomMeshSfx('move-step') + } else if (!snapping) { + lastSnapFactor = null + } updatePreview(factor) } const onWheel = (wheelEvent: WheelEvent) => { - wheelEvent.preventDefault() - const cuts = Math.min(32, Math.max(1, activeCuts + (wheelEvent.deltaY < 0 ? 1 : -1))) + const direction = consumeCustomMeshGestureWheel(wheelEvent) + if (direction === 0) return + const cuts = Math.min(32, Math.max(1, activeCuts + direction)) + if (cuts !== activeCuts) playCustomMeshSfx('resize-step') updatePreview(latestFactor, cuts) } @@ -1515,7 +2018,7 @@ function CustomMeshEditor({ window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onFirstPointerUp) window.removeEventListener('pointerdown', onSecondPointerDown, true) - window.removeEventListener('wheel', onWheel) + window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null @@ -1530,7 +2033,9 @@ function CustomMeshEditor({ setMode(latestSelection.mode) setSelectedIds(latestSelection.ids) setActiveId(latestSelection.ids.at(-1) ?? null) - triggerSFX('sfx:item-pick') + playCustomMeshSfx('operation-commit') + } else if (!commit) { + playCustomMeshSfx('cancel') } if (ownsEditSession()) { useInteractionScope.getState().begin(meshEditScope(node.id)) @@ -1552,7 +2057,7 @@ function CustomMeshEditor({ window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onFirstPointerUp, { once: true }) window.addEventListener('pointerdown', onSecondPointerDown, true) - window.addEventListener('wheel', onWheel, { passive: false }) + window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) }, @@ -1570,6 +2075,7 @@ function CustomMeshEditor({ } const draft = { topology: result.topology, selection: result.selection, operator } setModalDraft(draft) + setToolbarPanel(null) setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) useScene.getState().markDirty(node.id) @@ -1578,6 +2084,7 @@ function CustomMeshEditor({ const extrudeSelectedFace = () => { if (mode !== 'face' || selectedIds.length !== 1) return + playCustomMeshSfx('operation-start') previewCommand( { type: 'extrude-face', faceId: selectedIds[0]!, distance: Number(extrudeDistance) }, 'extrude', @@ -1586,6 +2093,7 @@ function CustomMeshEditor({ const insetSelectedFace = () => { if (mode !== 'face' || selectedIds.length !== 1) return + playCustomMeshSfx('operation-start') previewCommand( { type: 'inset-face', @@ -1597,55 +2105,30 @@ function CustomMeshEditor({ ) } - const scaleSelection = () => { - if (!gizmoOrigin) return - const factor = Number(scaleFactor) - previewCommand( - { - type: 'scale-components', - selection, - pivot: gizmoOrigin, - factors: [factor, factor, factor], - }, - 'scale', - ) - } - const deleteSelection = () => { if (selectedIds.length === 0) return + playCustomMeshSfx('tool-select') previewCommand({ type: 'delete-components', selection }, 'delete') } const mergeSelection = () => { if (mode !== 'vertex' || selectedIds.length < 2) return + playCustomMeshSfx('operation-start') previewCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') } const dissolveSelection = () => { if (mode !== 'edge' || selectedIds.length !== 1) return + playCustomMeshSfx('operation-start') previewCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') } - const bevelSelection = () => { - if (mode !== 'edge' || selectedIds.length !== 1) return - previewCommand( - { - type: 'bevel-edge', - edgeId: selectedIds[0]!, - width: Number(bevelWidth), - segments: Number(bevelSegments), - profile: Number(bevelProfile), - clampOverlap: bevelClamp, - }, - 'bevel', - ) - } - const updateSelection = (next: CustomMeshSelectionState) => { setMode(next.mode) setSelectedIds(next.ids) setActiveId(next.activeId) setError(null) + playCustomMeshSfx('component-select') } const selectAll = () => @@ -1660,7 +2143,7 @@ function CustomMeshEditor({ updateSelection(clearCustomMeshSelection({ mode, ids: selectedIds, activeId })) const keyboardActionsRef = useRef({ - bevelSelection, + canBevel: mode === 'edge', clearSelection, deleteSelection, dissolveSelection, @@ -1669,11 +2152,10 @@ function CustomMeshEditor({ insetSelectedFace, invertSelection, mergeSelection, - scaleSelection, selectAll, }) keyboardActionsRef.current = { - bevelSelection, + canBevel: mode === 'edge', clearSelection, deleteSelection, dissolveSelection, @@ -1682,7 +2164,6 @@ function CustomMeshEditor({ insetSelectedFace, invertSelection, mergeSelection, - scaleSelection, selectAll, } @@ -1701,23 +2182,40 @@ function CustomMeshEditor({ const actions = keyboardActionsRef.current let handled = true if (key === 'b' && (event.ctrlKey || event.metaKey)) { - actions.bevelSelection() + if (actions.canBevel) { + playCustomMeshSfx('tool-select') + setBevelSegments(DEFAULT_BEVEL_SEGMENTS) + setTransformTool('bevel') + setToolbarPanel(null) + } } else if (key === 'a') { if (event.altKey) actions.clearSelection() else actions.selectAll() } else if (key === 'i' && (event.ctrlKey || event.metaKey)) { actions.invertSelection() } else if (key === 'g') { - if (actions.hasSelection) setTransformTool('move') + if (actions.hasSelection) { + playCustomMeshSfx('tool-select') + setTransformTool('move') + } } else if (key === 'e') { actions.extrudeSelectedFace() } else if (key === 'i') { actions.insetSelectedFace() } else if (key === 'r') { - if (event.ctrlKey || event.metaKey) setTransformTool('loop-cut') - else if (actions.hasSelection) setTransformTool('rotate') + if (event.ctrlKey || event.metaKey) { + playCustomMeshSfx('tool-select') + setTransformTool('loop-cut') + setToolbarPanel(null) + } else if (actions.hasSelection) { + playCustomMeshSfx('tool-select') + setTransformTool('rotate') + } } else if (key === 's') { - actions.scaleSelection() + if (actions.hasSelection) { + playCustomMeshSfx('tool-select') + setTransformTool('scale') + } } else if (key === 'm') { actions.mergeSelection() } else if (key === 'd') { @@ -1735,29 +2233,45 @@ function CustomMeshEditor({ return () => window.removeEventListener('keydown', onKeyDown, true) }, [editing]) - const moveNode = () => { + const moveNode = (event: ReactMouseEvent) => { + event.stopPropagation() useEditor.getState().setMovingNode(node as never) useViewer.getState().setSelection({ selectedIds: [] }) triggerSFX('sfx:item-pick') } - const deleteNode = () => { + const deleteNode = (event: ReactMouseEvent) => { + event.stopPropagation() useViewer.getState().setSelection({ selectedIds: [] }) useScene.getState().deleteNode(node.id) + playCustomMeshSfx('delete') } const componentLabel = selectedIds.length === 1 ? mode : mode === 'vertex' ? 'vertices' : `${mode}s` + const selectionStatus = formatCustomMeshSelectionStatus(mode, selectedIds.length) + const operationAvailability = customMeshOperationAvailability(mode, selectedIds.length) + const loopCutActive = transformTool === 'loop-cut' + const bevelActive = transformTool === 'bevel' const componentStatus = modalDraft ? `${modalDraft.operator} preview · Enter to confirm · Esc to cancel` : transformTool === 'loop-cut' ? `Loop Cut · ${loopCutCount} cut${loopCutCount === 1 ? '' : 's'} · factor ${loopCutFactor.toFixed(2)} · first click chooses ring, second click confirms · wheel changes count` - : selectedIds.length === 0 - ? `Click a ${mode} to select it` - : transformTool === 'move' - ? `${selectedIds.length} ${componentLabel} selected · drag an axis to move · Alt for free movement` - : transformTool === 'rotate' - ? `${selectedIds.length} ${componentLabel} selected · drag a rotation ring · Alt for free rotation` - : `${selectedIds.length} ${componentLabel} selected · choose a transform or mesh operator` + : transformTool === 'bevel' + ? `Bevel · drag an edge to peel it · wheel changes segments (${bevelSegments}) · release to apply` + : selectedIds.length === 0 + ? `Click a ${mode} to select it` + : transformTool === 'move' + ? `${selectedIds.length} ${componentLabel} selected · drag an axis to move · Alt for free movement` + : transformTool === 'rotate' + ? `${selectedIds.length} ${componentLabel} selected · drag a rotation ring · Alt for free rotation` + : transformTool === 'scale' + ? `${selectedIds.length} ${componentLabel} selected · drag a colored handle to scale · Alt for free scaling` + : `${selectedIds.length} ${componentLabel} selected · choose a transform or mesh operator` + const showComponentStatus = + Boolean(error || modalDraft || dragAxis) || + transformTool === 'loop-cut' || + transformTool === 'bevel' || + selectedIds.length === 0 return ( @@ -1787,6 +2301,7 @@ function CustomMeshEditor({ end={end} id={edge.id} key={edge.id} + onPointerDown={transformTool === 'bevel' ? beginBevelDrag : undefined} onSelect={selectComponent} radius={componentRadius * 0.42} selected={selectedSet.has(edge.id)} @@ -1853,6 +2368,21 @@ function CustomMeshEditor({ ))} ) : null} + {gizmoOrigin && transformTool === 'scale' ? ( + + {(['x', 'y', 'z'] as const).map((axis) => ( + + ))} + + ) : null} {transformTool === 'loop-cut' ? displayTopology.edges.map((edge) => { const start = vertexById.get(edge.vertexIds[0]) @@ -1885,70 +2415,15 @@ function CustomMeshEditor({ onContextMenu={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} + ref={menuScaleRef} + style={{ transformOrigin: 'center center' }} > {editing ? ( -
-
- - Edit mode - - - switchMode('vertex')} - > - - - switchMode('edge')} - > - - - switchMode('face')} - > - - - - - All - - - Invert - - - Clear - - setXray((value) => !value)} - > - {xray ? : } - - {modalDraft ? ( - <> - - - - - - - - ) : null} - - - - -
-
+
+
setTransformTool('select')} > @@ -1956,178 +2431,299 @@ function CustomMeshEditor({ setTransformTool('move')} > - - setRotationSnapAngle(event.target.value)} - step="5" - type="number" - value={rotationSnapAngle} - /> setTransformTool('rotate')} > setTransformTool('loop-cut')} - > - - - - setLoopCutCount(Math.min(32, Math.max(1, Number(event.target.value) || 1))) - } - step="1" - type="number" - value={loopCutCount} - /> - setScaleFactor(event.target.value)} - step="0.1" - type="number" - value={scaleFactor} - /> - setTransformTool('scale')} > - - setExtrudeDistance(event.target.value)} - step="0.05" - type="number" - value={extrudeDistance} - /> - - - - setInsetAmount(event.target.value)} - step="0.05" - type="number" - value={insetAmount} - /> - - Inset - +
+ +
switchMode('vertex')} > - Merge + switchMode('edge')} > - Dissolve + - setBevelWidth(event.target.value)} - step="0.02" - type="number" - value={bevelWidth} - /> - setBevelSegments(event.target.value)} - step="1" - type="number" - value={bevelSegments} - /> - setBevelProfile(event.target.value)} - step="0.1" - type="number" - value={bevelProfile} - /> setBevelClamp((value) => !value)} + active={mode === 'face'} + disabled={Boolean(modalDraft || cancelDragRef.current)} + label="Face select (3)" + onClick={() => switchMode('face')} > - Clamp + - + + + {selectionStatus} + + +
+ + {toolbarPanel === 'operations' ? ( + +
+ 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} + shortcut="E" + > + + + 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} + shortcut="I" + > + + + + setLoopCutCount( + Math.min(32, Math.max(1, Number(event.target.value) || 1)), + ) + } + step="1" + type="number" + value={loopCutCount} + /> + } + label="Loop Cut and Slide" + onClick={() => { + playCustomMeshSfx('tool-select') + setTransformTool('loop-cut') + setToolbarPanel(null) + }} + shortcut="Ctrl+R" + > + + + + + + + + + { + playCustomMeshSfx('tool-select') + setBevelSegments(DEFAULT_BEVEL_SEGMENTS) + setTransformTool('bevel') + setToolbarPanel(null) + }} + shortcut="Ctrl+B" + > + + +
+
+ ) : null} +
+ + + {modalDraft ? ( + <> + + + + + + + + ) : ( + + + )} + +
+ setToolbarPanel((current) => (current === 'selection' ? null : 'selection')) + } > - + + {toolbarPanel === 'selection' ? ( + +
+ + + + + + + + + + setXray((value) => !value)} + > + {xray ? : } + +
+ + + +
+ + ) : null}
) : ( -
- - - - - - - - - -
+ )} - {editing ? ( + {editing && showComponentStatus ? (
{ + test('enables face operations only for one selected face', () => { + expect(customMeshOperationAvailability('face', 1)).toEqual({ + extrude: true, + inset: true, + merge: false, + dissolve: false, + bevel: false, + }) + expect(customMeshOperationAvailability('face', 2).extrude).toBe(false) + }) + + test('enables component-specific vertex and edge operations', () => { + expect(customMeshOperationAvailability('vertex', 2).merge).toBe(true) + expect(customMeshOperationAvailability('vertex', 1).merge).toBe(false) + expect(customMeshOperationAvailability('edge', 1)).toMatchObject({ + dissolve: true, + bevel: true, + }) + expect(customMeshOperationAvailability('edge', 0).bevel).toBe(true) + }) + + test('formats compact singular and plural selection labels', () => { + expect(formatCustomMeshSelectionStatus('face', 1)).toBe('1 FACE') + expect(formatCustomMeshSelectionStatus('edge', 2)).toBe('2 EDGES') + expect(formatCustomMeshSelectionStatus('vertex', 3)).toBe('3 VERTICES') + }) + + test('builds uniform and axis-specific scale factors', () => { + expect(customMeshScaleFactors('uniform', 1.5)).toEqual([1.5, 1.5, 1.5]) + expect(customMeshScaleFactors('x', 1.5)).toEqual([1.5, 1, 1]) + expect(customMeshScaleFactors('y', 0.5)).toEqual([1, 0.5, 1]) + expect(customMeshScaleFactors('z', 2)).toEqual([1, 1, 2]) + }) + + test('converts scale-handle movement into a positive snapped factor', () => { + expect(customMeshScaleFactorFromDrag(0.5, 1)).toBe(1.5) + expect(customMeshScaleFactorFromDrag(0.46, 1, 0.1)).toBe(1.5) + expect(customMeshScaleFactorFromDrag(-5, 1)).toBe(0.01) + }) + + test('maps bevel pointer travel into topology-relative width', () => { + expect(customMeshBevelWidthFromDrag(60, 80, 2, 1000)).toBeCloseTo(0.2) + expect(customMeshBevelWidthFromDrag(0, 0, 2, 1000)).toBe(0) + expect(customMeshBevelWidthFromDrag(10, 0, 2, 0)).toBe(20) + }) +}) diff --git a/packages/nodes/src/custom-mesh/toolbar-state.ts b/packages/nodes/src/custom-mesh/toolbar-state.ts new file mode 100644 index 0000000000..eae20cd33c --- /dev/null +++ b/packages/nodes/src/custom-mesh/toolbar-state.ts @@ -0,0 +1,63 @@ +export type CustomMeshToolbarMode = 'vertex' | 'edge' | 'face' +export type CustomMeshScaleAxis = 'uniform' | 'x' | 'y' | 'z' + +export type CustomMeshOperationAvailability = { + extrude: boolean + inset: boolean + merge: boolean + dissolve: boolean + bevel: boolean +} + +export function customMeshOperationAvailability( + mode: CustomMeshToolbarMode, + selectedCount: number, +): CustomMeshOperationAvailability { + return { + extrude: mode === 'face' && selectedCount === 1, + inset: mode === 'face' && selectedCount === 1, + merge: mode === 'vertex' && selectedCount >= 2, + dissolve: mode === 'edge' && selectedCount === 1, + bevel: mode === 'edge', + } +} + +export function formatCustomMeshSelectionStatus( + mode: CustomMeshToolbarMode, + selectedCount: number, +): string { + const label = selectedCount === 1 ? mode : mode === 'vertex' ? 'vertices' : `${mode}s` + return `${selectedCount} ${label}`.toUpperCase() +} + +export function customMeshScaleFactors( + axis: CustomMeshScaleAxis, + factor: number, +): [number, number, number] { + if (axis === 'uniform') return [factor, factor, factor] + return [axis === 'x' ? factor : 1, axis === 'y' ? factor : 1, axis === 'z' ? factor : 1] +} + +export function customMeshScaleFactorFromDrag( + distance: number, + handleLength: number, + snapStep = 0, +): number { + const safeLength = Math.max(Math.abs(handleLength), 1e-6) + let factor = 1 + distance / safeLength + if (Number.isFinite(snapStep) && snapStep > 0) { + factor = Math.round(factor / snapStep) * snapStep + } + return Math.max(0.01, factor) +} + +export function customMeshBevelWidthFromDrag( + deltaX: number, + deltaY: number, + topologyExtent: number, + viewportHeight: number, +): number { + const safeExtent = Math.max(Math.abs(topologyExtent), 0.001) + const safeViewportHeight = Math.max(Math.abs(viewportHeight), 1) + return (Math.hypot(deltaX, deltaY) * safeExtent) / safeViewportHeight +} From 5a46f6ae4ffd2995ef282b4ac3fe46e9f2720caa Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 12 Aug 2026 12:46:58 +0530 Subject: [PATCH 06/10] fix: support elevated placement and refine mesh editing --- .../hooks/spatial-grid/support-host-patch.ts | 121 ++- .../hooks/spatial-grid/support-host.test.ts | 55 ++ packages/core/src/index.ts | 2 + .../core/src/schema/nodes/custom-mesh.test.ts | 9 + packages/core/src/schema/nodes/custom-mesh.ts | 2 + .../tools/fence/fence-drafting.test.ts | 100 +++ .../components/tools/fence/fence-drafting.ts | 51 +- .../components/tools/item/use-draft-node.ts | 20 +- .../tools/item/use-placement-coordinator.tsx | 151 ++-- .../registry/move-registry-node-tool.tsx | 40 +- .../tools/shared/pointer-support-cap.test.ts | 136 ++++ .../tools/shared/pointer-support-cap.ts | 10 +- .../src/components/tools/stair/stair-tool.tsx | 76 +- .../tools/wall/wall-drafting.test.ts | 175 ++++ .../components/tools/wall/wall-drafting.ts | 114 +-- .../sidebar/panels/site-panel/tree-node.tsx | 1 + .../editor/src/hooks/use-keyboard.test.ts | 64 ++ packages/editor/src/hooks/use-keyboard.ts | 15 +- packages/nodes/src/column/tool.tsx | 68 +- .../nodes/src/custom-mesh/definition.test.ts | 54 ++ packages/nodes/src/custom-mesh/definition.ts | 17 +- .../nodes/src/custom-mesh/geometry.test.ts | 56 ++ packages/nodes/src/custom-mesh/geometry.ts | 19 +- packages/nodes/src/custom-mesh/paint.ts | 7 + packages/nodes/src/custom-mesh/parametrics.ts | 11 + packages/nodes/src/custom-mesh/selection.tsx | 754 +++++++++--------- packages/nodes/src/custom-mesh/slots.ts | 7 + packages/nodes/src/custom-mesh/tool.tsx | 9 +- .../src/custom-mesh/toolbar-state.test.ts | 14 + .../nodes/src/custom-mesh/toolbar-state.ts | 25 + packages/nodes/src/fence/tool.tsx | 75 +- packages/nodes/src/shared/floor-placement.ts | 1 + packages/nodes/src/wall/tool.tsx | 5 + wiki/architecture/vertical-model.md | 4 +- 34 files changed, 1683 insertions(+), 585 deletions(-) create mode 100644 packages/editor/src/components/tools/fence/fence-drafting.test.ts create mode 100644 packages/editor/src/hooks/use-keyboard.test.ts create mode 100644 packages/nodes/src/custom-mesh/paint.ts create mode 100644 packages/nodes/src/custom-mesh/parametrics.ts create mode 100644 packages/nodes/src/custom-mesh/slots.ts diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index 7a4f432590..f0ca5acc11 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -1,7 +1,11 @@ import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema' import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' -import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation' +import { + GROUND_SUPPORT_ID, + getFloorPlacedElevation, + getFloorPlacedFootprints, +} from './floor-placed-elevation' import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager' export type SupportSlabPatch = { supportSlabId: string | undefined } @@ -17,6 +21,18 @@ export type SupportSlabPatchOptions = { maxElevation?: number | null /** Pointer- or snap-decided host. Ground is a first-class support source. */ preferredSlabId?: string | null + /** Persist even an unambiguous host so later overlapping slabs cannot re-elect it. */ + pinSupport?: boolean +} + +export type FrozenFloorPlacementOptions = { + /** Canonical position before floor/support lift is applied. */ + position: [number, number, number] + rotation?: unknown + /** Exact level-local elevation hit on a non-slab construction surface. */ + elevation: number + /** Slab/ground supporting that construction surface, when known. */ + preferredSlabId?: string | null } export function resolveSupportSlabPatch( @@ -35,6 +51,30 @@ export function resolveSupportSlabPatch( const maxElevation = options?.maxElevation const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes }) + + if (options?.preferredSlabId === GROUND_SUPPORT_ID) { + return { supportSlabId: GROUND_SUPPORT_ID } + } + if (options?.preferredSlabId) { + for (const footprint of footprints) { + const position = footprint.position ?? (node as { position?: unknown }).position + if (!Array.isArray(position) || position.length !== 3) continue + const elevation = spatialGridManager.getHostSlabElevationForFootprint( + parent.id, + options.preferredSlabId, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + ) + if ( + elevation !== null && + (maxElevation == null || elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON) + ) { + return { supportSlabId: options.preferredSlabId } + } + } + } + const candidateElevations = new Set() let winner: { slabId: string; elevation: number } | null = null let cappedOut = false @@ -66,12 +106,61 @@ export function resolveSupportSlabPatch( } if (winner !== null) { - return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined } + return { + supportSlabId: + options?.pinSupport || candidateElevations.size >= 2 ? winner.slabId : undefined, + } } // Capped election chose the ground while overlapping slabs sit above the // cap: persist the ground host, or the uncapped per-frame election would // lift the committed node back onto the deck. - return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined } + return { + supportSlabId: options?.pinSupport || cappedOut ? GROUND_SUPPORT_ID : undefined, + } +} + +/** + * Freeze an exact pointed node-top elevation into a floor-placed node's + * existing canonical Y offset, while pinning the slab/ground beneath that + * surface. This deliberately does not create a live hosting edge to the + * pointed node: arbitrary mesh faces can be edited or sloped, so placement + * captures the plane the user chose at commit time. + */ +export function resolveFrozenFloorPlacementPatch( + node: AnyNode, + nodes: Record, + options: FrozenFloorPlacementOptions, +): SupportSlabPatch & { position: [number, number, number] } { + const effectiveNode = { + ...(node as Record), + position: options.position, + ...(options.rotation !== undefined ? { rotation: options.rotation } : {}), + } as AnyNode + const floorPlaced = nodeRegistry.get(effectiveNode.type)?.capabilities?.floorPlaced + if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(effectiveNode))) { + return { supportSlabId: undefined, position: options.position } + } + const supportPatch = resolveSupportSlabPatch(effectiveNode, nodes, { + maxElevation: options.elevation, + preferredSlabId: options.preferredSlabId, + pinSupport: true, + }) + const pinnedNode = { ...effectiveNode, ...supportPatch } as AnyNode + const supportElevation = getFloorPlacedElevation({ + node: pinnedNode, + nodes, + position: options.position, + rotation: options.rotation, + }) + + return { + ...supportPatch, + position: [ + options.position[0], + options.position[1] + options.elevation - supportElevation, + options.position[2], + ], + } } export function resolveWallSupportSlabPatch( @@ -212,6 +301,10 @@ export function resolveFenceSupportSlabPatch( const candidateElevations = new Set() let winner: { slabId: string; elevation: number } | null = null + if (options?.preferredSlabId === GROUND_SUPPORT_ID) { + return { supportSlabId: GROUND_SUPPORT_ID } + } + for (let i = 1; i < points.length; i++) { const [ax, az] = points[i - 1]! const [bx, bz] = points[i]! @@ -223,6 +316,22 @@ export function resolveFenceSupportSlabPatch( // (cos yRot, sin yRot) in XZ, so the segment angle aligns the band. const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0] + if (options?.preferredSlabId) { + const preferredElevation = spatialGridManager.getHostSlabElevationForFootprint( + parent.id, + options.preferredSlabId, + position, + dimensions, + rotation, + ) + if ( + preferredElevation !== null && + (maxElevation == null || preferredElevation <= maxElevation + SUPPORT_ELEVATION_EPSILON) + ) { + return { supportSlabId: options.preferredSlabId } + } + } + const candidates = spatialGridManager.getSupportCandidatesForFootprint( parent.id, position, @@ -243,7 +352,9 @@ export function resolveFenceSupportSlabPatch( } } - if (winner === null) return { supportSlabId: undefined } + if (winner === null) { + return { supportSlabId: options?.pinSupport ? GROUND_SUPPORT_ID : undefined } + } const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON - return { supportSlabId: persist ? winner.slabId : undefined } + return { supportSlabId: options?.pinSupport || persist ? winner.slabId : undefined } } diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts index 0138ed7150..0e3580c7ad 100644 --- a/packages/core/src/hooks/spatial-grid/support-host.test.ts +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -12,6 +12,7 @@ import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-eleva import { getWallBaseElevationForNodes, spatialGridManager } from './spatial-grid-manager' import { initSpatialGridSync } from './spatial-grid-sync' import { + resolveFrozenFloorPlacementPatch, resolveMovedWallSupportSlabPatch, resolveSupportSlabPatch, resolveWallSupportSlabPatch, @@ -271,6 +272,60 @@ describe('persisted support hosts (items)', () => { }) }) + test('a pinned placement is not lifted by a slab generated above it later', () => { + registerFloorPlacedItem() + + const level = makeLevel() + const node = makeFloorNode() + const supportPatch = resolveSupportSlabPatch(node, nodesFor(level, node), { + pinSupport: true, + }) + expect(supportPatch).toEqual({ supportSlabId: GROUND_SUPPORT_ID }) + + const pinnedNode = makeFloorNode(supportPatch as Partial) + addSlab(makeSlab('slab_generated', SQUARE, 2.45, { autoFromWalls: true })) + + expect( + getFloorPlacedElevation({ + node: pinnedNode, + nodes: nodesFor(level, pinnedNode), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) + + test('a frozen node-top placement keeps its exact height when a slab appears later', () => { + registerFloorPlacedItem() + const low = makeSlab('slab_low', SQUARE, 0.25) + addSlab(low) + + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node, low as AnyNode) + const patch = resolveFrozenFloorPlacementPatch(node, nodes, { + position: [0, 0, 0], + rotation: [0, 0, 0], + elevation: 2, + preferredSlabId: low.id, + }) + + expect(patch).toEqual({ supportSlabId: low.id, position: [0, 1.75, 0] }) + + const placed = makeFloorNode(patch as Partial) + const generated = makeSlab('slab_generated', SQUARE, 1.5, { autoFromWalls: true }) + addSlab(generated) + expect( + placed.position[1] + + getFloorPlacedElevation({ + node: placed, + nodes: nodesFor(level, placed, low as AnyNode, generated as AnyNode), + position: placed.position, + rotation: placed.rotation, + }), + ).toBeCloseTo(2) + }) + test('item support follows the RENDERED slab polygon (wall band adoption)', () => { registerFloorPlacedItem() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72014e5b9b..34ce64c245 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,7 +72,9 @@ export { } from './hooks/spatial-grid/spatial-grid-sync' export { type FenceSupportInput, + type FrozenFloorPlacementOptions, resolveFenceSupportSlabPatch, + resolveFrozenFloorPlacementPatch, resolveMovedWallSupportSlabPatch, resolveSupportSlabPatch, resolveWallSupportSlabPatch, diff --git a/packages/core/src/schema/nodes/custom-mesh.test.ts b/packages/core/src/schema/nodes/custom-mesh.test.ts index 7aa4175850..7d2b3715b1 100644 --- a/packages/core/src/schema/nodes/custom-mesh.test.ts +++ b/packages/core/src/schema/nodes/custom-mesh.test.ts @@ -16,6 +16,15 @@ describe('CustomMeshNode', () => { expect(inspectCustomMeshTopology(node.topology)).toEqual([]) }) + test('retains its pinned placement support', () => { + const node = CustomMeshNode.parse({ + name: 'Supported platform', + supportSlabId: 'ground', + }) + + expect(node.supportSlabId).toBe('ground') + }) + test('rejects a face loop without a persisted boundary edge', () => { const topology = createBoxCustomMeshTopology() topology.edges = topology.edges.filter((edge) => edge.id !== 'e4') diff --git a/packages/core/src/schema/nodes/custom-mesh.ts b/packages/core/src/schema/nodes/custom-mesh.ts index b662bb1c2a..82345c2546 100644 --- a/packages/core/src/schema/nodes/custom-mesh.ts +++ b/packages/core/src/schema/nodes/custom-mesh.ts @@ -156,12 +156,14 @@ export const CustomMeshNode = BaseNode.extend({ type: nodeType('custom-mesh'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + supportSlabId: z.string().optional(), topology: CustomMeshTopology.default(createBoxCustomMeshTopology), slots: z.record(z.string(), z.string()).optional(), }).describe(dedent` Custom mesh node - a topology-backed editable solid. - topology: persistent vertices, edges, and ordered face loops with stable IDs - position/rotation: level-local placement transform + - supportSlabId: persisted placement surface that prevents later slabs from lifting the mesh - slots: optional material references keyed by face materialSlot `) diff --git a/packages/editor/src/components/tools/fence/fence-drafting.test.ts b/packages/editor/src/components/tools/fence/fence-drafting.test.ts new file mode 100644 index 0000000000..ec1a81288b --- /dev/null +++ b/packages/editor/src/components/tools/fence/fence-drafting.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + GROUND_SUPPORT_ID, + type SlabNode, + spatialGridManager, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import useEditor from '../../../store/use-editor' +import { createFenceOnCurrentLevel } from './fence-drafting' + +const LEVEL_ID = 'level_test' as AnyNodeId + +function seedLevel(extraNodes: AnyNode[] = []) { + useScene.setState({ + nodes: Object.fromEntries([ + [ + LEVEL_ID, + { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: extraNodes.map((node) => node.id), + level: 0, + } as AnyNode, + ], + ...extraNodes.map((node) => [node.id, node] as const), + ]), + rootNodeIds: [LEVEL_ID], + dirtyNodes: new Set(), + collections: {}, + } as never) +} + +describe('createFenceOnCurrentLevel', () => { + beforeEach(() => { + spatialGridManager.clear() + useViewer.setState({ + selection: { + buildingId: null, + levelId: LEVEL_ID, + zoneId: null, + selectedIds: [], + }, + } as never) + useEditor.getState().setToolDefaults('fence', null) + seedLevel() + }) + + test('freezes a custom-mesh top above its underlying slab', () => { + const slab = { + id: 'slab_low', + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon: [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ], + holes: [], + holeMetadata: [], + elevation: 0.25, + thickness: 0.25, + recessed: false, + autoFromWalls: false, + } as SlabNode + seedLevel([slab as AnyNode]) + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) + + const fence = createFenceOnCurrentLevel([-1, 0], [1, 0], { + supportCap: 2, + preferredSupportSlabId: slab.id, + constructionElevation: 2, + }) + + expect(fence?.supportSlabId).toBe(slab.id) + expect(fence?.supportOffset).toBeCloseTo(1.75) + }) + + test('pins ground beneath a custom-mesh top when no slab exists', () => { + const fence = createFenceOnCurrentLevel([-1, 0], [1, 0], { + supportCap: 2, + preferredSupportSlabId: null, + constructionElevation: 2, + }) + + expect(fence?.supportSlabId).toBe(GROUND_SUPPORT_ID) + expect(fence?.supportOffset).toBeCloseTo(2) + }) +}) diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index fec5ab6afe..912635af9e 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -1,10 +1,12 @@ import { + type AnyNodeId, DEFAULT_ANGLE_STEP, FenceNode, getTwoPointFenceCurveTangents, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, + levelBaseElevationAt, resolveFenceSupportSlabPatch, snapPointAlongAngleRay, useScene, @@ -198,6 +200,39 @@ export type FenceCommitOptions = { * camera ray): those keep the uncapped max election. */ supportCap?: number | null + /** Slab/ground beneath a pointed non-slab surface. */ + preferredSupportSlabId?: string | null + /** Exact node-top construction plane selected by the pointer. */ + constructionElevation?: number | null +} + +function applyFenceConstructionSupport( + fence: FenceNode, + levelId: string, + nodes: ReturnType['nodes'], + options?: FenceCommitOptions, +): FenceNode { + const supportPatch = resolveFenceSupportSlabPatch({ ...fence, parentId: levelId }, nodes, { + maxElevation: options?.supportCap ?? null, + preferredSlabId: options?.preferredSupportSlabId ?? null, + pinSupport: options?.constructionElevation != null, + }) + const host = supportPatch.supportSlabId ? nodes[supportPatch.supportSlabId as AnyNodeId] : null + const baseElevation = + host?.type === 'slab' + ? host.elevation + : levelBaseElevationAt(nodes, levelId, fence.start[0], fence.start[1]) + const supportOffset = + options?.constructionElevation == null + ? fence.supportOffset + : options.constructionElevation - baseElevation + + return FenceNode.parse({ + ...fence, + ...supportPatch, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + }) } export function createFenceOnCurrentLevel( @@ -217,7 +252,7 @@ export function createFenceOnCurrentLevel( // spacing, …) merge in first; `name`/`start`/`end` always win. The // schema parse validates and drops anything unexpected. const defaults = useEditor.getState().toolDefaults.fence ?? {} - const fence = FenceNode.parse({ + const authoredFence = FenceNode.parse({ ...defaults, name: `Fence ${fenceCount + 1}`, start, @@ -225,11 +260,7 @@ export function createFenceOnCurrentLevel( }) // Fences run no per-frame support election — the persisted host IS the // lift (absent = level floor), so elect it at commit, pointer-capped. - fence.supportSlabId = resolveFenceSupportSlabPatch( - { ...fence, parentId: currentLevelId }, - nodes, - { maxElevation: options?.supportCap ?? null }, - ).supportSlabId + const fence = applyFenceConstructionSupport(authoredFence, currentLevelId, nodes, options) createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') @@ -264,7 +295,7 @@ export function createSplineFenceOnCurrentLevel( const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length const defaults = useEditor.getState().toolDefaults.fence ?? {} - const fence = FenceNode.parse({ + const authoredFence = FenceNode.parse({ ...defaults, name: `Fence ${fenceCount + 1}`, start, @@ -272,11 +303,7 @@ export function createSplineFenceOnCurrentLevel( path, tangents, }) - fence.supportSlabId = resolveFenceSupportSlabPatch( - { ...fence, parentId: currentLevelId }, - nodes, - { maxElevation: options?.supportCap ?? null }, - ).supportSlabId + const fence = applyFenceConstructionSupport(authoredFence, currentLevelId, nodes, options) createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index 297d6ebf74..74d903e7ac 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -46,7 +46,11 @@ export interface DraftNodeHandle { * commit lands on the surface the cursor pointed at. */ commit: ( finalUpdate: Partial, - options?: { supportElevationCap?: number | null }, + options?: { + supportElevationCap?: number | null + preferredSupportSlabId?: string | null + pinSupport?: boolean + }, ) => string | null /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */ destroy: () => void @@ -134,7 +138,11 @@ export function useDraftNode(): DraftNodeHandle { const commit = useCallback( ( finalUpdate: Partial, - options?: { supportElevationCap?: number | null }, + options?: { + supportElevationCap?: number | null + preferredSupportSlabId?: string | null + pinSupport?: boolean + }, ): string | null => { const draft = draftRef.current if (!draft) return null @@ -186,6 +194,8 @@ export function useDraftNode(): DraftNodeHandle { ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, { maxElevation: options?.supportElevationCap, + preferredSlabId: options?.preferredSupportSlabId, + pinSupport: options?.pinSupport, }), }) @@ -236,7 +246,11 @@ export function useDraftNode(): DraftNodeHandle { ...resolveSupportSlabPatch( finalNode, { ...nodes, [finalNode.id]: finalNode }, - { maxElevation: options?.supportElevationCap }, + { + maxElevation: options?.supportElevationCap, + preferredSlabId: options?.preferredSupportSlabId, + pinSupport: options?.pinSupport, + }, ), }) useScene.getState().createNode(committedNode, parentId) 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 048be2771d..f554af52ec 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -11,6 +11,7 @@ import { type ItemEvent, movingFootprintAnchors, type RoofEvent, + resolveFrozenFloorPlacementPatch, resolveLevelId, type ShelfEvent, sceneRegistry, @@ -63,6 +64,7 @@ import { updateLineGeometry, } from '../shared/placement-box-geometry' import { + type PointerSupportSurface, resolvePointerSupportElevation, resolvePointerSupportSurface, } from '../shared/pointer-support-cap' @@ -280,6 +282,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // by the MAX overlapping slab and a deck above the aimed-at floor // captures the item (and the grid-plane feedback makes it blink). const pointerSupportCapRef = useRef(null) + const pointerSupportSurfaceRef = useRef(null) + const frozenSupportSlabIdRef = useRef(undefined) const [dimensionBounds, setDimensionBounds] = useState(null) // Live camera ref — the shelf-stickiness test reconstructs the cursor world @@ -427,12 +431,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ): [number, number, number] => { const draft = draftNode.current if (!(draft && !asset?.attachTo)) return position - const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode) + const previewNode = getGridAlignedPreviewNode({ + ...draft, + ...nodeUpdate, + ...(pointerSupportSurfaceRef.current?.sourceNodeId + ? { supportSlabId: frozenSupportSlabIdRef.current } + : {}), + } as ItemNode) return getFloorStackPreviewPosition({ node: previewNode, position, rotation: previewNode.rotation, - maxElevation: pointerSupportCapRef.current, + maxElevation: pointerSupportSurfaceRef.current?.sourceNodeId + ? null + : pointerSupportCapRef.current, }) }, [asset?.attachTo, draftNode], @@ -465,6 +477,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // No pointer surface known yet — fall back to the uncapped election // (an adopted move draft keeps its persisted host until the first move). pointerSupportCapRef.current = null + pointerSupportSurfaceRef.current = null + frozenSupportSlabIdRef.current = undefined if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { @@ -517,6 +531,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useEditor.getState().setMode('select') } + const commitDraft = ( + nodeUpdate: Partial, + options?: { + supportElevationCap?: number | null + preferredSupportSlabId?: string | null + pinSupport?: boolean + }, + ) => { + const draftId = draftNode.current?.id ?? null + const wasAdopted = draftNode.isAdopted + const finalId = draftNode.commit(nodeUpdate, options) + if (draftId) { + useLiveTransforms.getState().clear(draftId) + } + return { committedId: finalId ?? draftId, wasAdopted } + } + const revalidate = (): boolean => { const placeable = altFreeRef.current || checkCanPlace(getContext(), validators) const color = placeable ? 0x22_c5_5e : 0xef_44_44 // green-500 : red-500 @@ -617,6 +648,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) + const floorAuthoredY = draftNode.current?.position[1] ?? 0 const preserveDragOffset = configRef.current.preserveDragOffset === true // The host the item was grabbed from + its pre-drag host-local position. // Each surface's grab anchor preserves the grab offset only on THAT host, @@ -875,6 +907,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // a drag over a deck-above-a-floor hop between the two surfaces). const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) pointerSupportCapRef.current = pointed?.elevation ?? null + pointerSupportSurfaceRef.current = pointed const surfaceEvent: GridEvent = pointed?.worldPoint && pointed.localPoint ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint } @@ -946,11 +979,31 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useAlignmentGuides.getState().clear() } - const gridPos: [number, number, number] = [ + let gridPos: [number, number, number] = [ result.gridPosition[0] + alignX, - result.gridPosition[1], + floorAuthoredY, result.gridPosition[2] + alignZ, ] + frozenSupportSlabIdRef.current = undefined + if (draft && pointed?.sourceNodeId) { + const effectiveNode = { + ...draft, + position: gridPos, + parentId: useViewer.getState().selection.levelId ?? draft.parentId, + } as ItemNode + const frozenPatch = resolveFrozenFloorPlacementPatch( + effectiveNode, + useScene.getState().nodes, + { + position: gridPos, + rotation: effectiveNode.rotation, + elevation: pointed.elevation, + preferredSlabId: pointed.supportSlabId, + }, + ) + gridPos = frozenPatch.position + frozenSupportSlabIdRef.current = frozenPatch.supportSlabId + } // Play snap sound when grid position changes if ( @@ -1002,20 +1055,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea 0, ] - // Clear live transform before commit - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted // Carry the pointer surface cap into the commit so the persisted // supportSlabId reproduces the capped election (elects the aimed-at // lower slab — or the ground — instead of a deck hanging above). - const finalId = draftNode.commit(result.nodeUpdate, { + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate, { supportElevationCap: pointerSupportCapRef.current, + preferredSupportSlabId: pointerSupportSurfaceRef.current?.supportSlabId, + pinSupport: pointerSupportSurfaceRef.current?.sourceNodeId != null, }) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + finishCommittedPlacement(committedId, wasAdopted, () => { draftNode.create( gridPosition.current, asset, @@ -1229,18 +1277,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() - // Clear live transform before commit - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) if (result.dirtyNodeId) { useScene.getState().dirtyNodes.add(result.dirtyNodeId) } - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + finishCommittedPlacement(committedId, wasAdopted, () => { const nodes = useScene.getState().nodes const enterResult = wallStrategy.enter( getContext(), @@ -1389,14 +1431,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + finishCommittedPlacement(committedId, wasAdopted, () => { const enterResult = roofWallStrategy.enter(getContext(), event, altFreeRef.current) if (enterResult) { applyTransition(enterResult) @@ -1641,13 +1678,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const result = shelfSurfaceStrategy.click(ctx, synthetic as never) if (result) { event.stopPropagation() - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) + finishCommittedPlacement(committedId, wasAdopted, () => { const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never) if (enterResult) { applyTransition(enterResult) @@ -1669,13 +1701,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const result = itemSurfaceStrategy.click(ctx, synthetic) if (result) { event.stopPropagation() - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) + finishCommittedPlacement(committedId, wasAdopted, () => { const enterResult = itemSurfaceStrategy.enter(ctx, synthetic) if (enterResult) { applyTransition(enterResult) @@ -1700,13 +1727,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const result = ceilingStrategy.click(ctx, synthetic, getActiveValidators()) if (result) { event.stopPropagation() - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) + finishCommittedPlacement(committedId, wasAdopted, () => { const nodes = useScene.getState().nodes const enterResult = ceilingStrategy.enter( getContext(), @@ -1731,15 +1753,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() - // Clear live transform before commit - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + finishCommittedPlacement(committedId, wasAdopted, () => { // Try to set up next draft on the same surface const enterResult = itemSurfaceStrategy.enter(getContext(), event) if (enterResult) { @@ -1862,15 +1878,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() - // Clear live transform before commit - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + finishCommittedPlacement(committedId, wasAdopted, () => { const nodes = useScene.getState().nodes const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) if (enterResult) { @@ -2003,14 +2013,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() - if (draftNode.current) { - useLiveTransforms.getState().clear(draftNode.current.id) - } - const committedId = draftNode.current?.id ?? null - const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + const { committedId, wasAdopted } = commitDraft(result.nodeUpdate) - finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { + finishCommittedPlacement(committedId, wasAdopted, () => { const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -2276,6 +2281,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:click', commitFloorOnSurfaceClick as never) emitter.on('roof:click', commitFloorOnSurfaceClick as never) emitter.on('shelf:click', commitFloorOnSurfaceClick as never) + emitter.on('custom-mesh:click', commitFloorOnSurfaceClick as never) if (dragMode) window.addEventListener('pointerup', onReleaseCommit) return () => { @@ -2316,6 +2322,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:click', commitFloorOnSurfaceClick as never) emitter.off('roof:click', commitFloorOnSurfaceClick as never) emitter.off('shelf:click', commitFloorOnSurfaceClick as never) + emitter.off('custom-mesh:click', commitFloorOnSurfaceClick as never) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index ccd37b3587..c49f823db1 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -23,6 +23,7 @@ import { resolveAlignment, resolveConnectivityUpdates, resolveFacingIndicator, + resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, sceneRegistry, spatialGridManager, @@ -56,7 +57,10 @@ import { DragBoundingBox } from '../shared/drag-bounding-box' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility' import { PlacementBox } from '../shared/placement-box' -import { resolvePointerSupportSurface } from '../shared/pointer-support-cap' +import { + type PointerSupportSurface, + resolvePointerSupportSurface, +} from '../shared/pointer-support-cap' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ @@ -231,6 +235,7 @@ const CLICK_TRIGGER_KINDS = [ 'roof-segment', 'stair', 'stair-segment', + 'custom-mesh', ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { @@ -243,6 +248,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refreshed per grid move. Caps the floor-support election so a deck // hanging above the aimed-at floor never lifts the dragged node. const supportCapRef = useRef(null) + const supportSurfaceRef = useRef(null) // Kinds whose `position` lives in a host parent's local frame declare // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the // plan-frame cursor through the capability's hooks and previews via @@ -420,6 +426,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // No pointer surface known yet — uncapped election (the node keeps its // persisted host / committed elevation until the first grid move). supportCapRef.current = null + supportSurfaceRef.current = null // Re-sync the box transform to the (possibly new) node. `node` changes // without this component remounting whenever a positioned preset re-arms a // fresh clone after a drop, or the user picks a different catalog tile — @@ -620,6 +627,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // single fixed point per pointer ray. const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) supportCapRef.current = pointed?.elevation ?? null + supportSurfaceRef.current = pointed const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0] const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] revealFreshPlacement() @@ -732,6 +740,24 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (guides.length > 0) useAlignmentGuides.getState().set(guides) } } + if (!parentFrame && pointed?.sourceNodeId) { + const rotation = toCommitRotation(rotationRef.current) + const effectiveNode = { + ...(node as Record), + position, + rotation, + } as AnyNode + position = resolveFrozenFloorPlacementPatch( + effectiveNode, + { ...useScene.getState().nodes, [node.id]: effectiveNode }, + { + position, + rotation, + elevation: pointed.elevation, + preferredSlabId: pointed.supportSlabId, + }, + ).position + } const visualPosition = getVisualPosition(position) hasMovedRef.current = true setCursorPosition(visualPosition) @@ -830,7 +856,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ...useScene.getState().nodes, [node.id]: effectiveNode, }, - { maxElevation: supportCapRef.current }, + { + maxElevation: supportCapRef.current, + preferredSlabId: supportSurfaceRef.current?.supportSlabId, + pinSupport: supportSurfaceRef.current?.sourceNodeId != null, + }, ), ...(isNew ? { @@ -902,7 +932,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ...useScene.getState().nodes, [reparsed.id]: reparsed, }, - { maxElevation: supportCapRef.current }, + { + maxElevation: supportCapRef.current, + preferredSlabId: supportSurfaceRef.current?.supportSlabId, + pinSupport: supportSurfaceRef.current?.sourceNodeId != null, + }, ), }) as AnyNode useScene.temporal.getState().resume() diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index 97fc1f5314..a55ce1f04c 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -2,16 +2,20 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, + getWallBaseElevationForNodes, + type SlabNode, sceneRegistry, spatialGridManager, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three' +import { createWallOnCurrentLevel } from '../wall/wall-drafting' import { resolvePointerSupportSurface } from './pointer-support-cap' const LEVEL_ID = 'level_test' as AnyNodeId const WALL_ID = 'wall_test' as AnyNodeId +const CUSTOM_MESH_ID = 'custom-mesh_test' as AnyNodeId describe('resolvePointerSupportSurface node tops', () => { beforeEach(() => { @@ -95,4 +99,136 @@ describe('resolvePointerSupportSurface node tops', () => { expect(support?.sourceNodeId).toBeNull() expect(support?.elevation).toBe(0) }) + + test('uses an upward-facing custom mesh for ordinary placement tools', () => { + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [CUSTOM_MESH_ID]: { + id: CUSTOM_MESH_ID, + type: 'custom-mesh', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + topology: { vertices: [], edges: [], faces: [] }, + } as AnyNode, + }, + })) + const platformMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial()) + platformMesh.position.y = 1 + platformMesh.updateMatrixWorld(true) + sceneRegistry.nodes.set(CUSTOM_MESH_ID, platformMesh) + sceneRegistry.byType['custom-mesh']!.add(CUSTOM_MESH_ID) + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface(camera, [0, 0, 0]) + + expect(support?.sourceNodeId).toBe(CUSTOM_MESH_ID) + expect(support?.elevation).toBeCloseTo(2) + expect(support?.worldPoint).toEqual([0, 2, 0]) + }) + + test('uses an upward-facing custom mesh as the wall construction surface', () => { + const lowSlab = { + id: 'slab_low', + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon: [ + [-2, 1], + [2, 1], + [2, 3], + [-2, 3], + ], + holes: [], + holeMetadata: [], + elevation: 0.25, + thickness: 0.25, + recessed: false, + autoFromWalls: false, + } as SlabNode + const highSlab = { + ...lowSlab, + id: 'slab_high', + polygon: [ + [-1, 1], + [0, 1], + [0, 3], + [-1, 3], + ], + elevation: 1, + } as SlabNode + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [lowSlab.id]: lowSlab, + [highSlab.id]: highSlab, + [CUSTOM_MESH_ID]: { + id: CUSTOM_MESH_ID, + type: 'custom-mesh', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + topology: { vertices: [], edges: [], faces: [] }, + } as AnyNode, + }, + })) + spatialGridManager.handleNodeCreated(lowSlab as AnyNode, LEVEL_ID) + spatialGridManager.handleNodeCreated(highSlab as AnyNode, LEVEL_ID) + const platformMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial()) + platformMesh.position.y = 1 + platformMesh.position.z = 2 + platformMesh.updateMatrixWorld(true) + sceneRegistry.nodes.set(CUSTOM_MESH_ID, platformMesh) + sceneRegistry.byType['custom-mesh']!.add(CUSTOM_MESH_ID) + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 2) + camera.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface(camera, [0, 0, 2], { + includeNodeTopSurfaces: true, + }) + + expect(support?.sourceNodeId).toBe(CUSTOM_MESH_ID) + expect(support?.elevation).toBeCloseTo(2) + expect(support?.worldPoint).toEqual([0, 2, 2]) + + const wall = createWallOnCurrentLevel([-0.75, 2], [0.75, 2], { + supportCap: support?.elevation, + preferredSupportSlabId: support?.supportSlabId, + constructionElevation: support?.elevation, + constructionHeight: 2.5, + flatConstructionBase: support?.sourceNodeId != null, + }) + expect(wall).not.toBeNull() + expect(getWallBaseElevationForNodes(wall!, useScene.getState().nodes)).toBeCloseTo(2) + const wallSupport = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall!.start, + wall!.end, + wall!.curveOffset, + wall!.thickness, + wall!.supportSlabId, + undefined, + wall!.supportOffset, + ) + expect( + wallSupport.baseSegments.every((segment) => Math.abs(segment.elevation - 2) < 1e-6), + ).toBe(true) + }) }) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index b01803bd89..aab9473da8 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -22,7 +22,8 @@ const nodeTopRaycaster = new Raycaster() const nodeTopNormal = new Vector3() const nodeTopNormalMatrix = new Matrix3() -const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column'] as const +const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column', 'custom-mesh'] as const +const DEFAULT_NODE_TOP_SURFACE_KINDS = ['custom-mesh'] as const export type PointerSupportSurface = { /** Level-local elevation of the pointed surface — the election cap. */ @@ -151,7 +152,10 @@ export function resolvePointerSupportSurface( localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] } - if (options?.includeNodeTopSurfaces) { + const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces + ? NODE_TOP_SURFACE_KINDS + : DEFAULT_NODE_TOP_SURFACE_KINDS + if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) { nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize()) const nodes = useScene.getState().nodes const registeredOwners = new Map( @@ -179,7 +183,7 @@ export function resolvePointerSupportSurface( } | undefined - for (const kind of NODE_TOP_SURFACE_KINDS) { + for (const kind of nodeTopSurfaceKinds) { for (const rawId of sceneRegistry.byType[kind] ?? []) { const nodeId = rawId as AnyNodeId const node = nodes[nodeId] diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index fed4e66d9d..9aa266c9fd 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -9,6 +9,7 @@ import { movingAlignmentAnchors, type NodeEvent, resolveAlignment, + resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, StairNode, StairSegmentNode, @@ -36,7 +37,10 @@ import useFacingPose from '../../../store/use-facing-pose' import { useStairBuildPreview } from '../../../store/use-stair-build-preview' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' -import { resolvePointerSupportSurface } from '../shared/pointer-support-cap' +import { + type PointerSupportSurface, + resolvePointerSupportSurface, +} from '../shared/pointer-support-cap' import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, @@ -76,6 +80,7 @@ const CLICK_TRIGGER_KINDS = [ 'roof-segment', 'stair', 'stair-segment', + 'custom-mesh', ] as const /** @@ -177,7 +182,7 @@ function commitStairPlacement( levelId: LevelNode['id'], position: [number, number, number], rotation: number, - supportElevationCap: number | null, + supportSurface: PointerSupportSurface | null, ): void { const { createNodes, nodes } = useScene.getState() const placementLevelId = resolveStairPlacementLevelId( @@ -214,11 +219,22 @@ function commitStairPlacement( [stair.id]: stair, [segment.id]: { ...segment, parentId: stair.id }, } as Record + const placementPatch = supportSurface?.sourceNodeId + ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, { + position, + rotation, + elevation: supportSurface.elevation, + preferredSlabId: supportSurface.supportSlabId, + }) + : { + position, + ...resolveSupportSlabPatch(stair, prospectiveNodes, { + maxElevation: supportSurface?.elevation, + }), + } const committedStair = StairNode.parse({ ...stair, - ...resolveSupportSlabPatch(stair, prospectiveNodes, { - maxElevation: supportElevationCap, - }), + ...placementPatch, }) const createdLevel = destinationPlan?.createdLevel @@ -243,7 +259,7 @@ export const StairTool: React.FC = () => { const cursorRef = useRef(null) const previewRef = useRef(null) const rotationRef = useRef(0) - const supportCapRef = useRef(null) + const supportSurfaceRef = useRef(null) const previousGridPosRef = useRef<[number, number] | null>(null) const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) @@ -263,7 +279,7 @@ export const StairTool: React.FC = () => { useStairBuildPreview.getState().reset() if (previewRef.current) previewRef.current.rotation.y = 0 lastCanonicalPositionRef.current = null - supportCapRef.current = null + supportSurfaceRef.current = null const buildPreviewScene = (position: [number, number, number], rotation: number) => { const nodes = useScene.getState().nodes @@ -313,23 +329,37 @@ export const StairTool: React.FC = () => { const applyDraftPreview = ( position: [number, number, number], rotation: number, - supportElevationCap: number | null, + supportSurface: PointerSupportSurface | null, ) => { - const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportElevationCap?.toFixed(3) ?? 'none'}` + const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'}` if (key === lastPreviewKey) return lastPreviewKey = key useStairBuildPreview.getState().setPreview([position[0], position[2]], rotation) const preview = buildPreviewScene(position, rotation) - const visualPosition = preview - ? getFloorStackPreviewPosition({ - node: preview.stair, - position, - rotation, - levelId: preview.placementLevelId, - nodes: preview.previewNodes, - maxElevation: supportElevationCap, - }) - : position + const frozenPatch = + preview && supportSurface?.sourceNodeId + ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, { + position, + rotation, + elevation: supportSurface.elevation, + preferredSlabId: supportSurface.supportSlabId, + }) + : null + const previewPosition = frozenPatch?.position ?? position + const previewStair = frozenPatch + ? ({ ...preview?.stair, ...frozenPatch } as AnyNode) + : preview?.stair + const visualPosition = + preview && previewStair + ? getFloorStackPreviewPosition({ + node: previewStair, + position: previewPosition, + rotation, + levelId: preview.placementLevelId, + nodes: preview.previewNodes, + maxElevation: supportSurface?.sourceNodeId ? null : supportSurface?.elevation, + }) + : previewPosition if (cursorRef.current) { cursorRef.current.position.set( visualPosition[0], @@ -425,7 +455,7 @@ export const StairTool: React.FC = () => { const resolveStairPosition = (event: MoveTriggerEvent): [number, number, number] | null => { const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) - supportCapRef.current = pointed?.elevation ?? null + supportSurfaceRef.current = pointed const fallbackPosition = 'node' in event ? lastCanonicalPositionRef.current : event.localPosition if (!pointed?.localPoint && !fallbackPosition) return null @@ -450,7 +480,7 @@ export const StairTool: React.FC = () => { if (!position) return const [gridX, , gridZ] = position lastCanonicalPositionRef.current = position - applyDraftPreview(position, rotationRef.current, supportCapRef.current) + applyDraftPreview(position, rotationRef.current, supportSurfaceRef.current) if ( (isGridSnapActive() || isMagneticSnapActive()) && @@ -484,7 +514,7 @@ export const StairTool: React.FC = () => { const position = resolveStairPosition(event) if (!position) return - commitStairPlacement(currentLevelId, position, rotationRef.current, supportCapRef.current) + commitStairPlacement(currentLevelId, position, rotationRef.current, supportSurfaceRef.current) openingPreview.clear() // Commit cleared the opening preview, so force the next hover (even on the // same cell) to rebuild rather than dedupe against the just-placed key. @@ -526,7 +556,7 @@ export const StairTool: React.FC = () => { applyDraftPreview( lastCanonicalPositionRef.current, rotationRef.current, - supportCapRef.current, + supportSurfaceRef.current, ) } else if (previewRef.current) { previewRef.current.rotation.y = rotationRef.current diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 2d1f3168e6..a0b5e9c17c 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -3,12 +3,17 @@ import { type AnyNode, type AnyNodeId, applyHeightPatch, + CustomMeshNode, createTerrainField, DoorNode as DoorSchema, encodeTerrainField, flattenPatch, GROUND_SUPPORT_ID, + getFloorPlacedElevation, + nodeRegistry, + registerNode, runAsSingleSceneHistoryStep, + SlabNode, spatialGridManager, useScene, type WallNode, @@ -133,6 +138,176 @@ describe('createWallOnCurrentLevel', () => { expect(support.elevation).toBe(1.75) }) + test('never exposes a raised wall at floor elevation during commit', () => { + const observed: WallNode[] = [] + const unsubscribe = useScene.subscribe((state) => { + const wall = Object.values(state.nodes).find( + (node): node is WallNode => node?.type === 'wall' && node.id !== 'wall_a', + ) + if (wall) observed.push(wall) + }) + + const created = createWallOnCurrentLevel([2, 2], [3, 2], { + supportCap: 1.75, + preferredSupportSlabId: GROUND_SUPPORT_ID, + constructionElevation: 1.75, + constructionHeight: 2.5, + }) + unsubscribe() + + expect(created).not.toBeNull() + expect(observed.length).toBeGreaterThan(0) + expect(observed.every((wall) => wall.supportSlabId === GROUND_SUPPORT_ID)).toBe(true) + expect(observed.every((wall) => wall.supportOffset === 1.75)).toBe(true) + }) + + test('keeps a node-top room plane flat across changing terrain', () => { + const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [0, 0] }) + const patch = flattenPatch(field, { minX: 3.5, minZ: 0, maxX: 4, maxZ: 4 }, 1) + if (!patch) throw new Error('Expected terrain patch') + const terrain = applyHeightPatch(field, patch) + const site = { + id: 'site_test', + type: 'site', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + terrain: encodeTerrainField(terrain), + } as unknown as AnyNode + const building = { + id: 'building_test', + type: 'building', + object: 'node', + parentId: site.id, + visible: true, + metadata: {}, + children: [LEVEL_ID], + position: [0, 0, 0], + rotation: [0, 0, 0], + } as AnyNode + const level = { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: building.id, + visible: true, + metadata: {}, + children: [], + level: 0, + baseElevation: 0, + height: 3, + } as AnyNode + useScene.setState({ + nodes: Object.fromEntries([site, building, level].map((node) => [node.id, node])), + rootNodeIds: [site.id], + dirtyNodes: new Set(), + } as never) + + const lowTerrainWall = createWallOnCurrentLevel([0, 0], [1, 0], { + constructionElevation: 3, + constructionHeight: 2.5, + flatConstructionBase: true, + supportCap: 3, + }) + const highTerrainWall = createWallOnCurrentLevel([4, 0], [4, 1], { + constructionElevation: 3, + constructionHeight: 2.5, + flatConstructionBase: true, + supportCap: 3, + }) + + expect(lowTerrainWall?.supportSlabId).toBe(GROUND_SUPPORT_ID) + expect(highTerrainWall?.supportSlabId).toBe(GROUND_SUPPORT_ID) + expect(lowTerrainWall?.supportOffset).toBeCloseTo(3) + expect(highTerrainWall?.supportOffset).toBeCloseTo(2) + expect( + spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + lowTerrainWall!.start, + lowTerrainWall!.end, + lowTerrainWall!.curveOffset, + lowTerrainWall!.thickness, + lowTerrainWall!.supportSlabId, + undefined, + lowTerrainWall!.supportOffset, + ).elevation, + ).toBeCloseTo(3) + expect( + spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + highTerrainWall!.start, + highTerrainWall!.end, + highTerrainWall!.curveOffset, + highTerrainWall!.thickness, + highTerrainWall!.supportSlabId, + undefined, + highTerrainWall!.supportOffset, + ).elevation, + ).toBeCloseTo(3) + }) + + test('pins an existing construction source before a generated room slab can lift it', () => { + nodeRegistry._reset() + spatialGridManager.clear() + registerNode({ + kind: 'custom-mesh', + schemaVersion: 2, + schema: CustomMeshNode, + category: 'structure', + defaults: () => CustomMeshNode.parse({ name: 'Custom Mesh' }), + capabilities: { + floorPlaced: { + footprint: () => ({ dimensions: [4, 2.4, 4], rotation: [0, 0, 0] }), + }, + }, + } as never) + + const slope = CustomMeshNode.parse({ + name: 'Existing slope', + parentId: LEVEL_ID, + position: [0, 0, 0], + }) + seedLevel([makeWall([0, 0], [4, 0], 'wall_a')], [slope as AnyNode]) + + createWallOnCurrentLevel([0, 1], [1, 1], { + constructionElevation: 2.4, + constructionHeight: 2.5, + constructionSourceNodeId: slope.id, + flatConstructionBase: true, + supportCap: 2.4, + }) + + const pinnedSlope = useScene.getState().nodes[slope.id] as CustomMeshNode + expect(pinnedSlope.supportSlabId).toBe(GROUND_SUPPORT_ID) + + const generatedSlab = SlabNode.parse({ + polygon: [ + [-2, -2], + [2, -2], + [2, 2], + [-2, 2], + ], + elevation: 2.45, + autoFromWalls: true, + parentId: LEVEL_ID, + }) + spatialGridManager.handleNodeCreated(generatedSlab as AnyNode, LEVEL_ID) + + expect( + getFloorPlacedElevation({ + node: pinnedSlope, + nodes: { + ...useScene.getState().nodes, + [generatedSlab.id]: generatedSlab as AnyNode, + }, + position: pinnedSlope.position, + rotation: [0, pinnedSlope.rotation, 0], + }), + ).toBe(0) + }) + test('2D terrain construction options freeze the first-point elevation and wall height', () => { const field = createTerrainField({ cols: 5, rows: 5, spacing: 1, origin: [-2, -2] }) const patch = flattenPatch(field, { minX: -2, minZ: -2, maxX: 2, maxZ: 2 }, 1.5) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 60d5c44de0..77fe3ef7ab 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -7,6 +7,7 @@ import { GROUND_SUPPORT_ID, getScaledDimensions, type ItemNode, + resolveSupportSlabPatch, resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, @@ -501,6 +502,10 @@ export type WallConstructionOptions = { constructionElevation?: number | null /** Height shown by the draft ghost. */ constructionHeight?: number | null + /** Keep a node-top construction plane flat instead of filling down to lower slab segments. */ + flatConstructionBase?: boolean + /** Node whose top surface established this construction plane. */ + constructionSourceNodeId?: AnyNodeId | null } export function resolveTerrainWallConstructionOptions( @@ -603,6 +608,24 @@ export function createWallOnCurrentLevel( return null } + const constructionSourceNodeId = options?.constructionSourceNodeId + if (constructionSourceNodeId) { + const sourceNodes = useScene.getState().nodes + const sourceNode = sourceNodes[constructionSourceNodeId] + const currentSupport = + sourceNode && 'supportSlabId' in sourceNode + ? (sourceNode.supportSlabId as string | undefined) + : undefined + if (sourceNode && currentSupport == null) { + const sourceSupportPatch = resolveSupportSlabPatch(sourceNode, sourceNodes, { + pinSupport: true, + }) + if (sourceSupportPatch.supportSlabId != null) { + updateNodes([{ id: constructionSourceNodeId, data: sourceSupportPatch }]) + } + } + } + const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length // A placed wall preset seeds `toolDefaults.wall` (thickness, height, // materials, sides) before the tool activates; merge those first so the @@ -613,55 +636,54 @@ export function createWallOnCurrentLevel( name: `Wall ${wallCount + 1}`, start: resolvedStart, end: resolvedEnd, + parentId: currentLevelId, + }) + const sceneNodes = useScene.getState().nodes + const nodesWithWall = { ...sceneNodes, [wall.id]: wall as AnyNode } + const terrainBase = terrainSupportLift( + nodesWithWall, + currentLevelId, + wall.start[0], + wall.start[1], + ) + const preferredSupportSlabId = options?.flatConstructionBase + ? GROUND_SUPPORT_ID + : (options?.preferredSupportSlabId ?? + (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null)) + const supportPatch = resolveWallSupportSlabPatch(wall, nodesWithWall, { + maxElevation: options?.supportCap ?? null, + preferredSlabId: preferredSupportSlabId, + }) + const sourceSupport = spatialGridManager.getSlabSupportForWall( + currentLevelId, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + supportPatch.supportSlabId, + options?.supportCap ?? null, + ) + const supportOffset = + options?.constructionElevation == null + ? undefined + : options.constructionElevation - sourceSupport.elevation + const preserveDraftHeight = + wall.height == null && + options?.constructionHeight != null && + options.constructionElevation != null && + (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6) + const committedWall = WallSchema.parse({ + ...wall, + ...supportPatch, + height: preserveDraftHeight ? options?.constructionHeight : wall.height, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, }) - createNode(wall, currentLevelId) - const createdWall = useScene.getState().nodes[wall.id] - if (createdWall?.type === 'wall') { - const terrainBase = terrainSupportLift( - useScene.getState().nodes, - currentLevelId, - createdWall.start[0], - createdWall.start[1], - ) - const preferredSupportSlabId = - options?.preferredSupportSlabId ?? - (options?.constructionElevation != null && terrainBase != null ? GROUND_SUPPORT_ID : null) - const supportPatch = resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { - maxElevation: options?.supportCap ?? null, - preferredSlabId: preferredSupportSlabId, - }) - const supportSlabId = supportPatch.supportSlabId - const sourceSupport = spatialGridManager.getSlabSupportForWall( - currentLevelId, - createdWall.start, - createdWall.end, - createdWall.curveOffset, - createdWall.thickness, - supportSlabId, - options?.supportCap ?? null, - ) - const supportOffset = - options?.constructionElevation == null - ? undefined - : options.constructionElevation - sourceSupport.elevation - const preserveDraftHeight = - createdWall.height == null && - options?.constructionHeight != null && - options.constructionElevation != null && - (terrainBase != null || Math.abs(options.constructionElevation) > 1e-6) - useScene.getState().updateNode(createdWall.id, { - ...supportPatch, - height: preserveDraftHeight - ? (options?.constructionHeight ?? createdWall.height) - : createdWall.height, - supportOffset: - supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, - }) - } + createNode(committedWall, currentLevelId) sfxEmitter.emit('sfx:structure-build') - const committedWall = useScene.getState().nodes[wall.id] - return committedWall?.type === 'wall' ? committedWall : wall + const storedWall = useScene.getState().nodes[committedWall.id] + return storedWall?.type === 'wall' ? storedWall : committedWall }) } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 8254c83680..83764b30df 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -136,6 +136,7 @@ const treeNodeByType: Record< cabinet: RegistryTreeNode, 'cabinet-module': RegistryTreeNode, 'box-vent': RegistryTreeNode, + 'custom-mesh': RegistryTreeNode, ceiling: CeilingTreeNode, chimney: ChimneyTreeNode, dormer: DormerTreeNode, diff --git a/packages/editor/src/hooks/use-keyboard.test.ts b/packages/editor/src/hooks/use-keyboard.test.ts new file mode 100644 index 0000000000..8b7d2698b1 --- /dev/null +++ b/packages/editor/src/hooks/use-keyboard.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + CustomMeshNode, + clearSceneHistory, + useScene, +} from '@pascal-app/core' +import { meshEditScope } from '../lib/interaction/scope' +import useInteractionScope from '../store/use-interaction-scope' +import { runHistoryShortcut } from './use-keyboard' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ( + callback, +) => { + callback(0) + return 0 +} +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const NODE_ID = 'custom-mesh_history' as AnyNodeId + +beforeEach(() => { + const node = CustomMeshNode.parse({ id: NODE_ID, position: [0, 0, 0] }) + useScene.setState({ + nodes: { [NODE_ID]: node }, + rootNodeIds: [NODE_ID], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + } as never) + clearSceneHistory() + useScene.getState().updateNode(NODE_ID, { position: [1, 2, 3] } as Partial) +}) + +afterEach(() => { + useInteractionScope.getState().end() + clearSceneHistory() +}) + +describe('history shortcuts during custom mesh editing', () => { + test('undoes and redoes mesh changes without leaving component selection mode', () => { + useInteractionScope.getState().begin(meshEditScope(NODE_ID)) + + expect(runHistoryShortcut('undo')).toBe(true) + expect((useScene.getState().nodes[NODE_ID] as CustomMeshNode).position).toEqual([0, 0, 0]) + expect(useInteractionScope.getState().scope).toEqual({ + kind: 'mesh-editing', + nodeId: NODE_ID, + phase: 'selecting', + }) + + expect(runHistoryShortcut('redo')).toBe(true) + expect((useScene.getState().nodes[NODE_ID] as CustomMeshNode).position).toEqual([1, 2, 3]) + expect(useInteractionScope.getState().scope).toEqual({ + kind: 'mesh-editing', + nodeId: NODE_ID, + phase: 'selecting', + }) + }) +}) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 940ed820fa..a8479cdfbb 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -141,6 +141,8 @@ const cancelInteractionForHistoryShortcut = () => { guideEmitter.emit('guide:cancel-reference-scale') return true } + const activeScope = useInteractionScope.getState().scope + if (activeScope.kind === 'mesh-editing' && activeScope.phase === 'selecting') return false _toolCancelConsumed = false emitter.emit('tool:cancel') if (_toolCancelConsumed) return true @@ -162,6 +164,13 @@ const cancelInteractionForHistoryShortcut = () => { return false } +export const runHistoryShortcut = (direction: 'undo' | 'redo') => { + if (cancelInteractionForHistoryShortcut()) return false + if (direction === 'redo') runRedo() + else runUndo() + return true +} + export const useKeyboard = ({ isVersionPreviewMode = false, disabled = false, @@ -419,13 +428,11 @@ export const useKeyboard = ({ } else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() - if (cancelInteractionForHistoryShortcut()) return - runRedo() + runHistoryShortcut('redo') } else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() - if (cancelInteractionForHistoryShortcut()) return - runUndo() + runHistoryShortcut('undo') } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { e.preventDefault() const { buildingId, levelId } = useViewer.getState().selection diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 8daf2e9e17..5b68588786 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -7,6 +7,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, useScene, } from '@pascal-app/core' @@ -16,6 +17,8 @@ import { isGridSnapActive, isMagneticSnapActive, movementSfxStepKey, + type PointerSupportSurface, + resolvePointerSupportSurface, triggerSFX, useAlignmentGuides, useEditor, @@ -23,6 +26,7 @@ import { usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import type { Group } from 'three' import { @@ -63,7 +67,11 @@ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, num */ const ColumnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const cursorRef = useRef(null) + const supportSurfaceRef = useRef(null) const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) @@ -85,16 +93,49 @@ const ColumnTool = () => { // node, so nothing real is excluded. let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) + const pointedSurfaceFor = (event: FloorPlacementClickTriggerEvent) => + typeof HTMLCanvasElement !== 'undefined' && + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + + const resolveColumnPlacement = ( + position: [number, number, number], + surface: PointerSupportSurface | null, + ) => { + const column = ColumnNode.parse({ + ...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position), + parentId: activeLevelId, + }) + const nodes = { ...useScene.getState().nodes, [column.id]: column } + const patch = surface?.sourceNodeId + ? resolveFrozenFloorPlacementPatch(column, nodes, { + position, + rotation: column.rotation, + elevation: surface.elevation, + preferredSlabId: surface.supportSlabId, + }) + : { + position, + ...resolveSupportSlabPatch(column, nodes, { + maxElevation: surface?.elevation, + }), + } + return { column, patch } + } + const onGridMove = (event: GridEvent) => { if (!cursorVisibleRef.current) { cursorVisibleRef.current = true setCursorVisible(true) } + const pointed = pointedSurfaceFor(event) + supportSurfaceRef.current = pointed const { position: alignedPosition, guides } = resolveAlignedFloorPlacement({ node: previewNode, - rawX: event.localPosition[0], - rawZ: event.localPosition[2], + rawX: pointed?.localPoint?.[0] ?? event.localPosition[0], + rawZ: pointed?.localPoint?.[2] ?? event.localPosition[2], gridStep: useEditor.getState().gridSnapStep, candidates: alignmentCandidates, showAlignment: isAlignmentGuideActive(), @@ -108,17 +149,20 @@ const ColumnTool = () => { collectStructuralGridAxes(useScene.getState().nodes, activeLevelId), ) : null - const position: [number, number, number] = structuralSnap + const planPosition: [number, number, number] = structuralSnap ? [structuralSnap.point[0], alignedPosition[1], structuralSnap.point[1]] : alignedPosition + const { patch } = resolveColumnPlacement(planPosition, pointed) + const position = patch.position if (structuralSnap) useAlignmentGuides.getState().clear() else useAlignmentGuides.getState().set(guides) const visualPosition = getFloorStackPreviewPosition({ - node: previewNode, + node: { ...previewNode, ...patch }, position, rotation: previewNode.rotation, levelId: activeLevelId, + maxElevation: pointed?.sourceNodeId ? null : pointed?.elevation, }) cursorRef.current?.position.set(...visualPosition) // Forward-facing floor triangle, drawn by the editor-side overlay. Columns @@ -149,6 +193,8 @@ const ColumnTool = () => { } const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { + const pointed = pointedSurfaceFor(event) ?? supportSurfaceRef.current + supportSurfaceRef.current = pointed const fallbackPosition = lastCursorRef.current ?? getLevelLocalSnappedPosition( @@ -164,17 +210,13 @@ const ColumnTool = () => { collectStructuralGridAxes(useScene.getState().nodes, activeLevelId), ) : null - const position: [number, number, number] = structuralSnap - ? [structuralSnap.point[0], fallbackPosition[1], structuralSnap.point[1]] - : fallbackPosition - - const column = ColumnNode.parse({ - ...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position), - parentId: activeLevelId, - }) + const planPosition: [number, number, number] = structuralSnap + ? [structuralSnap.point[0], 0, structuralSnap.point[1]] + : [fallbackPosition[0], 0, fallbackPosition[2]] + const { column, patch } = resolveColumnPlacement(planPosition, pointed) const committedColumn = ColumnNode.parse({ ...column, - ...resolveSupportSlabPatch(column, useScene.getState().nodes), + ...patch, }) useScene.getState().createNode(committedColumn, activeLevelId) useViewer.getState().setSelection({ selectedIds: [committedColumn.id] }) diff --git a/packages/nodes/src/custom-mesh/definition.test.ts b/packages/nodes/src/custom-mesh/definition.test.ts index 9115035c6c..abe5c3722e 100644 --- a/packages/nodes/src/custom-mesh/definition.test.ts +++ b/packages/nodes/src/custom-mesh/definition.test.ts @@ -10,6 +10,60 @@ describe('custom mesh placement bounds', () => { }) }) + test('exposes whole-mesh position controls in the inspector', () => { + expect(customMeshDefinition.parametrics?.groups).toEqual([ + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ]) + }) + + test('exposes the entire mesh as one paintable material target', () => { + const node = CustomMeshNode.parse({ name: 'Paintable mesh' }) + const paint = customMeshDefinition.capabilities.paint + + expect(customMeshDefinition.capabilities.slots?.(node)).toEqual([ + { slotId: 'body', label: 'Whole mesh' }, + ]) + expect( + paint?.resolveRole({ + node, + materialIndex: null, + }), + ).toBe('body') + expect( + paint?.buildPatch({ + node, + role: 'body', + material: undefined, + materialPreset: 'library:metal-steel', + }), + ).toEqual({ slots: { body: 'library:metal-steel' } }) + }) + + test('declares its edited top as a stackable surface', () => { + const base = CustomMeshNode.parse({ name: 'Raised mesh', position: [0, 2, 0] }) + const node = { + ...base, + topology: { + ...base.topology, + vertices: base.topology.vertices.map((vertex) => ({ + ...vertex, + position: [vertex.position[0], vertex.position[1] + 1, vertex.position[2]] as [ + number, + number, + number, + ], + })), + }, + } + const height = customMeshDefinition.capabilities.surfaces?.top?.height + + expect(typeof height).toBe('function') + expect(typeof height === 'function' ? height(node) : height).toBeCloseTo(3.4) + }) + test('keeps asymmetric edited topology centered during a rotated drag', () => { const base = CustomMeshNode.parse({ name: 'Asymmetric mesh', diff --git a/packages/nodes/src/custom-mesh/definition.ts b/packages/nodes/src/custom-mesh/definition.ts index 4acb871741..57117a8cde 100644 --- a/packages/nodes/src/custom-mesh/definition.ts +++ b/packages/nodes/src/custom-mesh/definition.ts @@ -6,7 +6,10 @@ import { import type { FloorplanNodeExtension } from '@pascal-app/editor' import { buildCustomMeshFloorplan } from './floorplan' import { buildCustomMeshGeometry } from './geometry' +import { customMeshPaint } from './paint' +import { customMeshParametrics } from './parametrics' import { CustomMeshNode } from './schema' +import { customMeshSlots } from './slots' export function customMeshBounds(node: CustomMeshNodeType) { const xs = node.topology.vertices.map((vertex) => vertex.position[0]) @@ -42,7 +45,7 @@ function footprintPosition(node: CustomMeshNodeType, center: [number, number, nu export const customMeshDefinition: NodeDefinition = { kind: 'custom-mesh', - schemaVersion: 1, + schemaVersion: 2, schema: CustomMeshNode, category: 'structure', surfaceRole: 'wall', @@ -66,6 +69,15 @@ export const customMeshDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, + surfaces: { + top: { + height: (rawNode) => { + const node = rawNode as CustomMeshNodeType + const { size, center } = customMeshBounds(node) + return center[1] + size[1] / 2 + }, + }, + }, movable: { axes: ['x', 'z'], gridSnap: true }, duplicable: true, deletable: true, @@ -82,11 +94,14 @@ export const customMeshDefinition: NodeDefinition = { }, collides: true, }, + slots: () => customMeshSlots(), + paint: customMeshPaint, }, geometry: buildCustomMeshGeometry, geometryKey: (node) => JSON.stringify([node.topology, node.slots]), floorplan: buildCustomMeshFloorplan, + parametrics: customMeshParametrics, affordanceTools: { selection: () => import('./selection'), }, diff --git a/packages/nodes/src/custom-mesh/geometry.test.ts b/packages/nodes/src/custom-mesh/geometry.test.ts index 7635c86a0c..8656a57fdf 100644 --- a/packages/nodes/src/custom-mesh/geometry.test.ts +++ b/packages/nodes/src/custom-mesh/geometry.test.ts @@ -3,6 +3,7 @@ import { CustomMeshNode } from '@pascal-app/core' import { Mesh, type Vector3Tuple } from 'three' import { applyCustomMeshCommand } from './commands' import { buildCustomMeshGeometry } from './geometry' +import { customMeshPaint } from './paint' describe('buildCustomMeshGeometry', () => { test('derives a render mesh from persistent topology', () => { @@ -18,6 +19,61 @@ describe('buildCustomMeshGeometry', () => { expect(mesh.geometry.userData.customMeshFaces).toHaveLength(6) }) + test('uses the whole-mesh body material for every topology face slot', () => { + const base = CustomMeshNode.parse({ + name: 'Painted mesh', + slots: { + body: 'library:metal-steel', + accent: 'library:preset-softwhite', + }, + }) + const node = { + ...base, + topology: { + ...base.topology, + faces: base.topology.faces.map((face, index) => ({ + ...face, + materialSlot: index % 2 === 0 ? 'body' : 'accent', + })), + }, + } + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh)) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + expect(Array.isArray(mesh.material)).toBe(false) + expect(new Set(materials).size).toBe(1) + expect(mesh.userData.slotId).toBe('body') + }) + + test('previews the selected paint material across the whole mesh', () => { + const node = CustomMeshNode.parse({ name: 'Preview mesh' }) + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh)) return + mesh.userData.__fromGeometry = true + const previous = mesh.material + const restore = customMeshPaint.applyPreview({ + node, + role: 'body', + material: { + preset: 'custom', + properties: { color: '#c2410c' }, + }, + materialPreset: undefined, + root: group, + }) + + expect(restore).toBeFunction() + expect(mesh.material).not.toBe(previous) + restore?.() + expect(mesh.material).toBe(previous) + }) + test('rebuilds the extruded topology into additional face triangles', () => { const node = CustomMeshNode.parse({ name: 'Box' }) const result = applyCustomMeshCommand(node.topology, { diff --git a/packages/nodes/src/custom-mesh/geometry.ts b/packages/nodes/src/custom-mesh/geometry.ts index a9359546c7..dde691be88 100644 --- a/packages/nodes/src/custom-mesh/geometry.ts +++ b/packages/nodes/src/custom-mesh/geometry.ts @@ -15,6 +15,7 @@ import { Vector3, } from 'three' import { customMeshFaceNormal } from './commands' +import { CUSTOM_MESH_SLOT_ID } from './slots' type Point = [number, number, number] const SMOOTH_NORMAL_ANGLE_COSINE = Math.cos(Math.PI / 6) @@ -75,8 +76,6 @@ export function buildCustomMeshGeometry( const normals: number[] = [] const uvs: number[] = [] const faceRanges: { faceId: string; start: number; count: number }[] = [] - const slotIds = [...new Set(node.topology.faces.map((face) => face.materialSlot))] - const materialIndex = new Map(slotIds.map((slotId, index) => [slotId, index])) const faceNormals = new Map( node.topology.faces.flatMap((face) => { const normal = customMeshFaceNormal(node.topology, face) @@ -132,7 +131,7 @@ export function buildCustomMeshGeometry( } } const count = positions.length / 3 - start - geometry.addGroup(start, count, materialIndex.get(face.materialSlot) ?? 0) + geometry.addGroup(start, count, 0) faceRanges.push({ faceId: face.id, start, count }) } @@ -143,18 +142,16 @@ export function buildCustomMeshGeometry( geometry.computeBoundingSphere() geometry.userData.customMeshFaces = faceRanges - const materials = slotIds.map((slotId) => { - const ref = node.slots?.[slotId] - return ( - (ref ? resolveMaterialRef(ref, ctx?.materials, shading) : null) ?? - createDefaultMaterial('#b8c5d1', 0.72, shading) - ) - }) - const mesh = new Mesh(geometry, materials.length === 1 ? materials[0] : materials) + const materialRef = node.slots?.[CUSTOM_MESH_SLOT_ID] + const material = + (materialRef ? resolveMaterialRef(materialRef, ctx?.materials, shading) : null) ?? + createDefaultMaterial('#b8c5d1', 0.72, shading) + const mesh = new Mesh(geometry, material) mesh.name = 'custom-mesh-body' mesh.castShadow = true mesh.receiveShadow = true mesh.userData.customMesh = true + mesh.userData.slotId = CUSTOM_MESH_SLOT_ID group.add(mesh) return group } diff --git a/packages/nodes/src/custom-mesh/paint.ts b/packages/nodes/src/custom-mesh/paint.ts new file mode 100644 index 0000000000..bfb9d2471f --- /dev/null +++ b/packages/nodes/src/custom-mesh/paint.ts @@ -0,0 +1,7 @@ +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' +import { CUSTOM_MESH_SLOT_ID } from './slots' + +export const customMeshPaint = createSlotPaintCapability({ + resolveRole: () => CUSTOM_MESH_SLOT_ID, + applyPreview: previewGeometrySlot, +}) diff --git a/packages/nodes/src/custom-mesh/parametrics.ts b/packages/nodes/src/custom-mesh/parametrics.ts new file mode 100644 index 0000000000..10b15f85e1 --- /dev/null +++ b/packages/nodes/src/custom-mesh/parametrics.ts @@ -0,0 +1,11 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { CustomMeshNode } from './schema' + +export const customMeshParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], +} diff --git a/packages/nodes/src/custom-mesh/selection.tsx b/packages/nodes/src/custom-mesh/selection.tsx index 9bcee8245a..184d8f696e 100644 --- a/packages/nodes/src/custom-mesh/selection.tsx +++ b/packages/nodes/src/custom-mesh/selection.tsx @@ -35,9 +35,7 @@ import { Ellipsis, Eye, EyeOff, - MousePointer2, Move3D, - Rotate3D, Rows3, Scaling, ScanLine, @@ -55,7 +53,6 @@ import { useState, } from 'react' import { - BoxGeometry, BufferGeometry, ConeGeometry, CylinderGeometry, @@ -65,6 +62,7 @@ import { LineSegments, type Object3D, Plane, + PlaneGeometry, Quaternion, Raycaster, SphereGeometry, @@ -96,6 +94,7 @@ import { } from './selection-model' import { customMeshBevelWidthFromDrag, + customMeshComponentStatus, customMeshOperationAvailability, customMeshScaleFactorFromDrag, customMeshScaleFactors, @@ -105,14 +104,15 @@ import { type ComponentMode = CustomMeshSelection['mode'] type Point = [number, number, number] type Axis = 'x' | 'y' | 'z' -type TransformTool = 'select' | 'move' | 'rotate' | 'scale' | 'loop-cut' | 'bevel' -type ModalOperator = 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete' -type ToolbarPanel = 'operations' | 'selection' | null -type ModalDraft = { - topology: CustomMeshTopology - selection: CustomMeshSelection - operator: ModalOperator +type PlaneAxes = 'xy' | 'xz' | 'yz' +type TransformOperation = 'translate' | 'rotate' | 'scale' +type ActiveTransform = { + operation: TransformOperation + constraint: Axis } +type TransformTool = 'transform' | 'loop-cut' | 'bevel' +type TopologyOperator = 'extrude' | 'inset' | 'merge' | 'dissolve' | 'delete' +type ToolbarPanel = 'operations' | 'selection' | null const AXIS_VECTORS: Record = { x: [1, 0, 0], @@ -120,9 +120,17 @@ const AXIS_VECTORS: Record = { z: [0, 0, 1], } const AXIS_COLORS: Record = { - x: '#ef4444', - y: '#22c55e', - z: '#3b82f6', + x: '#ff2060', + y: '#20df80', + z: '#2080ff', +} +const PIVOT_HOVERED_COLOR = '#ffff40' +const GIZMO_RENDER_ORDER = 1300 +const GIZMO_HIT_RENDER_ORDER = GIZMO_RENDER_ORDER + 1 +const PLANE_NORMAL: Record = { + xy: 'z', + xz: 'y', + yz: 'x', } const COMPONENT_ACTIVE_COLOR = '#ff9a24' const COMPONENT_SELECTED_COLOR = '#ff6d00' @@ -132,11 +140,11 @@ const DEFAULT_BEVEL_SEGMENTS = 6 const ROTATION_SNAP_ANGLE_DEGREES = 15 const FLOATING_PANEL_CLASS = - 'corner-smooth pointer-events-auto flex rounded-[18px] border border-border/45 bg-background/96 p-1.5 shadow-elevation-4 backdrop-blur-xl' + 'pointer-events-auto flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md' const TOOLBAR_POPOVER_CLASS = 'absolute top-[calc(100%+10px)] left-1/2 z-50 w-72 -translate-x-1/2 rounded-xl border border-border/50 bg-background/98 p-2 shadow-elevation-4 backdrop-blur-xl' const OPERATION_INPUT_CLASS = - 'h-7 w-14 rounded-md border border-border/50 bg-accent/25 px-1.5 text-right font-mono text-[10px] text-foreground tabular-nums outline-none hover:border-border/80 focus:border-ring disabled:opacity-35' + 'h-6 w-12 rounded-md border border-border/50 bg-accent/25 px-1 text-right font-mono text-[10px] text-foreground tabular-nums outline-none hover:border-border/80 focus:border-ring disabled:opacity-35' const playCustomMeshSfx = (action: CustomMeshSfxAction) => triggerSFX(customMeshSfx(action)) @@ -597,39 +605,56 @@ function FaceHandle({ ) } -function AxisHandle({ +function AxisTransformHandle({ axis, length, radius, - active, - appearance = 'move', - onPointerDown, + moveActive, + scaleActive, + onMovePointerDown, + onScalePointerDown, }: { axis: Axis length: number radius: number - active: boolean - appearance?: 'move' | 'scale' - onPointerDown: (axis: Axis, event: ThreeEvent) => void + moveActive: boolean + scaleActive: boolean + onMovePointerDown: (axis: Axis, event: ThreeEvent) => void + onScalePointerDown: (axis: Axis, event: ThreeEvent) => void }) { - const [hovered, setHovered] = useState(false) + const [hovered, setHovered] = useState(null) const shaftGeometry = useMemo( - () => new CylinderGeometry(radius, radius, length * 0.72, 10), + () => new CylinderGeometry(radius * 0.35, radius * 0.35, length * 0.8, 10), [length, radius], ) - const tipGeometry = useMemo( - () => - appearance === 'scale' - ? new BoxGeometry(radius * 4.5, radius * 4.5, radius * 4.5) - : new ConeGeometry(radius * 2.5, length * 0.28, 12), - [appearance, length, radius], + const arrowGeometry = useMemo( + () => new ConeGeometry(radius * 1.6, length * 0.2, 24), + [length, radius], ) - const hitGeometry = useMemo( + const moveHitGeometry = useMemo( () => new CylinderGeometry(radius * 4.5, radius * 4.5, length, 8), [length, radius], ) - const material = useMemo( - () => new MeshBasicNodeMaterial({ depthTest: false, depthWrite: false }), + const scaleGeometry = useMemo(() => new SphereGeometry(radius * 1.3, 12, 12), [radius]) + const scaleHitGeometry = useMemo(() => new SphereGeometry(radius * 4.2, 12, 8), [radius]) + const moveMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), + [], + ) + const scaleMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), [], ) const hitMaterial = useMemo( @@ -644,38 +669,183 @@ function AxisHandle({ [axis], ) useEffect(() => { - material.color.set(active || hovered ? '#fef3c7' : AXIS_COLORS[axis]) - }, [active, axis, hovered, material]) + moveMaterial.color.set( + moveActive || hovered === 'translate' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + ) + scaleMaterial.color.set( + scaleActive || hovered === 'scale' ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis], + ) + }, [axis, hovered, moveActive, moveMaterial, scaleActive, scaleMaterial]) useEffect( () => () => { shaftGeometry.dispose() - tipGeometry.dispose() - hitGeometry.dispose() - material.dispose() + arrowGeometry.dispose() + moveHitGeometry.dispose() + scaleGeometry.dispose() + scaleHitGeometry.dispose() + moveMaterial.dispose() + scaleMaterial.dispose() hitMaterial.dispose() }, - [hitGeometry, hitMaterial, material, shaftGeometry, tipGeometry], + [ + arrowGeometry, + hitMaterial, + moveHitGeometry, + moveMaterial, + scaleGeometry, + scaleHitGeometry, + scaleMaterial, + shaftGeometry, + ], ) const rotation: Point = axis === 'x' ? [0, 0, -Math.PI / 2] : axis === 'z' ? [Math.PI / 2, 0, 0] : [0, 0, 0] + const scalePosition = length * 1.2 return ( {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + {}} - renderOrder={1210} + renderOrder={GIZMO_RENDER_ORDER} /> { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onMovePointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered('translate') + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(null) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + position={[0, length * 0.5, 0]} + renderOrder={GIZMO_HIT_RENDER_ORDER} + /> + {}} + renderOrder={GIZMO_RENDER_ORDER} + /> + { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + swallowNextClick() + onScalePointerDown(axis, event) + }} + onPointerEnter={(event) => { + event.stopPropagation() + setHovered('scale') + document.body.style.cursor = 'grab' + }} + onPointerLeave={() => { + setHovered(null) + if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + }} + position={[0, scalePosition, 0]} + renderOrder={GIZMO_HIT_RENDER_ORDER} + /> + + ) +} + +function PlaneMoveHandle({ + plane, + offset, + size, + active, + onPointerDown, +}: { + plane: PlaneAxes + offset: number + size: number + active: boolean + onPointerDown: (axis: Axis, event: ThreeEvent) => void +}) { + const [hovered, setHovered] = useState(false) + const geometry = useMemo(() => new PlaneGeometry(size, size), [size]) + const hitGeometry = useMemo(() => new PlaneGeometry(size * 1.45, size * 1.45), [size]) + const normalAxis = PLANE_NORMAL[plane] + const material = useMemo( + () => + new MeshBasicNodeMaterial({ + color: AXIS_COLORS[normalAxis], + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + opacity: 1, + }), + [normalAxis], + ) + const hitMaterial = useMemo( + () => + new MeshBasicNodeMaterial({ + color: '#ffffff', + depthTest: false, + depthWrite: false, + side: DoubleSide, + transparent: true, + opacity: 0, + }), + [], + ) + useEffect(() => { + material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[normalAxis]) + }, [active, hovered, material, normalAxis]) + useEffect( + () => () => { + geometry.dispose() + hitGeometry.dispose() + material.dispose() + hitMaterial.dispose() + }, + [geometry, hitGeometry, hitMaterial, material], + ) + const position: Point = + plane === 'xy' + ? [offset, offset, 0] + : plane === 'xz' + ? [offset, 0, offset] + : [0, offset, offset] + const rotation: Point = + plane === 'xz' ? [-Math.PI / 2, 0, 0] : plane === 'yz' ? [0, Math.PI / 2, 0] : [0, 0, 0] + + return ( + + {}} - renderOrder={1210} + renderOrder={GIZMO_RENDER_ORDER} /> { event.stopPropagation() setHovered(true) - document.body.style.cursor = 'grab' + document.body.style.cursor = 'move' }} onPointerLeave={() => { setHovered(false) - if (document.body.style.cursor === 'grab') document.body.style.cursor = '' + if (document.body.style.cursor === 'move') document.body.style.cursor = '' }} - position={[0, length * 0.5, 0]} - renderOrder={1211} + renderOrder={GIZMO_HIT_RENDER_ORDER} /> ) @@ -717,11 +886,22 @@ function RotationHandle({ onPointerDown: (axis: Axis, event: ThreeEvent) => void }) { const [hovered, setHovered] = useState(false) - const ringGeometry = useMemo(() => new TorusGeometry(radius, tube, 8, 64), [radius, tube]) - const hitGeometry = useMemo(() => new TorusGeometry(radius, tube * 4.5, 8, 64), [radius, tube]) - const arrowGeometry = useMemo(() => new ConeGeometry(tube * 2.8, tube * 7, 12), [tube]) + const ringGeometry = useMemo( + () => new TorusGeometry(radius, tube * 0.35, 8, 32, Math.PI / 2), + [radius, tube], + ) + const hitGeometry = useMemo( + () => new TorusGeometry(radius, tube * 4.5, 8, 32, Math.PI / 2), + [radius, tube], + ) const material = useMemo( - () => new MeshBasicNodeMaterial({ depthTest: false, depthWrite: false }), + () => + new MeshBasicNodeMaterial({ + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + }), [], ) const hitMaterial = useMemo( @@ -736,20 +916,19 @@ function RotationHandle({ [axis], ) useEffect(() => { - material.color.set(active || hovered ? '#fef3c7' : AXIS_COLORS[axis]) + material.color.set(active || hovered ? PIVOT_HOVERED_COLOR : AXIS_COLORS[axis]) }, [active, axis, hovered, material]) useEffect( () => () => { ringGeometry.dispose() hitGeometry.dispose() - arrowGeometry.dispose() material.dispose() hitMaterial.dispose() }, - [arrowGeometry, hitGeometry, hitMaterial, material, ringGeometry], + [hitGeometry, hitMaterial, material, ringGeometry], ) const rotation: Point = - axis === 'x' ? [0, Math.PI / 2, 0] : axis === 'y' ? [-Math.PI / 2, 0, 0] : [0, 0, 0] + axis === 'x' ? [0, -Math.PI / 2, 0] : axis === 'y' ? [Math.PI / 2, 0, 0] : [0, 0, 0] return ( @@ -758,24 +937,7 @@ function RotationHandle({ layers={EDITOR_LAYER} material={material} raycast={() => {}} - renderOrder={1210} - /> - {}} - renderOrder={1210} - /> - {}} - renderOrder={1210} - rotation={[0, 0, Math.PI]} + renderOrder={GIZMO_RENDER_ORDER} /> ) @@ -939,11 +1101,9 @@ function ToolbarButton({ {controls ?
{controls}
: null} @@ -1095,21 +1255,19 @@ function CustomMeshEditor({ const [mode, setMode] = useState('face') const [selectedIds, setSelectedIds] = useState([]) const [activeId, setActiveId] = useState(null) - const [transformTool, setTransformTool] = useState('select') + const [transformTool, setTransformTool] = useState('transform') const [xray, setXray] = useState(false) const [previewTopology, setPreviewTopology] = useState(null) - const [dragAxis, setDragAxis] = useState(null) + const [activeTransform, setActiveTransform] = useState(null) const [loopCutSegments, setLoopCutSegments] = useState<[Point, Point][] | null>(null) 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 [modalDraft, setModalDraft] = useState(null) const [toolbarPanel, setToolbarPanel] = useState(null) const [error, setError] = useState(null) const cancelDragRef = useRef<(() => void) | null>(null) - const modalDraftRef = useRef(null) const displayTopology = previewTopology ?? node.topology const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selection = useMemo( @@ -1119,9 +1277,11 @@ function CustomMeshEditor({ const extent = topologyExtent(displayTopology) const componentRadius = Math.min(0.055, Math.max(0.022, extent * 0.011)) const gizmoOrigin = selectionCentroid(displayTopology, selection) - const gizmoLength = Math.min(0.72, Math.max(0.26, extent * 0.18)) - const gizmoRadius = Math.min(0.026, Math.max(0.009, extent * 0.006)) - const rotationGizmoRadius = Math.min(1, Math.max(0.45, extent * 0.28)) + const gizmoLength = Math.min(1.15, Math.max(0.42, extent * 0.29)) + const gizmoRadius = Math.min(0.04, Math.max(0.014, extent * 0.009)) + const rotationGizmoRadius = gizmoLength * 0.65 + const planeHandleSize = gizmoLength * 0.2 + const planeHandleOffset = gizmoLength * 0.25 const vertexById = useMemo(() => topologyVertexMap(displayTopology), [displayTopology]) const menuAnchor = useMemo(() => { const xs = displayTopology.vertices.map((vertex) => vertex.position[0]) @@ -1152,8 +1312,6 @@ function CustomMeshEditor({ } }) - modalDraftRef.current = modalDraft - const ownsEditSession = useCallback(() => { const scope = useInteractionScope.getState().scope return scope.kind === 'mesh-editing' && scope.nodeId === node.id @@ -1172,44 +1330,16 @@ function CustomMeshEditor({ useScene.getState().markDirty(node.id) endOwnedScope() setPreviewTopology(null) - setModalDraft(null) setSelectedIds([]) setActiveId(null) - setTransformTool('select') - setDragAxis(null) + setTransformTool('transform') + setActiveTransform(null) setLoopCutSegments(null) setToolbarPanel(null) setError(null) playCustomMeshSfx('finish') }, [endOwnedScope, node.id]) - const cancelModalDraft = useCallback(() => { - useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - setPreviewTopology(null) - setModalDraft(null) - setToolbarPanel(null) - setError(null) - if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) - playCustomMeshSfx('cancel') - }, [node.id, ownsEditSession]) - - const confirmModalDraft = useCallback(() => { - const draft = modalDraftRef.current - if (!draft) return - useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(node.id) - useScene.getState().updateNode(node.id, { topology: draft.topology }) - setPreviewTopology(null) - setModalDraft(null) - setMode(draft.selection.mode) - setSelectedIds(draft.selection.ids) - setActiveId(draft.selection.ids.at(-1) ?? null) - setError(null) - if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) - playCustomMeshSfx(draft.operator === 'delete' ? 'delete' : 'operation-commit') - }, [node.id, ownsEditSession]) - useEffect( () => () => { cancelDragRef.current?.() @@ -1228,10 +1358,9 @@ function CustomMeshEditor({ useLiveNodeOverrides.getState().clear(node.id) useScene.getState().markDirty(node.id) setPreviewTopology(null) - setModalDraft(null) setToolbarPanel(null) setLoopCutSegments(null) - setDragAxis(null) + setActiveTransform(null) }, [editing, node.id]) useEffect(() => { @@ -1242,12 +1371,11 @@ function CustomMeshEditor({ setToolbarPanel(null) playCustomMeshSfx('cancel') } else if (cancelDragRef.current) cancelDragRef.current() - else if (modalDraftRef.current) cancelModalDraft() else exitEditMode() } emitter.on('tool:cancel', onToolCancel) return () => emitter.off('tool:cancel', onToolCancel) - }, [cancelModalDraft, editing, exitEditMode, toolbarPanel]) + }, [editing, exitEditMode, toolbarPanel]) useEffect(() => { if (!(editing && toolbarPanel)) return @@ -1264,13 +1392,7 @@ function CustomMeshEditor({ if (!editing) return const onGridClick = () => { const scope = useInteractionScope.getState().scope - if ( - scope.kind !== 'mesh-editing' || - scope.nodeId !== node.id || - cancelDragRef.current || - modalDraftRef.current - ) - return + if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return setSelectedIds([]) setActiveId(null) setError(null) @@ -1292,7 +1414,7 @@ function CustomMeshEditor({ if (event.key === 'Tab') { event.preventDefault() event.stopImmediatePropagation() - if (cancelDragRef.current || modalDraftRef.current) return + if (cancelDragRef.current) return if (editing) { exitEditMode() } else if (useInteractionScope.getState().scope.kind === 'idle') { @@ -1300,7 +1422,7 @@ function CustomMeshEditor({ setMode('face') setSelectedIds(face ? [face.id] : []) setActiveId(face?.id ?? null) - setTransformTool('select') + setTransformTool('transform') setToolbarPanel(null) setError(null) useInteractionScope.getState().begin(meshEditScope(node.id)) @@ -1309,12 +1431,6 @@ function CustomMeshEditor({ return } if (!editing) return - if (event.key === 'Enter' && modalDraftRef.current) { - event.preventDefault() - event.stopImmediatePropagation() - confirmModalDraft() - return - } const nextMode = event.key === '1' ? 'vertex' @@ -1343,16 +1459,7 @@ function CustomMeshEditor({ } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) - }, [ - activeId, - confirmModalDraft, - editing, - exitEditMode, - mode, - node.id, - node.topology, - selectedIds, - ]) + }, [activeId, editing, exitEditMode, mode, node.id, node.topology, selectedIds]) useEffect(() => { const validIds = new Set( @@ -1372,7 +1479,7 @@ function CustomMeshEditor({ setMode('face') setSelectedIds(face ? [face.id] : []) setActiveId(face?.id ?? null) - setTransformTool('select') + setTransformTool('transform') setToolbarPanel(null) setError(null) useInteractionScope.getState().begin(meshEditScope(node.id)) @@ -1415,7 +1522,6 @@ function CustomMeshEditor({ const selectComponent = useCallback( (id: string, additive: boolean, event: ThreeEvent) => { - if (modalDraftRef.current) return if (!componentIsVisible(id, event)) return const next = selectCustomMeshComponent({ mode, ids: selectedIds, activeId }, id, additive) setSelectedIds(next.ids) @@ -1427,7 +1533,7 @@ function CustomMeshEditor({ ) const switchMode = (nextMode: ComponentMode) => { - if (cancelDragRef.current || modalDraftRef.current) return + if (cancelDragRef.current) return const converted = convertCustomMeshSelection( displayTopology, { mode, ids: selectedIds, activeId }, @@ -1454,7 +1560,7 @@ function CustomMeshEditor({ [camera, gl.domElement], ) - const beginAxisDrag = useCallback( + const beginTranslationDrag = useCallback( (axis: Axis, event: ThreeEvent) => { if (!ownsEditSession() || selectedIds.length === 0 || cancelDragRef.current) return const origin = selectionCentroid(displayTopology, selection) @@ -1463,49 +1569,51 @@ function CustomMeshEditor({ const originLocal = new Vector3(...origin) const worldOrigin = target.localToWorld(originLocal.clone()) const localAxis = new Vector3(...AXIS_VECTORS[axis]) - const worldTip = target.localToWorld(originLocal.clone().add(localAxis)) - const worldAxis = worldTip.sub(worldOrigin).normalize() + 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 baseTopology = displayTopology const baseSelection = selection const previousInputDragging = useViewer.getState().inputDragging const previousCursor = document.body.style.cursor let latestTopology: CustomMeshTopology | null = null - let latestDistance = 0 - let lastSnapDistance: number | null = null + let latestDelta: Point = [0, 0, 0] + let lastSnapDelta: string | null = null let finished = false useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'translate')) playCustomMeshSfx('drag-start') useViewer.getState().setInputDragging(true) - setDragAxis(axis) + setActiveTransform({ operation: 'translate', constraint: axis }) document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { - const parameter = closestAxisParameterToRay( - worldOrigin, - worldAxis, - makeRay(pointerEvent.clientX, pointerEvent.clientY), - ) + 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) - const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2 - let distance = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) + delta[axisIndex] = localPoint.getComponent(axisIndex) - originLocal.getComponent(axisIndex) const snapping = isGridSnapActive() && !pointerEvent.altKey if (snapping) { const step = useEditor.getState().gridSnapStep - if (step > 0) distance = Math.round(distance / step) * step + if (step > 0) { + delta[axisIndex] = Math.round(delta[axisIndex] / step) * step + } } - if (snapping && Math.abs(distance) > 1e-6 && distance !== lastSnapDistance) { - lastSnapDistance = distance + const snapDelta = delta.join(':') + const magnitude = Math.hypot(...delta) + if (snapping && magnitude > 1e-6 && snapDelta !== lastSnapDelta) { + lastSnapDelta = snapDelta playCustomMeshSfx('move-step') } else if (!snapping) { - lastSnapDistance = null + lastSnapDelta = null } - const delta: Point = [0, 0, 0] - delta[axisIndex] = distance const result = applyCustomMeshCommand(baseTopology, { type: 'translate-components', selection: baseSelection, @@ -1515,7 +1623,7 @@ function CustomMeshEditor({ setError(result.error) return } - latestDistance = distance + latestDelta = delta latestTopology = result.topology setPreviewTopology(result.topology) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) @@ -1535,8 +1643,8 @@ function CustomMeshEditor({ useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) - setDragAxis(null) - if (commit && latestTopology && Math.abs(latestDistance) > 1e-6) { + setActiveTransform(null) + if (commit && latestTopology && Math.hypot(...latestDelta) > 1e-6) { useScene.getState().updateNode(node.id, { topology: latestTopology }) playCustomMeshSfx('finish') } else if (!commit) { @@ -1592,7 +1700,7 @@ function CustomMeshEditor({ useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'rotate')) playCustomMeshSfx('drag-start') useViewer.getState().setInputDragging(true) - setDragAxis(axis) + setActiveTransform({ operation: 'rotate', constraint: axis }) document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { @@ -1650,7 +1758,7 @@ function CustomMeshEditor({ useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) - setDragAxis(null) + setActiveTransform(null) if (commit && latestTopology && Math.abs(latestAngle) > 1e-6) { useScene.getState().updateNode(node.id, { topology: latestTopology }) playCustomMeshSfx('finish') @@ -1700,7 +1808,7 @@ function CustomMeshEditor({ useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'scale')) playCustomMeshSfx('drag-start') useViewer.getState().setInputDragging(true) - setDragAxis(axis) + setActiveTransform({ operation: 'scale', constraint: axis }) document.body.style.cursor = 'grabbing' const onMove = (pointerEvent: PointerEvent) => { @@ -1753,7 +1861,7 @@ function CustomMeshEditor({ useViewer.getState().setInputDragging(previousInputDragging) document.body.style.cursor = previousCursor setPreviewTopology(null) - setDragAxis(null) + setActiveTransform(null) if (commit && latestTopology && Math.abs(latestFactor - 1) > 1e-6) { useScene.getState().updateNode(node.id, { topology: latestTopology }) playCustomMeshSfx('finish') @@ -1940,7 +2048,6 @@ function CustomMeshEditor({ let latestFactor = 0.5 let activeCuts = loopCutCount let lastSnapFactor: number | null = null - let firstStageConfirmed = false let finished = false const updatePreview = (factor: number, cuts = activeCuts) => { @@ -1976,7 +2083,6 @@ function CustomMeshEditor({ document.body.style.cursor = 'ew-resize' const onMove = (pointerEvent: PointerEvent) => { - if (!firstStageConfirmed) return const parameter = closestAxisParameterToRay( worldStart, worldAxis, @@ -2016,8 +2122,7 @@ function CustomMeshEditor({ if (finished) return finished = true window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onFirstPointerUp) - window.removeEventListener('pointerdown', onSecondPointerDown, true) + window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('blur', onPointerCancel) @@ -2042,21 +2147,11 @@ function CustomMeshEditor({ } swallowNextClick() } - const onFirstPointerUp = () => { - firstStageConfirmed = true - } - const onSecondPointerDown = (pointerEvent: PointerEvent) => { - if (!firstStageConfirmed || (pointerEvent.button !== 0 && pointerEvent.button !== 2)) return - pointerEvent.preventDefault() - pointerEvent.stopImmediatePropagation() - if (pointerEvent.button === 2) updatePreview(0.5) - finish(true) - } + const onPointerUp = () => finish(true) const onPointerCancel = () => finish(false) cancelDragRef.current = onPointerCancel window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onFirstPointerUp, { once: true }) - window.addEventListener('pointerdown', onSecondPointerDown, true) + window.addEventListener('pointerup', onPointerUp, { once: true }) window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) @@ -2064,8 +2159,8 @@ function CustomMeshEditor({ [displayTopology, loopCutCount, makeRay, node.id, ownsEditSession, target], ) - const previewCommand = (command: CustomMeshCommand, operator: ModalOperator) => { - if (cancelDragRef.current || modalDraftRef.current) return + const commitCommand = (command: CustomMeshCommand, operator: TopologyOperator) => { + if (cancelDragRef.current) return useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', operator)) const result = applyCustomMeshCommand(node.topology, command) if (!result.ok) { @@ -2073,19 +2168,19 @@ function CustomMeshEditor({ setError(result.error) return } - const draft = { topology: result.topology, selection: result.selection, operator } - setModalDraft(draft) + useScene.getState().updateNode(node.id, { topology: result.topology }) + setMode(result.selection.mode) + setSelectedIds(result.selection.ids) + setActiveId(result.selection.ids.at(-1) ?? null) setToolbarPanel(null) - setPreviewTopology(result.topology) - useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) - useScene.getState().markDirty(node.id) setError(null) + if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) + playCustomMeshSfx(operator === 'delete' ? 'delete' : 'operation-commit') } const extrudeSelectedFace = () => { if (mode !== 'face' || selectedIds.length !== 1) return - playCustomMeshSfx('operation-start') - previewCommand( + commitCommand( { type: 'extrude-face', faceId: selectedIds[0]!, distance: Number(extrudeDistance) }, 'extrude', ) @@ -2093,8 +2188,7 @@ function CustomMeshEditor({ const insetSelectedFace = () => { if (mode !== 'face' || selectedIds.length !== 1) return - playCustomMeshSfx('operation-start') - previewCommand( + commitCommand( { type: 'inset-face', faceId: selectedIds[0]!, @@ -2107,20 +2201,17 @@ function CustomMeshEditor({ const deleteSelection = () => { if (selectedIds.length === 0) return - playCustomMeshSfx('tool-select') - previewCommand({ type: 'delete-components', selection }, 'delete') + commitCommand({ type: 'delete-components', selection }, 'delete') } const mergeSelection = () => { if (mode !== 'vertex' || selectedIds.length < 2) return - playCustomMeshSfx('operation-start') - previewCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') + commitCommand({ type: 'merge-vertices', vertexIds: selectedIds }, 'merge') } const dissolveSelection = () => { if (mode !== 'edge' || selectedIds.length !== 1) return - playCustomMeshSfx('operation-start') - previewCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') + commitCommand({ type: 'dissolve-edge', edgeId: selectedIds[0]! }, 'dissolve') } const updateSelection = (next: CustomMeshSelectionState) => { @@ -2196,7 +2287,7 @@ function CustomMeshEditor({ } else if (key === 'g') { if (actions.hasSelection) { playCustomMeshSfx('tool-select') - setTransformTool('move') + setTransformTool('transform') } } else if (key === 'e') { actions.extrudeSelectedFace() @@ -2209,12 +2300,12 @@ function CustomMeshEditor({ setToolbarPanel(null) } else if (actions.hasSelection) { playCustomMeshSfx('tool-select') - setTransformTool('rotate') + setTransformTool('transform') } } else if (key === 's') { if (actions.hasSelection) { playCustomMeshSfx('tool-select') - setTransformTool('scale') + setTransformTool('transform') } } else if (key === 'm') { actions.mergeSelection() @@ -2246,32 +2337,18 @@ function CustomMeshEditor({ playCustomMeshSfx('delete') } - const componentLabel = - selectedIds.length === 1 ? mode : mode === 'vertex' ? 'vertices' : `${mode}s` const selectionStatus = formatCustomMeshSelectionStatus(mode, selectedIds.length) const operationAvailability = customMeshOperationAvailability(mode, selectedIds.length) const loopCutActive = transformTool === 'loop-cut' const bevelActive = transformTool === 'bevel' - const componentStatus = modalDraft - ? `${modalDraft.operator} preview · Enter to confirm · Esc to cancel` - : transformTool === 'loop-cut' - ? `Loop Cut · ${loopCutCount} cut${loopCutCount === 1 ? '' : 's'} · factor ${loopCutFactor.toFixed(2)} · first click chooses ring, second click confirms · wheel changes count` - : transformTool === 'bevel' - ? `Bevel · drag an edge to peel it · wheel changes segments (${bevelSegments}) · release to apply` - : selectedIds.length === 0 - ? `Click a ${mode} to select it` - : transformTool === 'move' - ? `${selectedIds.length} ${componentLabel} selected · drag an axis to move · Alt for free movement` - : transformTool === 'rotate' - ? `${selectedIds.length} ${componentLabel} selected · drag a rotation ring · Alt for free rotation` - : transformTool === 'scale' - ? `${selectedIds.length} ${componentLabel} selected · drag a colored handle to scale · Alt for free scaling` - : `${selectedIds.length} ${componentLabel} selected · choose a transform or mesh operator` - const showComponentStatus = - Boolean(error || modalDraft || dragAxis) || - transformTool === 'loop-cut' || - transformTool === 'bevel' || - selectedIds.length === 0 + const componentStatus = customMeshComponentStatus({ + mode, + selectedCount: selectedIds.length, + tool: transformTool, + loopCutCount, + loopCutFactor, + bevelSegments, + }) return ( @@ -2340,27 +2417,45 @@ function CustomMeshEditor({ ) }) : null} - {gizmoOrigin && transformTool === 'move' ? ( + {gizmoOrigin && transformTool === 'transform' ? ( {(['x', 'y', 'z'] as const).map((axis) => ( - + ))} + {(Object.keys(PLANE_NORMAL) as PlaneAxes[]).map((plane) => ( + ))} - - ) : null} - {gizmoOrigin && transformTool === 'rotate' ? ( - {(['x', 'y', 'z'] as const).map((axis) => ( ) : null} - {gizmoOrigin && transformTool === 'scale' ? ( - - {(['x', 'y', 'z'] as const).map((axis) => ( - - ))} - - ) : null} {transformTool === 'loop-cut' ? displayTopology.edges.map((edge) => { const start = vertexById.get(edge.vertexIds[0]) @@ -2419,76 +2499,40 @@ function CustomMeshEditor({ style={{ transformOrigin: 'center center' }} > {editing ? ( -
-
- setTransformTool('select')} - > - - - setTransformTool('move')} - > - - - setTransformTool('rotate')} - > - - - setTransformTool('scale')} - > - - -
- -
- switchMode('vertex')} - > - - - switchMode('edge')} - > - - - switchMode('face')} - > - - -
- - +
+ setTransformTool('transform')} + > + + + switchMode('vertex')} + > + + + switchMode('edge')} + > + + + switchMode('face')} + > + + + {selectionStatus} @@ -2497,12 +2541,11 @@ function CustomMeshEditor({ aria-expanded={toolbarPanel === 'operations'} aria-haspopup="dialog" className={cn( - 'flex h-9 min-w-28 items-center justify-center gap-2 rounded-lg px-3 text-xs transition-colors disabled:opacity-35', + 'flex h-7 min-w-24 items-center justify-center gap-1.5 rounded-md px-2 text-xs transition-colors disabled:opacity-35', toolbarPanel === 'operations' - ? 'bg-accent text-foreground ring-1 ring-border/60 ring-inset' - : 'text-muted-foreground hover:bg-accent/70 hover:text-foreground', + ? 'bg-accent text-foreground' + : 'text-muted-foreground hover:bg-accent hover:text-foreground', )} - disabled={Boolean(modalDraft)} onClick={(event) => { event.stopPropagation() playCustomMeshSfx('tool-select') @@ -2516,8 +2559,8 @@ function CustomMeshEditor({ {toolbarPanel === 'operations' ? ( - -
+ +
- - {modalDraft ? ( - <> - - - - - - - - ) : ( - - - - )} + + +
setToolbarPanel((current) => (current === 'selection' ? null : 'selection')) @@ -2723,7 +2745,7 @@ function CustomMeshEditor({ ) : ( )} - {editing && showComponentStatus ? ( + {editing && (error || componentStatus) ? (
{ useEditor.getState().gridSnapStep, !isGridSnapActive(), ) - const node = CustomMeshNode.parse({ + const draftNode = CustomMeshNode.parse({ ...customMeshDefinition.defaults(), name: 'Custom Mesh', parentId: activeLevelId, position, }) - const placement = canPlaceOnFloor(activeLevelId, position, size, [0, node.rotation, 0]) + const placement = canPlaceOnFloor(activeLevelId, position, size, [0, draftNode.rotation, 0]) setValidPlacement(placement.valid) if (!placement.valid) { stopPlacementCommitPropagation(event) return } + const node = CustomMeshNode.parse({ + ...draftNode, + ...resolveSupportSlabPatch(draftNode, sceneApi.nodes(), { pinSupport: true }), + }) sceneApi.upsert(node, activeLevelId) selectNode(node.id) triggerSFX('sfx:structure-build') diff --git a/packages/nodes/src/custom-mesh/toolbar-state.test.ts b/packages/nodes/src/custom-mesh/toolbar-state.test.ts index 4ea064c19d..acf9c85571 100644 --- a/packages/nodes/src/custom-mesh/toolbar-state.test.ts +++ b/packages/nodes/src/custom-mesh/toolbar-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { customMeshBevelWidthFromDrag, + customMeshComponentStatus, customMeshOperationAvailability, customMeshScaleFactorFromDrag, customMeshScaleFactors, @@ -35,6 +36,19 @@ describe('custom mesh toolbar state', () => { expect(formatCustomMeshSelectionStatus('vertex', 3)).toBe('3 VERTICES') }) + test('does not show a secondary help strip for a selected transform', () => { + expect( + customMeshComponentStatus({ + mode: 'face', + selectedCount: 1, + tool: 'transform', + loopCutCount: 1, + loopCutFactor: 0.5, + bevelSegments: 6, + }), + ).toBeNull() + }) + test('builds uniform and axis-specific scale factors', () => { expect(customMeshScaleFactors('uniform', 1.5)).toEqual([1.5, 1.5, 1.5]) expect(customMeshScaleFactors('x', 1.5)).toEqual([1.5, 1, 1]) diff --git a/packages/nodes/src/custom-mesh/toolbar-state.ts b/packages/nodes/src/custom-mesh/toolbar-state.ts index eae20cd33c..bff42f254e 100644 --- a/packages/nodes/src/custom-mesh/toolbar-state.ts +++ b/packages/nodes/src/custom-mesh/toolbar-state.ts @@ -1,5 +1,6 @@ export type CustomMeshToolbarMode = 'vertex' | 'edge' | 'face' export type CustomMeshScaleAxis = 'uniform' | 'x' | 'y' | 'z' +export type CustomMeshTransformTool = 'transform' | 'loop-cut' | 'bevel' export type CustomMeshOperationAvailability = { extrude: boolean @@ -30,6 +31,30 @@ export function formatCustomMeshSelectionStatus( return `${selectedCount} ${label}`.toUpperCase() } +export function customMeshComponentStatus({ + mode, + selectedCount, + tool, + loopCutCount, + loopCutFactor, + bevelSegments, +}: { + mode: CustomMeshToolbarMode + selectedCount: number + tool: CustomMeshTransformTool + loopCutCount: number + loopCutFactor: number + bevelSegments: 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` + } + return selectedCount === 0 ? `Click a ${mode} to select it` : null +} + export function customMeshScaleFactors( axis: CustomMeshScaleAxis, factor: number, diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 35c1bf6b3b..77238ea1d2 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -34,6 +34,7 @@ import { isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, + type PointerSupportSurface, publishPlacementSurface, resolvePointerSupportSurface, type SegmentAngleReference, @@ -477,6 +478,7 @@ const StraightFenceTool: React.FC = () => { const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) + const constructionSurface = useRef(null) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -524,6 +526,7 @@ const StraightFenceTool: React.FC = () => { const stopDrafting = () => { buildingState.current = 0 + constructionSurface.current = null previewRef.current.visible = false setDraftMeasurement(null) setAxisGuide(null) @@ -542,14 +545,20 @@ const StraightFenceTool: React.FC = () => { // (`event.localPosition[1]`) sits at the lift the committed fence // will get. Aiming past the deck edge drops it back to the floor. const pointed = pointedSurfaceFor(cameraRef.current, event) - if (pointed) { + const activeSurface = constructionSurface.current ?? pointed + if (activeSurface) { publishPlacementSurface( - surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + surfacePointScratch.set(event.position[0], activeSurface.worldY, event.position[2]), SURFACE_UP, ) } + const activeY = activeSurface?.localPoint?.[1] ?? event.localPosition[1] const { walls, fences } = getCurrentLevelElements() - const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] + const pointedLocal = buildingState.current === 0 ? pointed?.localPoint : null + const localPoint: FencePlanPoint = [ + pointedLocal?.[0] ?? event.localPosition[0], + pointedLocal?.[2] ?? event.localPosition[2], + ] // While drafting, the segment locks to 15° rays from its start. // Snapping is governed by the snapping mode (`'off'` is the bypass); // there is no Shift hold-to-bypass. Alignment follows the magnetic snap @@ -568,7 +577,7 @@ const StraightFenceTool: React.FC = () => { }), { applySnap: !angleLocked }, ) - endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + endingPoint.current.set(snappedLocal[0], activeY, snappedLocal[1]) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z]) draftPreview.setFenceDraftEnd(snappedLocal) @@ -619,7 +628,7 @@ const StraightFenceTool: React.FC = () => { magnetic: isMagneticSnapActive(), }), ) - cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) + cursorRef.current.position.set(snappedPoint[0], activeY, snappedPoint[1]) setDraftMeasurement(null) setAxisGuide(null) } @@ -633,6 +642,7 @@ const StraightFenceTool: React.FC = () => { } const { walls, fences } = getCurrentLevelElements() + const pointed = pointedSurfaceFor(cameraRef.current, event) const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] if (buildingState.current === 0) { @@ -644,7 +654,12 @@ const StraightFenceTool: React.FC = () => { magnetic: isMagneticSnapActive(), }), ) - startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + startingPoint.current.set( + snappedStart[0], + pointed?.localPoint?.[1] ?? event.localPosition[1], + snappedStart[1], + ) + constructionSurface.current = pointed endingPoint.current.copy(startingPoint.current) buildingState.current = 1 const draftPreview = useFloorplanDraftPreview.getState() @@ -675,11 +690,15 @@ const StraightFenceTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return - const pointed = pointedSurfaceFor(cameraRef.current, event) + const pointedSurface = constructionSurface.current ?? pointed const createdFence = createFenceOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, - { supportCap: pointed ? pointed.elevation : null }, + { + supportCap: pointedSurface?.elevation ?? null, + preferredSupportSlabId: pointedSurface?.supportSlabId ?? null, + constructionElevation: pointedSurface?.sourceNodeId ? pointedSurface.elevation : null, + }, ) if (!createdFence) return @@ -700,7 +719,11 @@ const StraightFenceTool: React.FC = () => { // chains its next segment from the same point (its own snap // pipeline can resolve a slightly different endpoint). useSegmentDraftChain.getState().setChainStart('fence', [nextStart[0], nextStart[1]]) - startingPoint.current.set(nextStart[0], event.localPosition[1], nextStart[1]) + startingPoint.current.set( + nextStart[0], + constructionSurface.current?.localPoint?.[1] ?? event.localPosition[1], + nextStart[1], + ) endingPoint.current.copy(startingPoint.current) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftEnd(null) @@ -806,7 +829,7 @@ const SplineFenceDraft: React.FC = () => { cameraRef.current = camera // Pointer cap for the commit (Enter / double-click carry no useful grid // event of their own) — last resolved on move/click. - const supportCapRef = useRef(null) + const supportSurfaceRef = useRef(null) const draftRef = useRef(draftPoints) draftRef.current = draftPoints @@ -831,7 +854,11 @@ const SplineFenceDraft: React.FC = () => { const points = draftRef.current if (points.length >= 2) { const created = createSplineFenceOnCurrentLevel(points, undefined, { - supportCap: supportCapRef.current, + supportCap: supportSurfaceRef.current?.elevation ?? null, + preferredSupportSlabId: supportSurfaceRef.current?.supportSlabId ?? null, + constructionElevation: supportSurfaceRef.current?.sourceNodeId + ? supportSurfaceRef.current.elevation + : null, }) if (created) { triggerSFX('sfx:item-place') @@ -848,27 +875,37 @@ const SplineFenceDraft: React.FC = () => { const trackPointedSurface = (event: GridEvent) => { const pointed = pointedSurfaceFor(cameraRef.current, event) - if (!pointed) return - supportCapRef.current = pointed.elevation + if (!pointed) return null + if (draftRef.current.length === 0) supportSurfaceRef.current = pointed + const activeSurface = supportSurfaceRef.current ?? pointed publishPlacementSurface( - surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + surfacePointScratch.set(event.position[0], activeSurface.worldY, event.position[2]), SURFACE_UP, ) - setLiftY(event.localPosition[1]) + setLiftY(activeSurface.localPoint?.[1] ?? event.localPosition[1]) + return pointed } const onMove = (event: GridEvent) => { - trackPointedSurface(event) - setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) + const pointed = trackPointedSurface(event) + setCursor( + snapPoint([ + pointed?.localPoint?.[0] ?? event.localPosition[0], + pointed?.localPoint?.[2] ?? event.localPosition[2], + ]), + ) } const onClick = (event: GridEvent) => { - trackPointedSurface(event) + const pointed = trackPointedSurface(event) if (event.nativeEvent.detail >= 2) { commit() return } - const point = snapPoint([event.localPosition[0], event.localPosition[2]]) + const point = snapPoint([ + pointed?.localPoint?.[0] ?? event.localPosition[0], + pointed?.localPoint?.[2] ?? event.localPosition[2], + ]) triggerSFX('sfx:grid-snap') setDraftPoints((prev) => [...prev, point]) } diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index e5b3841dcc..0513ec9da4 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -25,6 +25,7 @@ export const FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS = [ 'roof-segment', 'stair', 'stair-segment', + 'custom-mesh', ] as const export type FloorPlacementClickTriggerEvent = GridEvent | NodeEvent diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index b376b81555..9ff049a8ef 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -480,6 +480,7 @@ export const WallTool: React.FC = () => { // snapping onto the chain's own segments never reads as a join. const chainWallIds = useRef([]) const constructionPlane = useRef(null) + const flatConstructionBase = useRef(false) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) const [axisGuide, setAxisGuide] = useState(null) @@ -604,6 +605,7 @@ export const WallTool: React.FC = () => { const stopDrafting = () => { buildingState.current = 0 constructionPlane.current = null + flatConstructionBase.current = false chainFirstVertex.current = null chainWallIds.current = [] const draftPreview = useFloorplanDraftPreview.getState() @@ -754,6 +756,7 @@ export const WallTool: React.FC = () => { : null) ?? resolveEventConstructionPlane(event, pointed) const plane = resampleTerrainConstructionPlane(resolvedPlane, snappedStart) constructionPlane.current = plane + flatConstructionBase.current = pointed?.sourceNodeId != null publishHorizontalConstructionPlane(event, plane) gridPosition = snappedStart startingPoint.current.set(snappedStart[0], plane.localY, snappedStart[1]) @@ -798,6 +801,8 @@ export const WallTool: React.FC = () => { preferredSupportSlabId: constructionPlane.current?.supportSlabId ?? null, constructionElevation: constructionPlane.current?.elevation ?? null, constructionHeight: previewHeightRef.current, + constructionSourceNodeId: constructionPlane.current?.sourceNodeId ?? null, + flatConstructionBase: flatConstructionBase.current, }, ) if (!createdWall) return diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 44baec32a5..f774046673 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -26,7 +26,7 @@ The invariant, in one sentence: | `slab.recessed` | Recess intent: open shell whose floor is `elevation` and whose rim is `recessedRimElevation`. Excluded from "covering" queries and wall-face adoption. | Solid slab. | | `slab.recessedRimElevation` | Optional rim anchor for a raised/lowered recess. Relative presets preserve this anchor while changing depth. | Level plane (`0`), preserving legacy pools. | | `slab.fillToTerrain` | Adds a terrain-following perimeter foundation below a solid slab's fixed underside. The walking surface and authored structural thickness stay flat. | No terrain foundation. | -| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. | Support is elected per query (coverage election for walls, footprint max for items). | +| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. Structural custom meshes always pin their placement-time host so a room slab generated above them cannot feed back and lift the platform. | Support is elected per query (coverage election for walls, footprint max for items). | | `wall.supportOffset` | Optional level-local delta from the elected support. Terrain wall chains use it to keep every segment on the first point's construction plane while storing only one number, never terrain samples. | Zero offset: the wall sits directly on its elected slab or sculpted ground source. | | `fence.supportOffset` | Optional level-local delta from the fence's slab host or level plane. It translates the complete fence while preserving height. | Zero offset: the fence sits directly on its host or level plane. | | `wall.fillToTerrain` | Extends the wall downward from its authored base to the terrain with independently sampled left/right faces. The wall body height and top stay unchanged. | Fixed base with no terrain infill. | @@ -66,7 +66,7 @@ Two schema rules protect these semantics: ## Pointer-decided placement -Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest eligible surface plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck. Wall drafting may additionally include upward-facing wall, stackable-item, and column meshes. Those node-top hits freeze a scalar construction plane for the throw; they are not a persistent hosting edge and do not follow later host edits. Commits persist the elected slab/ground source plus `wall.supportOffset`. 2D floorplan placement has no camera ray and keeps max-election. +Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest eligible surface plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck. Upward-facing custom-mesh geometry is a shared placement surface for slabs, fences, columns, stairs, items, and registry-driven floor objects; wall drafting may additionally include upward-facing wall, stackable-item, and column geometry. Those node-top hits freeze a scalar construction plane for the throw; they are not a persistent hosting edge and do not follow later host edits. Slabs store the plane as `elevation`, walls and fences as `supportOffset`, and floor-placed position nodes as their canonical Y offset. Each also pins the slab or ground beneath the custom mesh, preventing a later generated slab from feeding back and lifting the placed object. Ordinary slab/ground hits persist their elected support source and retain the normal stepped-base behavior. 2D floorplan placement has no camera ray and keeps max-election. Wall and slab drafting share the horizontal construction-plane resolver. A slab freezes the first snapped vertex's plane, keeps later vertices on that flat plane, and translates its authored From 5bbbde0fdd4e98874a58d974379aa6c970b99e8a Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 12 Aug 2026 13:30:45 +0530 Subject: [PATCH 07/10] fix: make surface placement registry-driven --- packages/core/src/events/bus.ts | 5 + .../tools/item/use-placement-coordinator.tsx | 14 +- .../registry/move-registry-node-tool.tsx | 45 ++----- .../tools/shared/pointer-support-cap.test.ts | 122 +++++++----------- .../tools/shared/pointer-support-cap.ts | 13 +- .../src/components/tools/stair/stair-tool.tsx | 35 +---- .../src/components/tools/tool-manager.tsx | 9 +- .../components/ui/helpers/helper-manager.tsx | 6 +- .../src/components/ui/helpers/item-helper.tsx | 2 +- packages/editor/src/lib/interaction/scope.ts | 2 + packages/editor/src/lib/snapping-mode.test.ts | 15 ++- packages/editor/src/lib/snapping-mode.ts | 5 +- packages/nodes/src/column/definition.ts | 1 + packages/nodes/src/custom-mesh/tool.tsx | 55 ++++++-- packages/nodes/src/item/definition.ts | 8 ++ .../nodes/src/shared/floor-placement.test.ts | 54 +++++++- packages/nodes/src/shared/floor-placement.ts | 44 ++----- packages/nodes/src/wall/definition.ts | 8 +- packages/viewer/src/hooks/use-node-events.ts | 1 + 19 files changed, 223 insertions(+), 221 deletions(-) diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 8783fa2937..799315f439 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -160,6 +160,10 @@ type GridEvents = { [K in `grid:${EventSuffix}`]: GridEvent } +type GenericNodeEvents = { + [K in `node:${EventSuffix}`]: NodeEvent +} + export interface CameraControlEvent { nodeId: AnyNode['id'] } @@ -291,6 +295,7 @@ type SelectionEvents = { } type EditorEvents = GridEvents & + GenericNodeEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & NodeEvents<'cabinet', CabinetEvent> & 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 f554af52ec..c0426fa62c 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -2276,12 +2276,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (placementState.current.surface !== 'floor') return onGridClick(event as unknown as GridEvent) } - emitter.on('wall:click', commitFloorOnSurfaceClick as never) - emitter.on('item:click', commitFloorOnSurfaceClick as never) - emitter.on('ceiling:click', commitFloorOnSurfaceClick as never) - emitter.on('roof:click', commitFloorOnSurfaceClick as never) - emitter.on('shelf:click', commitFloorOnSurfaceClick as never) - emitter.on('custom-mesh:click', commitFloorOnSurfaceClick as never) + emitter.on('node:click', commitFloorOnSurfaceClick as never) if (dragMode) window.addEventListener('pointerup', onReleaseCommit) return () => { @@ -2317,12 +2312,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) - emitter.off('wall:click', commitFloorOnSurfaceClick as never) - emitter.off('item:click', commitFloorOnSurfaceClick as never) - emitter.off('ceiling:click', commitFloorOnSurfaceClick as never) - emitter.off('roof:click', commitFloorOnSurfaceClick as never) - emitter.off('shelf:click', commitFloorOnSurfaceClick as never) - emitter.off('custom-mesh:click', commitFloorOnSurfaceClick as never) + emitter.off('node:click', commitFloorOnSurfaceClick as never) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index c49f823db1..feb34b72af 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -10,7 +10,6 @@ import { bboxCornerAnchors, collectAlignmentAnchors, createSceneApi, - type EventSuffix, emitter, footprintAABBFrom, type GridEvent, @@ -207,12 +206,10 @@ const ALIGNMENT_THRESHOLD_M = 0.08 * Cancel imperatively snaps the mesh back to its original position and * resumes history without ever having touched the store mid-drag. * - * **Commit triggers**: the tool listens for `grid:click` *and* the - * common node click events (shelf / item / slab / ceiling / wall / - * fence / column / roof / stair). A click on the grid plane fires - * `grid:click`; a click on the moved node itself (or any other 3D - * geometry the ray happens to land on) fires the corresponding node - * click event. Without the node-click listeners, clicking on the + * **Commit triggers**: the tool listens for `grid:click` and the generic + * `node:click` event. A click on the grid plane fires `grid:click`; a click + * on the moved node itself (or any other 3D geometry the ray happens to land + * on) fires `node:click`. Without the node-click listener, clicking on the * cursor's own mesh during a move would silently drop the commit — * the user perceives "click did nothing" because the click hit the * vertical face of e.g. a shelf instead of the grid plane below it. @@ -223,21 +220,6 @@ const ALIGNMENT_THRESHOLD_M = 0.08 */ type ClickTriggerEvent = GridEvent | NodeEvent -const CLICK_TRIGGER_KINDS = [ - 'shelf', - 'item', - 'slab', - 'ceiling', - 'wall', - 'fence', - 'column', - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', - 'custom-mesh', -] as const - export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Live camera ref — the pointer-surface cap reconstructs the cursor world // ray (camera → grid hit) to find which walking surface is aimed at. @@ -816,8 +798,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { * AND scene updated) — never the original. */ const commitAtCursor = (event: ClickTriggerEvent) => { - // One physical click can reach here twice: node clicks (`slab:click`, - // `item:click`, …) are synthesized on *pointerup* (`use-node-events`), + // One physical click can reach here twice: `node:click` is synthesized + // on *pointerup* (`use-node-events`), // while `grid:click` rides the browser's native *click* event from a // canvas DOM listener (`use-grid-events`) that deliberately ignores // stopPropagation — and this effect stays subscribed until React @@ -1041,15 +1023,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } window.addEventListener('pointerup', onPlacementDragPointerUp) - // Listen on every common kind's click event too. mitt's typing keeps - // `${kind}:click` as a fixed union so the cast is safe at runtime — - // we're just routing them through the shared commit path. - type SuffixedKey = `${K}:${EventSuffix}` - type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> - for (const kind of CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.on(key, commitAtCursor as never) - } + emitter.on('node:click', commitAtCursor) const onCancel = () => { useLiveTransforms.getState().clear(node.id) @@ -1074,10 +1048,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { emitter.off('grid:move', onGridMove) emitter.off('grid:click', commitAtCursor) window.removeEventListener('pointerup', onPlacementDragPointerUp) - for (const kind of CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.off(key, commitAtCursor as never) - } + emitter.off('node:click', commitAtCursor) emitter.off('tool:cancel', onCancel) // Restore the moved meshes' raycast so they're hoverable / selectable // again after the drag ends. diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index a55ce1f04c..a9cf565981 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -1,8 +1,11 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, + type AnyNodeDefinition, type AnyNodeId, getWallBaseElevationForNodes, + nodeRegistry, + registerNode, type SlabNode, sceneRegistry, spatialGridManager, @@ -10,14 +13,28 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BoxGeometry, Mesh, MeshBasicMaterial, PerspectiveCamera } from 'three' +import { z } from 'zod' import { createWallOnCurrentLevel } from '../wall/wall-drafting' import { resolvePointerSupportSurface } from './pointer-support-cap' const LEVEL_ID = 'level_test' as AnyNodeId const WALL_ID = 'wall_test' as AnyNodeId -const CUSTOM_MESH_ID = 'custom-mesh_test' as AnyNodeId +const PLATFORM_ID = 'plugin-platform_test' as AnyNodeId +const PLATFORM_KIND = 'plugin-platform' describe('resolvePointerSupportSurface node tops', () => { + beforeAll(() => { + if (nodeRegistry.has(PLATFORM_KIND)) return + registerNode({ + kind: PLATFORM_KIND, + schemaVersion: 1, + schema: z.object({}), + category: 'structure', + defaults: () => ({}), + capabilities: { surfaces: { top: { height: 2 } } }, + } as unknown as AnyNodeDefinition) + }) + beforeEach(() => { spatialGridManager.clear() sceneRegistry.clear() @@ -63,66 +80,29 @@ describe('resolvePointerSupportSurface node tops', () => { sceneRegistry.clear() }) - test('prefers the nearest upward-facing registered node surface over the ground', () => { - const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial()) - wallMesh.position.y = 1 - wallMesh.updateMatrixWorld(true) - sceneRegistry.nodes.set(WALL_ID, wallMesh) - sceneRegistry.byType.wall!.add(WALL_ID) - - const camera = new PerspectiveCamera() - camera.position.set(0, 5, 0) - camera.updateMatrixWorld(true) - - const support = resolvePointerSupportSurface(camera, [0, 0, 0], { - includeNodeTopSurfaces: true, - }) - - expect(support?.sourceNodeId).toBe(WALL_ID) - expect(support?.elevation).toBeCloseTo(2) - expect(support?.worldPoint).toEqual([0, 2, 0]) - }) - - test('keeps the ground result when node-top surfaces are not requested', () => { - const wallMesh = new Mesh(new BoxGeometry(2, 2, 0.2), new MeshBasicMaterial()) - wallMesh.position.y = 1 - wallMesh.updateMatrixWorld(true) - sceneRegistry.nodes.set(WALL_ID, wallMesh) - sceneRegistry.byType.wall!.add(WALL_ID) - - const camera = new PerspectiveCamera() - camera.position.set(0, 5, 0) - camera.updateMatrixWorld(true) - - const support = resolvePointerSupportSurface(camera, [0, 0, 0]) - - expect(support?.sourceNodeId).toBeNull() - expect(support?.elevation).toBe(0) - }) - - test('uses an upward-facing custom mesh for ordinary placement tools', () => { + const addPluginPlatform = (z = 0) => { useScene.setState((state) => ({ nodes: { ...state.nodes, - [CUSTOM_MESH_ID]: { - id: CUSTOM_MESH_ID, - type: 'custom-mesh', + [PLATFORM_ID]: { + id: PLATFORM_ID, + type: PLATFORM_KIND, object: 'node', parentId: LEVEL_ID, visible: true, metadata: {}, - children: [], - position: [0, 0, 0], - rotation: 0, - topology: { vertices: [], edges: [], faces: [] }, - } as AnyNode, + } as unknown as AnyNode, }, })) const platformMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial()) - platformMesh.position.y = 1 + platformMesh.position.set(0, 1, z) platformMesh.updateMatrixWorld(true) - sceneRegistry.nodes.set(CUSTOM_MESH_ID, platformMesh) - sceneRegistry.byType['custom-mesh']!.add(CUSTOM_MESH_ID) + sceneRegistry.nodes.set(PLATFORM_ID, platformMesh) + sceneRegistry.byType[PLATFORM_KIND]!.add(PLATFORM_ID) + } + + test('discovers a plugin-declared top surface without a kind-name list', () => { + addPluginPlatform() const camera = new PerspectiveCamera() camera.position.set(0, 5, 0) @@ -130,12 +110,27 @@ describe('resolvePointerSupportSurface node tops', () => { const support = resolvePointerSupportSurface(camera, [0, 0, 0]) - expect(support?.sourceNodeId).toBe(CUSTOM_MESH_ID) + expect(support?.sourceNodeId).toBe(PLATFORM_ID) expect(support?.elevation).toBeCloseTo(2) expect(support?.worldPoint).toEqual([0, 2, 0]) }) - test('uses an upward-facing custom mesh as the wall construction surface', () => { + test('keeps the ground result when node-top surfaces are explicitly disabled', () => { + addPluginPlatform() + + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 0) + camera.updateMatrixWorld(true) + + const support = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: false, + }) + + expect(support?.sourceNodeId).toBeNull() + expect(support?.elevation).toBe(0) + }) + + test('uses a plugin-declared top as the wall construction surface', () => { const lowSlab = { id: 'slab_low', type: 'slab', @@ -173,28 +168,11 @@ describe('resolvePointerSupportSurface node tops', () => { ...state.nodes, [lowSlab.id]: lowSlab, [highSlab.id]: highSlab, - [CUSTOM_MESH_ID]: { - id: CUSTOM_MESH_ID, - type: 'custom-mesh', - object: 'node', - parentId: LEVEL_ID, - visible: true, - metadata: {}, - children: [], - position: [0, 0, 0], - rotation: 0, - topology: { vertices: [], edges: [], faces: [] }, - } as AnyNode, }, })) spatialGridManager.handleNodeCreated(lowSlab as AnyNode, LEVEL_ID) spatialGridManager.handleNodeCreated(highSlab as AnyNode, LEVEL_ID) - const platformMesh = new Mesh(new BoxGeometry(4, 2, 4), new MeshBasicMaterial()) - platformMesh.position.y = 1 - platformMesh.position.z = 2 - platformMesh.updateMatrixWorld(true) - sceneRegistry.nodes.set(CUSTOM_MESH_ID, platformMesh) - sceneRegistry.byType['custom-mesh']!.add(CUSTOM_MESH_ID) + addPluginPlatform(2) const camera = new PerspectiveCamera() camera.position.set(0, 5, 2) @@ -204,7 +182,7 @@ describe('resolvePointerSupportSurface node tops', () => { includeNodeTopSurfaces: true, }) - expect(support?.sourceNodeId).toBe(CUSTOM_MESH_ID) + expect(support?.sourceNodeId).toBe(PLATFORM_ID) expect(support?.elevation).toBeCloseTo(2) expect(support?.worldPoint).toEqual([0, 2, 2]) diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index aab9473da8..ef2621db56 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -4,6 +4,7 @@ import { GROUND_SUPPORT_ID, type ItemNode, isLowProfileItemSurface, + nodeRegistry, sceneRegistry, spatialGridManager, useScene, @@ -22,9 +23,6 @@ const nodeTopRaycaster = new Raycaster() const nodeTopNormal = new Vector3() const nodeTopNormalMatrix = new Matrix3() -const NODE_TOP_SURFACE_KINDS = ['wall', 'item', 'column', 'custom-mesh'] as const -const DEFAULT_NODE_TOP_SURFACE_KINDS = ['custom-mesh'] as const - export type PointerSupportSurface = { /** Level-local elevation of the pointed surface — the election cap. */ elevation: number @@ -152,9 +150,12 @@ export function resolvePointerSupportSurface( localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] } - const nodeTopSurfaceKinds = options?.includeNodeTopSurfaces - ? NODE_TOP_SURFACE_KINDS - : DEFAULT_NODE_TOP_SURFACE_KINDS + const nodeTopSurfaceKinds = + options?.includeNodeTopSurfaces === false + ? [] + : Array.from(nodeRegistry.entries()) + .filter(([, definition]) => definition.capabilities.surfaces?.top !== undefined) + .map(([kind]) => kind) if (nodeTopSurfaceKinds.some((kind) => (sceneRegistry.byType[kind]?.size ?? 0) > 0)) { nodeTopRaycaster.set(worldRayOrigin, worldRayDirection.clone().normalize()) const nodes = useScene.getState().nodes diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 9aa266c9fd..b5b841c9ee 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -2,7 +2,6 @@ import { type AnyNode, collectAlignmentAnchors, createSurfaceOpeningPreviewController, - type EventSuffix, emitter, type GridEvent, type LevelNode, @@ -68,21 +67,6 @@ const ALIGNMENT_THRESHOLD_M = 0.08 type ClickTriggerEvent = GridEvent | NodeEvent type MoveTriggerEvent = GridEvent | NodeEvent -const CLICK_TRIGGER_KINDS = [ - 'shelf', - 'item', - 'slab', - 'ceiling', - 'wall', - 'fence', - 'column', - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', - 'custom-mesh', -] as const - /** * Generates the step-profile geometry for the ghost preview. * Same algorithm as StairSystem's generateStairSegmentGeometry. @@ -566,26 +550,15 @@ export const StairTool: React.FC = () => { emitter.on('grid:move', onPointerMove) emitter.on('grid:click', commitAtCursor) - type SuffixedKey = `${K}:${EventSuffix}` - type ClickKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> - type MoveKey = SuffixedKey<(typeof CLICK_TRIGGER_KINDS)[number]> - for (const kind of CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.on(key, commitAtCursor as never) - const moveKey = `${kind}:move` as MoveKey - emitter.on(moveKey, onPointerMove as never) - } + emitter.on('node:click', commitAtCursor) + emitter.on('node:move', onPointerMove) window.addEventListener('keydown', onKeyDown) return () => { emitter.off('grid:move', onPointerMove) emitter.off('grid:click', commitAtCursor) - for (const kind of CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.off(key, commitAtCursor as never) - const moveKey = `${kind}:move` as MoveKey - emitter.off(moveKey, onPointerMove as never) - } + emitter.off('node:click', commitAtCursor) + emitter.off('node:move', onPointerMove) window.removeEventListener('keydown', onKeyDown) useAlignmentGuides.getState().clear() openingPreview.clear() diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index f70203fa18..4c3c01b592 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense, useMemo } from 'react' import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary' import useEditor, { type Phase, type Tool } from '../../store/use-editor' -import { +import useInteractionScope, { useControlPointReshape, useEditingHole, useEndpointReshape, @@ -105,6 +105,9 @@ export const ToolManager: React.FC = () => { const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const movingNode = useMovingNode() + const registryToolOwnsPlacement = useInteractionScope( + (state) => state.scope.kind === 'placing' && state.scope.driver === 'registry-tool', + ) const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin) const endpointReshape = useEndpointReshape() const controlPointReshape = useControlPointReshape() @@ -244,7 +247,7 @@ export const ToolManager: React.FC = () => { // (the scene writes the overlay makes still mirror into the 3D view). A // 3D-initiated move leaves the origin null until its own commit, so this only // suppresses the 3D tool for genuinely 2D-owned moves. - const showMover = movingNode != null && movingNodeOrigin !== '2d' + const showMover = movingNode != null && movingNodeOrigin !== '2d' && !registryToolOwnsPlacement // Registry-first: if the active tool's kind has a NodeDefinition with a // tool contribution, the registry-driven tool takes over. @@ -386,7 +389,7 @@ export const ToolManager: React.FC = () => { )} {/* Registry-first: when the active tool's kind has a registered NodeDefinition with a tool contribution, mount it here. */} - {!movingNode && useRegistryTool && RegistryToolComponent && ( + {(!movingNode || registryToolOwnsPlacement) && useRegistryTool && RegistryToolComponent && ( diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index ff1f2af62f..217fcdaeb1 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -227,13 +227,13 @@ export function HelperManager() { const movingContinuationContext = isFreshPlacementMetadata(movingNode.metadata) ? continuationContextOf(movingNode.type) : null - // Force-place only makes sense for kinds that collision-validate their drop; - // structural kinds (wall/slab/…) never reject, so don't advertise Alt. + const collisionValidatesDrop = + nodeRegistry.get(movingNode.type)?.capabilities.floorPlaced?.collides === true return ( ) diff --git a/packages/editor/src/components/ui/helpers/item-helper.tsx b/packages/editor/src/components/ui/helpers/item-helper.tsx index e20947b800..02138eb9b1 100644 --- a/packages/editor/src/components/ui/helpers/item-helper.tsx +++ b/packages/editor/src/components/ui/helpers/item-helper.tsx @@ -6,7 +6,7 @@ interface ItemHelperProps { showEsc?: boolean snapContext?: SnapContext | null // Whether to advertise Alt = force-place. Only meaningful for kinds that - // collision-validate their drop (structural kinds never reject, so it's hidden). + // collision-validate their drop. showForce?: boolean // Set for a fresh point-kind placement (e.g. a positioned preset) so the // once/repeat continuation chip shows; null for an existing-node move. diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 52022082a8..5f610202f0 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -35,6 +35,8 @@ export type InteractionScope = nodeType: string view: InteractionView pressDrag: boolean + /** Omitted means the generic move tool, preserving existing producers. */ + driver?: 'move-tool' | 'registry-tool' } // Moving an existing node. | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView } diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index cd343c2dbf..3a1641646c 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -102,6 +102,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => { nodeId?: string tool?: string handle?: string + operator?: string }, mode = 'select', tool: string | null = null, @@ -122,8 +123,18 @@ describe('snapContextOf (profile-driven, node-declared)', () => { ).toBeNull() }) - it('gives custom-mesh edit mode the shared grid and angle context', () => { - expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1' })).toBe('wall') + it('gives mesh rotation the angle context and other edit operations polygon snapping', () => { + expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1' })).toBe('polygon') + expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1', operator: 'translate' })).toBe( + 'polygon', + ) + expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1', operator: 'scale' })).toBe( + 'polygon', + ) + expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1', operator: 'loop-cut' })).toBe( + 'polygon', + ) + expect(ctx({ kind: 'mesh-editing', nodeId: 'custom-mesh_1', operator: 'rotate' })).toBe('wall') }) it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => { diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 3c97260ce0..ecd5869374 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -148,6 +148,7 @@ export function snapContextOf(args: { nodeId?: string tool?: string handle?: string + operator?: string } mode: string tool: string | null @@ -167,7 +168,9 @@ export function snapContextOf(args: { } switch (scope.kind) { case 'mesh-editing': - return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), true) : null + return scope.nodeId + ? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate') + : null case 'handle-drag': if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 109df7c8b7..d5557e1b68 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -346,6 +346,7 @@ export const columnDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, + surfaces: { top: { height: (node) => (node as ColumnNodeType).height } }, duplicable: true, deletable: true, // Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the diff --git a/packages/nodes/src/custom-mesh/tool.tsx b/packages/nodes/src/custom-mesh/tool.tsx index 1c6aa20649..6b52b9abbe 100644 --- a/packages/nodes/src/custom-mesh/tool.tsx +++ b/packages/nodes/src/custom-mesh/tool.tsx @@ -17,6 +17,7 @@ import { triggerSFX, useAlignmentGuides, useEditor, + useInteractionScope, useRegistryToolContext, } from '@pascal-app/editor' import { useEffect, useMemo, useRef, useState } from 'react' @@ -24,6 +25,7 @@ import type { Group } from 'three' import { type FloorPlacementClickTriggerEvent, getLevelLocalSnappedPosition, + isForcePlacementEvent, resolveAlignedFloorPlacement, stopPlacementCommitPropagation, subscribeFloorPlacementClicks, @@ -54,12 +56,27 @@ const CustomMeshTool = () => { let lastPosition: [number, number, number] | null = null let alignmentCandidates = collectAlignmentAnchors(sceneApi.nodes(), previewNode.id) const { size } = customMeshBounds(previewNode) + useInteractionScope.getState().begin({ + kind: 'placing', + node: CustomMeshNode.parse({ + ...previewNode, + parentId: activeLevelId, + metadata: { isNew: true }, + }), + nodeId: previewNode.id, + nodeType: previewNode.type, + view: '3d', + pressDrag: false, + driver: 'registry-tool', + }) const onGridMove = (event: GridEvent) => { if (!cursorVisibleRef.current) { cursorVisibleRef.current = true setCursorVisible(true) } + const forcePlacement = isForcePlacementEvent(event) + const gridSnapActive = isGridSnapActive() const { position, guides } = resolveAlignedFloorPlacement({ node: previewNode, rawX: event.localPosition[0], @@ -67,8 +84,8 @@ const CustomMeshTool = () => { gridStep: useEditor.getState().gridSnapStep, candidates: alignmentCandidates, showAlignment: isAlignmentGuideActive(), - applyAlignmentSnap: isMagneticSnapActive(), - bypassGrid: !isGridSnapActive(), + applyAlignmentSnap: !forcePlacement && isMagneticSnapActive(), + bypassGrid: forcePlacement || !gridSnapActive, }) useAlignmentGuides.getState().set(guides) const visualPosition = getFloorStackPreviewPosition({ @@ -80,11 +97,11 @@ const CustomMeshTool = () => { cursorRef.current?.position.set(...visualPosition) lastPosition = position const placement = canPlaceOnFloor(activeLevelId, position, size, [0, previewNode.rotation, 0]) - setValidPlacement(placement.valid) + setValidPlacement(forcePlacement || placement.valid) const snapKey = movementSfxStepKey({ coords: [position[0], position[2]], - gridSnapActive: isGridSnapActive(), + gridSnapActive: !forcePlacement && gridSnapActive, gridStep: useEditor.getState().gridSnapStep, }) if (snapKey !== previousSnapRef.current) { @@ -94,14 +111,21 @@ const CustomMeshTool = () => { } const commit = (event: FloorPlacementClickTriggerEvent) => { - const position = - lastPosition ?? - getLevelLocalSnappedPosition( - activeLevelId, - event, - useEditor.getState().gridSnapStep, - !isGridSnapActive(), - ) + const forcePlacement = isForcePlacementEvent(event) + const position = forcePlacement + ? getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + true, + ) + : (lastPosition ?? + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + !isGridSnapActive(), + )) const draftNode = CustomMeshNode.parse({ ...customMeshDefinition.defaults(), name: 'Custom Mesh', @@ -109,8 +133,8 @@ const CustomMeshTool = () => { position, }) const placement = canPlaceOnFloor(activeLevelId, position, size, [0, draftNode.rotation, 0]) - setValidPlacement(placement.valid) - if (!placement.valid) { + setValidPlacement(forcePlacement || placement.valid) + if (!(forcePlacement || placement.valid)) { stopPlacementCommitPropagation(event) return } @@ -138,6 +162,9 @@ const CustomMeshTool = () => { emitter.off('grid:move', onGridMove) unsubscribe() useAlignmentGuides.getState().clear() + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'placing' && scope.nodeId === previewNode.id) } }, [activeLevelId, canPlaceOnFloor, previewNode, sceneApi, selectNode]) diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 74a758846a..c9c85a7262 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -206,6 +206,14 @@ export const itemDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, + surfaces: { + top: { + height: (node) => { + const item = node as ItemNodeType + return (item.asset.surface?.height ?? item.asset.dimensions[1]) * item.scale[1] + }, + }, + }, duplicable: true, deletable: true, paint: itemPaint, diff --git a/packages/nodes/src/shared/floor-placement.test.ts b/packages/nodes/src/shared/floor-placement.test.ts index 6cffafbd3b..f575b7fb01 100644 --- a/packages/nodes/src/shared/floor-placement.test.ts +++ b/packages/nodes/src/shared/floor-placement.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from 'bun:test' -import { type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core' +import { emitter, type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core' import { Object3D } from 'three' -import { getLevelLocalSnappedPosition, resolveAlignedFloorPlacement } from './floor-placement' +import { + getLevelLocalSnappedPosition, + isForcePlacementEvent, + resolveAlignedFloorPlacement, + subscribeFloorPlacementClicks, + subscribeFloorPlacementDoubleClicks, +} from './floor-placement' const nativeEvent = {} as GridEvent['nativeEvent'] @@ -34,4 +40,48 @@ describe('floor placement helpers', () => { expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25]) }) + + test('recognizes Alt as force placement', () => { + const event = { + nativeEvent: { altKey: true }, + } as unknown as GridEvent + + expect(isForcePlacementEvent(event)).toBe(true) + expect( + isForcePlacementEvent({ + ...event, + nativeEvent: { altKey: false } as GridEvent['nativeEvent'], + }), + ).toBe(false) + }) + + test('routes generic node clicks and double-clicks without enumerating node kinds', () => { + const node = ShelfNode.parse({ position: [0, 0, 0] }) + const event: NodeEvent = { + node, + position: [0, 0, 0], + localPosition: [0, 0, 0], + object: new Object3D(), + stopPropagation: () => {}, + nativeEvent, + } + let clicks = 0 + let doubleClicks = 0 + const unsubscribeClick = subscribeFloorPlacementClicks(() => { + clicks += 1 + }) + const unsubscribeDoubleClick = subscribeFloorPlacementDoubleClicks(() => { + doubleClicks += 1 + }) + + emitter.emit('node:click', event) + emitter.emit('node:double-click', event) + unsubscribeClick() + unsubscribeDoubleClick() + emitter.emit('node:click', event) + emitter.emit('node:double-click', event) + + expect(clicks).toBe(1) + expect(doubleClicks).toBe(1) + }) }) diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index 0513ec9da4..2f76566054 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -1,6 +1,5 @@ import { type AnyNode, - type EventSuffix, emitter, type GridEvent, movingFootprintAnchors, @@ -13,23 +12,12 @@ import { Vector3 } from 'three' export const FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M = 0.08 -export const FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS = [ - 'shelf', - 'item', - 'slab', - 'ceiling', - 'wall', - 'fence', - 'column', - 'roof', - 'roof-segment', - 'stair', - 'stair-segment', - 'custom-mesh', -] as const - export type FloorPlacementClickTriggerEvent = GridEvent | NodeEvent +export function isForcePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + return event.nativeEvent?.altKey === true +} + type FloorPlacementAlignmentArgs = { node: AnyNode rawX: number @@ -133,19 +121,11 @@ export function subscribeFloorPlacementClicks( onClick: (event: FloorPlacementClickTriggerEvent) => void, ) { emitter.on('grid:click', onClick) - type SuffixedKey = `${K}:${EventSuffix}` - type ClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.on(key, onClick as never) - } + emitter.on('node:click', onClick) return () => { emitter.off('grid:click', onClick) - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:click` as ClickKey - emitter.off(key, onClick as never) - } + emitter.off('node:click', onClick) } } @@ -153,18 +133,10 @@ export function subscribeFloorPlacementDoubleClicks( onDoubleClick: (event: FloorPlacementClickTriggerEvent) => void, ) { emitter.on('grid:double-click', onDoubleClick) - type SuffixedKey = `${K}:${EventSuffix}` - type DoubleClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:double-click` as DoubleClickKey - emitter.on(key, onDoubleClick as never) - } + emitter.on('node:double-click', onDoubleClick) return () => { emitter.off('grid:double-click', onDoubleClick) - for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { - const key = `${kind}:double-click` as DoubleClickKey - emitter.off(key, onDoubleClick as never) - } + emitter.off('node:double-click', onDoubleClick) } } diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 0eb259d4ad..1612341287 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,4 +1,9 @@ -import type { AnyNodeId, NodeDefinition } from '@pascal-app/core' +import { + type AnyNodeId, + DEFAULT_WALL_HEIGHT, + type NodeDefinition, + type WallNode as WallNodeType, +} from '@pascal-app/core' import type { FloorplanNodeExtension } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' @@ -73,6 +78,7 @@ export const wallDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, // Front + back faces host items (paintings, shelves, switches). surfaces: { + top: { height: (node) => (node as WallNodeType).height ?? DEFAULT_WALL_HEIGHT }, sides: { faces: 'all' }, }, duplicable: true, diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 98fd5d043b..b583f2a83e 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -34,6 +34,7 @@ export function useNodeEvents(node: NodeByKind, type: // keys; the `as never` cast lets us emit a kind-specific payload // through that generic surface without enumerating every kind. emitter.emit(eventKey, payload as never) + emitter.emit(`node:${suffix}`, payload) } // Camera drags (orbit / pan / dolly) suppress ALL node pointer events. From 0c94973dc73d9c48664915cf536e76afd4b7b616 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 12 Aug 2026 19:11:35 +0530 Subject: [PATCH 08/10] feat(nodes): add custom mesh face materials --- packages/editor/src/index.tsx | 1 + .../nodes/src/custom-mesh/commands.test.ts | 117 ++++++ .../nodes/src/custom-mesh/definition.test.ts | 34 +- packages/nodes/src/custom-mesh/definition.ts | 2 +- .../src/custom-mesh/edit-session.test.ts | 76 ++++ .../nodes/src/custom-mesh/edit-session.ts | 55 +++ .../nodes/src/custom-mesh/geometry.test.ts | 120 +++++- packages/nodes/src/custom-mesh/geometry.ts | 30 +- .../src/custom-mesh/material-slots.test.ts | 167 ++++++++ .../nodes/src/custom-mesh/material-slots.ts | 174 +++++++++ packages/nodes/src/custom-mesh/paint.ts | 54 ++- packages/nodes/src/custom-mesh/panel.tsx | 355 ++++++++++++++++++ packages/nodes/src/custom-mesh/parametrics.ts | 1 + packages/nodes/src/custom-mesh/selection.tsx | 98 ++--- packages/nodes/src/custom-mesh/slots.ts | 21 +- wiki/architecture/interaction-scope.md | 2 + wiki/architecture/materials-and-themes.md | 18 + wiki/blender-material-assignment-research.md | 176 +++++++++ 18 files changed, 1423 insertions(+), 78 deletions(-) create mode 100644 packages/nodes/src/custom-mesh/edit-session.test.ts create mode 100644 packages/nodes/src/custom-mesh/edit-session.ts create mode 100644 packages/nodes/src/custom-mesh/material-slots.test.ts create mode 100644 packages/nodes/src/custom-mesh/material-slots.ts create mode 100644 packages/nodes/src/custom-mesh/panel.tsx create mode 100644 wiki/blender-material-assignment-research.md diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 3b71db1014..a1d1f2e287 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -320,6 +320,7 @@ export { continuationContextOf, nextContinuation, } from './lib/continuation' +export { createEditorApi } from './lib/editor-api' export { clearStructuralElevationGuide, collectElevationSnapTargets, diff --git a/packages/nodes/src/custom-mesh/commands.test.ts b/packages/nodes/src/custom-mesh/commands.test.ts index 7053369224..c968569e98 100644 --- a/packages/nodes/src/custom-mesh/commands.test.ts +++ b/packages/nodes/src/custom-mesh/commands.test.ts @@ -54,6 +54,27 @@ describe('applyCustomMeshCommand', () => { expect(inspectCustomMeshTopology(second.topology)).toEqual([]) }) + test('inherits the source face material across an extruded cap and side faces', () => { + const topology = createBoxCustomMeshTopology() + topology.faces = topology.faces.map((face) => + face.id === 'f-top' ? { ...face, materialSlot: 'accent' } : face, + ) + const originalFaceIds = new Set(topology.faces.map((face) => face.id)) + const result = applyCustomMeshCommand(topology, { + type: 'extrude-face', + faceId: 'f-top', + distance: 0.25, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + const inheritedFaces = result.topology.faces.filter( + (face) => face.id === 'f-top' || !originalFaceIds.has(face.id), + ) + expect(inheritedFaces).toHaveLength(5) + expect(inheritedFaces.every((face) => face.materialSlot === 'accent')).toBe(true) + }) + test('reports an invalid face selection without changing topology', () => { const topology = createBoxCustomMeshTopology() expect( @@ -149,6 +170,28 @@ describe('applyCustomMeshCommand', () => { expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) + test('inherits the source face material across an inset cap and ring', () => { + const topology = createBoxCustomMeshTopology() + topology.faces = topology.faces.map((face) => + face.id === 'f-top' ? { ...face, materialSlot: 'accent' } : face, + ) + const originalFaceIds = new Set(topology.faces.map((face) => face.id)) + const result = applyCustomMeshCommand(topology, { + type: 'inset-face', + faceId: 'f-top', + amount: 0.2, + depth: 0, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + const inheritedFaces = result.topology.faces.filter( + (face) => face.id === 'f-top' || !originalFaceIds.has(face.id), + ) + expect(inheritedFaces).toHaveLength(5) + expect(inheritedFaces.every((face) => face.materialSlot === 'accent')).toBe(true) + }) + test('deletes selected faces, edges, or vertices without invalid references', () => { for (const selection of [ { mode: 'face' as const, ids: ['f-top'] }, @@ -205,6 +248,26 @@ describe('applyCustomMeshCommand', () => { expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) + test('keeps the first adjacent face material when dissolving a mixed-material edge', () => { + const topology = createBoxCustomMeshTopology() + topology.faces = topology.faces.map((face) => + face.id === 'f-top' + ? { ...face, materialSlot: 'top' } + : face.id === 'f-front' + ? { ...face, materialSlot: 'front' } + : face, + ) + const result = applyCustomMeshCommand(topology, { + type: 'dissolve-edge', + edgeId: 'e4', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.topology.faces.find((face) => face.id === 'f-top')?.materialSlot).toBe('top') + expect(result.topology.faces.some((face) => face.id === 'f-front')).toBe(false) + }) + test('cuts a connected quad ring and selects the inserted loop', () => { const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { type: 'loop-cut', @@ -234,6 +297,33 @@ describe('applyCustomMeshCommand', () => { expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) + test('preserves each source face material when a loop cut splits the ring', () => { + const topology = createBoxCustomMeshTopology() + topology.faces = topology.faces.map((face) => ({ ...face, materialSlot: face.id })) + const result = applyCustomMeshCommand(topology, { + type: 'loop-cut', + edgeId: 'e8', + factor: 0.25, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + const counts = Object.fromEntries( + topology.faces.map((face) => [ + face.id, + result.topology.faces.filter((resultFace) => resultFace.materialSlot === face.id).length, + ]), + ) + expect(counts).toEqual({ + 'f-bottom': 1, + 'f-top': 1, + 'f-front': 2, + 'f-right': 2, + 'f-back': 2, + 'f-left': 2, + }) + }) + test('stops a loop cut cleanly before a non-quad face', () => { const dissolved = applyCustomMeshCommand(createBoxCustomMeshTopology(), { type: 'dissolve-edge', @@ -294,4 +384,31 @@ describe('applyCustomMeshCommand', () => { ).toBeCloseTo(0.2, 6) expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) + + test('uses the first adjacent face material for new bevel bands in stable topology order', () => { + const topology = createBoxCustomMeshTopology() + topology.faces = topology.faces.map((face) => + face.id === 'f-bottom' + ? { ...face, materialSlot: 'bottom' } + : face.id === 'f-front' + ? { ...face, materialSlot: 'front' } + : face, + ) + const originalFaceIds = new Set(topology.faces.map((face) => face.id)) + const result = applyCustomMeshCommand(topology, { + type: 'bevel-edge', + edgeId: 'e0', + width: 0.2, + segments: 3, + profile: 0.5, + clampOverlap: true, + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + const bevelBands = result.topology.faces.filter((face) => !originalFaceIds.has(face.id)) + expect(bevelBands).toHaveLength(3) + expect(bevelBands.every((face) => face.materialSlot === 'bottom')).toBe(true) + expect(result.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe('front') + }) }) diff --git a/packages/nodes/src/custom-mesh/definition.test.ts b/packages/nodes/src/custom-mesh/definition.test.ts index abe5c3722e..910cfddbc2 100644 --- a/packages/nodes/src/custom-mesh/definition.test.ts +++ b/packages/nodes/src/custom-mesh/definition.test.ts @@ -17,21 +17,38 @@ describe('custom mesh placement bounds', () => { fields: [{ key: 'position', kind: 'vec3' }], }, ]) + expect(customMeshDefinition.parametrics?.customPanel).toBeFunction() }) - test('exposes the entire mesh as one paintable material target', () => { - const node = CustomMeshNode.parse({ name: 'Paintable mesh' }) + test('exposes topology material slots as paintable targets', () => { + const base = CustomMeshNode.parse({ + name: 'Paintable mesh', + slots: { accent: 'library:preset-softwhite' }, + }) + const node = { + ...base, + topology: { + ...base.topology, + faces: base.topology.faces.map((face, index) => ({ + ...face, + materialSlot: index === 0 ? 'accent' : 'body', + })), + }, + } const paint = customMeshDefinition.capabilities.paint expect(customMeshDefinition.capabilities.slots?.(node)).toEqual([ - { slotId: 'body', label: 'Whole mesh' }, + { slotId: 'body', label: 'Body' }, + { slotId: 'accent', label: 'Accent' }, ]) + const hitObject = { userData: { slotIds: ['body', 'accent'] } } expect( paint?.resolveRole({ node, - materialIndex: null, + hitObject: hitObject as never, + materialIndex: 1, }), - ).toBe('body') + ).toBe('accent') expect( paint?.buildPatch({ node, @@ -39,7 +56,12 @@ describe('custom mesh placement bounds', () => { material: undefined, materialPreset: 'library:metal-steel', }), - ).toEqual({ slots: { body: 'library:metal-steel' } }) + ).toEqual({ + slots: { + accent: 'library:preset-softwhite', + body: 'library:metal-steel', + }, + }) }) test('declares its edited top as a stackable surface', () => { diff --git a/packages/nodes/src/custom-mesh/definition.ts b/packages/nodes/src/custom-mesh/definition.ts index 57117a8cde..9d42e4cf05 100644 --- a/packages/nodes/src/custom-mesh/definition.ts +++ b/packages/nodes/src/custom-mesh/definition.ts @@ -94,7 +94,7 @@ export const customMeshDefinition: NodeDefinition = { }, collides: true, }, - slots: () => customMeshSlots(), + slots: (rawNode) => customMeshSlots(rawNode as CustomMeshNodeType), paint: customMeshPaint, }, diff --git a/packages/nodes/src/custom-mesh/edit-session.test.ts b/packages/nodes/src/custom-mesh/edit-session.test.ts new file mode 100644 index 0000000000..45f9992f38 --- /dev/null +++ b/packages/nodes/src/custom-mesh/edit-session.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { createBoxCustomMeshTopology } from '@pascal-app/core' +import useCustomMeshEditSession from './edit-session' +import { createCustomMeshSelection } from './selection-model' + +describe('custom mesh edit session', () => { + beforeEach(() => { + useCustomMeshEditSession.setState({ + nodeId: null, + selection: createCustomMeshSelection('face'), + activeMaterialSlotId: null, + }) + }) + + test('owns one transient selection session at a time', () => { + const first = createCustomMeshSelection('face', ['f-top']) + useCustomMeshEditSession.getState().begin('custom-mesh_1', first) + expect(useCustomMeshEditSession.getState()).toMatchObject({ + nodeId: 'custom-mesh_1', + selection: first, + }) + + const second = createCustomMeshSelection('edge', ['e0']) + useCustomMeshEditSession.getState().begin('custom-mesh_2', second) + expect(useCustomMeshEditSession.getState()).toMatchObject({ + nodeId: 'custom-mesh_2', + selection: second, + }) + }) + + test('rejects selection writes and cleanup from a non-owner', () => { + const selection = createCustomMeshSelection('face', ['f-top']) + useCustomMeshEditSession.getState().begin('custom-mesh_1', selection) + useCustomMeshEditSession + .getState() + .setSelection('custom-mesh_2', createCustomMeshSelection('vertex', ['v0'])) + useCustomMeshEditSession.getState().setActiveMaterialSlot('custom-mesh_2', 'accent') + useCustomMeshEditSession.getState().end('custom-mesh_2') + + expect(useCustomMeshEditSession.getState()).toMatchObject({ + nodeId: 'custom-mesh_1', + selection, + activeMaterialSlotId: null, + }) + }) + + test('reconciles removed component IDs and preserves a valid active component', () => { + const topology = createBoxCustomMeshTopology() + useCustomMeshEditSession.getState().begin('custom-mesh_1', { + mode: 'face', + ids: ['f-bottom', 'missing', 'f-top'], + activeId: 'missing', + }) + useCustomMeshEditSession.getState().reconcileSelection('custom-mesh_1', topology) + + expect(useCustomMeshEditSession.getState().selection).toEqual({ + mode: 'face', + ids: ['f-bottom', 'f-top'], + activeId: 'f-top', + }) + }) + + test('ends only the owned session and resets transient selection', () => { + useCustomMeshEditSession + .getState() + .begin('custom-mesh_1', createCustomMeshSelection('face', ['f-top'])) + useCustomMeshEditSession.getState().setActiveMaterialSlot('custom-mesh_1', 'accent') + useCustomMeshEditSession.getState().end('custom-mesh_1') + + expect(useCustomMeshEditSession.getState()).toMatchObject({ + nodeId: null, + selection: { mode: 'face', ids: [], activeId: null }, + activeMaterialSlotId: null, + }) + }) +}) diff --git a/packages/nodes/src/custom-mesh/edit-session.ts b/packages/nodes/src/custom-mesh/edit-session.ts new file mode 100644 index 0000000000..364866b6cc --- /dev/null +++ b/packages/nodes/src/custom-mesh/edit-session.ts @@ -0,0 +1,55 @@ +import type { CustomMeshTopology } from '@pascal-app/core' +import { create } from 'zustand' +import { type CustomMeshSelectionState, createCustomMeshSelection } from './selection-model' + +type CustomMeshEditSessionState = { + nodeId: string | null + selection: CustomMeshSelectionState + activeMaterialSlotId: string | null + begin: (nodeId: string, selection: CustomMeshSelectionState) => void + end: (nodeId: string) => void + setSelection: (nodeId: string, selection: CustomMeshSelectionState) => void + setActiveMaterialSlot: (nodeId: string, slotId: string) => void + reconcileSelection: (nodeId: string, topology: CustomMeshTopology) => void +} + +const emptySelection = () => createCustomMeshSelection('face') + +const useCustomMeshEditSession = create((set) => ({ + nodeId: null, + selection: emptySelection(), + activeMaterialSlotId: null, + begin: (nodeId, selection) => set({ nodeId, selection, activeMaterialSlotId: null }), + end: (nodeId) => + set((state) => + state.nodeId === nodeId + ? { nodeId: null, selection: emptySelection(), activeMaterialSlotId: null } + : 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 + const validIds = new Set( + state.selection.mode === 'vertex' + ? topology.vertices.map((vertex) => vertex.id) + : state.selection.mode === 'edge' + ? topology.edges.map((edge) => edge.id) + : topology.faces.map((face) => face.id), + ) + const ids = state.selection.ids.filter((id) => validIds.has(id)) + const activeId = + state.selection.activeId && ids.includes(state.selection.activeId) + ? state.selection.activeId + : (ids.at(-1) ?? null) + if (ids.length === state.selection.ids.length && activeId === state.selection.activeId) { + return state + } + return { selection: { ...state.selection, ids, activeId } } + }), +})) + +export default useCustomMeshEditSession diff --git a/packages/nodes/src/custom-mesh/geometry.test.ts b/packages/nodes/src/custom-mesh/geometry.test.ts index 8656a57fdf..e8a37048e3 100644 --- a/packages/nodes/src/custom-mesh/geometry.test.ts +++ b/packages/nodes/src/custom-mesh/geometry.test.ts @@ -19,7 +19,7 @@ describe('buildCustomMeshGeometry', () => { expect(mesh.geometry.userData.customMeshFaces).toHaveLength(6) }) - test('uses the whole-mesh body material for every topology face slot', () => { + test('maps topology face slots to geometry groups and material-array entries', () => { const base = CustomMeshNode.parse({ name: 'Painted mesh', slots: { @@ -42,21 +42,78 @@ describe('buildCustomMeshGeometry', () => { expect(mesh).toBeInstanceOf(Mesh) if (!(mesh instanceof Mesh)) return - const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] - expect(Array.isArray(mesh.material)).toBe(false) - expect(new Set(materials).size).toBe(1) - expect(mesh.userData.slotId).toBe('body') + expect(Array.isArray(mesh.material)).toBe(true) + expect(mesh.material).toHaveLength(2) + expect(mesh.geometry.groups.map((group) => group.materialIndex)).toEqual([0, 1, 0, 1, 0, 1]) + expect(mesh.userData.slotIds).toEqual(['body', 'accent']) }) - test('previews the selected paint material across the whole mesh', () => { - const node = CustomMeshNode.parse({ name: 'Preview mesh' }) + test('resolves and previews only the hit material slot', () => { + const base = CustomMeshNode.parse({ + name: 'Preview mesh', + slots: { accent: 'library:preset-softwhite' }, + }) + const node = { + ...base, + topology: { + ...base.topology, + faces: base.topology.faces.map((face, index) => ({ + ...face, + materialSlot: index === 1 ? 'accent' : 'body', + })), + }, + } const group = buildCustomMeshGeometry(node) const mesh = group.getObjectByName('custom-mesh-body') expect(mesh).toBeInstanceOf(Mesh) if (!(mesh instanceof Mesh)) return mesh.userData.__fromGeometry = true + expect(customMeshPaint.resolveRole({ node, hitObject: mesh, materialIndex: 1 })).toBe('accent') + expect(Array.isArray(mesh.material)).toBe(true) + if (!Array.isArray(mesh.material)) return const previous = mesh.material + const restore = customMeshPaint.applyPreview({ + node, + role: 'accent', + material: { + preset: 'custom', + properties: { color: '#c2410c' }, + }, + materialPreset: undefined, + root: group, + }) + + expect(restore).toBeFunction() + expect(Array.isArray(mesh.material)).toBe(true) + if (!Array.isArray(mesh.material)) return + expect(mesh.material[0]).toBe(previous[0]) + expect(mesh.material[1]).not.toBe(previous[1]) + restore?.() + expect(mesh.material).toBe(previous) + }) + + test('previews body across face slots that render with the body fallback', () => { + const base = CustomMeshNode.parse({ name: 'Fallback preview mesh' }) + const node = { + ...base, + topology: { + ...base.topology, + faces: base.topology.faces.map((face, index) => ({ + ...face, + materialSlot: index === 1 ? 'accent' : 'body', + })), + }, + } + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh) || !Array.isArray(mesh.material)) return + mesh.userData.__fromGeometry = true + const previous = mesh.material + expect(previous[1]).toBe(previous[0]) + const restore = customMeshPaint.applyPreview({ node, role: 'body', @@ -68,8 +125,55 @@ describe('buildCustomMeshGeometry', () => { root: group, }) + expect(Array.isArray(mesh.material)).toBe(true) + if (!Array.isArray(mesh.material)) return + expect(mesh.material[1]).toBe(mesh.material[0]) + expect(mesh.material[0]).not.toBe(previous[0]) + restore?.() + expect(mesh.material).toBe(previous) + }) + + test('previews a face slot when textures-off rendering supplies one material', () => { + const base = CustomMeshNode.parse({ + name: 'Textures-off preview mesh', + slots: { accent: 'library:preset-softwhite' }, + }) + const node = { + ...base, + topology: { + ...base.topology, + faces: base.topology.faces.map((face, index) => ({ + ...face, + materialSlot: index === 1 ? 'accent' : 'body', + })), + }, + } + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh) || !Array.isArray(mesh.material)) return + mesh.userData.__fromGeometry = true + const previous = mesh.material[0]! + mesh.material = previous + + const restore = customMeshPaint.applyPreview({ + node, + role: 'accent', + material: { + preset: 'custom', + properties: { color: '#c2410c' }, + }, + materialPreset: undefined, + root: group, + }) + expect(restore).toBeFunction() - expect(mesh.material).not.toBe(previous) + expect(Array.isArray(mesh.material)).toBe(true) + if (!Array.isArray(mesh.material)) return + expect(mesh.material).toHaveLength(2) + expect(mesh.material[0]).toBe(previous) + expect(mesh.material[1]).not.toBe(previous) restore?.() expect(mesh.material).toBe(previous) }) diff --git a/packages/nodes/src/custom-mesh/geometry.ts b/packages/nodes/src/custom-mesh/geometry.ts index dde691be88..f988265693 100644 --- a/packages/nodes/src/custom-mesh/geometry.ts +++ b/packages/nodes/src/custom-mesh/geometry.ts @@ -15,7 +15,7 @@ import { Vector3, } from 'three' import { customMeshFaceNormal } from './commands' -import { CUSTOM_MESH_SLOT_ID } from './slots' +import { CUSTOM_MESH_BODY_SLOT_ID, customMeshMaterialSlotIds } from './material-slots' type Point = [number, number, number] const SMOOTH_NORMAL_ANGLE_COSINE = Math.cos(Math.PI / 6) @@ -76,6 +76,8 @@ export function buildCustomMeshGeometry( const normals: number[] = [] const uvs: number[] = [] const faceRanges: { faceId: string; start: number; count: number }[] = [] + const slotIds = customMeshMaterialSlotIds(node.topology, node.slots) + const materialIndexBySlotId = new Map(slotIds.map((slotId, index) => [slotId, index])) const faceNormals = new Map( node.topology.faces.flatMap((face) => { const normal = customMeshFaceNormal(node.topology, face) @@ -131,7 +133,7 @@ export function buildCustomMeshGeometry( } } const count = positions.length / 3 - start - geometry.addGroup(start, count, 0) + geometry.addGroup(start, count, materialIndexBySlotId.get(face.materialSlot) ?? 0) faceRanges.push({ faceId: face.id, start, count }) } @@ -142,16 +144,30 @@ export function buildCustomMeshGeometry( geometry.computeBoundingSphere() geometry.userData.customMeshFaces = faceRanges - const materialRef = node.slots?.[CUSTOM_MESH_SLOT_ID] - const material = - (materialRef ? resolveMaterialRef(materialRef, ctx?.materials, shading) : null) ?? + const bodyMaterialRef = node.slots?.[CUSTOM_MESH_BODY_SLOT_ID] + const bodyMaterial = + (bodyMaterialRef ? resolveMaterialRef(bodyMaterialRef, ctx?.materials, shading) : null) ?? createDefaultMaterial('#b8c5d1', 0.72, shading) - const mesh = new Mesh(geometry, material) + const bodyFallbackSlotIds: string[] = [] + const materials = slotIds.map((slotId) => { + const materialRef = node.slots?.[slotId] + if (slotId === CUSTOM_MESH_BODY_SLOT_ID) return bodyMaterial + if (!materialRef) { + bodyFallbackSlotIds.push(slotId) + return bodyMaterial + } + const resolved = resolveMaterialRef(materialRef, ctx?.materials, shading) + if (resolved) return resolved + bodyFallbackSlotIds.push(slotId) + return bodyMaterial + }) + const mesh = new Mesh(geometry, materials) mesh.name = 'custom-mesh-body' mesh.castShadow = true mesh.receiveShadow = true mesh.userData.customMesh = true - mesh.userData.slotId = CUSTOM_MESH_SLOT_ID + mesh.userData.slotIds = slotIds + mesh.userData.bodyFallbackSlotIds = bodyFallbackSlotIds group.add(mesh) return group } diff --git a/packages/nodes/src/custom-mesh/material-slots.test.ts b/packages/nodes/src/custom-mesh/material-slots.test.ts new file mode 100644 index 0000000000..b58dba1725 --- /dev/null +++ b/packages/nodes/src/custom-mesh/material-slots.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'bun:test' +import { createBoxCustomMeshTopology } from '@pascal-app/core' +import { + assignCustomMeshMaterial, + customMeshMaterialSelection, + customMeshMaterialSlotIds, + removeUnusedCustomMeshMaterialSlots, + selectCustomMeshFacesByMaterialSlot, + unusedCustomMeshMaterialSlotIds, +} from './material-slots' + +describe('custom mesh material slots', () => { + test('lists body, persisted, and face-referenced slots in stable order', () => { + const topology = createBoxCustomMeshTopology() + topology.faces[0] = { ...topology.faces[0], materialSlot: 'orphaned' } + + expect( + customMeshMaterialSlotIds(topology, { + accent: 'scene:accent', + body: 'scene:body', + }), + ).toEqual(['body', 'accent', 'orphaned']) + }) + + test('reports single and mixed face assignments using the active face', () => { + const topology = createBoxCustomMeshTopology() + topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } + + expect(customMeshMaterialSelection(topology, ['f-bottom'], 'f-bottom')).toEqual({ + kind: 'single', + slotId: 'body', + activeSlotId: 'body', + }) + expect(customMeshMaterialSelection(topology, ['f-bottom', 'f-top'], 'f-top')).toEqual({ + kind: 'mixed', + activeSlotId: 'accent', + }) + expect(customMeshMaterialSelection(topology, [], null)).toEqual({ + kind: 'empty', + activeSlotId: null, + }) + }) + + test('selects and deselects every face assigned to a slot without replacing other selection', () => { + const topology = createBoxCustomMeshTopology() + topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } + topology.faces[2] = { ...topology.faces[2], materialSlot: 'accent' } + + expect(selectCustomMeshFacesByMaterialSlot(topology, ['f-bottom'], 'accent', 'select')).toEqual( + ['f-bottom', 'f-top', 'f-front'], + ) + expect( + selectCustomMeshFacesByMaterialSlot( + topology, + ['f-bottom', 'f-top', 'f-front'], + 'accent', + 'deselect', + ), + ).toEqual(['f-bottom']) + }) + + test('removes only unused object slots while preserving body and face references', () => { + const topology = createBoxCustomMeshTopology() + topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } + const slots = { + body: 'scene:body', + accent: 'scene:accent', + discarded: 'scene:shared', + } + + expect(unusedCustomMeshMaterialSlotIds(topology, slots)).toEqual(['discarded']) + expect(removeUnusedCustomMeshMaterialSlots(topology, slots)).toEqual({ + slots: { + body: 'scene:body', + accent: 'scene:accent', + }, + removedSlotIds: ['discarded'], + changed: true, + }) + expect(topology.faces[1].materialSlot).toBe('accent') + }) + + test('keeps slot identity on cleanup no-op and collapses an empty mapping', () => { + const topology = createBoxCustomMeshTopology() + const slots = { body: 'scene:body' } + const noOp = removeUnusedCustomMeshMaterialSlots(topology, slots) + + expect(noOp).toEqual({ slots, removedSlotIds: [], changed: false }) + expect(noOp.slots).toBe(slots) + expect(removeUnusedCustomMeshMaterialSlots(topology, { discarded: 'scene:shared' })).toEqual({ + slots: undefined, + removedSlotIds: ['discarded'], + changed: true, + }) + }) + + test('assigns an existing slot to all selected faces in one immutable result', () => { + const topology = createBoxCustomMeshTopology() + const slots = { accent: 'scene:accent' } + const result = assignCustomMeshMaterial(topology, slots, ['f-bottom', 'f-top'], { + kind: 'slot', + slotId: 'accent', + }) + + expect(result.changed).toBe(true) + expect(result.slots).toBe(slots) + expect(result.topology.faces.slice(0, 2).map((face) => face.materialSlot)).toEqual([ + 'accent', + 'accent', + ]) + expect(topology.faces[0].materialSlot).toBe('body') + }) + + test('reuses a slot by material identity and allocates only when needed', () => { + const topology = createBoxCustomMeshTopology() + const existing = assignCustomMeshMaterial(topology, { accent: 'scene:accent' }, ['f-top'], { + kind: 'material', + materialRef: 'scene:accent', + }) + expect(existing.slotId).toBe('accent') + expect(existing.slots).toEqual({ accent: 'scene:accent' }) + + const added = assignCustomMeshMaterial(existing.topology, existing.slots, ['f-front'], { + kind: 'material', + materialRef: 'library:oak', + }) + expect(added.slotId).toBe('material-1') + expect(added.slots).toEqual({ + accent: 'scene:accent', + 'material-1': 'library:oak', + }) + }) + + test('prefers the canonical body slot when duplicate bindings already exist', () => { + const topology = createBoxCustomMeshTopology() + const result = assignCustomMeshMaterial( + topology, + { accent: 'scene:shared', body: 'scene:shared' }, + ['f-top'], + { kind: 'material', materialRef: 'scene:shared' }, + ) + + expect(result.slotId).toBe('body') + expect(result.changed).toBe(false) + }) + + test('does not allocate or mutate for an empty or no-op assignment', () => { + const topology = createBoxCustomMeshTopology() + const empty = assignCustomMeshMaterial(topology, undefined, [], { + kind: 'material', + materialRef: 'scene:accent', + }) + expect(empty).toEqual({ + topology, + slots: undefined, + slotId: 'material-1', + changed: false, + }) + + const noOp = assignCustomMeshMaterial(topology, undefined, ['f-top'], { + kind: 'slot', + slotId: 'body', + }) + expect(noOp.changed).toBe(false) + expect(noOp.topology).toBe(topology) + }) +}) diff --git a/packages/nodes/src/custom-mesh/material-slots.ts b/packages/nodes/src/custom-mesh/material-slots.ts new file mode 100644 index 0000000000..985f7438aa --- /dev/null +++ b/packages/nodes/src/custom-mesh/material-slots.ts @@ -0,0 +1,174 @@ +import type { CustomMeshTopology, MaterialRef } from '@pascal-app/core' + +export const CUSTOM_MESH_BODY_SLOT_ID = 'body' + +export type CustomMeshMaterialSlots = Record | undefined + +export type CustomMeshMaterialSelection = + | { kind: 'empty'; activeSlotId: null } + | { kind: 'single'; activeSlotId: string; slotId: string } + | { kind: 'mixed'; activeSlotId: string | null } + +export type CustomMeshMaterialAssignment = + | { kind: 'slot'; slotId: string } + | { kind: 'material'; materialRef: MaterialRef } + +export type CustomMeshMaterialAssignmentResult = { + topology: CustomMeshTopology + slots: CustomMeshMaterialSlots + slotId: string + changed: boolean +} + +export type CustomMeshMaterialSlotCleanupResult = { + slots: CustomMeshMaterialSlots + removedSlotIds: string[] + changed: boolean +} + +export function customMeshMaterialSlotIds( + topology: CustomMeshTopology, + slots: CustomMeshMaterialSlots, +): string[] { + const slotIds = new Set([CUSTOM_MESH_BODY_SLOT_ID]) + for (const slotId of Object.keys(slots ?? {})) slotIds.add(slotId) + for (const face of topology.faces) slotIds.add(face.materialSlot) + return [...slotIds] +} + +export function customMeshMaterialSelection( + topology: CustomMeshTopology, + selectedFaceIds: readonly string[], + activeFaceId: string | null, +): CustomMeshMaterialSelection { + const selected = new Set(selectedFaceIds) + const selectedFaces = topology.faces.filter((face) => selected.has(face.id)) + const firstSelectedFace = selectedFaces[0] + if (!firstSelectedFace) return { kind: 'empty', activeSlotId: null } + + const firstSlotId = firstSelectedFace.materialSlot + const activeSlotId = + topology.faces.find((face) => face.id === activeFaceId && selected.has(face.id)) + ?.materialSlot ?? null + + if (selectedFaces.every((face) => face.materialSlot === firstSlotId)) { + return { + kind: 'single', + slotId: firstSlotId, + activeSlotId: activeSlotId ?? firstSlotId, + } + } + return { kind: 'mixed', activeSlotId } +} + +export function unusedCustomMeshMaterialSlotIds( + topology: CustomMeshTopology, + slots: CustomMeshMaterialSlots, +): string[] { + const used = new Set([ + CUSTOM_MESH_BODY_SLOT_ID, + ...topology.faces.map((face) => face.materialSlot), + ]) + return Object.keys(slots ?? {}).filter((slotId) => !used.has(slotId)) +} + +export function removeUnusedCustomMeshMaterialSlots( + topology: CustomMeshTopology, + slots: CustomMeshMaterialSlots, +): CustomMeshMaterialSlotCleanupResult { + const removedSlotIds = unusedCustomMeshMaterialSlotIds(topology, slots) + if (removedSlotIds.length === 0) return { slots, removedSlotIds, changed: false } + + const removed = new Set(removedSlotIds) + const retainedEntries = Object.entries(slots ?? {}).filter(([slotId]) => !removed.has(slotId)) + return { + slots: retainedEntries.length > 0 ? Object.fromEntries(retainedEntries) : undefined, + removedSlotIds, + changed: true, + } +} + +export function selectCustomMeshFacesByMaterialSlot( + topology: CustomMeshTopology, + 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), + ] +} + +function findSlotIdForMaterialRef( + topology: CustomMeshTopology, + slots: CustomMeshMaterialSlots, + materialRef: MaterialRef, +): string | null { + return ( + customMeshMaterialSlotIds(topology, slots).find((slotId) => slots?.[slotId] === materialRef) ?? + null + ) +} + +function allocateMaterialSlotId( + topology: CustomMeshTopology, + slots: CustomMeshMaterialSlots, +): string { + const used = new Set(customMeshMaterialSlotIds(topology, slots)) + let index = 1 + while (used.has(`material-${index}`)) index += 1 + return `material-${index}` +} + +export function assignCustomMeshMaterial( + topology: CustomMeshTopology, + slots: CustomMeshMaterialSlots, + selectedFaceIds: readonly string[], + assignment: CustomMeshMaterialAssignment, +): CustomMeshMaterialAssignmentResult { + const selected = new Set(selectedFaceIds) + const hasSelectedFace = topology.faces.some((face) => selected.has(face.id)) + let nextSlots = slots + let slotId: string + + if (assignment.kind === 'slot') { + slotId = assignment.slotId + if (!customMeshMaterialSlotIds(topology, slots).includes(slotId)) { + return { topology, slots, slotId, changed: false } + } + } else { + const existingSlotId = findSlotIdForMaterialRef(topology, slots, assignment.materialRef) + slotId = existingSlotId ?? allocateMaterialSlotId(topology, slots) + if (!existingSlotId && hasSelectedFace) { + nextSlots = { ...slots, [slotId]: assignment.materialRef } + } + } + + if (!hasSelectedFace) return { topology, slots, slotId, changed: false } + if (topology.faces.every((face) => !selected.has(face.id) || face.materialSlot === slotId)) { + return { topology, slots, slotId, changed: false } + } + + return { + topology: { + ...topology, + faces: topology.faces.map((face) => + selected.has(face.id) ? { ...face, materialSlot: slotId } : face, + ), + }, + slots: nextSlots, + slotId, + changed: true, + } +} diff --git a/packages/nodes/src/custom-mesh/paint.ts b/packages/nodes/src/custom-mesh/paint.ts index bfb9d2471f..37f1e3ae56 100644 --- a/packages/nodes/src/custom-mesh/paint.ts +++ b/packages/nodes/src/custom-mesh/paint.ts @@ -1,7 +1,53 @@ -import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' -import { CUSTOM_MESH_SLOT_ID } from './slots' +import type { PaintPreviewArgs, PaintResolveArgs } from '@pascal-app/core' +import type { Mesh, Object3D } from 'three' +import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import { CUSTOM_MESH_BODY_SLOT_ID } from './material-slots' + +function resolveCustomMeshPaintRole(args: PaintResolveArgs): string | null { + const slotIds = (args.hitObject?.userData as { slotIds?: unknown } | undefined)?.slotIds + if (!Array.isArray(slotIds)) return null + const slotId = slotIds[args.materialIndex ?? 0] + return typeof slotId === 'string' ? slotId : null +} + +function previewCustomMeshSlot(args: PaintPreviewArgs): (() => void) | null { + const preview = buildSlotPreviewMaterial(args.material, args.materialPreset) + if (!preview) return () => {} + + const restores: Array<() => void> = [] + ;(args.root as Object3D).traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || mesh.userData.__fromGeometry !== true) return + const userData = mesh.userData as { slotIds?: unknown; bodyFallbackSlotIds?: unknown } + const slotIds = userData.slotIds + if (!Array.isArray(slotIds)) return + const bodyFallbackSlotIds = new Set( + Array.isArray(userData.bodyFallbackSlotIds) ? userData.bodyFallbackSlotIds : [], + ) + const materialIndices = slotIds.flatMap((slotId, index) => + slotId === args.role || + (args.role === CUSTOM_MESH_BODY_SLOT_ID && bodyFallbackSlotIds.has(slotId)) + ? [index] + : [], + ) + if (materialIndices.length === 0) return + + const previous = mesh.material + const next = Array.isArray(previous) ? previous.slice() : slotIds.map(() => previous) + for (const materialIndex of materialIndices) next[materialIndex] = preview + mesh.material = next + restores.push(() => { + mesh.material = previous + }) + }) + + if (restores.length === 0) return null + return () => { + for (let index = restores.length - 1; index >= 0; index -= 1) restores[index]?.() + } +} export const customMeshPaint = createSlotPaintCapability({ - resolveRole: () => CUSTOM_MESH_SLOT_ID, - applyPreview: previewGeometrySlot, + resolveRole: resolveCustomMeshPaintRole, + applyPreview: previewCustomMeshSlot, }) diff --git a/packages/nodes/src/custom-mesh/panel.tsx b/packages/nodes/src/custom-mesh/panel.tsx new file mode 100644 index 0000000000..475c06cf16 --- /dev/null +++ b/packages/nodes/src/custom-mesh/panel.tsx @@ -0,0 +1,355 @@ +'use client' + +import { + type AnyNodeId, + type CustomMeshNode, + getCatalogMaterialById, + parseMaterialRef, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + createEditorApi, + MaterialPicker, + PanelSection, + PanelWrapper, + SliderControl, + triggerSFX, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import useCustomMeshEditSession from './edit-session' +import { + assignCustomMeshMaterial, + customMeshMaterialSelection, + removeUnusedCustomMeshMaterialSlots, + selectCustomMeshFacesByMaterialSlot, + unusedCustomMeshMaterialSlotIds, +} from './material-slots' +import { customMeshSlots } from './slots' + +function materialRefLabel( + ref: string | undefined, + sceneMaterials: ReturnType['materials'], +): string { + const parsed = parseMaterialRef(ref) + if (!parsed) return 'Default material' + if (parsed.kind === 'scene') + return sceneMaterials[parsed.id as keyof typeof sceneMaterials]?.name ?? ref ?? parsed.id + return getCatalogMaterialById(parsed.id)?.label ?? ref ?? parsed.id +} + +export default function CustomMeshPanel() { + const selectedId = useViewer((state) => state.selection.selectedIds[0]) + const setViewerSelection = useViewer((state) => state.setSelection) + const node = useScene((state) => { + if (!selectedId) return null + const selected = state.nodes[selectedId as AnyNodeId] + return selected?.type === 'custom-mesh' ? (selected as CustomMeshNode) : null + }) + const nodeRef = useRef(node) + nodeRef.current = node + const sceneMaterials = useScene((state) => state.materials) + const editing = useInteractionScope( + (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === selectedId, + ) + const sessionNodeId = useCustomMeshEditSession((state) => state.nodeId) + const selection = useCustomMeshEditSession((state) => state.selection) + const activeMaterialSlotId = useCustomMeshEditSession((state) => state.activeMaterialSlotId) + const [pendingMaterialRef, setPendingMaterialRef] = useState(null) + + 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 + useCustomMeshEditSession.getState().setActiveMaterialSlot(nodeId, activeFaceSlotId) + setPendingMaterialRef(null) + }, [activeFaceId, activeFaceSlotId, nodeId]) + + const close = useCallback(() => { + setViewerSelection({ selectedIds: [] }) + }, [setViewerSelection]) + + const move = useCallback(() => { + const current = nodeRef.current + if (!current) return + triggerSFX('sfx:item-pick') + createEditorApi().engageMove(current) + setViewerSelection({ selectedIds: [] }) + }, [setViewerSelection]) + + const updatePositionX = useCallback((value: number) => { + const current = nodeRef.current + if (!current) return + useScene.getState().updateNode(current.id, { + position: [value, current.position[1], current.position[2]], + }) + }, []) + + const updatePositionY = useCallback((value: number) => { + const current = nodeRef.current + if (!current) return + useScene.getState().updateNode(current.id, { + position: [current.position[0], value, current.position[2]], + }) + }, []) + + const updatePositionZ = useCallback((value: number) => { + const current = nodeRef.current + if (!current) return + useScene.getState().updateNode(current.id, { + position: [current.position[0], current.position[1], value], + }) + }, []) + + if (!node) return null + + const selectedFaceIds = + editing && sessionNodeId === node.id && selection.mode === 'face' ? selection.ids : [] + const materialSelection = customMeshMaterialSelection( + node.topology, + selectedFaceIds, + selection.activeId, + ) + const slotDeclarations = customMeshSlots(node) + const activeSlotId = + sessionNodeId === node.id ? activeMaterialSlotId : materialSelection.activeSlotId + const activeSlotRef = activeSlotId ? node.slots?.[activeSlotId] : undefined + const chosenMaterialRef = pendingMaterialRef ?? activeSlotRef + const canOperateOnFaces = editing && selection.mode === 'face' + const unusedSlotIds = unusedCustomMeshMaterialSlotIds(node.topology, node.slots) + + const chooseSlot = (slotId: string) => { + useCustomMeshEditSession.getState().setActiveMaterialSlot(node.id, slotId) + setPendingMaterialRef(null) + } + + const assignMaterial = () => { + const assignment = pendingMaterialRef + ? { kind: 'material' as const, materialRef: pendingMaterialRef } + : activeSlotId + ? { kind: 'slot' as const, slotId: activeSlotId } + : null + if (!assignment) return + const result = assignCustomMeshMaterial(node.topology, node.slots, selectedFaceIds, assignment) + if (!result.changed) return + useScene.getState().updateNode(node.id, { + topology: result.topology, + slots: result.slots, + }) + useCustomMeshEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) + setPendingMaterialRef(null) + triggerSFX('sfx:menu-click') + } + + const filterSelection = (operation: 'select' | 'deselect') => { + if (!(canOperateOnFaces && activeSlotId)) return + const ids = selectCustomMeshFacesByMaterialSlot( + node.topology, + selection.ids, + activeSlotId, + operation, + ) + const activeId = ids.includes(selection.activeId ?? '') + ? selection.activeId + : (ids.at(-1) ?? null) + useCustomMeshEditSession.getState().setSelection(node.id, { + mode: 'face', + ids, + activeId, + }) + } + + const removeUnusedSlots = () => { + if (editing) return + const result = removeUnusedCustomMeshMaterialSlots(node.topology, node.slots) + if (!result.changed) return + useScene.getState().updateNode(node.id, { slots: result.slots }) + triggerSFX('sfx:menu-click') + } + + const selectionLabel = !editing + ? 'Enter Edit Mode to assign faces' + : selection.mode !== 'face' + ? 'Switch to Face Select (3)' + : materialSelection.kind === 'empty' + ? 'No faces selected' + : materialSelection.kind === 'mixed' + ? `${selectedFaceIds.length} faces · Mixed materials` + : `${selectedFaceIds.length} ${selectedFaceIds.length === 1 ? 'face' : 'faces'} · ${materialRefLabel(node.slots?.[materialSelection.slotId], sceneMaterials)}` + + return ( + + + {( + [ + { axis: 0, label: 'X', onChange: updatePositionX }, + { axis: 1, label: 'Y', onChange: updatePositionY }, + { axis: 2, label: 'Z', onChange: updatePositionZ }, + ] as const + ).map(({ axis, label, onChange }) => ( + + ))} + + + +
+ {selectionLabel} +
+ +
+ {slotDeclarations.map((slot) => { + const ref = node.slots?.[slot.slotId] + const active = !pendingMaterialRef && activeSlotId === slot.slotId + return ( + + ) + })} +
+ + {Object.keys(sceneMaterials).length > 0 ? ( +
+
+ Scene materials +
+
+ {Object.entries(sceneMaterials).map(([id, sceneMaterial]) => { + const ref = toSceneMaterialRef(id) + return ( + + ) + })} +
+
+ ) : null} + +
+ filterSelection('select')} + /> + filterSelection('deselect')} + /> + +
+ +
+ +
+
+ + +
+ +
+
+ + + + } label="Move" onClick={move} /> + } + label="Delete" + onClick={() => { + useScene.getState().deleteNode(node.id) + setViewerSelection({ selectedIds: [] }) + }} + /> + + +
+ ) +} diff --git a/packages/nodes/src/custom-mesh/parametrics.ts b/packages/nodes/src/custom-mesh/parametrics.ts index 10b15f85e1..1b31a835f2 100644 --- a/packages/nodes/src/custom-mesh/parametrics.ts +++ b/packages/nodes/src/custom-mesh/parametrics.ts @@ -8,4 +8,5 @@ export const customMeshParametrics: ParametricDescriptor = { fields: [{ key: 'position', kind: 'vec3' }], }, ], + customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/custom-mesh/selection.tsx b/packages/nodes/src/custom-mesh/selection.tsx index 184d8f696e..96177e22e6 100644 --- a/packages/nodes/src/custom-mesh/selection.tsx +++ b/packages/nodes/src/custom-mesh/selection.tsx @@ -80,6 +80,7 @@ import { customMeshLoopCutSegments, customMeshSelectionVertexIds, } from './commands' +import useCustomMeshEditSession from './edit-session' import { triangulateCustomMeshFace } from './geometry' import { CUSTOM_MESH_WHEEL_OPTIONS, consumeCustomMeshGestureWheel } from './gesture-wheel' import { type CustomMeshSfxAction, customMeshSfx } from './interaction-sfx' @@ -138,6 +139,7 @@ const COMPONENT_HOVER_COLOR = '#ffb020' const COMPONENT_IDLE_COLOR = '#737982' const DEFAULT_BEVEL_SEGMENTS = 6 const ROTATION_SNAP_ANGLE_DEGREES = 15 +const EMPTY_COMPONENT_IDS: string[] = [] const FLOATING_PANEL_CLASS = 'pointer-events-auto flex items-center gap-1 rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur-md' @@ -1252,9 +1254,15 @@ function CustomMeshEditor({ const editing = useInteractionScope( (state) => state.scope.kind === 'mesh-editing' && state.scope.nodeId === node.id, ) - const [mode, setMode] = useState('face') - const [selectedIds, setSelectedIds] = useState([]) - const [activeId, setActiveId] = useState(null) + const mode = useCustomMeshEditSession((state) => + state.nodeId === node.id ? state.selection.mode : 'face', + ) + const selectedIds = useCustomMeshEditSession((state) => + state.nodeId === node.id ? state.selection.ids : EMPTY_COMPONENT_IDS, + ) + const activeId = useCustomMeshEditSession((state) => + state.nodeId === node.id ? state.selection.activeId : null, + ) const [transformTool, setTransformTool] = useState('transform') const [xray, setXray] = useState(false) const [previewTopology, setPreviewTopology] = useState(null) @@ -1329,9 +1337,8 @@ function CustomMeshEditor({ useLiveNodeOverrides.getState().clear(node.id) useScene.getState().markDirty(node.id) endOwnedScope() + useCustomMeshEditSession.getState().end(node.id) setPreviewTopology(null) - setSelectedIds([]) - setActiveId(null) setTransformTool('transform') setActiveTransform(null) setLoopCutSegments(null) @@ -1346,6 +1353,7 @@ function CustomMeshEditor({ useLiveNodeOverrides.getState().clear(node.id) useScene.getState().markDirty(node.id) endOwnedScope() + useCustomMeshEditSession.getState().end(node.id) if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' }, [endOwnedScope, node.id], @@ -1361,6 +1369,7 @@ function CustomMeshEditor({ setToolbarPanel(null) setLoopCutSegments(null) setActiveTransform(null) + useCustomMeshEditSession.getState().end(node.id) }, [editing, node.id]) useEffect(() => { @@ -1393,14 +1402,13 @@ function CustomMeshEditor({ const onGridClick = () => { const scope = useInteractionScope.getState().scope if (scope.kind !== 'mesh-editing' || scope.nodeId !== node.id || cancelDragRef.current) return - setSelectedIds([]) - setActiveId(null) + useCustomMeshEditSession.getState().setSelection(node.id, { mode, ids: [], activeId: null }) setError(null) playCustomMeshSfx('component-select') } emitter.on('grid:click', onGridClick) return () => emitter.off('grid:click', onGridClick) - }, [editing, node.id]) + }, [editing, mode, node.id]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -1419,9 +1427,11 @@ function CustomMeshEditor({ exitEditMode() } else if (useInteractionScope.getState().scope.kind === 'idle') { const face = preferredFace(node.topology) - setMode('face') - setSelectedIds(face ? [face.id] : []) - setActiveId(face?.id ?? null) + useCustomMeshEditSession.getState().begin(node.id, { + mode: 'face', + ids: face ? [face.id] : [], + activeId: face?.id ?? null, + }) setTransformTool('transform') setToolbarPanel(null) setError(null) @@ -1451,9 +1461,7 @@ function CustomMeshEditor({ }, nextMode, ) - setMode(converted.mode) - setSelectedIds(converted.ids) - setActiveId(converted.activeId) + useCustomMeshEditSession.getState().setSelection(node.id, converted) setError(null) playCustomMeshSfx('tool-select') } @@ -1462,23 +1470,17 @@ function CustomMeshEditor({ }, [activeId, editing, exitEditMode, mode, node.id, node.topology, selectedIds]) useEffect(() => { - const validIds = new Set( - mode === 'vertex' - ? node.topology.vertices.map((vertex) => vertex.id) - : mode === 'edge' - ? node.topology.edges.map((edge) => edge.id) - : node.topology.faces.map((face) => face.id), - ) - setSelectedIds((current) => current.filter((id) => validIds.has(id))) - setActiveId((current) => (current && validIds.has(current) ? current : null)) - }, [mode, node.topology]) + useCustomMeshEditSession.getState().reconcileSelection(node.id, node.topology) + }, [node.id, node.topology]) const enterEditMode = (event: ReactMouseEvent) => { event.stopPropagation() const face = preferredFace(node.topology) - setMode('face') - setSelectedIds(face ? [face.id] : []) - setActiveId(face?.id ?? null) + useCustomMeshEditSession.getState().begin(node.id, { + mode: 'face', + ids: face ? [face.id] : [], + activeId: face?.id ?? null, + }) setTransformTool('transform') setToolbarPanel(null) setError(null) @@ -1524,12 +1526,11 @@ function CustomMeshEditor({ (id: string, additive: boolean, event: ThreeEvent) => { if (!componentIsVisible(id, event)) return const next = selectCustomMeshComponent({ mode, ids: selectedIds, activeId }, id, additive) - setSelectedIds(next.ids) - setActiveId(next.activeId) + useCustomMeshEditSession.getState().setSelection(node.id, next) setError(null) playCustomMeshSfx('component-select') }, - [activeId, componentIsVisible, mode, selectedIds], + [activeId, componentIsVisible, mode, node.id, selectedIds], ) const switchMode = (nextMode: ComponentMode) => { @@ -1539,9 +1540,7 @@ function CustomMeshEditor({ { mode, ids: selectedIds, activeId }, nextMode, ) - setMode(converted.mode) - setSelectedIds(converted.ids) - setActiveId(converted.activeId) + useCustomMeshEditSession.getState().setSelection(node.id, converted) setToolbarPanel(null) setError(null) } @@ -1910,9 +1909,11 @@ function CustomMeshEditor({ let latestSelection: CustomMeshSelection | null = null let finished = false - setMode('edge') - setSelectedIds([edgeId]) - setActiveId(edgeId) + useCustomMeshEditSession.getState().setSelection(node.id, { + mode: 'edge', + ids: [edgeId], + activeId: edgeId, + }) setToolbarPanel(null) setError(null) useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'bevel')) @@ -1985,9 +1986,10 @@ function CustomMeshEditor({ setPreviewTopology(null) if (commit && latestTopology && latestSelection && latestWidth > 1e-6) { useScene.getState().updateNode(node.id, { topology: latestTopology }) - setMode(latestSelection.mode) - setSelectedIds(latestSelection.ids) - setActiveId(latestSelection.ids.at(-1) ?? null) + useCustomMeshEditSession.getState().setSelection(node.id, { + ...latestSelection, + activeId: latestSelection.ids.at(-1) ?? null, + }) playCustomMeshSfx('operation-commit') } else if (!commit) { playCustomMeshSfx('cancel') @@ -2135,9 +2137,10 @@ function CustomMeshEditor({ setLoopCutSegments(null) if (commit && latestTopology && latestSelection && latestFactor > 0) { useScene.getState().updateNode(node.id, { topology: latestTopology }) - setMode(latestSelection.mode) - setSelectedIds(latestSelection.ids) - setActiveId(latestSelection.ids.at(-1) ?? null) + useCustomMeshEditSession.getState().setSelection(node.id, { + ...latestSelection, + activeId: latestSelection.ids.at(-1) ?? null, + }) playCustomMeshSfx('operation-commit') } else if (!commit) { playCustomMeshSfx('cancel') @@ -2169,9 +2172,10 @@ function CustomMeshEditor({ return } useScene.getState().updateNode(node.id, { topology: result.topology }) - setMode(result.selection.mode) - setSelectedIds(result.selection.ids) - setActiveId(result.selection.ids.at(-1) ?? null) + useCustomMeshEditSession.getState().setSelection(node.id, { + ...result.selection, + activeId: result.selection.ids.at(-1) ?? null, + }) setToolbarPanel(null) setError(null) if (ownsEditSession()) useInteractionScope.getState().begin(meshEditScope(node.id)) @@ -2215,9 +2219,7 @@ function CustomMeshEditor({ } const updateSelection = (next: CustomMeshSelectionState) => { - setMode(next.mode) - setSelectedIds(next.ids) - setActiveId(next.activeId) + useCustomMeshEditSession.getState().setSelection(node.id, next) setError(null) playCustomMeshSfx('component-select') } diff --git a/packages/nodes/src/custom-mesh/slots.ts b/packages/nodes/src/custom-mesh/slots.ts index 27faaf93dc..ff69a19a44 100644 --- a/packages/nodes/src/custom-mesh/slots.ts +++ b/packages/nodes/src/custom-mesh/slots.ts @@ -1,7 +1,20 @@ -import type { SlotDeclaration } from '@pascal-app/core' +import type { CustomMeshNode, SlotDeclaration } from '@pascal-app/core' +import { CUSTOM_MESH_BODY_SLOT_ID, customMeshMaterialSlotIds } from './material-slots' -export const CUSTOM_MESH_SLOT_ID = 'body' +export const CUSTOM_MESH_SLOT_ID = CUSTOM_MESH_BODY_SLOT_ID -export function customMeshSlots(): SlotDeclaration[] { - return [{ slotId: CUSTOM_MESH_SLOT_ID, label: 'Whole mesh' }] +function slotLabel(slotId: string): string { + if (slotId === CUSTOM_MESH_SLOT_ID) return 'Body' + return slotId + .split('-') + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(' ') +} + +export function customMeshSlots(node: CustomMeshNode): SlotDeclaration[] { + return customMeshMaterialSlotIds(node.topology, node.slots).map((slotId) => ({ + slotId, + label: slotLabel(slotId), + })) } diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 145708cd6b..4ea84bd29c 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -62,6 +62,8 @@ single owner. Exactly one scope at a time; the only writable shape is | `end()` | Return to idle atomically. Both commit and cancel call it; the write-vs-revert distinction lives in the interaction body, not here. | | `endIf(match)` | Return to idle only if the active scope satisfies `match`. | +The `mesh-editing` scope is the global ownership summary, not a container for kind-specific component state. Custom mesh keeps its vertex/edge/face mode, selected IDs, active component, and active material slot in a kind-owned transient store under `packages/nodes/src/custom-mesh/`. The canvas affordance and custom inspector share that store while the scope owns the session. Entering another mesh transfers ownership; scope loss, explicit exit, and unmount clear only the matching node's session. Persisted topology and material slots remain in `useScene`. + **Atomic-end invariant.** `end()` sets the scope back to `IDLE_SCOPE` in one write — no interaction payload can leak past the end of its interaction (no stale `nodeId`, no half-cleared flags). `endIf` exists because scope is currently diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index 78d32ea155..b5ad4b7386 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -62,6 +62,24 @@ So picking the Mediterranean theme gives a blue roof + warm walls without touchi Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useViewer` (or receives them threaded from `GeometrySystem`) and **must include `sceneTheme` in its material cache key and its rebuild dependency array**, or theme switches won't re-colour. `GeometrySystem` marks every geometry node dirty on any of those changing. +## Custom-mesh face materials + +Custom meshes use the reusable `MaterialRef` model at face granularity. `CustomMeshNode.slots` maps stable object-local slot IDs to `scene:` or `library:` references, while each `CustomMeshFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved face 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 the same order as `userData.slotIds`, allowing the paint capability to map a raycast `materialIndex` back to the persistent slot. Face UVs retain the world-scale projection contract below. + +Material choice and face assignment are separate. Choosing an object slot, scene material, or library material changes only the transient assignment source. **Assign** reuses or creates one object slot by exact `MaterialRef` identity and commits `topology` plus `slots` in one `updateNode` call. **Select** and **Deselect** only add or subtract faces using the active slot from transient component selection. Assigning `body` restores the base appearance; there is no unassigned face state. + +Topology operators preserve assignments deterministically: + +- retained and transformed faces keep their slot; +- extrude caps/sides and inset caps/rings inherit the source face; +- loop-cut pieces inherit the face they split; +- bevel bands and mixed-material dissolve use the first adjacent face in stable `topology.faces` order; +- deleting the last face that uses a slot does not delete its reusable material. + +Object-mode **Remove unused slots** deletes only object-local slot bindings that no face references. It never removes `body`, remaps faces, or deletes the referenced reusable scene material. Cleanup is disabled during mesh Edit Mode, matching the separation between face assignment and object-level slot structure. + ### External plugin renderers Plugin renderers follow the same four axes through the public `@pascal-app/viewer` diff --git a/wiki/blender-material-assignment-research.md b/wiki/blender-material-assignment-research.md new file mode 100644 index 0000000000..285bb859ed --- /dev/null +++ b/wiki/blender-material-assignment-research.md @@ -0,0 +1,176 @@ +# Blender Edit-Mode Material Assignment Research + +## Purpose and conclusion + +This brief records Blender's per-face material workflow and translates its useful interaction contracts into a Pascal custom-mesh plan. Blender behavior claims use only the official Blender manual, Python API, and source mirror. Source links are pinned to commit `ce63cce6b7d645d6565f0f973142209b5069a7b2`; the research was completed on 2026-08-12. + +The central design is deliberately two-level: + +1. A reusable **Material** data-block owns the appearance. +2. An object's ordered **material slots** reference materials. +3. Each face stores one slot choice; it does not contain or duplicate the material. + +Pascal already has the corresponding persistent pieces in a safer stable-ID form: `CustomMeshFace.materialSlot` identifies an object-local slot, `CustomMeshNode.slots` maps slot IDs to reusable `MaterialRef` values, and the scene owns reusable materials. The missing product surface is an editor-owned active slot plus explicit face assignment controls. + +## Blender's data model + +Materials are reusable data-blocks that can be assigned to one or more objects. Material slots link those data-blocks to an object/mesh. Blender starts with one slot applying one material to the whole object; multiple slots allow different parts of the mesh to use different materials. [Blender material assignment manual](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#material-slots) + +For a mesh, each polygon has one zero-based `material_index`, documented as the polygon's material-slot index with a default of `0`. It is therefore a many-faces-to-one-slot mapping: every face chooses exactly one slot, while any number of faces can share it. [Blender `MeshPolygon.material_index` API](https://docs.blender.org/api/current/bpy.types.MeshPolygon.html#bpy.types.MeshPolygon.material_index) + +Slot identity and Material identity are different: + +- A slot is an object/mesh-local position in an ordered list. +- A Material is a reusable data-block referenced by a slot. +- The same Material can be reused in other objects and can even occur in more than one slot on an object. Blender's assignment code explicitly prefers the active object's slot index before falling back to searching for a matching Material data-block, because duplicate slot references are possible. [Blender material assignment operator](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L310-L343) + +The Material data-block picker is the reuse surface. It lists materials in the current blend file, supports name search, and lets the user place an existing Material in the selected slot instead of duplicating it. [Blender reusing existing materials](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#reusing-existing-materials) + +Blender additionally supports linking slot materials to either a specific object or its shared mesh data. That distinction matters for Blender instances, but Pascal should not copy it unless custom meshes later gain shared editable topology instances: Pascal's existing scene `MaterialRef` plus per-node `slots` mapping already provides the relevant reuse boundary. [Blender material slot link behavior](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#data-block) + +## Edit Mode workflow + +Blender exposes the object material-slot list in Material Properties. In Edit Mode it adds three face-oriented actions below the list: **Assign**, **Select**, and **Deselect**. The documented workflow for applying a second material is: + +1. Begin with the base material covering the object. +2. Enter Edit Mode and Face Select. +3. Select one or many target faces. +4. Add/select a material slot and choose a new or existing Material for it. +5. Press **Assign**. + +[Blender Edit Mode material controls](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#edit-mode), [Blender multiple-material workflow](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#multiple-materials) + +The three actions have distinct semantics: + +| Action | Blender behavior | Important invariant | +| ------------ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| **Assign** | Writes the active slot index to every selected face. | It overwrites those faces' previous assignments and leaves unselected faces unchanged. | +| **Select** | Selects visible faces whose assignment matches the active slot. | It adds matching faces to the current selection; it does not clear unrelated selected faces. | +| **Deselect** | Deselects visible faces whose assignment matches the active slot. | It subtracts matching faces from the current selection; it does not change any assignment. | + +The manual defines the public actions. The source shows that Assign loops over selected edit-mesh faces and sets `efa->mat_nr`, while Select/Deselect visit matching, non-hidden faces and set only their selection state. [Blender Assign implementation](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L344-L408), [Blender material Select/Deselect implementation](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/mesh/editmesh_select.cc#L3450-L3466) + +Assigning multiple selected faces is a single operator and a single undoable action. Repeating Assign with another active slot directly replaces the selected faces' earlier slot index; there is no layered material stack per face. [Blender material assignment operator registration](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L400-L408) + +### Active slot synchronization + +When face picking resolves a face, Blender sets the object's active material index from that face's `mat_nr`. This makes the material panel follow the active/clicked face rather than forcing the user to hunt for its slot manually. [Blender face-pick active-material synchronization](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/mesh/editmesh_select.cc#L2918-L2925) + +This does not mean every multi-face selection has one material. A selection can contain faces with mixed assignments; the active face supplies the panel's active slot, and an explicit Assign then makes all selected faces use that slot. + +### Choosing a Material is not Assign + +There are two mutations that should remain conceptually separate: + +- Replacing the Material referenced by an existing slot changes the appearance of **every face already using that slot**. +- Pressing Assign changes the slot choice of **the currently selected faces**. + +Blender's UI makes the distinction through the slot list/data-block chooser and the separate Assign button. Pascal may streamline the number of clicks, but a control that edits a shared slot must not look like a face-scoped assignment. Otherwise changing one selected face could unexpectedly repaint many unselected faces. + +## Removal, cleanup, and the absence of per-face unassign + +Blender does not provide an **Unassign** action for faces. A face is moved back to the base appearance by assigning the base/first slot to it. Deselect only changes selection. + +Removing a material slot is an object-level structural action, not per-face unassignment, and current Blender blocks it during Edit Mode. [Blender material-slot removal poll](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/editors/render/render_shading.cc#L245-L302) + +When a slot is removed in Object Mode, Blender removes that ordered entry and remaps face indices. Higher indices shift down; faces using the removed nonzero slot fall back to the preceding slot, while removing slot zero leaves its faces at index zero so they use the new first slot. This is an implementation consequence of Blender's ordinal indices, not a desirable interaction to copy blindly. [Blender slot removal](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/blenkernel/intern/material.cc#L1461-L1546), [Blender mesh material-index remapping](https://github.com/blender/blender/blob/ce63cce6b7d645d6565f0f973142209b5069a7b2/source/blender/blenkernel/intern/mesh.cc#L1812-L1834) + +Blender distinguishes slot cleanup from deleting reusable materials: + +- **Remove Unused Slots** removes slots not referenced by object geometry. +- **Remove All Materials** clears the active object's slots, but the Material data-blocks remain available in the blend file. +- Unlinking a Material from one object does not destroy it while it has other users; zero-user persistence follows Blender's general data-block lifecycle. + +[Blender slot cleanup](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#slot-list), [Blender deleting a material](https://docs.blender.org/manual/en/5.0/render/materials/assignment.html#deleting-a-material), [Blender data-block lifecycle](https://docs.blender.org/manual/en/5.0/files/data_blocks.html) + +## Recommended Pascal contract + +This section is a product inference based on Blender's behavior and Pascal's current model. + +### Persistent state + +Keep Pascal's stable string slot IDs rather than copying Blender's fragile ordered indices: + +```ts +type CustomMeshFace = { + id: string; + vertexIds: string[]; + materialSlot: string; +}; + +type CustomMeshNode = { + topology: { faces: CustomMeshFace[] /* ... */ }; + slots?: Record; +}; +``` + +The initial custom mesh keeps every face on `materialSlot: "body"`. Its `body` slot resolves to one reusable scene/library material, preserving the current one-material default. Adding another material creates a new stable slot ID and maps it to a `MaterialRef`; assigning faces changes only their `materialSlot` string. + +The current schema already has this shape in [`custom-mesh.ts`](../packages/core/src/schema/nodes/custom-mesh.ts). No face should store copied shader/color/texture properties. + +### Transient editor state + +The Edit Mode session should own an `activeMaterialSlotId`. It should not be persisted as scene data and it should remain independent of component selection. + +- Clicking a face makes that face active and synchronizes `activeMaterialSlotId` to its slot. +- Shift-selecting additional faces may produce a mixed-material selection; the active face still drives the displayed slot. +- Manually choosing a slot in the panel changes `activeMaterialSlotId` without repainting anything. +- Assign is enabled only when at least one editable face is selected. +- Assign produces one immutable topology update and one undo entry, regardless of face count. + +### Side-panel shape + +A Blender-derived Pascal panel can be compact: + +1. **Face Material** section visible in custom-mesh Edit Mode, primarily in Face selection mode. +2. Active slot/material preview and a searchable reusable-material chooser backed by the current scene materials. Library materials may appear too, but choosing one should resolve/mint the same shared `MaterialRef` used elsewhere in Pascal. +3. **Assign to selected** as the explicit mutating action, with the selection count in its label or nearby. +4. **Select faces** and **Deselect faces** for the active slot; these are valuable once models have many faces. +5. An add-slot path that reuses a scene Material instead of creating a duplicate. +6. Slot rename and remove/cleanup can be deferred; removal needs an explicit Pascal fallback policy because stable IDs do not require Blender's accidental preceding-slot remap. + +For a faster UX, clicking a material search result could perform “ensure object slot + assign to selected” in one undoable command. If adopted, label it as assignment and keep slot editing elsewhere; silently replacing the active slot's shared material reference would have much broader effects. + +### Material identity and slot deduplication + +For the MVP, one object-local slot per `MaterialRef` is the least surprising policy. If the chosen reusable material already has a slot on the mesh, activate and assign that slot; otherwise create a slot and assign it. Blender permits duplicate slots referencing the same Material, but Pascal has no demonstrated need for duplicate semantic slots yet. + +Do not infer that identical-looking materials are the same. Reuse should be based on `MaterialRef` identity. A later explicit duplicate/copy action can create an independently editable scene material. + +### Topology-operation invariant + +Every topology command must preserve or deterministically derive face assignments: + +- Retained faces keep their `materialSlot`. +- Split/inset faces inherit from their source face unless the operator defines otherwise. +- Extruded caps inherit the source face; new side faces need a documented source/fallback rule. +- Merge/dissolve across mixed materials needs a deterministic active/source-face policy. +- Deleting the last face using a slot does not need to delete the reusable Material; optional slot cleanup is separate. + +This is more important than matching Blender's exact removal remap because custom-mesh commands already operate on stable face identities. + +## MVP acceptance matrix + +| Case | Expected result | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| New custom mesh | All faces resolve through the single `body` slot and show one material. | +| Click a face | The face becomes active/selected and the panel follows its assigned material. | +| Select several faces, choose a reused scene material, Assign | One object slot is reused or created; every selected face points to it; unselected faces are unchanged. | +| Selected faces have mixed materials | Panel communicates Mixed while preserving the active face/slot; Assign normalizes only the selection. | +| Change which slot is active | No face appearance changes until Assign. | +| Edit the Material referenced by a slot | Every face/object using that reusable Material updates. | +| Select faces by active material | Matching visible faces are added without clearing unrelated selected faces. | +| Deselect faces by active material | Matching visible faces are removed from selection; assignments are unchanged. | +| Reassign a face to `body` | Face returns to the base material; no null/unassigned state is needed. | +| Undo a 20-face assignment | One undo restores every prior per-face slot assignment. | +| Extrude/inset an assigned face | New faces follow the documented inheritance rule and every slot reference remains valid. | +| Remove an unused object slot | No face changes and the reusable scene Material remains available. | + +## Implemented decisions + +1. Pascal exposes the object slot list and reusable scene/library material choices in the custom-mesh inspector. +2. Material choice and face assignment remain separate; **Assign** is the explicit mutating action. +3. Mixed selections show **Mixed materials**, while the active face continues to drive the active slot. +4. **Select** and **Deselect** ship with the MVP and modify only transient face selection. +5. Object-mode cleanup removes unused object-local slots only. `body` remains permanent, reusable scene materials remain available, and no implicit ordinal remapping is introduced. +6. Extrude and inset inherit the source face, loop-cut pieces inherit the face they split, and bevel/dissolve use the first adjacent face in stable topology order. From 32dc57cc80f6b096e9fe12f806a3536a5c258648 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 13 Aug 2026 08:32:13 +0530 Subject: [PATCH 09/10] fix(nodes): refine custom mesh face materials --- .../nodes/src/custom-mesh/definition.test.ts | 26 +- packages/nodes/src/custom-mesh/definition.ts | 2 - .../nodes/src/custom-mesh/geometry.test.ts | 139 ++++++--- .../src/custom-mesh/material-slots.test.ts | 72 +++-- .../nodes/src/custom-mesh/material-slots.ts | 76 +++-- packages/nodes/src/custom-mesh/paint.test.ts | 177 ++++++++++++ packages/nodes/src/custom-mesh/paint.ts | 175 ++++++++++-- packages/nodes/src/custom-mesh/panel.tsx | 267 +++++++++++------- packages/nodes/src/shared/slot-paint.ts | 57 ++-- wiki/architecture/materials-and-themes.md | 10 +- wiki/blender-material-assignment-research.md | 21 +- 11 files changed, 745 insertions(+), 277 deletions(-) create mode 100644 packages/nodes/src/custom-mesh/paint.test.ts diff --git a/packages/nodes/src/custom-mesh/definition.test.ts b/packages/nodes/src/custom-mesh/definition.test.ts index 910cfddbc2..bd7bc0e39e 100644 --- a/packages/nodes/src/custom-mesh/definition.test.ts +++ b/packages/nodes/src/custom-mesh/definition.test.ts @@ -20,7 +20,7 @@ describe('custom mesh placement bounds', () => { expect(customMeshDefinition.parametrics?.customPanel).toBeFunction() }) - test('exposes topology material slots as paintable targets', () => { + test('keeps paint face-scoped instead of exposing object-slot fan-out', () => { const base = CustomMeshNode.parse({ name: 'Paintable mesh', slots: { accent: 'library:preset-softwhite' }, @@ -37,29 +37,25 @@ describe('custom mesh placement bounds', () => { } const paint = customMeshDefinition.capabilities.paint - expect(customMeshDefinition.capabilities.slots?.(node)).toEqual([ - { slotId: 'body', label: 'Body' }, - { slotId: 'accent', label: 'Accent' }, - ]) - const hitObject = { userData: { slotIds: ['body', 'accent'] } } - expect( - paint?.resolveRole({ - node, - hitObject: hitObject as never, - materialIndex: 1, - }), - ).toBe('accent') + expect(customMeshDefinition.capabilities.slots).toBeUndefined() + expect(paint?.commit).toBeFunction() expect( paint?.buildPatch({ node, - role: 'body', + role: 'face_f-bottom', material: undefined, materialPreset: 'library:metal-steel', }), ).toEqual({ + topology: { + ...node.topology, + faces: node.topology.faces.map((face) => + face.id === 'f-bottom' ? { ...face, materialSlot: 'material-1' } : face, + ), + }, slots: { accent: 'library:preset-softwhite', - body: 'library:metal-steel', + 'material-1': 'library:metal-steel', }, }) }) diff --git a/packages/nodes/src/custom-mesh/definition.ts b/packages/nodes/src/custom-mesh/definition.ts index 9d42e4cf05..6017d72e25 100644 --- a/packages/nodes/src/custom-mesh/definition.ts +++ b/packages/nodes/src/custom-mesh/definition.ts @@ -9,7 +9,6 @@ import { buildCustomMeshGeometry } from './geometry' import { customMeshPaint } from './paint' import { customMeshParametrics } from './parametrics' import { CustomMeshNode } from './schema' -import { customMeshSlots } from './slots' export function customMeshBounds(node: CustomMeshNodeType) { const xs = node.topology.vertices.map((vertex) => vertex.position[0]) @@ -94,7 +93,6 @@ export const customMeshDefinition: NodeDefinition = { }, collides: true, }, - slots: (rawNode) => customMeshSlots(rawNode as CustomMeshNodeType), paint: customMeshPaint, }, diff --git a/packages/nodes/src/custom-mesh/geometry.test.ts b/packages/nodes/src/custom-mesh/geometry.test.ts index e8a37048e3..7caf2fde11 100644 --- a/packages/nodes/src/custom-mesh/geometry.test.ts +++ b/packages/nodes/src/custom-mesh/geometry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { CustomMeshNode } from '@pascal-app/core' -import { Mesh, type Vector3Tuple } from 'three' +import { Mesh, Ray, Vector3, type Vector3Tuple } from 'three' import { applyCustomMeshCommand } from './commands' import { buildCustomMeshGeometry } from './geometry' import { customMeshPaint } from './paint' @@ -48,19 +48,67 @@ describe('buildCustomMeshGeometry', () => { expect(mesh.userData.slotIds).toEqual(['body', 'accent']) }) - test('resolves and previews only the hit material slot', () => { - const base = CustomMeshNode.parse({ - name: 'Preview mesh', - slots: { accent: 'library:preset-softwhite' }, - }) + test('resolves every default-box surface to its stable topology face', () => { + const node = CustomMeshNode.parse({ name: 'Raycast mesh' }) + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh)) return + const rays: Array<[string, Vector3Tuple, Vector3Tuple]> = [ + ['f-bottom', [0, -10, 0], [0, 1, 0]], + ['f-top', [0, 10, 0], [0, -1, 0]], + ['f-front', [0, 1.2, -10], [0, 0, 1]], + ['f-right', [10, 1.2, 0], [-1, 0, 0]], + ['f-back', [0, 1.2, 10], [0, 0, -1]], + ['f-left', [-10, 1.2, 0], [1, 0, 0]], + ] + + for (const [faceId, origin, direction] of rays) { + expect( + customMeshPaint.resolveRole({ + node, + hitObject: mesh, + materialIndex: 0, + ray: new Ray(new Vector3(...origin), new Vector3(...direction)), + }), + ).toBe(`face_${faceId}`) + } + }) + + test('resolves a face through the rendered mesh world transform', () => { + const node = CustomMeshNode.parse({ name: 'Transformed raycast mesh' }) + const group = buildCustomMeshGeometry(node) + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh)) return + group.position.set(3, 2, -4) + group.rotation.y = Math.PI / 2 + group.updateMatrixWorld(true) + const origin = new Vector3(0, 1.2, -10).applyMatrix4(group.matrixWorld) + const direction = new Vector3(0, 0, 1).transformDirection(group.matrixWorld) + + expect( + customMeshPaint.resolveRole({ + node, + hitObject: mesh, + materialIndex: 0, + ray: new Ray(origin, direction), + }), + ).toBe('face_f-front') + }) + + test('omits malformed faces from geometry and paint hit metadata', () => { + const base = CustomMeshNode.parse({ name: 'Malformed topology mesh' }) const node = { ...base, topology: { ...base.topology, - faces: base.topology.faces.map((face, index) => ({ - ...face, - materialSlot: index === 1 ? 'accent' : 'body', - })), + faces: [ + ...base.topology.faces, + { id: 'f-malformed', vertexIds: ['v0', 'v1', 'missing'], materialSlot: 'body' }, + ], }, } const group = buildCustomMeshGeometry(node) @@ -68,33 +116,20 @@ describe('buildCustomMeshGeometry', () => { expect(mesh).toBeInstanceOf(Mesh) if (!(mesh instanceof Mesh)) return - mesh.userData.__fromGeometry = true - expect(customMeshPaint.resolveRole({ node, hitObject: mesh, materialIndex: 1 })).toBe('accent') - expect(Array.isArray(mesh.material)).toBe(true) - if (!Array.isArray(mesh.material)) return - const previous = mesh.material - const restore = customMeshPaint.applyPreview({ - node, - role: 'accent', - material: { - preset: 'custom', - properties: { color: '#c2410c' }, - }, - materialPreset: undefined, - root: group, - }) - - expect(restore).toBeFunction() - expect(Array.isArray(mesh.material)).toBe(true) - if (!Array.isArray(mesh.material)) return - expect(mesh.material[0]).toBe(previous[0]) - expect(mesh.material[1]).not.toBe(previous[1]) - restore?.() - expect(mesh.material).toBe(previous) + expect(mesh.geometry.getAttribute('position').count).toBe(36) + expect(mesh.geometry.userData.customMeshFaces).toHaveLength(6) + expect( + mesh.geometry.userData.customMeshFaces.some( + (range: { faceId: string }) => range.faceId === 'f-malformed', + ), + ).toBe(false) }) - test('previews body across face slots that render with the body fallback', () => { - const base = CustomMeshNode.parse({ name: 'Fallback preview mesh' }) + test('resolves and previews only the hit topology face', () => { + const base = CustomMeshNode.parse({ + name: 'Preview mesh', + slots: { accent: 'library:preset-softwhite' }, + }) const node = { ...base, topology: { @@ -109,14 +144,22 @@ describe('buildCustomMeshGeometry', () => { const mesh = group.getObjectByName('custom-mesh-body') expect(mesh).toBeInstanceOf(Mesh) - if (!(mesh instanceof Mesh) || !Array.isArray(mesh.material)) return + if (!(mesh instanceof Mesh)) return mesh.userData.__fromGeometry = true + const role = customMeshPaint.resolveRole({ + node, + hitObject: mesh, + materialIndex: 1, + ray: new Ray(new Vector3(0, 10, 0), new Vector3(0, -1, 0)), + }) + expect(role).toBe('face_f-top') + expect(Array.isArray(mesh.material)).toBe(true) + if (!Array.isArray(mesh.material)) return const previous = mesh.material - expect(previous[1]).toBe(previous[0]) - + const previousGroupIndices = mesh.geometry.groups.map((group) => group.materialIndex) const restore = customMeshPaint.applyPreview({ node, - role: 'body', + role: role!, material: { preset: 'custom', properties: { color: '#c2410c' }, @@ -125,12 +168,16 @@ describe('buildCustomMeshGeometry', () => { root: group, }) + expect(restore).toBeFunction() expect(Array.isArray(mesh.material)).toBe(true) if (!Array.isArray(mesh.material)) return - expect(mesh.material[1]).toBe(mesh.material[0]) - expect(mesh.material[0]).not.toBe(previous[0]) + expect(mesh.material.slice(0, previous.length)).toEqual(previous) + expect(mesh.material).toHaveLength(previous.length + 1) + expect(mesh.geometry.groups[0]?.materialIndex).toBe(previousGroupIndices[0]) + expect(mesh.geometry.groups[1]?.materialIndex).toBe(previous.length) restore?.() expect(mesh.material).toBe(previous) + expect(mesh.geometry.groups.map((group) => group.materialIndex)).toEqual(previousGroupIndices) }) test('previews a face slot when textures-off rendering supplies one material', () => { @@ -156,10 +203,11 @@ describe('buildCustomMeshGeometry', () => { mesh.userData.__fromGeometry = true const previous = mesh.material[0]! mesh.material = previous + const previousGroupIndices = mesh.geometry.groups.map((group) => group.materialIndex) const restore = customMeshPaint.applyPreview({ node, - role: 'accent', + role: 'face_f-top', material: { preset: 'custom', properties: { color: '#c2410c' }, @@ -171,11 +219,14 @@ describe('buildCustomMeshGeometry', () => { expect(restore).toBeFunction() expect(Array.isArray(mesh.material)).toBe(true) if (!Array.isArray(mesh.material)) return - expect(mesh.material).toHaveLength(2) + expect(mesh.material).toHaveLength(3) expect(mesh.material[0]).toBe(previous) - expect(mesh.material[1]).not.toBe(previous) + expect(mesh.material[1]).toBe(previous) + expect(mesh.material[2]).not.toBe(previous) + expect(mesh.geometry.groups[1]?.materialIndex).toBe(2) restore?.() expect(mesh.material).toBe(previous) + expect(mesh.geometry.groups.map((group) => group.materialIndex)).toEqual(previousGroupIndices) }) test('rebuilds the extruded topology into additional face triangles', () => { diff --git a/packages/nodes/src/custom-mesh/material-slots.test.ts b/packages/nodes/src/custom-mesh/material-slots.test.ts index b58dba1725..eb224a8a22 100644 --- a/packages/nodes/src/custom-mesh/material-slots.test.ts +++ b/packages/nodes/src/custom-mesh/material-slots.test.ts @@ -2,14 +2,32 @@ import { describe, expect, test } from 'bun:test' import { createBoxCustomMeshTopology } from '@pascal-app/core' import { assignCustomMeshMaterial, + collectReusableCustomMeshMaterialRefs, customMeshMaterialSelection, customMeshMaterialSlotIds, - removeUnusedCustomMeshMaterialSlots, + removeCustomMeshMaterialSlot, selectCustomMeshFacesByMaterialSlot, - unusedCustomMeshMaterialSlotIds, } from './material-slots' describe('custom mesh material slots', () => { + test('offers catalog refs already used in scene slots when no scene materials exist', () => { + expect( + collectReusableCustomMeshMaterialRefs([{ slots: { body: 'library:metal-steel' } }], []), + ).toEqual(['library:metal-steel']) + }) + + test('deduplicates used refs and includes unused reusable scene materials', () => { + expect( + collectReusableCustomMeshMaterialRefs( + [ + { 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 = createBoxCustomMeshTopology() topology.faces[0] = { ...topology.faces[0], materialSlot: 'orphaned' } @@ -59,39 +77,43 @@ describe('custom mesh material slots', () => { ).toEqual(['f-bottom']) }) - test('removes only unused object slots while preserving body and face references', () => { + test('removes a material slot and remaps all of its faces to the first slot', () => { const topology = createBoxCustomMeshTopology() topology.faces[1] = { ...topology.faces[1], materialSlot: 'accent' } - const slots = { - body: 'scene:body', - accent: 'scene:accent', - discarded: 'scene:shared', - } + topology.faces[2] = { ...topology.faces[2], materialSlot: 'accent' } - expect(unusedCustomMeshMaterialSlotIds(topology, slots)).toEqual(['discarded']) - expect(removeUnusedCustomMeshMaterialSlots(topology, slots)).toEqual({ - slots: { - body: 'scene:body', - accent: 'scene:accent', - }, - removedSlotIds: ['discarded'], - changed: true, - }) + const result = removeCustomMeshMaterialSlot( + topology, + { body: 'scene:body', accent: 'scene:accent', trim: 'scene:trim' }, + 'accent', + ) + + expect(result.changed).toBe(true) + expect(result.fallbackSlotId).toBe('body') + expect(result.topology.faces.slice(1, 3).map((face) => face.materialSlot)).toEqual([ + 'body', + 'body', + ]) + expect(result.slots).toEqual({ body: 'scene:body', trim: 'scene:trim' }) expect(topology.faces[1].materialSlot).toBe('accent') }) - test('keeps slot identity on cleanup no-op and collapses an empty mapping', () => { + test('removes an unused slot but never removes the first body slot', () => { const topology = createBoxCustomMeshTopology() - const slots = { body: 'scene:body' } - const noOp = removeUnusedCustomMeshMaterialSlots(topology, slots) + const slots = { body: 'scene:body', accent: 'scene:accent' } - expect(noOp).toEqual({ slots, removedSlotIds: [], changed: false }) - expect(noOp.slots).toBe(slots) - expect(removeUnusedCustomMeshMaterialSlots(topology, { discarded: 'scene:shared' })).toEqual({ - slots: undefined, - removedSlotIds: ['discarded'], + const removed = removeCustomMeshMaterialSlot(topology, slots, 'accent') + expect(removed).toEqual({ + topology, + slots: { body: 'scene:body' }, + fallbackSlotId: 'body', changed: true, }) + + const body = removeCustomMeshMaterialSlot(topology, slots, 'body') + expect(body).toEqual({ topology, slots, fallbackSlotId: 'body', changed: false }) + expect(body.topology).toBe(topology) + expect(body.slots).toBe(slots) }) test('assigns an existing slot to all selected faces in one immutable result', () => { diff --git a/packages/nodes/src/custom-mesh/material-slots.ts b/packages/nodes/src/custom-mesh/material-slots.ts index 985f7438aa..2483f83ecd 100644 --- a/packages/nodes/src/custom-mesh/material-slots.ts +++ b/packages/nodes/src/custom-mesh/material-slots.ts @@ -1,4 +1,9 @@ -import type { CustomMeshTopology, MaterialRef } from '@pascal-app/core' +import { + type CustomMeshTopology, + type MaterialRef, + parseMaterialRef, + toSceneMaterialRef, +} from '@pascal-app/core' export const CUSTOM_MESH_BODY_SLOT_ID = 'body' @@ -20,12 +25,37 @@ export type CustomMeshMaterialAssignmentResult = { changed: boolean } -export type CustomMeshMaterialSlotCleanupResult = { +export type CustomMeshMaterialSlotRemovalResult = { + topology: CustomMeshTopology slots: CustomMeshMaterialSlots - removedSlotIds: string[] + fallbackSlotId: string changed: boolean } +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 collectReusableCustomMeshMaterialRefs( + 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) + } + } + for (const id of sceneMaterialIds) refs.add(toSceneMaterialRef(id)) + return [...refs] +} + export function customMeshMaterialSlotIds( topology: CustomMeshTopology, slots: CustomMeshMaterialSlots, @@ -61,29 +91,35 @@ export function customMeshMaterialSelection( return { kind: 'mixed', activeSlotId } } -export function unusedCustomMeshMaterialSlotIds( +export function removeCustomMeshMaterialSlot( topology: CustomMeshTopology, slots: CustomMeshMaterialSlots, -): string[] { - const used = new Set([ - CUSTOM_MESH_BODY_SLOT_ID, - ...topology.faces.map((face) => face.materialSlot), - ]) - return Object.keys(slots ?? {}).filter((slotId) => !used.has(slotId)) -} + slotId: string, +): CustomMeshMaterialSlotRemovalResult { + const slotIds = customMeshMaterialSlotIds(topology, slots) + const fallbackSlotId = slotIds[0] ?? CUSTOM_MESH_BODY_SLOT_ID + if (slotId === fallbackSlotId || !slotIds.includes(slotId)) { + return { topology, slots, fallbackSlotId, changed: false } + } -export function removeUnusedCustomMeshMaterialSlots( - topology: CustomMeshTopology, - slots: CustomMeshMaterialSlots, -): CustomMeshMaterialSlotCleanupResult { - const removedSlotIds = unusedCustomMeshMaterialSlotIds(topology, slots) - if (removedSlotIds.length === 0) return { slots, removedSlotIds, changed: false } + const remapsFaces = topology.faces.some((face) => face.materialSlot === slotId) + const removesBinding = Object.hasOwn(slots ?? {}, slotId) + if (!(remapsFaces || removesBinding)) { + return { topology, slots, fallbackSlotId, changed: false } + } - const removed = new Set(removedSlotIds) - const retainedEntries = Object.entries(slots ?? {}).filter(([slotId]) => !removed.has(slotId)) + const retainedEntries = Object.entries(slots ?? {}).filter(([candidate]) => candidate !== slotId) return { + topology: remapsFaces + ? { + ...topology, + faces: topology.faces.map((face) => + face.materialSlot === slotId ? { ...face, materialSlot: fallbackSlotId } : face, + ), + } + : topology, slots: retainedEntries.length > 0 ? Object.fromEntries(retainedEntries) : undefined, - removedSlotIds, + fallbackSlotId, changed: true, } } diff --git a/packages/nodes/src/custom-mesh/paint.test.ts b/packages/nodes/src/custom-mesh/paint.test.ts new file mode 100644 index 0000000000..6ff2dfbfbd --- /dev/null +++ b/packages/nodes/src/custom-mesh/paint.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + CustomMeshNode, + generateSceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { customMeshPaint } from './paint' + +describe('custom mesh face paint', () => { + let node: CustomMeshNode + + beforeEach(() => { + node = CustomMeshNode.parse({ name: 'Paint target' }) + useScene.setState({ + nodes: { [node.id]: node }, + materials: {}, + dirtyNodes: new Set(), + readOnly: false, + }) + useScene.temporal.getState().clear() + }) + + afterEach(() => { + useScene.setState({ nodes: {}, materials: {}, dirtyNodes: new Set(), readOnly: false }) + useScene.temporal.getState().clear() + }) + + test('paints one face and reuses the same object slot on another face', () => { + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material: undefined, + materialPreset: 'library:metal-steel', + }) + let painted = useScene.getState().nodes[node.id] + expect(painted?.type).toBe('custom-mesh') + if (painted?.type !== 'custom-mesh') return + expect(painted.topology.faces.find((face) => face.id === 'f-top')?.materialSlot).toBe( + 'material-1', + ) + expect(painted.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe('body') + expect(painted.slots).toEqual({ 'material-1': 'library:metal-steel' }) + + customMeshPaint.commit?.({ + node: painted, + role: 'face_f-front', + material: undefined, + materialPreset: 'library:metal-steel', + }) + painted = useScene.getState().nodes[node.id] + expect(painted?.type).toBe('custom-mesh') + if (painted?.type !== 'custom-mesh') return + expect(painted.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe( + 'material-1', + ) + expect(painted.slots).toEqual({ 'material-1': 'library:metal-steel' }) + }) + + test('reuses a structurally matching scene material instead of creating one', () => { + const materialId = generateSceneMaterialId() + const material = { preset: 'custom' as const, properties: { color: '#c2410c' } } + useScene.setState({ + materials: { + [materialId]: { id: materialId, name: 'Shared red', material }, + }, + }) + + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material, + materialPreset: undefined, + }) + + const painted = useScene.getState().nodes[node.id] + expect(painted?.type).toBe('custom-mesh') + if (painted?.type !== 'custom-mesh') return + expect(Object.keys(useScene.getState().materials)).toEqual([materialId]) + expect(painted.slots).toEqual({ 'material-1': toSceneMaterialRef(materialId) }) + }) + + test('commits the face and a new reusable scene material in one undo step', () => { + const material = { preset: 'custom' as const, properties: { color: '#c2410c' } } + + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material, + materialPreset: undefined, + }) + + expect(Object.keys(useScene.getState().materials)).toHaveLength(1) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[node.id]).toEqual(node) + expect(useScene.getState().materials).toEqual({}) + }) + + test('does not mutate a read-only scene or create an orphan material', () => { + useScene.setState({ readOnly: true }) + useScene.temporal.getState().clear() + + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material: { preset: 'custom', properties: { color: '#c2410c' } }, + materialPreset: undefined, + }) + + expect(useScene.getState().nodes[node.id]).toEqual(node) + expect(useScene.getState().materials).toEqual({}) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + }) + + test('does not create an orphan material for a semantic no-op', () => { + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material: undefined, + materialPreset: undefined, + }) + + expect(useScene.getState().nodes[node.id]).toEqual(node) + expect(useScene.getState().materials).toEqual({}) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + }) + + test('reuses the body slot when painting with its material reference', () => { + const topology = { + ...node.topology, + faces: node.topology.faces.map((face) => + face.id === 'f-top' ? { ...face, materialSlot: 'accent' } : face, + ), + } + node = { ...node, topology, slots: { body: 'library:metal-steel', accent: 'library:wood' } } + useScene.setState({ nodes: { [node.id]: node } }) + useScene.temporal.getState().clear() + + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material: undefined, + materialPreset: 'library:metal-steel', + }) + + const painted = useScene.getState().nodes[node.id] + expect(painted?.type).toBe('custom-mesh') + if (painted?.type !== 'custom-mesh') return + expect(painted.topology.faces.find((face) => face.id === 'f-top')?.materialSlot).toBe('body') + expect(painted.slots).toEqual({ body: 'library:metal-steel', accent: 'library:wood' }) + }) + + test('eraser returns only the painted face to body', () => { + customMeshPaint.commit?.({ + node, + role: 'face_f-top', + material: undefined, + materialPreset: 'library:metal-steel', + }) + const painted = useScene.getState().nodes[node.id] + expect(painted?.type).toBe('custom-mesh') + if (painted?.type !== 'custom-mesh') return + + customMeshPaint.commit?.({ + node: painted, + role: 'face_f-top', + material: undefined, + materialPreset: undefined, + }) + + const erased = useScene.getState().nodes[node.id] + expect(erased?.type).toBe('custom-mesh') + if (erased?.type !== 'custom-mesh') return + expect(erased.topology.faces.find((face) => face.id === 'f-top')?.materialSlot).toBe('body') + }) +}) diff --git a/packages/nodes/src/custom-mesh/paint.ts b/packages/nodes/src/custom-mesh/paint.ts index 37f1e3ae56..05234f430e 100644 --- a/packages/nodes/src/custom-mesh/paint.ts +++ b/packages/nodes/src/custom-mesh/paint.ts @@ -1,43 +1,145 @@ -import type { PaintPreviewArgs, PaintResolveArgs } from '@pascal-app/core' -import type { Mesh, Object3D } from 'three' -import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' -import { CUSTOM_MESH_BODY_SLOT_ID } from './material-slots' +import { + type AnyNode, + type AnyNodeId, + type CustomMeshNode, + type MaterialRef, + type PaintCapability, + type PaintPatchArgs, + type PaintPreviewArgs, + type PaintResolveArgs, + parseMaterialRef, + type SceneMaterialId, + useScene, +} from '@pascal-app/core' +import { type Mesh, type Object3D, Raycaster } from 'three' +import { buildSlotPreviewMaterial, resolveSlotPaintMaterialRef } from '../shared/slot-paint' +import { assignCustomMeshMaterial, CUSTOM_MESH_BODY_SLOT_ID } from './material-slots' + +const CUSTOM_MESH_FACE_PAINT_PREFIX = 'face_' +const customMeshPaintRaycaster = new Raycaster() + +type CustomMeshFaceRange = { faceId: string; start: number; count: number } + +function paintRoleForFace(faceId: string): string { + return `${CUSTOM_MESH_FACE_PAINT_PREFIX}${faceId}` +} + +function faceIdFromPaintRole(role: string): string | null { + if (!role.startsWith(CUSTOM_MESH_FACE_PAINT_PREFIX)) return null + return role.slice(CUSTOM_MESH_FACE_PAINT_PREFIX.length) || null +} + +function customMeshFaceRanges(mesh: Mesh): CustomMeshFaceRange[] { + const ranges = mesh.geometry.userData.customMeshFaces + return Array.isArray(ranges) ? ranges : [] +} function resolveCustomMeshPaintRole(args: PaintResolveArgs): string | null { - const slotIds = (args.hitObject?.userData as { slotIds?: unknown } | undefined)?.slotIds - if (!Array.isArray(slotIds)) return null - const slotId = slotIds[args.materialIndex ?? 0] - return typeof slotId === 'string' ? slotId : null + const mesh = args.hitObject as Mesh | undefined + if (!(mesh?.isMesh && args.ray)) return null + mesh.updateWorldMatrix(true, false) + customMeshPaintRaycaster.ray.copy(args.ray) + const hit = customMeshPaintRaycaster.intersectObject(mesh, false)[0] + if (hit?.faceIndex == null) return null + const triangleStart = hit.faceIndex * 3 + const range = customMeshFaceRanges(mesh).find( + (candidate) => + triangleStart >= candidate.start && triangleStart < candidate.start + candidate.count, + ) + return range ? paintRoleForFace(range.faceId) : null } -function previewCustomMeshSlot(args: PaintPreviewArgs): (() => void) | null { +function assignPaintedFace( + node: CustomMeshNode, + role: string, + materialRef: MaterialRef | undefined, +) { + const faceId = faceIdFromPaintRole(role) + if (!faceId) return null + return assignCustomMeshMaterial( + node.topology, + node.slots, + [faceId], + materialRef + ? { kind: 'material', materialRef } + : { kind: 'slot', slotId: CUSTOM_MESH_BODY_SLOT_ID }, + ) +} + +function buildCustomMeshFacePaintPatch(args: PaintPatchArgs): Partial { + const node = args.node as CustomMeshNode + if (args.material && !args.materialPreset) return {} + const result = assignPaintedFace(node, args.role, args.materialPreset) + return result?.changed ? { topology: result.topology, slots: result.slots } : {} +} + +function commitCustomMeshFacePaint(args: PaintPatchArgs): void { + const nodeId = args.node.id as AnyNodeId + const state = useScene.getState() + const current = state.nodes[nodeId] + if (current?.type !== 'custom-mesh') return + const resolution = resolveSlotPaintMaterialRef( + state.materials, + args.material, + args.materialPreset, + ) + if (!resolution) return + const result = assignPaintedFace(current, args.role, resolution.ref) + if (!result?.changed) return + let committed = false + useScene.setState((scene) => { + if (scene.readOnly || scene.nodes[nodeId]?.type !== 'custom-mesh') return scene + committed = true + return { + materials: resolution.newSceneMaterial + ? { + ...scene.materials, + [resolution.newSceneMaterial.id as SceneMaterialId]: resolution.newSceneMaterial, + } + : scene.materials, + nodes: { + ...scene.nodes, + [nodeId]: { + ...scene.nodes[nodeId], + topology: result.topology, + slots: result.slots, + } as AnyNode, + }, + } + }) + if (committed) useScene.getState().markDirty(nodeId) +} + +function previewCustomMeshFace(args: PaintPreviewArgs): (() => void) | null { const preview = buildSlotPreviewMaterial(args.material, args.materialPreset) if (!preview) return () => {} + const faceId = faceIdFromPaintRole(args.role) + if (!faceId) return null const restores: Array<() => void> = [] ;(args.root as Object3D).traverse((object) => { const mesh = object as Mesh if (!mesh.isMesh || mesh.userData.__fromGeometry !== true) return - const userData = mesh.userData as { slotIds?: unknown; bodyFallbackSlotIds?: unknown } - const slotIds = userData.slotIds - if (!Array.isArray(slotIds)) return - const bodyFallbackSlotIds = new Set( - Array.isArray(userData.bodyFallbackSlotIds) ? userData.bodyFallbackSlotIds : [], + const range = customMeshFaceRanges(mesh).find((candidate) => candidate.faceId === faceId) + if (!range) return + const materialGroup = mesh.geometry.groups.find( + (group) => group.start === range.start && group.count === range.count, ) - const materialIndices = slotIds.flatMap((slotId, index) => - slotId === args.role || - (args.role === CUSTOM_MESH_BODY_SLOT_ID && bodyFallbackSlotIds.has(slotId)) - ? [index] - : [], - ) - if (materialIndices.length === 0) return + if (!materialGroup) return - const previous = mesh.material - const next = Array.isArray(previous) ? previous.slice() : slotIds.map(() => previous) - for (const materialIndex of materialIndices) next[materialIndex] = preview - mesh.material = next + const previousMaterial = mesh.material + const previousMaterialIndex = materialGroup.materialIndex + const slotIds = (mesh.userData as { slotIds?: unknown }).slotIds + const next = Array.isArray(previousMaterial) + ? previousMaterial.slice() + : Array.isArray(slotIds) + ? slotIds.map(() => previousMaterial) + : [previousMaterial] + materialGroup.materialIndex = next.length + mesh.material = [...next, preview] restores.push(() => { - mesh.material = previous + materialGroup.materialIndex = previousMaterialIndex + mesh.material = previousMaterial }) }) @@ -47,7 +149,22 @@ function previewCustomMeshSlot(args: PaintPreviewArgs): (() => void) | null { } } -export const customMeshPaint = createSlotPaintCapability({ +export const customMeshPaint: PaintCapability = { resolveRole: resolveCustomMeshPaintRole, - applyPreview: previewCustomMeshSlot, -}) + buildPatch: buildCustomMeshFacePaintPatch, + commit: commitCustomMeshFacePaint, + applyPreview: previewCustomMeshFace, + getEffectiveMaterial: ({ node, role }) => { + if (node.type !== 'custom-mesh') return null + const faceId = faceIdFromPaintRole(role) + const slotId = faceId + ? node.topology.faces.find((face) => face.id === faceId)?.materialSlot + : null + const ref = slotId ? node.slots?.[slotId] : undefined + const parsed = parseMaterialRef(ref) + if (!parsed) return null + if (parsed.kind === 'library') return { material: undefined, materialPreset: ref } + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + return sceneMaterial ? { material: sceneMaterial.material, materialPreset: undefined } : null + }, +} diff --git a/packages/nodes/src/custom-mesh/panel.tsx b/packages/nodes/src/custom-mesh/panel.tsx index 475c06cf16..1bdf3f5b92 100644 --- a/packages/nodes/src/custom-mesh/panel.tsx +++ b/packages/nodes/src/custom-mesh/panel.tsx @@ -5,14 +5,12 @@ import { type CustomMeshNode, getCatalogMaterialById, parseMaterialRef, - toSceneMaterialRef, useScene, } from '@pascal-app/core' import { ActionButton, ActionGroup, createEditorApi, - MaterialPicker, PanelSection, PanelWrapper, SliderControl, @@ -20,18 +18,20 @@ import { useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Move, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, Move, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import useCustomMeshEditSession from './edit-session' import { assignCustomMeshMaterial, + collectReusableCustomMeshMaterialRefs, customMeshMaterialSelection, - removeUnusedCustomMeshMaterialSlots, + removeCustomMeshMaterialSlot, selectCustomMeshFacesByMaterialSlot, - unusedCustomMeshMaterialSlotIds, } from './material-slots' import { customMeshSlots } from './slots' +const REUSABLE_MATERIAL_REF_SEPARATOR = '\u001f' + function materialRefLabel( ref: string | undefined, sceneMaterials: ReturnType['materials'], @@ -43,6 +43,27 @@ function materialRefLabel( return getCatalogMaterialById(parsed.id)?.label ?? ref ?? parsed.id } +function materialRefPreview( + ref: string | undefined, + sceneMaterials: ReturnType['materials'], +): { color: string; imageUrl?: string } { + const parsed = parseMaterialRef(ref) + if (!parsed) return { color: '#71717a' } + if (parsed.kind === 'scene') { + const material = sceneMaterials[parsed.id as keyof typeof sceneMaterials]?.material + return { + color: material?.properties?.color ?? '#71717a', + imageUrl: material?.texture?.url, + } + } + const catalogMaterial = getCatalogMaterialById(parsed.id) + return { + color: + catalogMaterial?.previewColor ?? catalogMaterial?.preset.mapProperties.color ?? '#71717a', + imageUrl: catalogMaterial?.previewThumbnailUrl, + } +} + export default function CustomMeshPanel() { const selectedId = useViewer((state) => state.selection.selectedIds[0]) const setViewerSelection = useViewer((state) => state.setSelection) @@ -54,13 +75,22 @@ export default function CustomMeshPanel() { const nodeRef = useRef(node) nodeRef.current = node const sceneMaterials = useScene((state) => state.materials) + const reusableMaterialRefsKey = useScene((state) => + collectReusableCustomMeshMaterialRefs( + 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 = useCustomMeshEditSession((state) => state.nodeId) const selection = useCustomMeshEditSession((state) => state.selection) const activeMaterialSlotId = useCustomMeshEditSession((state) => state.activeMaterialSlotId) - const [pendingMaterialRef, setPendingMaterialRef] = useState(null) const activeFaceSlotId = useMemo(() => { if (!(node && sessionNodeId === node.id && selection.mode === 'face')) return null @@ -77,7 +107,6 @@ export default function CustomMeshPanel() { if (syncedActiveFaceRef.current === syncKey) return syncedActiveFaceRef.current = syncKey useCustomMeshEditSession.getState().setActiveMaterialSlot(nodeId, activeFaceSlotId) - setPendingMaterialRef(null) }, [activeFaceId, activeFaceSlotId, nodeId]) const close = useCallback(() => { @@ -129,30 +158,52 @@ export default function CustomMeshPanel() { const activeSlotId = sessionNodeId === node.id ? activeMaterialSlotId : materialSelection.activeSlotId const activeSlotRef = activeSlotId ? node.slots?.[activeSlotId] : undefined - const chosenMaterialRef = pendingMaterialRef ?? activeSlotRef const canOperateOnFaces = editing && selection.mode === 'face' - const unusedSlotIds = unusedCustomMeshMaterialSlotIds(node.topology, node.slots) + const faceCountBySlot = new Map() + for (const face of node.topology.faces) { + faceCountBySlot.set(face.materialSlot, (faceCountBySlot.get(face.materialSlot) ?? 0) + 1) + } const chooseSlot = (slotId: string) => { useCustomMeshEditSession.getState().setActiveMaterialSlot(node.id, slotId) - setPendingMaterialRef(null) + } + + const chooseReusableMaterial = (materialRef: string) => { + if (!(materialRef && selectedFaceIds.length > 0)) return + const result = assignCustomMeshMaterial(node.topology, node.slots, selectedFaceIds, { + kind: 'material', + materialRef, + }) + if (result.changed) { + useScene.getState().updateNode(node.id, { + topology: result.topology, + slots: result.slots, + }) + triggerSFX('sfx:menu-click') + } + useCustomMeshEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) + } + + 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 = () => { - const assignment = pendingMaterialRef - ? { kind: 'material' as const, materialRef: pendingMaterialRef } - : activeSlotId - ? { kind: 'slot' as const, slotId: activeSlotId } - : null - if (!assignment) return - const result = assignCustomMeshMaterial(node.topology, node.slots, selectedFaceIds, assignment) + if (!activeSlotId) return + const result = assignCustomMeshMaterial(node.topology, node.slots, selectedFaceIds, { + kind: 'slot', + slotId: activeSlotId, + }) if (!result.changed) return useScene.getState().updateNode(node.id, { topology: result.topology, slots: result.slots, }) useCustomMeshEditSession.getState().setActiveMaterialSlot(node.id, result.slotId) - setPendingMaterialRef(null) triggerSFX('sfx:menu-click') } @@ -174,11 +225,14 @@ export default function CustomMeshPanel() { }) } - const removeUnusedSlots = () => { - if (editing) return - const result = removeUnusedCustomMeshMaterialSlots(node.topology, node.slots) + const removeMaterialSlot = (slotId: string) => { + const result = removeCustomMeshMaterialSlot(node.topology, node.slots, slotId) if (!result.changed) return - useScene.getState().updateNode(node.id, { slots: result.slots }) + useScene.getState().updateNode(node.id, { + topology: result.topology, + slots: result.slots, + }) + useCustomMeshEditSession.getState().setActiveMaterialSlot(node.id, result.fallbackSlotId) triggerSFX('sfx:menu-click') } @@ -226,69 +280,100 @@ export default function CustomMeshPanel() { {selectionLabel}
-
- {slotDeclarations.map((slot) => { +
+ {slotDeclarations.map((slot, index) => { const ref = node.slots?.[slot.slotId] - const active = !pendingMaterialRef && activeSlotId === slot.slotId + const active = activeSlotId === slot.slotId + const preview = materialRefPreview(ref, sceneMaterials) + const faceCount = faceCountBySlot.get(slot.slotId) ?? 0 + const materialLabel = materialRefLabel(ref, sceneMaterials) return ( - - ) - })} -
+ - {Object.keys(sceneMaterials).length > 0 ? ( -
-
- Scene materials -
-
- {Object.entries(sceneMaterials).map(([id, sceneMaterial]) => { - const ref = toSceneMaterialRef(id) - return ( + {index > 0 ? ( - ) - })} -
-
- ) : null} + ) : null} +
+ ) + })} +
+ +
+ + +
- -
- -
- - - -
- -
diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index 35c7ce9561..22cbe9a3cd 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -67,6 +67,36 @@ function findMatchingSceneMaterial( return null } +export type SlotPaintMaterialResolution = { + ref: string | undefined + newSceneMaterial: SceneMaterial | null +} + +export function resolveSlotPaintMaterialRef( + materials: Record, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): SlotPaintMaterialResolution | null { + if (material === undefined && materialPreset === undefined) { + return { ref: undefined, newSceneMaterial: null } + } + if (materialPreset) return { ref: materialPreset, newSceneMaterial: null } + if (!material) return null + + const existing = findMatchingSceneMaterial(materials, material) + if (existing) return { ref: toSceneMaterialRef(existing.id), newSceneMaterial: null } + + const id = generateSceneMaterialId() + return { + ref: toSceneMaterialRef(id), + newSceneMaterial: { + id, + name: `Material ${Object.keys(materials).length + 1}`, + material, + }, + } +} + function commitSlotPaint( node: SlotsNode, role: string, @@ -76,30 +106,9 @@ function commitSlotPaint( const nodeId = node.id as AnyNodeId const state = useScene.getState() const currentNode = (state.nodes[nodeId] as SlotsNode | undefined) ?? node - - let ref: string | undefined - let newSceneMaterial: SceneMaterial | null = null - - if (material === undefined && materialPreset === undefined) { - ref = undefined - } else if (materialPreset) { - ref = materialPreset - } else if (material) { - const existing = findMatchingSceneMaterial(state.materials, material) - if (existing) { - ref = toSceneMaterialRef(existing.id) - } else { - const id = generateSceneMaterialId() - newSceneMaterial = { - id, - name: `Material ${Object.keys(state.materials).length + 1}`, - material, - } - ref = toSceneMaterialRef(id) - } - } else { - return - } + const resolution = resolveSlotPaintMaterialRef(state.materials, material, materialPreset) + if (!resolution) return + const { ref, newSceneMaterial } = resolution const nextSlots = { ...(currentNode.slots ?? {}) } if (ref) nextSlots[role] = ref diff --git a/wiki/architecture/materials-and-themes.md b/wiki/architecture/materials-and-themes.md index b5ad4b7386..cbde8eee2d 100644 --- a/wiki/architecture/materials-and-themes.md +++ b/wiki/architecture/materials-and-themes.md @@ -66,9 +66,13 @@ Each of these reads `shading`/`textures`/`colorPreset`/`sceneTheme` from `useVie Custom meshes use the reusable `MaterialRef` model at face granularity. `CustomMeshNode.slots` maps stable object-local slot IDs to `scene:` or `library:` references, while each `CustomMeshFace.materialSlot` stores one slot ID. `body` is the permanent base slot and the fallback for unbound or unresolved face 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 the same order as `userData.slotIds`, allowing the paint capability to map a raycast `materialIndex` back to the persistent slot. Face UVs retain the world-scale projection contract below. +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.customMeshFaces`. 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. -Material choice and face assignment are separate. Choosing an object slot, scene material, or library material changes only the transient assignment source. **Assign** reuses or creates one object slot by exact `MaterialRef` identity and commits `topology` plus `slots` in one `updateNode` call. **Select** and **Deselect** only add or subtract faces using the active slot from transient component selection. Assigning `body` restores the base appearance; there is no unassigned face state. +The custom-mesh inspector lists only slots already present on that mesh; it does not embed the material library. A compact dropdown below the list exposes deduplicated `MaterialRef`s already used by scene node slots, plus custom scene-material datablocks that are not yet in use. With faces selected in Edit Mode, choosing a reusable material immediately reuses an object slot with the same `MaterialRef`, or creates one when that material is new to the mesh, and assigns it to those faces in one update. Choosing an object 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 object slot from transient component selection. + +Deleting a non-body item from the Face Materials list removes only that mesh-local slot. Every face assigned to it is remapped to the first slot (`body`) in the same node update, and `body` becomes the active assignment source. The reusable scene or library material remains available to other nodes. + +The global Paint tool is the creation path for new face materials. A paint hit resolves one topology face, reuses an existing object slot by exact `MaterialRef` identity or creates one stable slot, and assigns only that face. A one-off material reuses a structurally matching scene material before creating a new reusable scene material. Erasing assigns `body`; there is no unassigned face state. Topology operators preserve assignments deterministically: @@ -78,8 +82,6 @@ Topology operators preserve assignments deterministically: - bevel bands and mixed-material dissolve use the first adjacent face in stable `topology.faces` order; - deleting the last face that uses a slot does not delete its reusable material. -Object-mode **Remove unused slots** deletes only object-local slot bindings that no face references. It never removes `body`, remaps faces, or deletes the referenced reusable scene material. Cleanup is disabled during mesh Edit Mode, matching the separation between face assignment and object-level slot structure. - ### External plugin renderers Plugin renderers follow the same four axes through the public `@pascal-app/viewer` diff --git a/wiki/blender-material-assignment-research.md b/wiki/blender-material-assignment-research.md index 285bb859ed..203df408af 100644 --- a/wiki/blender-material-assignment-research.md +++ b/wiki/blender-material-assignment-research.md @@ -123,13 +123,13 @@ The Edit Mode session should own an `activeMaterialSlotId`. It should not be per A Blender-derived Pascal panel can be compact: 1. **Face Material** section visible in custom-mesh Edit Mode, primarily in Face selection mode. -2. Active slot/material preview and a searchable reusable-material chooser backed by the current scene materials. Library materials may appear too, but choosing one should resolve/mint the same shared `MaterialRef` used elsewhere in Pascal. -3. **Assign to selected** as the explicit mutating action, with the selection count in its label or nearby. +2. Active slot/material preview showing only slots already used by the custom mesh, followed by a compact data-block dropdown for material references already used in the scene plus custom scene materials. The full catalog remains in the global Paint tool instead of being duplicated in this inspector. +3. **Assign to selected** as the explicit mutating action for reusing an existing mesh slot, with the selection count in its label or nearby. 4. **Select faces** and **Deselect faces** for the active slot; these are valuable once models have many faces. -5. An add-slot path that reuses a scene Material instead of creating a duplicate. -6. Slot rename and remove/cleanup can be deferred; removal needs an explicit Pascal fallback policy because stable IDs do not require Blender's accidental preceding-slot remap. +5. Painting a face with the global Paint tool adds or reuses an object slot by `MaterialRef` identity. +6. Deleting a non-body slot remaps its faces to the permanent first slot (`body`) in the same update; deleting a mesh-local slot never deletes the reusable material. -For a faster UX, clicking a material search result could perform “ensure object slot + assign to selected” in one undoable command. If adopted, label it as assignment and keep slot editing elsewhere; silently replacing the active slot's shared material reference would have much broader effects. +Painting a face performs “ensure object slot + assign this face” in one undoable command. It never silently replaces the active slot's shared material reference, which would have much broader effects. ### Material identity and slot deduplication @@ -155,7 +155,8 @@ This is more important than matching Blender's exact removal remap because custo | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | New custom mesh | All faces resolve through the single `body` slot and show one material. | | Click a face | The face becomes active/selected and the panel follows its assigned material. | -| Select several faces, choose a reused scene material, Assign | One object slot is reused or created; every selected face points to it; unselected faces are unchanged. | +| Paint a face with a reusable material | One object slot is reused or created; only the painted face points to it. | +| Select several faces, choose an existing mesh slot, Assign | Every selected face points to that slot; unselected faces are unchanged. | | Selected faces have mixed materials | Panel communicates Mixed while preserving the active face/slot; Assign normalizes only the selection. | | Change which slot is active | No face appearance changes until Assign. | | Edit the Material referenced by a slot | Every face/object using that reusable Material updates. | @@ -164,13 +165,13 @@ This is more important than matching Blender's exact removal remap because custo | Reassign a face to `body` | Face returns to the base material; no null/unassigned state is needed. | | Undo a 20-face assignment | One undo restores every prior per-face slot assignment. | | Extrude/inset an assigned face | New faces follow the documented inheritance rule and every slot reference remains valid. | -| Remove an unused object slot | No face changes and the reusable scene Material remains available. | +| Delete a used non-body object slot | Its faces move to `body`, `body` becomes active, and the reusable Material remains available. | ## Implemented decisions -1. Pascal exposes the object slot list and reusable scene/library material choices in the custom-mesh inspector. -2. Material choice and face assignment remain separate; **Assign** is the explicit mutating action. +1. Pascal exposes the object slot list plus a compact reusable-material dropdown in the custom-mesh inspector. It contains deduplicated material references used by scene node slots and custom scene materials; the full catalog stays in the global Paint tool, which also adds or reuses slots when it paints individual faces. +2. Choosing a reusable material from the compact dropdown assigns it immediately to the selected faces. Choosing an existing object slot remains non-mutating until **Assign**. 3. Mixed selections show **Mixed materials**, while the active face continues to drive the active slot. 4. **Select** and **Deselect** ship with the MVP and modify only transient face selection. -5. Object-mode cleanup removes unused object-local slots only. `body` remains permanent, reusable scene materials remain available, and no implicit ordinal remapping is introduced. +5. `body` remains permanent. Per-item deletion remaps affected faces to `body` and preserves reusable scene materials. 6. Extrude and inset inherit the source face, loop-cut pieces inherit the face they split, and bevel/dissolve use the first adjacent face in stable topology order. From 3a33729d2fae5a7c7706fad0977c10ac56e71be5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 13 Aug 2026 10:18:47 +0530 Subject: [PATCH 10/10] fix(editor): address custom mesh review findings --- .../hooks/spatial-grid/support-host-patch.ts | 161 +++++++++++++++ packages/core/src/index.ts | 6 + packages/core/src/registry/types.ts | 4 +- packages/core/src/schema/index.ts | 2 + .../core/src/schema/nodes/custom-mesh.test.ts | 14 ++ packages/core/src/schema/nodes/custom-mesh.ts | 19 +- packages/core/src/services/hosting.test.ts | 16 ++ packages/core/src/services/hosting.ts | 15 +- .../src/components/editor/floorplan-panel.tsx | 2 +- .../components/tools/fence/fence-drafting.ts | 54 +----- .../tools/wall/wall-drafting.test.ts | 2 +- .../components/tools/wall/wall-drafting.ts | 147 ++------------ .../src/lib/interaction/hot-set.test.ts | 1 + .../lib/interaction/overlay-policy.test.ts | 1 + packages/editor/src/lib/interaction/scope.ts | 3 +- packages/editor/src/store/use-editor.tsx | 70 +------ .../src/store/use-interaction-scope.test.ts | 3 + .../nodes/src/custom-mesh/commands.test.ts | 20 ++ packages/nodes/src/custom-mesh/commands.ts | 32 ++- .../nodes/src/custom-mesh/definition.test.ts | 7 + packages/nodes/src/custom-mesh/definition.ts | 4 +- .../nodes/src/custom-mesh/geometry.test.ts | 16 ++ packages/nodes/src/custom-mesh/geometry.ts | 21 +- .../custom-mesh/loop-cut-interaction.test.ts | 19 ++ .../src/custom-mesh/loop-cut-interaction.ts | 19 ++ packages/nodes/src/custom-mesh/paint.test.ts | 15 +- packages/nodes/src/custom-mesh/preview.tsx | 16 +- packages/nodes/src/custom-mesh/selection.tsx | 183 +++++++++++++----- packages/nodes/src/custom-mesh/tool.tsx | 111 ++++++++--- packages/nodes/src/wall/definition.test.ts | 25 +++ packages/nodes/src/wall/definition.ts | 12 +- wiki/architecture/events.md | 15 +- wiki/architecture/interaction-scope.md | 2 +- 33 files changed, 653 insertions(+), 384 deletions(-) create mode 100644 packages/nodes/src/custom-mesh/loop-cut-interaction.test.ts create mode 100644 packages/nodes/src/custom-mesh/loop-cut-interaction.ts diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index f0ca5acc11..f2e4556a8c 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -1,5 +1,7 @@ +import { levelBaseElevationAt, terrainSupportLift } from '../../lib/terrain-support' import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' import { GROUND_SUPPORT_ID, @@ -358,3 +360,162 @@ export function resolveFenceSupportSlabPatch( const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON return { supportSlabId: options?.pinSupport || persist ? winner.slabId : undefined } } + +export type FenceConstructionOptions = { + supportCap?: number | null + preferredSupportSlabId?: string | null + constructionElevation?: number | null +} + +export function resolveFenceConstructionSupport( + fence: FenceNode, + levelId: string, + nodes: Record, + options?: FenceConstructionOptions, +): FenceNode { + const supportPatch = resolveFenceSupportSlabPatch({ ...fence, parentId: levelId }, nodes, { + maxElevation: options?.supportCap ?? null, + preferredSlabId: options?.preferredSupportSlabId ?? null, + pinSupport: options?.constructionElevation != null, + }) + const host = supportPatch.supportSlabId ? nodes[supportPatch.supportSlabId] : null + const baseElevation = + host?.type === 'slab' + ? host.elevation + : levelBaseElevationAt(nodes, levelId, fence.start[0], fence.start[1]) + const supportOffset = + options?.constructionElevation == null + ? fence.supportOffset + : options.constructionElevation - baseElevation + + return { + ...fence, + ...supportPatch, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + } +} + +export type WallConstructionOptions = { + supportCap?: number | null + preferredSupportSlabId?: string | null + constructionElevation?: number | null + constructionHeight?: number | null + flatConstructionBase?: boolean + constructionSourceNodeId?: AnyNodeId | null +} + +export function resolveTerrainWallConstructionOptions( + nodes: Record, + levelId: string, + point: readonly [number, number], + defaults?: Record, +): WallConstructionOptions | undefined { + const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1]) + if (constructionElevation == null) return undefined + + const level = nodes[levelId] + const constructionHeight = + typeof defaults?.height === 'number' + ? defaults.height + : level?.type === 'level' + ? (level.height ?? DEFAULT_LEVEL_HEIGHT) + : DEFAULT_LEVEL_HEIGHT + + return { + constructionElevation, + constructionHeight, + supportCap: constructionElevation, + } +} + +export type WallConstructionResolution = { + walls: WallNode[] + sourceSupportUpdate: { id: AnyNodeId; data: SupportSlabPatch } | null +} + +export function resolveWallConstruction( + nodes: Record, + levelId: string, + walls: readonly WallNode[], + options?: WallConstructionOptions, +): WallConstructionResolution { + let resolvedNodes = nodes + let sourceSupportUpdate: WallConstructionResolution['sourceSupportUpdate'] = null + const constructionSourceNodeId = options?.constructionSourceNodeId + if (constructionSourceNodeId) { + const sourceNode = nodes[constructionSourceNodeId] + const currentSupport = + sourceNode && 'supportSlabId' in sourceNode + ? (sourceNode.supportSlabId as string | undefined) + : undefined + if (sourceNode && currentSupport == null) { + const data = resolveSupportSlabPatch(sourceNode, nodes, { pinSupport: true }) + if (data.supportSlabId != null) { + sourceSupportUpdate = { id: constructionSourceNodeId, data } + resolvedNodes = { + ...nodes, + [constructionSourceNodeId]: { ...sourceNode, ...data } as AnyNode, + } + } + } + } + + const resolvedWalls = walls.map((createdWall) => { + const wallWithParent = { ...createdWall, parentId: levelId as AnyNodeId } as WallNode + const terrainBase = terrainSupportLift( + resolvedNodes, + levelId, + createdWall.start[0], + createdWall.start[1], + ) + const wallOptions = + options?.preferredSupportSlabId === GROUND_SUPPORT_ID && + terrainBase == null && + !options.flatConstructionBase + ? undefined + : options + const preferredSupportSlabId = + wallOptions?.flatConstructionBase === true + ? GROUND_SUPPORT_ID + : (wallOptions?.preferredSupportSlabId ?? + (wallOptions?.constructionElevation != null && terrainBase != null + ? GROUND_SUPPORT_ID + : null)) + const supportPatch = resolveWallSupportSlabPatch(wallWithParent, resolvedNodes, { + maxElevation: wallOptions?.supportCap ?? null, + preferredSlabId: preferredSupportSlabId, + }) + const sourceSupport = spatialGridManager.getSlabSupportForWall( + levelId, + createdWall.start, + createdWall.end, + createdWall.curveOffset, + createdWall.thickness, + supportPatch.supportSlabId, + wallOptions?.supportCap ?? null, + ) + const groundDraft = + preferredSupportSlabId === GROUND_SUPPORT_ID && + (terrainBase != null || wallOptions?.flatConstructionBase === true) + const supportOffset = + groundDraft && wallOptions?.constructionElevation != null + ? wallOptions.constructionElevation - sourceSupport.elevation + : undefined + const preserveDraftHeight = + groundDraft && + createdWall.height == null && + wallOptions?.constructionHeight != null && + wallOptions.constructionElevation != null + + return { + ...wallWithParent, + ...supportPatch, + height: preserveDraftHeight ? wallOptions.constructionHeight : createdWall.height, + supportOffset: + supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, + } as WallNode + }) + + return { walls: resolvedWalls, sourceSupportUpdate } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9bc3e4f389..fce59ad807 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -71,15 +71,21 @@ export { resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' export { + type FenceConstructionOptions, type FenceSupportInput, type FrozenFloorPlacementOptions, + resolveFenceConstructionSupport, resolveFenceSupportSlabPatch, resolveFrozenFloorPlacementPatch, resolveMovedWallSupportSlabPatch, resolveSupportSlabPatch, + resolveTerrainWallConstructionOptions, + resolveWallConstruction, resolveWallSupportSlabPatch, type SupportSlabPatch, type SupportSlabPatchOptions, + type WallConstructionOptions, + type WallConstructionResolution, } from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index d85da0d846..4e51be1554 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -2022,7 +2022,9 @@ export type SnappableConfig = { export type SnapPointKind = 'start' | 'end' | 'midpoint' | 'center' | 'corners' export type SurfacesConfig = { - top?: { height: number | ((n: AnyNode) => number) } + top?: { + height: number | ((n: AnyNode, context: { nodes: Record }) => number) + } sides?: { faces: 'all' | ReadonlyArray } custom?: SurfaceQuery } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7747659309..1ab29bf8b5 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -75,6 +75,7 @@ export { } from './nodes/construction-dimension' export { CupolaNode } from './nodes/cupola' export { + CUSTOM_MESH_BODY_MATERIAL_REF, CustomMeshEdge, CustomMeshFace, CustomMeshNode, @@ -82,6 +83,7 @@ export { type CustomMeshTopologyIssue, CustomMeshVertex, createBoxCustomMeshTopology, + customMeshUndirectedEdgeKey, inspectCustomMeshTopology, } from './nodes/custom-mesh' export { diff --git a/packages/core/src/schema/nodes/custom-mesh.test.ts b/packages/core/src/schema/nodes/custom-mesh.test.ts index 7d2b3715b1..d9857ebca3 100644 --- a/packages/core/src/schema/nodes/custom-mesh.test.ts +++ b/packages/core/src/schema/nodes/custom-mesh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { + CUSTOM_MESH_BODY_MATERIAL_REF, CustomMeshNode, CustomMeshTopology, createBoxCustomMeshTopology, @@ -13,6 +14,7 @@ describe('CustomMeshNode', () => { expect(node.topology.vertices).toHaveLength(8) expect(node.topology.edges).toHaveLength(12) expect(node.topology.faces).toHaveLength(6) + expect(node.slots).toEqual({ body: CUSTOM_MESH_BODY_MATERIAL_REF }) expect(inspectCustomMeshTopology(node.topology)).toEqual([]) }) @@ -25,6 +27,18 @@ describe('CustomMeshNode', () => { expect(node.supportSlabId).toBe('ground') }) + test('repairs legacy slot maps that predate the reusable body binding', () => { + const node = CustomMeshNode.parse({ + name: 'Legacy painted mesh', + slots: { accent: 'library:metal-steel' }, + }) + + expect(node.slots).toEqual({ + body: CUSTOM_MESH_BODY_MATERIAL_REF, + accent: 'library:metal-steel', + }) + }) + test('rejects a face loop without a persisted boundary edge', () => { const topology = createBoxCustomMeshTopology() topology.edges = topology.edges.filter((edge) => edge.id !== 'e4') diff --git a/packages/core/src/schema/nodes/custom-mesh.ts b/packages/core/src/schema/nodes/custom-mesh.ts index 82345c2546..44c425b3ab 100644 --- a/packages/core/src/schema/nodes/custom-mesh.ts +++ b/packages/core/src/schema/nodes/custom-mesh.ts @@ -34,7 +34,11 @@ export type CustomMeshTopologyIssue = { message: string } -const edgeKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`) +export const CUSTOM_MESH_BODY_MATERIAL_REF = 'library:concrete-drywall' + +export function customMeshUndirectedEdgeKey(a: string, b: string) { + return a < b ? `${a}\u0000${b}` : `${b}\u0000${a}` +} export function inspectCustomMeshTopology(topology: CustomMeshTopology): CustomMeshTopologyIssue[] { const issues: CustomMeshTopologyIssue[] = [] @@ -67,7 +71,7 @@ export function inspectCustomMeshTopology(topology: CustomMeshTopology): CustomM }) } }) - const key = edgeKey(a, b) + const key = customMeshUndirectedEdgeKey(a, b) if (edgeKeys.has(key)) { issues.push({ path: ['edges', index], message: `Duplicate edge: ${a}–${b}` }) } @@ -90,7 +94,7 @@ export function inspectCustomMeshTopology(topology: CustomMeshTopology): CustomM }) } const nextVertexId = face.vertexIds[(vertexIndex + 1) % face.vertexIds.length] - if (nextVertexId && !edgeKeys.has(edgeKey(vertexId, nextVertexId))) { + if (nextVertexId && !edgeKeys.has(customMeshUndirectedEdgeKey(vertexId, nextVertexId))) { issues.push({ path: ['faces', index, 'vertexIds', vertexIndex], message: `Missing edge for face boundary: ${vertexId}–${nextVertexId}`, @@ -158,13 +162,18 @@ export const CustomMeshNode = BaseNode.extend({ rotation: z.number().default(0), supportSlabId: z.string().optional(), topology: CustomMeshTopology.default(createBoxCustomMeshTopology), - slots: z.record(z.string(), z.string()).optional(), + slots: z + .record(z.string(), z.string()) + .default({}) + .transform( + (slots): Record => ({ body: CUSTOM_MESH_BODY_MATERIAL_REF, ...slots }), + ), }).describe(dedent` Custom mesh node - a topology-backed editable solid. - topology: persistent vertices, edges, and ordered face loops with stable IDs - position/rotation: level-local placement transform - supportSlabId: persisted placement surface that prevents later slabs from lifting the mesh - - slots: optional material references keyed by face materialSlot + - slots: material references keyed by face materialSlot; body always starts reusable `) export type CustomMeshNode = z.infer diff --git a/packages/core/src/services/hosting.test.ts b/packages/core/src/services/hosting.test.ts index 55934aeca4..a75719c5aa 100644 --- a/packages/core/src/services/hosting.test.ts +++ b/packages/core/src/services/hosting.test.ts @@ -192,6 +192,22 @@ describe('getSurface / getTopSurfaceHeight', () => { expect(getTopSurfaceHeight(makeNode('shelf', 'high'))).toBe(1.8) expect(getTopSurfaceHeight(makeNode('shelf', 'low'))).toBe(0.3) }) + + test('passes the complete node record to context-aware surface resolvers', () => { + const host = makeNode('platform', 'platform') + const support = makeNode('support', 'support') + registerNode( + makeDef('platform', { + surfaces: { + top: { + height: (_node: any, { nodes }: any) => (nodes[id('support')] ? 2.4 : 0), + }, + }, + }), + ) + + expect(getTopSurfaceHeight(host, { [host.id]: host, [support.id]: support })).toBe(2.4) + }) }) describe('clampYToHostTop', () => { diff --git a/packages/core/src/services/hosting.ts b/packages/core/src/services/hosting.ts index 9394f586a1..7026ade37c 100644 --- a/packages/core/src/services/hosting.ts +++ b/packages/core/src/services/hosting.ts @@ -105,11 +105,14 @@ export function getSurface(host: AnyNode): SurfacesConfig | null { * Resolves the stackable top height of a host (e.g. table surface, slab top, * stair landing). Returns `null` when the host has no `surfaces.top`. */ -export function getTopSurfaceHeight(host: AnyNode): number | null { +export function getTopSurfaceHeight( + host: AnyNode, + nodes: Record = { [host.id]: host }, +): number | null { const surfaces = getSurface(host) if (!surfaces?.top) return null const { height } = surfaces.top - return typeof height === 'function' ? height(host) : height + return typeof height === 'function' ? height(host, { nodes }) : height } /** @@ -152,7 +155,11 @@ export function pickHost(args: { * Convenience: clamps a Y coordinate to the top of a host surface, when one * is declared. Returns the original Y if the host has no top surface. */ -export function clampYToHostTop(host: AnyNode, originalY: number): number { - const top = getTopSurfaceHeight(host) +export function clampYToHostTop( + host: AnyNode, + originalY: number, + nodes?: Record, +): number { + const top = getTopSurfaceHeight(host, nodes) return top == null ? originalY : top } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 99c771931b..36a7037cb0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -33,6 +33,7 @@ import { type RoofNode, type RoofSegmentNode, resolveSlabPlacementElevation, + resolveTerrainWallConstructionOptions, type SiteNode, type SlabNode, SlabNode as SlabNodeSchema, @@ -184,7 +185,6 @@ import { chainEndJoinsExistingWall, createWallOnCurrentLevel, isSegmentLongEnough, - resolveTerrainWallConstructionOptions, snapWallDraftPoint, snapWallDraftPointDetailed, snapPointToGrid as snapWallPointToGrid, diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 912635af9e..b610ce4106 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -1,13 +1,12 @@ import { - type AnyNodeId, DEFAULT_ANGLE_STEP, + type FenceConstructionOptions as FenceCommitOptions, FenceNode, getTwoPointFenceCurveTangents, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, - levelBaseElevationAt, - resolveFenceSupportSlabPatch, + resolveFenceConstructionSupport, snapPointAlongAngleRay, useScene, type WallNode, @@ -190,51 +189,6 @@ export function snapFenceDraftPoint(args: { return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint } -export type FenceCommitOptions = { - /** - * Pointer-decided support cap (level-local Y) from - * `resolvePointerSupportSurface` — the 3D tool passes the elevation of - * the surface the commit click actually aimed at, so a fence drawn on a - * deck top persists the deck as its lift host while one drawn at the - * floor underneath stays grounded. Omitted by 2D floor-plan commits (no - * camera ray): those keep the uncapped max election. - */ - supportCap?: number | null - /** Slab/ground beneath a pointed non-slab surface. */ - preferredSupportSlabId?: string | null - /** Exact node-top construction plane selected by the pointer. */ - constructionElevation?: number | null -} - -function applyFenceConstructionSupport( - fence: FenceNode, - levelId: string, - nodes: ReturnType['nodes'], - options?: FenceCommitOptions, -): FenceNode { - const supportPatch = resolveFenceSupportSlabPatch({ ...fence, parentId: levelId }, nodes, { - maxElevation: options?.supportCap ?? null, - preferredSlabId: options?.preferredSupportSlabId ?? null, - pinSupport: options?.constructionElevation != null, - }) - const host = supportPatch.supportSlabId ? nodes[supportPatch.supportSlabId as AnyNodeId] : null - const baseElevation = - host?.type === 'slab' - ? host.elevation - : levelBaseElevationAt(nodes, levelId, fence.start[0], fence.start[1]) - const supportOffset = - options?.constructionElevation == null - ? fence.supportOffset - : options.constructionElevation - baseElevation - - return FenceNode.parse({ - ...fence, - ...supportPatch, - supportOffset: - supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, - }) -} - export function createFenceOnCurrentLevel( start: FencePlanPoint, end: FencePlanPoint, @@ -260,7 +214,7 @@ export function createFenceOnCurrentLevel( }) // Fences run no per-frame support election — the persisted host IS the // lift (absent = level floor), so elect it at commit, pointer-capped. - const fence = applyFenceConstructionSupport(authoredFence, currentLevelId, nodes, options) + const fence = resolveFenceConstructionSupport(authoredFence, currentLevelId, nodes, options) createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') @@ -303,7 +257,7 @@ export function createSplineFenceOnCurrentLevel( path, tangents, }) - const fence = applyFenceConstructionSupport(authoredFence, currentLevelId, nodes, options) + const fence = resolveFenceConstructionSupport(authoredFence, currentLevelId, nodes, options) createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index 4326cb075a..bb8beb0486 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -14,6 +14,7 @@ import { initSpaceDetectionSync, nodeRegistry, registerNode, + resolveTerrainWallConstructionOptions, runAsSingleSceneHistoryStep, SlabNode, spatialGridManager, @@ -27,7 +28,6 @@ import useInteractionScope from '../../../store/use-interaction-scope' import { createWallOnCurrentLevel, resolveEndpointWallSplit, - resolveTerrainWallConstructionOptions, snapWallDraftPointDetailed, } from './wall-drafting' import type { WallPlanPoint } from './wall-snap-geometry' diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 202a2fcf25..d38285b04b 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -1,18 +1,13 @@ import { - type AnyNode, type AnyNodeId, DEFAULT_ANGLE_STEP, - DEFAULT_LEVEL_HEIGHT, - GROUND_SUPPORT_ID, planWallInsertion, planWallSplitAtPoint, - resolveSupportSlabPatch, - resolveWallSupportSlabPatch, + resolveWallConstruction, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, - spatialGridManager, - terrainSupportLift, useScene, + type WallConstructionOptions, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -210,45 +205,6 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } -export type WallConstructionOptions = { - /** Pointer-decided maximum support elevation in level-local metres. */ - supportCap?: number | null - /** Support source selected by the first click or inherited from a snapped wall. */ - preferredSupportSlabId?: string | null - /** Frozen level-local Y shown by the draft ghost. */ - constructionElevation?: number | null - /** Height shown by the draft ghost. */ - constructionHeight?: number | null - /** Keep a node-top construction plane flat instead of filling down to lower slab segments. */ - flatConstructionBase?: boolean - /** Node whose top surface established this construction plane. */ - constructionSourceNodeId?: AnyNodeId | null -} - -export function resolveTerrainWallConstructionOptions( - nodes: Record, - levelId: string, - point: WallPlanPoint, - defaults?: Record, -): WallConstructionOptions | undefined { - const constructionElevation = terrainSupportLift(nodes, levelId, point[0], point[1]) - if (constructionElevation == null) return undefined - - const level = nodes[levelId] - const constructionHeight = - typeof defaults?.height === 'number' - ? defaults.height - : level?.type === 'level' - ? (level.height ?? DEFAULT_LEVEL_HEIGHT) - : DEFAULT_LEVEL_HEIGHT - - return { - constructionElevation, - constructionHeight, - supportCap: constructionElevation, - } -} - export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, @@ -274,95 +230,22 @@ export function createWallOnCurrentLevel( if (!result.ok) return null const { plan } = result - const constructionSourceNodeId = options?.constructionSourceNodeId - if (constructionSourceNodeId) { - const sourceNodes = useScene.getState().nodes - const sourceNode = sourceNodes[constructionSourceNodeId] - const currentSupport = - sourceNode && 'supportSlabId' in sourceNode - ? (sourceNode.supportSlabId as string | undefined) - : undefined - if (sourceNode && currentSupport == null) { - const sourceSupportPatch = resolveSupportSlabPatch(sourceNode, sourceNodes, { - pinSupport: true, - }) - if (sourceSupportPatch.supportSlabId != null) { - useScene - .getState() - .updateNodes([{ id: constructionSourceNodeId, data: sourceSupportPatch }]) - } - } - } - - const committedNodes = useScene.getState().nodes - const finalizedWalls = plan.insertedWalls.map((createdWall) => { - const wallWithParent = { ...createdWall, parentId: currentLevelId } as WallNode - const terrainBase = terrainSupportLift( - committedNodes, - currentLevelId, - createdWall.start[0], - createdWall.start[1], - ) - // A ground-preferred draft is frozen only for sculpted terrain or an - // explicitly flat node-top construction plane. On ordinary flat ground, - // the ground plane is just the backdrop the chain started from: drop the - // draft options so the wall commits plane-bound. - const wallOptions = - options?.preferredSupportSlabId === GROUND_SUPPORT_ID && - terrainBase == null && - !options.flatConstructionBase - ? undefined - : options - const preferredSupportSlabId = - wallOptions?.flatConstructionBase === true - ? GROUND_SUPPORT_ID - : (wallOptions?.preferredSupportSlabId ?? - (wallOptions?.constructionElevation != null && terrainBase != null - ? GROUND_SUPPORT_ID - : null)) - const supportPatch = resolveWallSupportSlabPatch(wallWithParent, committedNodes, { - maxElevation: wallOptions?.supportCap ?? null, - preferredSlabId: preferredSupportSlabId, - }) - const supportSlabId = supportPatch.supportSlabId - const sourceSupport = spatialGridManager.getSlabSupportForWall( - currentLevelId, - createdWall.start, - createdWall.end, - createdWall.curveOffset, - createdWall.thickness, - supportSlabId, - wallOptions?.supportCap ?? null, - ) - // Freezing the draft plane into the node (explicit height + offset from - // the elected support) is reserved for terrain and explicitly flat - // node-top construction. Every other wall stays plane-bound, so a wall - // merely started on a slab or deck never receives the ghost height. - const groundDraft = - preferredSupportSlabId === GROUND_SUPPORT_ID && - (terrainBase != null || wallOptions?.flatConstructionBase === true) - const supportOffset = - groundDraft && wallOptions?.constructionElevation != null - ? wallOptions.constructionElevation - sourceSupport.elevation - : undefined - const preserveDraftHeight = - groundDraft && - createdWall.height == null && - wallOptions?.constructionHeight != null && - wallOptions.constructionElevation != null - return { - ...wallWithParent, - ...supportPatch, - height: preserveDraftHeight - ? (wallOptions?.constructionHeight ?? createdWall.height) - : createdWall.height, - supportOffset: - supportOffset != null && Math.abs(supportOffset) > 1e-6 ? supportOffset : undefined, - } as WallNode - }) + const construction = resolveWallConstruction(nodes, currentLevelId, plan.insertedWalls, options) + const finalizedWalls = construction.walls const finalizedWallsById = new Map(finalizedWalls.map((wall) => [wall.id, wall])) + const sourceUpdate = construction.sourceSupportUpdate + const sourceAlreadyUpdated = sourceUpdate + ? plan.changes.update.some((operation) => operation.id === sourceUpdate.id) + : false applyNodeChanges({ ...plan.changes, + update: plan.changes.update + .map((operation) => + sourceUpdate?.id === operation.id + ? { ...operation, data: { ...operation.data, ...sourceUpdate.data } } + : operation, + ) + .concat(sourceUpdate && !sourceAlreadyUpdated ? [sourceUpdate] : []), create: plan.changes.create.map((operation) => ({ ...operation, node: finalizedWallsById.get(operation.node.id as WallNode['id']) ?? operation.node, diff --git a/packages/editor/src/lib/interaction/hot-set.test.ts b/packages/editor/src/lib/interaction/hot-set.test.ts index 0ee68e5944..e965530da4 100644 --- a/packages/editor/src/lib/interaction/hot-set.test.ts +++ b/packages/editor/src/lib/interaction/hot-set.test.ts @@ -109,6 +109,7 @@ describe('isCandidateInHotSet — by scope', () => { nodeType: 'item', view: '3d' as const, pressDrag: false, + driver: 'move-tool' as const, } expect(isCandidateInHotSet(scope, surfaceClass, floor)).toBe(true) expect(isCandidateInHotSet(scope, surfaceClass, ceilingFan)).toBe(false) diff --git a/packages/editor/src/lib/interaction/overlay-policy.test.ts b/packages/editor/src/lib/interaction/overlay-policy.test.ts index 08560c856d..3dbbdb0d02 100644 --- a/packages/editor/src/lib/interaction/overlay-policy.test.ts +++ b/packages/editor/src/lib/interaction/overlay-policy.test.ts @@ -13,6 +13,7 @@ const ACTIVE_SCOPES: ActiveInteractionScope[] = [ nodeType: 'item', view: '3d', pressDrag: false, + driver: 'move-tool', }, { kind: 'moving', node: mockNode('i1', 'item'), nodeId: 'i1', nodeType: 'item', view: '2d' }, { kind: 'handle-drag', nodeId: 'w1', handle: 'height' }, diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index 5f610202f0..b6e05b09fd 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -35,8 +35,7 @@ export type InteractionScope = nodeType: string view: InteractionView pressDrag: boolean - /** Omitted means the generic move tool, preserving existing producers. */ - driver?: 'move-tool' | 'registry-tool' + driver: 'move-tool' | 'registry-tool' } // Moving an existing node. | { kind: 'moving'; node: AnyNode; nodeId: string; nodeType: string; view: InteractionView } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 651d01001b..417a8b1941 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -6,35 +6,17 @@ import { type AnyNodeId, type BrushSettings, type BuildingNode, - type CabinetModuleNode, - type CabinetNode, - type CeilingNode, type ChimneyMaterialRole, - type ChimneyNode, - type ColumnNode, DEFAULT_BRUSH_SETTINGS, - type DoorNode, - type DormerNode, type DormerSurfaceMaterialRole, - type ElevatorNode, - type FenceNode, - type ItemNode, type LevelNode, nodeRegistry, - type RoofNode, - type RoofSegmentNode, type RoofSurfaceMaterialRole, - type SlabNode, type Space, - type SpawnNode, - type StairNode, - type StairSegmentNode, type StairSurfaceMaterialRole, type TerrainVerb, useScene, - type WallNode, type WallSurfaceSide, - type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' @@ -80,31 +62,6 @@ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const MIN_FLOORPLAN_PANE_RATIO = 0.15 const MAX_FLOORPLAN_PANE_RATIO = 0.85 -function resolveMovingNodeTarget( - node: - | ItemNode - | WindowNode - | DoorNode - | ElevatorNode - | CeilingNode - | ChimneyNode - | ColumnNode - | DormerNode - | SlabNode - | WallNode - | FenceNode - | RoofNode - | RoofSegmentNode - | SpawnNode - | StairNode - | StairSegmentNode - | BuildingNode - | CabinetNode - | CabinetModuleNode, -) { - return node -} - export type ViewMode = '3d' | '2d' | 'split' export type SplitOrientation = 'horizontal' | 'vertical' export type WorkspaceMode = 'edit' | 'studio' @@ -310,29 +267,7 @@ type EditorState = { setPlacementDragMode: (dragMode: boolean) => void roofHostDragArmedId: AnyNodeId | null setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void - setMovingNode: ( - node: - | ItemNode - | WindowNode - | DoorNode - | ElevatorNode - | CeilingNode - | ChimneyNode - | ColumnNode - | DormerNode - | SlabNode - | WallNode - | FenceNode - | RoofNode - | RoofSegmentNode - | SpawnNode - | StairNode - | StairSegmentNode - | BuildingNode - | CabinetNode - | CabinetModuleNode - | null, - ) => void + setMovingNode: (node: AnyNode | null) => void /** * Which view (2D floor plan or 3D viewer) most recently completed * the active move — set by the committing or cancelling side just @@ -1085,7 +1020,7 @@ const useEditor = create()( set({ placementDragMode: false }) return } - const targetNode = resolveMovingNodeTarget(node) + const targetNode = node const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew) if (isNew) { scope.begin({ @@ -1095,6 +1030,7 @@ const useEditor = create()( nodeType: targetNode.type, view: '3d', pressDrag: get().placementDragMode, + driver: 'move-tool', }) } else { scope.begin({ diff --git a/packages/editor/src/store/use-interaction-scope.test.ts b/packages/editor/src/store/use-interaction-scope.test.ts index 3f6568f6e7..c9fbe6488b 100644 --- a/packages/editor/src/store/use-interaction-scope.test.ts +++ b/packages/editor/src/store/use-interaction-scope.test.ts @@ -72,6 +72,7 @@ describe('use-interaction-scope state machine', () => { nodeType: 'item', view: '3d', pressDrag: false, + driver: 'move-tool', }) s.update({ pressDrag: true }) const scope = useInteractionScope.getState().scope @@ -105,6 +106,7 @@ describe('use-interaction-scope state machine', () => { nodeType: 'item', view: '3d', pressDrag: true, + driver: 'move-tool', }) expect(useInteractionScope.getState().scope.kind).toBe('moving') }) @@ -205,6 +207,7 @@ describe('derived flag views are leak-free (no parallel flags)', () => { nodeType: 'item', view: '3d', pressDrag: false, + driver: 'move-tool', }, { kind: 'moving', node: mockNode('i', 'item'), nodeId: 'i', nodeType: 'item', view: '3d' }, { kind: 'drafting', tool: 'wall' }, diff --git a/packages/nodes/src/custom-mesh/commands.test.ts b/packages/nodes/src/custom-mesh/commands.test.ts index c968569e98..f65192e663 100644 --- a/packages/nodes/src/custom-mesh/commands.test.ts +++ b/packages/nodes/src/custom-mesh/commands.test.ts @@ -359,6 +359,26 @@ describe('applyCustomMeshCommand', () => { expect(inspectCustomMeshTopology(result.topology)).toEqual([]) }) + test('keeps multiple loop cuts centered until multi-cut sliding is supported', () => { + const centered = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'loop-cut', + edgeId: 'e8', + factor: 0.5, + cuts: 3, + }) + const attemptedSlide = applyCustomMeshCommand(createBoxCustomMeshTopology(), { + type: 'loop-cut', + edgeId: 'e8', + factor: 0.8, + cuts: 3, + }) + + expect(centered.ok).toBe(true) + expect(attemptedSlide.ok).toBe(true) + if (!(centered.ok && attemptedSlide.ok)) return + expect(attemptedSlide.topology).toEqual(centered.topology) + }) + test('bevels a manifold box edge with width, segments, profile, and overlap clamping', () => { const result = applyCustomMeshCommand(createBoxCustomMeshTopology(), { type: 'bevel-edge', diff --git a/packages/nodes/src/custom-mesh/commands.ts b/packages/nodes/src/custom-mesh/commands.ts index d50b04ea98..02c8972bf0 100644 --- a/packages/nodes/src/custom-mesh/commands.ts +++ b/packages/nodes/src/custom-mesh/commands.ts @@ -3,6 +3,7 @@ import { type CustomMeshFace, type CustomMeshTopology, type CustomMeshVertex, + customMeshUndirectedEdgeKey, inspectCustomMeshTopology, } from '@pascal-app/core' import type { CustomMeshSelection } from './selection-model' @@ -130,8 +131,6 @@ function nextNumericId(prefix: string, ids: readonly string[]): () => string { } } -const topologyEdgeKey = (a: string, b: string) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`) - type LoopCutStep = { faceId: string fromEdgeId: string @@ -164,13 +163,13 @@ function resolveLoopCutRing(topology: CustomMeshTopology, edgeId: string): LoopC const startEdge = topology.edges.find((edge) => edge.id === edgeId) if (!startEdge) return null const edgeByKey = new Map( - topology.edges.map((edge) => [topologyEdgeKey(...edge.vertexIds), edge] as const), + topology.edges.map((edge) => [customMeshUndirectedEdgeKey(...edge.vertexIds), edge] as const), ) const facesByEdgeId = new Map() for (const face of topology.faces) { for (let index = 0; index < face.vertexIds.length; index += 1) { const edge = edgeByKey.get( - topologyEdgeKey( + customMeshUndirectedEdgeKey( face.vertexIds[index]!, face.vertexIds[(index + 1) % face.vertexIds.length]!, ), @@ -204,7 +203,7 @@ function resolveLoopCutRing(topology: CustomMeshTopology, edgeId: string): LoopC if (!(face && orientedVertices) || face.vertexIds.length !== 4) return null const oppositeVertices = oppositeOrientedEdgeVertices(face, orientedVertices) if (!oppositeVertices) return null - const oppositeEdge = edgeByKey.get(topologyEdgeKey(...oppositeVertices)) + const oppositeEdge = edgeByKey.get(customMeshUndirectedEdgeKey(...oppositeVertices)) if (!oppositeEdge) return null const existingOrientation = orientedEdgeVertices.get(oppositeEdge.id) if ( @@ -283,8 +282,7 @@ function loopCutFractions(factor: number, cuts: number): number[] | null { return null if (count === 1) return [factor] const spacing = 1 / (count + 1) - const offset = (factor - 0.5) * spacing * 1.96 - return Array.from({ length: count }, (_, index) => (index + 1) * spacing + offset) + return Array.from({ length: count }, (_, index) => (index + 1) * spacing) } function augmentFaceLoop( @@ -296,7 +294,7 @@ function augmentFaceLoop( const current = face.vertexIds[index]! const next = face.vertexIds[(index + 1) % face.vertexIds.length]! augmented.push(current) - const cuts = cutVerticesByEdgeKey.get(topologyEdgeKey(current, next)) + const cuts = cutVerticesByEdgeKey.get(customMeshUndirectedEdgeKey(current, next)) if (!cuts) continue augmented.push(...(cuts.edgeOrder[0] === current ? cuts.ids : [...cuts.ids].reverse())) } @@ -361,7 +359,7 @@ function loopCut( const ids = fractions.map(() => allocateVertexId()) cutVerticesByEdgeId.set(ringEdgeId, ids) const edgeOrderIds = edge.vertexIds[0] === fromId ? ids : [...ids].reverse() - cutVerticesByEdgeKey.set(topologyEdgeKey(...edge.vertexIds), { + cutVerticesByEdgeKey.set(customMeshUndirectedEdgeKey(...edge.vertexIds), { edgeOrder: edge.vertexIds, ids: edgeOrderIds, }) @@ -374,7 +372,7 @@ function loopCut( } const splitBoundaryEdges = topology.edges.flatMap((edge) => { - const cutIds = cutVerticesByEdgeKey.get(topologyEdgeKey(...edge.vertexIds))?.ids + const cutIds = cutVerticesByEdgeKey.get(customMeshUndirectedEdgeKey(...edge.vertexIds))?.ids if (!cutIds) return [edge] const chain = [edge.vertexIds[0], ...cutIds, edge.vertexIds[1]] return chain.slice(0, -1).map((vertexId, index) => ({ @@ -512,7 +510,7 @@ function rebuildEdgesFromFaces( allocateEdgeId: () => string, ): CustomMeshEdge[] { const oldByKey = new Map( - topology.edges.map((edge) => [topologyEdgeKey(...edge.vertexIds), edge] as const), + topology.edges.map((edge) => [customMeshUndirectedEdgeKey(...edge.vertexIds), edge] as const), ) const seen = new Set() const edges: CustomMeshEdge[] = [] @@ -522,7 +520,7 @@ function rebuildEdgesFromFaces( face.vertexIds[index]!, face.vertexIds[(index + 1) % face.vertexIds.length]!, ] as [string, string] - const key = topologyEdgeKey(...vertexIds) + const key = customMeshUndirectedEdgeKey(...vertexIds) if (seen.has(key)) continue seen.add(key) const old = oldByKey.get(key) @@ -1056,17 +1054,13 @@ function deleteComponents( const removedKeys = new Set( edges .filter((edge) => selected.has(edge.id)) - .map((edge) => - edge.vertexIds[0] < edge.vertexIds[1] - ? `${edge.vertexIds[0]}\u0000${edge.vertexIds[1]}` - : `${edge.vertexIds[1]}\u0000${edge.vertexIds[0]}`, - ), + .map((edge) => customMeshUndirectedEdgeKey(...edge.vertexIds)), ) edges = edges.filter((edge) => !selected.has(edge.id)) faces = faces.filter((face) => face.vertexIds.every((vertexId, index) => { const next = face.vertexIds[(index + 1) % face.vertexIds.length]! - const key = vertexId < next ? `${vertexId}\u0000${next}` : `${next}\u0000${vertexId}` + const key = customMeshUndirectedEdgeKey(vertexId, next) return !removedKeys.has(key) }), ) @@ -1115,7 +1109,7 @@ function mergeVertices( const a = mapVertexId(edge.vertexIds[0]) const b = mapVertexId(edge.vertexIds[1]) if (a === b) return [] - const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}` + const key = customMeshUndirectedEdgeKey(a, b) if (edgeKeys.has(key)) return [] edgeKeys.add(key) return [{ ...edge, vertexIds: [a, b] }] diff --git a/packages/nodes/src/custom-mesh/definition.test.ts b/packages/nodes/src/custom-mesh/definition.test.ts index bd7bc0e39e..cfb2190caa 100644 --- a/packages/nodes/src/custom-mesh/definition.test.ts +++ b/packages/nodes/src/custom-mesh/definition.test.ts @@ -3,6 +3,12 @@ import { CustomMeshNode } from '@pascal-app/core' import { customMeshDefinition } from './definition' describe('custom mesh placement bounds', () => { + test('starts with a reusable body material', () => { + expect(customMeshDefinition.defaults().slots).toEqual({ + body: 'library:concrete-drywall', + }) + }) + test('uses the dedicated editable-cube icon in the build palette', () => { expect(customMeshDefinition.presentation?.icon).toEqual({ kind: 'url', @@ -54,6 +60,7 @@ describe('custom mesh placement bounds', () => { ), }, slots: { + body: 'library:concrete-drywall', accent: 'library:preset-softwhite', 'material-1': 'library:metal-steel', }, diff --git a/packages/nodes/src/custom-mesh/definition.ts b/packages/nodes/src/custom-mesh/definition.ts index 6017d72e25..19065108ef 100644 --- a/packages/nodes/src/custom-mesh/definition.ts +++ b/packages/nodes/src/custom-mesh/definition.ts @@ -1,4 +1,5 @@ import { + CUSTOM_MESH_BODY_MATERIAL_REF, type CustomMeshNode as CustomMeshNodeType, createBoxCustomMeshTopology, type NodeDefinition, @@ -44,7 +45,7 @@ function footprintPosition(node: CustomMeshNodeType, center: [number, number, nu export const customMeshDefinition: NodeDefinition = { kind: 'custom-mesh', - schemaVersion: 2, + schemaVersion: 3, schema: CustomMeshNode, category: 'structure', surfaceRole: 'wall', @@ -64,6 +65,7 @@ export const customMeshDefinition: NodeDefinition = { position: [0, 0, 0], rotation: 0, topology: createBoxCustomMeshTopology(), + slots: { body: CUSTOM_MESH_BODY_MATERIAL_REF }, }), capabilities: { diff --git a/packages/nodes/src/custom-mesh/geometry.test.ts b/packages/nodes/src/custom-mesh/geometry.test.ts index 7caf2fde11..a4e67292b8 100644 --- a/packages/nodes/src/custom-mesh/geometry.test.ts +++ b/packages/nodes/src/custom-mesh/geometry.test.ts @@ -1,11 +1,27 @@ import { describe, expect, test } from 'bun:test' import { CustomMeshNode } from '@pascal-app/core' +import { createSurfaceRoleMaterial } from '@pascal-app/viewer' import { Mesh, Ray, Vector3, type Vector3Tuple } from 'three' import { applyCustomMeshCommand } from './commands' import { buildCustomMeshGeometry } from './geometry' import { customMeshPaint } from './paint' describe('buildCustomMeshGeometry', () => { + test('uses the active theme role when the body material cannot resolve', () => { + const node = CustomMeshNode.parse({ + name: 'Themed mesh', + slots: { body: 'scene:missing' }, + }) + const group = buildCustomMeshGeometry(node, undefined, 'rendered', true, 'blueprint', 'studio') + const mesh = group.getObjectByName('custom-mesh-body') + + expect(mesh).toBeInstanceOf(Mesh) + if (!(mesh instanceof Mesh) || !Array.isArray(mesh.material)) return + expect(mesh.material[0]).toBe( + createSurfaceRoleMaterial('wall', 'blueprint', undefined, 'studio'), + ) + }) + test('derives a render mesh from persistent topology', () => { const node = CustomMeshNode.parse({ name: 'Box' }) const group = buildCustomMeshGeometry(node) diff --git a/packages/nodes/src/custom-mesh/geometry.ts b/packages/nodes/src/custom-mesh/geometry.ts index f988265693..c519f53f70 100644 --- a/packages/nodes/src/custom-mesh/geometry.ts +++ b/packages/nodes/src/custom-mesh/geometry.ts @@ -4,10 +4,16 @@ import type { CustomMeshTopology, GeometryContext, } from '@pascal-app/core' -import { createDefaultMaterial, type RenderShading, resolveMaterialRef } from '@pascal-app/viewer' +import { + type ColorPreset, + createSurfaceRoleMaterial, + type RenderShading, + resolveMaterialRef, +} from '@pascal-app/viewer' import { BufferGeometry, Float32BufferAttribute, + FrontSide, Group, Mesh, ShapeUtils, @@ -66,8 +72,11 @@ export function triangulateCustomMeshFace( export function buildCustomMeshGeometry( node: CustomMeshNode, - ctx?: GeometryContext, + ctx?: Pick, shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, ): Group { const group = new Group() group.name = 'custom-mesh-geometry' @@ -145,9 +154,11 @@ export function buildCustomMeshGeometry( geometry.userData.customMeshFaces = faceRanges const bodyMaterialRef = node.slots?.[CUSTOM_MESH_BODY_SLOT_ID] + const roleMaterial = createSurfaceRoleMaterial('wall', colorPreset, FrontSide, sceneTheme) const bodyMaterial = - (bodyMaterialRef ? resolveMaterialRef(bodyMaterialRef, ctx?.materials, shading) : null) ?? - createDefaultMaterial('#b8c5d1', 0.72, shading) + (textures && bodyMaterialRef + ? resolveMaterialRef(bodyMaterialRef, ctx?.materials, shading) + : null) ?? roleMaterial const bodyFallbackSlotIds: string[] = [] const materials = slotIds.map((slotId) => { const materialRef = node.slots?.[slotId] @@ -156,7 +167,7 @@ export function buildCustomMeshGeometry( bodyFallbackSlotIds.push(slotId) return bodyMaterial } - const resolved = resolveMaterialRef(materialRef, ctx?.materials, shading) + const resolved = textures ? resolveMaterialRef(materialRef, ctx?.materials, shading) : null if (resolved) return resolved bodyFallbackSlotIds.push(slotId) return bodyMaterial diff --git a/packages/nodes/src/custom-mesh/loop-cut-interaction.test.ts b/packages/nodes/src/custom-mesh/loop-cut-interaction.test.ts new file mode 100644 index 0000000000..88bf3ee55c --- /dev/null +++ b/packages/nodes/src/custom-mesh/loop-cut-interaction.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test' +import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' + +describe('loop cut interaction', () => { + test('uses two confirmations for a cut and slide', () => { + expect(resolveLoopCutPointerAction('choosing-ring', 0)).toBe('begin-slide') + expect(resolveLoopCutPointerAction('sliding', 0)).toBe('commit-current') + }) + + test('cancels before the draft and commits centered from the slide stage', () => { + expect(resolveLoopCutPointerAction('choosing-ring', 2)).toBe('cancel') + expect(resolveLoopCutPointerAction('sliding', 2)).toBe('commit-centered') + }) + + test('defers multi-cut sliding while retaining single-cut slide input', () => { + expect(resolveLoopCutSlideFactor(1, 0.8)).toBe(0.8) + expect(resolveLoopCutSlideFactor(3, 0.8)).toBe(0.5) + }) +}) diff --git a/packages/nodes/src/custom-mesh/loop-cut-interaction.ts b/packages/nodes/src/custom-mesh/loop-cut-interaction.ts new file mode 100644 index 0000000000..bbe4984cb2 --- /dev/null +++ b/packages/nodes/src/custom-mesh/loop-cut-interaction.ts @@ -0,0 +1,19 @@ +export type LoopCutInteractionStage = 'choosing-ring' | 'sliding' + +export type LoopCutPointerAction = 'begin-slide' | 'commit-current' | 'commit-centered' | 'cancel' + +export function resolveLoopCutPointerAction( + stage: LoopCutInteractionStage, + button: number, +): LoopCutPointerAction | null { + if (stage === 'choosing-ring') { + if (button === 0) return 'begin-slide' + return button === 2 ? 'cancel' : null + } + if (button === 0) return 'commit-current' + return button === 2 ? 'commit-centered' : null +} + +export function resolveLoopCutSlideFactor(cuts: number, requestedFactor: number): number { + return cuts === 1 ? requestedFactor : 0.5 +} diff --git a/packages/nodes/src/custom-mesh/paint.test.ts b/packages/nodes/src/custom-mesh/paint.test.ts index 6ff2dfbfbd..d321eb8228 100644 --- a/packages/nodes/src/custom-mesh/paint.test.ts +++ b/packages/nodes/src/custom-mesh/paint.test.ts @@ -40,7 +40,10 @@ describe('custom mesh face paint', () => { 'material-1', ) expect(painted.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe('body') - expect(painted.slots).toEqual({ 'material-1': 'library:metal-steel' }) + expect(painted.slots).toEqual({ + body: 'library:concrete-drywall', + 'material-1': 'library:metal-steel', + }) customMeshPaint.commit?.({ node: painted, @@ -54,7 +57,10 @@ describe('custom mesh face paint', () => { expect(painted.topology.faces.find((face) => face.id === 'f-front')?.materialSlot).toBe( 'material-1', ) - expect(painted.slots).toEqual({ 'material-1': 'library:metal-steel' }) + expect(painted.slots).toEqual({ + body: 'library:concrete-drywall', + 'material-1': 'library:metal-steel', + }) }) test('reuses a structurally matching scene material instead of creating one', () => { @@ -77,7 +83,10 @@ describe('custom mesh face paint', () => { expect(painted?.type).toBe('custom-mesh') if (painted?.type !== 'custom-mesh') return expect(Object.keys(useScene.getState().materials)).toEqual([materialId]) - expect(painted.slots).toEqual({ 'material-1': toSceneMaterialRef(materialId) }) + expect(painted.slots).toEqual({ + body: 'library:concrete-drywall', + 'material-1': toSceneMaterialRef(materialId), + }) }) test('commits the face and a new reusable scene material in one undo step', () => { diff --git a/packages/nodes/src/custom-mesh/preview.tsx b/packages/nodes/src/custom-mesh/preview.tsx index f19b552773..1b75fa119e 100644 --- a/packages/nodes/src/custom-mesh/preview.tsx +++ b/packages/nodes/src/custom-mesh/preview.tsx @@ -2,6 +2,7 @@ import type { CustomMeshNode } from '@pascal-app/core' import { EDITOR_LAYER } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo } from 'react' import { Color, type Material, Mesh } from 'three' import { buildCustomMeshGeometry } from './geometry' @@ -13,8 +14,19 @@ export default function CustomMeshPreview({ node: CustomMeshNode valid?: boolean }) { + const shading = useViewer((state) => state.shading) + const textures = useViewer((state) => state.textures) + const colorPreset = useViewer((state) => state.colorPreset) + const sceneTheme = useViewer((state) => state.sceneTheme) const preview = useMemo(() => { - const next = buildCustomMeshGeometry(node) + const next = buildCustomMeshGeometry( + node, + undefined, + shading, + textures, + colorPreset, + sceneTheme, + ) const ownedMaterials: Material[] = [] next.traverse((child) => { child.layers.set(EDITOR_LAYER) @@ -34,7 +46,7 @@ export default function CustomMeshPreview({ child.material = Array.isArray(child.material) ? materials : materials[0]! }) return { object: next, ownedMaterials } - }, [node, valid]) + }, [colorPreset, node, sceneTheme, shading, textures, valid]) useEffect( () => () => { diff --git a/packages/nodes/src/custom-mesh/selection.tsx b/packages/nodes/src/custom-mesh/selection.tsx index 96177e22e6..986eece357 100644 --- a/packages/nodes/src/custom-mesh/selection.tsx +++ b/packages/nodes/src/custom-mesh/selection.tsx @@ -84,6 +84,7 @@ import useCustomMeshEditSession from './edit-session' import { triangulateCustomMeshFace } from './geometry' import { CUSTOM_MESH_WHEEL_OPTIONS, consumeCustomMeshGestureWheel } from './gesture-wheel' import { type CustomMeshSfxAction, customMeshSfx } from './interaction-sfx' +import { resolveLoopCutPointerAction, resolveLoopCutSlideFactor } from './loop-cut-interaction' import { signedAngleAroundAxis, unwrapRotationDelta } from './rotation-drag' import { type CustomMeshSelectionState, @@ -1268,6 +1269,8 @@ function CustomMeshEditor({ const [previewTopology, setPreviewTopology] = useState(null) const [activeTransform, setActiveTransform] = useState(null) 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') @@ -1342,6 +1345,8 @@ function CustomMeshEditor({ setTransformTool('transform') setActiveTransform(null) setLoopCutSegments(null) + setLoopCutEdgeId(null) + setLoopCutSliding(false) setToolbarPanel(null) setError(null) playCustomMeshSfx('finish') @@ -1368,6 +1373,8 @@ function CustomMeshEditor({ setPreviewTopology(null) setToolbarPanel(null) setLoopCutSegments(null) + setLoopCutEdgeId(null) + setLoopCutSliding(false) setActiveTransform(null) useCustomMeshEditSession.getState().end(node.id) }, [editing, node.id]) @@ -1380,11 +1387,17 @@ function CustomMeshEditor({ setToolbarPanel(null) playCustomMeshSfx('cancel') } else if (cancelDragRef.current) cancelDragRef.current() - else exitEditMode() + else if (transformTool === 'loop-cut') { + setTransformTool('transform') + setLoopCutEdgeId(null) + setLoopCutSegments(null) + setError(null) + playCustomMeshSfx('cancel') + } else exitEditMode() } emitter.on('tool:cancel', onToolCancel) return () => emitter.off('tool:cancel', onToolCancel) - }, [editing, exitEditMode, toolbarPanel]) + }, [editing, exitEditMode, toolbarPanel, transformTool]) useEffect(() => { if (!(editing && toolbarPanel)) return @@ -2011,26 +2024,71 @@ function CustomMeshEditor({ [bevelSegments, displayTopology, extent, gl.domElement, node.id, ownsEditSession], ) - const previewLoopCut = useCallback( - (edgeId: string | null) => { - if (cancelDragRef.current) return - if (!edgeId) { - setLoopCutSegments(null) - setError(null) - return - } - const segments = customMeshLoopCutSegments(displayTopology, edgeId, 0.5, loopCutCount) - setLoopCutSegments(segments) - setError(segments ? null : 'Loop cut requires a connected ring of quad faces') - }, - [displayTopology, loopCutCount], - ) + const previewLoopCut = useCallback((edgeId: string | null) => { + if (cancelDragRef.current) return + setLoopCutEdgeId(edgeId) + }, []) + + useEffect(() => { + if (!(editing && transformTool === 'loop-cut') || loopCutSliding) return + if (!loopCutEdgeId) { + setLoopCutSegments(null) + setError(null) + return + } + const segments = customMeshLoopCutSegments(node.topology, loopCutEdgeId, 0.5, loopCutCount) + setLoopCutSegments(segments) + setLoopCutFactor(0.5) + setError(segments ? null : 'Loop cut requires a connected ring of quad faces') + }, [editing, loopCutCount, loopCutEdgeId, loopCutSliding, node.topology, transformTool]) + + useEffect(() => { + if (transformTool === 'loop-cut' || loopCutSliding) return + setLoopCutEdgeId(null) + setLoopCutSegments(null) + setLoopCutFactor(0.5) + }, [loopCutSliding, transformTool]) + + useEffect(() => { + if (!(editing && transformTool === 'loop-cut' && !loopCutSliding)) return + const onWheel = (event: WheelEvent) => { + const direction = consumeCustomMeshGestureWheel(event) + if (direction === 0) return + setLoopCutCount((current) => { + const next = Math.min(32, Math.max(1, current + direction)) + if (next !== current) playCustomMeshSfx('resize-step') + return next + }) + } + const onPointerDown = (event: PointerEvent) => { + if (resolveLoopCutPointerAction('choosing-ring', event.button) !== 'cancel') return + event.preventDefault() + event.stopImmediatePropagation() + setTransformTool('transform') + setLoopCutEdgeId(null) + setLoopCutSegments(null) + setError(null) + playCustomMeshSfx('cancel') + swallowNextClick() + } + window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.addEventListener('pointerdown', onPointerDown, true) + return () => { + window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.removeEventListener('pointerdown', onPointerDown, true) + } + }, [editing, loopCutSliding, transformTool]) - const beginLoopCutDrag = useCallback( + const beginLoopCutSlide = useCallback( (edgeId: string, event: ThreeEvent) => { - if (event.nativeEvent.button !== 0 || !ownsEditSession() || cancelDragRef.current) return - const edge = displayTopology.edges.find((entry) => entry.id === edgeId) - const vertices = topologyVertexMap(displayTopology) + if ( + resolveLoopCutPointerAction('choosing-ring', event.nativeEvent.button) !== 'begin-slide' || + !ownsEditSession() || + cancelDragRef.current + ) + return + const edge = node.topology.edges.find((entry) => entry.id === edgeId) + const vertices = topologyVertexMap(node.topology) const start = edge ? vertices.get(edge.vertexIds[0]) : null const end = edge ? vertices.get(edge.vertexIds[1]) : null if (!(edge && start && end)) return @@ -2042,36 +2100,41 @@ function CustomMeshEditor({ if (worldLength < 1e-6) return const worldAxis = worldDirection.normalize() const initialParameter = closestAxisParameterToRay(worldStart, worldAxis, event.ray) - const baseTopology = displayTopology + const baseTopology = node.topology const previousInputDragging = useViewer.getState().inputDragging const previousCursor = document.body.style.cursor let latestTopology: CustomMeshTopology | null = null let latestSelection: CustomMeshSelection | null = null let latestFactor = 0.5 - let activeCuts = loopCutCount + const activeCuts = loopCutCount let lastSnapFactor: number | null = null let finished = false + let confirmationAttached = false - const updatePreview = (factor: number, cuts = activeCuts) => { + const updatePreview = (factor: number) => { + const effectiveFactor = resolveLoopCutSlideFactor(activeCuts, factor) const result = applyCustomMeshCommand(baseTopology, { type: 'loop-cut', edgeId, - factor, - cuts, + factor: effectiveFactor, + cuts: activeCuts, }) - const segments = customMeshLoopCutSegments(baseTopology, edgeId, factor, cuts) + const segments = customMeshLoopCutSegments( + baseTopology, + edgeId, + effectiveFactor, + activeCuts, + ) if (!result.ok || !segments) { setError(result.ok ? 'Could not preview loop cut' : result.error) return false } - latestFactor = factor - activeCuts = cuts + latestFactor = effectiveFactor latestTopology = result.topology latestSelection = result.selection setPreviewTopology(result.topology) setLoopCutSegments(segments) - setLoopCutCount(cuts) - setLoopCutFactor(factor) + setLoopCutFactor(effectiveFactor) useLiveNodeOverrides.getState().set(node.id, { topology: result.topology }) useScene.getState().markDirty(node.id) setError(null) @@ -2082,9 +2145,11 @@ function CustomMeshEditor({ useInteractionScope.getState().begin(meshEditScope(node.id, 'operating', 'loop-cut')) playCustomMeshSfx('operation-start') useViewer.getState().setInputDragging(true) + setLoopCutSliding(true) document.body.style.cursor = 'ew-resize' const onMove = (pointerEvent: PointerEvent) => { + if (activeCuts > 1) return const parameter = closestAxisParameterToRay( worldStart, worldAxis, @@ -2112,20 +2177,13 @@ function CustomMeshEditor({ updatePreview(factor) } - const onWheel = (wheelEvent: WheelEvent) => { - const direction = consumeCustomMeshGestureWheel(wheelEvent) - if (direction === 0) return - const cuts = Math.min(32, Math.max(1, activeCuts + direction)) - if (cuts !== activeCuts) playCustomMeshSfx('resize-step') - updatePreview(latestFactor, cuts) - } - - const finish = (commit: boolean) => { + const finish = (outcome: 'commit-current' | 'commit-centered' | 'cancel') => { if (finished) return + if (outcome === 'commit-centered' && !updatePreview(0.5)) outcome = 'cancel' finished = true window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + if (confirmationAttached) window.removeEventListener('pointerdown', onConfirm, true) + window.removeEventListener('contextmenu', onContextMenu, true) window.removeEventListener('pointercancel', onPointerCancel) window.removeEventListener('blur', onPointerCancel) cancelDragRef.current = null @@ -2135,14 +2193,16 @@ function CustomMeshEditor({ document.body.style.cursor = previousCursor setPreviewTopology(null) setLoopCutSegments(null) - if (commit && latestTopology && latestSelection && latestFactor > 0) { + setLoopCutEdgeId(null) + setLoopCutSliding(false) + if (outcome !== 'cancel' && latestTopology && latestSelection && latestFactor > 0) { useScene.getState().updateNode(node.id, { topology: latestTopology }) useCustomMeshEditSession.getState().setSelection(node.id, { ...latestSelection, activeId: latestSelection.ids.at(-1) ?? null, }) playCustomMeshSfx('operation-commit') - } else if (!commit) { + } else if (outcome === 'cancel') { playCustomMeshSfx('cancel') } if (ownsEditSession()) { @@ -2150,16 +2210,30 @@ function CustomMeshEditor({ } swallowNextClick() } - const onPointerUp = () => finish(true) - const onPointerCancel = () => finish(false) + const onConfirm = (pointerEvent: PointerEvent) => { + const action = resolveLoopCutPointerAction('sliding', pointerEvent.button) + if (action !== 'commit-current' && action !== 'commit-centered') return + pointerEvent.preventDefault() + pointerEvent.stopImmediatePropagation() + finish(action) + } + const onContextMenu = (contextEvent: MouseEvent) => { + contextEvent.preventDefault() + contextEvent.stopImmediatePropagation() + } + const onPointerCancel = () => finish('cancel') cancelDragRef.current = onPointerCancel window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onPointerUp, { once: true }) - window.addEventListener('wheel', onWheel, CUSTOM_MESH_WHEEL_OPTIONS) + window.addEventListener('contextmenu', onContextMenu, true) window.addEventListener('pointercancel', onPointerCancel, { once: true }) window.addEventListener('blur', onPointerCancel, { once: true }) + queueMicrotask(() => { + if (finished) return + confirmationAttached = true + window.addEventListener('pointerdown', onConfirm, true) + }) }, - [displayTopology, loopCutCount, makeRay, node.id, ownsEditSession, target], + [loopCutCount, makeRay, node.id, node.topology, ownsEditSession, target], ) const commitCommand = (command: CustomMeshCommand, operator: TopologyOperator) => { @@ -2328,7 +2402,7 @@ function CustomMeshEditor({ const moveNode = (event: ReactMouseEvent) => { event.stopPropagation() - useEditor.getState().setMovingNode(node as never) + useEditor.getState().setMovingNode(node) useViewer.getState().setSelection({ selectedIds: [] }) triggerSFX('sfx:item-pick') } @@ -2465,7 +2539,7 @@ function CustomMeshEditor({ ))} ) : null} - {transformTool === 'loop-cut' + {transformTool === 'loop-cut' && !loopCutSliding ? displayTopology.edges.map((edge) => { const start = vertexById.get(edge.vertexIds[0]) const end = vertexById.get(edge.vertexIds[1]) @@ -2475,7 +2549,7 @@ function CustomMeshEditor({ end={end} key={edge.id} onHover={previewLoopCut} - onPointerDown={beginLoopCutDrag} + onPointerDown={beginLoopCutSlide} radius={componentRadius * 3.2} start={start} /> @@ -2772,6 +2846,11 @@ const CustomMeshSelectionAffordance = () => { }) const [target, setTarget] = useState(null) const nodeId = node?.id ?? null + const scopeAllowsAffordance = useInteractionScope( + (state) => + state.scope.kind === 'idle' || + (state.scope.kind === 'mesh-editing' && state.scope.nodeId === nodeId), + ) useEffect(() => { if (!nodeId) { @@ -2788,7 +2867,7 @@ const CustomMeshSelectionAffordance = () => { return () => window.cancelAnimationFrame(frameId) }, [nodeId]) - if (!node || !target) return null + if (!node || !target || !scopeAllowsAffordance) return null const mount = target.parent ?? target return createPortal( , diff --git a/packages/nodes/src/custom-mesh/tool.tsx b/packages/nodes/src/custom-mesh/tool.tsx index 6b52b9abbe..fc5b3ea788 100644 --- a/packages/nodes/src/custom-mesh/tool.tsx +++ b/packages/nodes/src/custom-mesh/tool.tsx @@ -5,6 +5,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveFrozenFloorPlacementPatch, resolveSupportSlabPatch, useSpatialQuery, } from '@pascal-app/core' @@ -14,12 +15,15 @@ import { isGridSnapActive, isMagneticSnapActive, movementSfxStepKey, + type PointerSupportSurface, + resolvePointerSupportSurface, triggerSFX, useAlignmentGuides, useEditor, useInteractionScope, useRegistryToolContext, } from '@pascal-app/editor' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import type { Group } from 'three' import { @@ -36,7 +40,11 @@ import CustomMeshPreview from './preview' const CustomMeshTool = () => { const { activeLevelId, sceneApi, selectNode } = useRegistryToolContext() const { canPlaceOnFloor } = useSpatialQuery() + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const cursorRef = useRef(null) + const supportSurfaceRef = useRef(null) const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) @@ -70,38 +78,81 @@ const CustomMeshTool = () => { driver: 'registry-tool', }) + const pointedSurfaceFor = (event: GridEvent | FloorPlacementClickTriggerEvent) => + typeof HTMLCanvasElement !== 'undefined' && + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + + const resolvePlacement = ( + position: [number, number, number], + surface: PointerSupportSurface | null, + ) => { + const draftNode = CustomMeshNode.parse({ + ...customMeshDefinition.defaults(), + name: 'Custom Mesh', + parentId: activeLevelId, + position, + }) + const nodes = { ...sceneApi.nodes(), [draftNode.id]: draftNode } + const patch = surface?.sourceNodeId + ? resolveFrozenFloorPlacementPatch(draftNode, nodes, { + position, + rotation: draftNode.rotation, + elevation: surface.elevation, + preferredSlabId: surface.supportSlabId, + }) + : { + position, + ...resolveSupportSlabPatch(draftNode, nodes, { + maxElevation: surface?.elevation, + pinSupport: true, + }), + } + return { draftNode, patch } + } + const onGridMove = (event: GridEvent) => { if (!cursorVisibleRef.current) { cursorVisibleRef.current = true setCursorVisible(true) } const forcePlacement = isForcePlacementEvent(event) + const pointed = pointedSurfaceFor(event) + supportSurfaceRef.current = pointed const gridSnapActive = isGridSnapActive() const { position, guides } = resolveAlignedFloorPlacement({ node: previewNode, - rawX: event.localPosition[0], - rawZ: event.localPosition[2], + rawX: pointed?.localPoint?.[0] ?? event.localPosition[0], + rawZ: pointed?.localPoint?.[2] ?? event.localPosition[2], gridStep: useEditor.getState().gridSnapStep, candidates: alignmentCandidates, showAlignment: isAlignmentGuideActive(), - applyAlignmentSnap: !forcePlacement && isMagneticSnapActive(), - bypassGrid: forcePlacement || !gridSnapActive, + applyAlignmentSnap: isMagneticSnapActive(), + bypassGrid: !gridSnapActive, }) useAlignmentGuides.getState().set(guides) + const { patch } = resolvePlacement(position, pointed) + const resolvedPosition = patch.position const visualPosition = getFloorStackPreviewPosition({ - node: previewNode, - position, + node: { ...previewNode, ...patch }, + position: resolvedPosition, rotation: previewNode.rotation, levelId: activeLevelId, + maxElevation: pointed?.sourceNodeId ? null : pointed?.elevation, }) cursorRef.current?.position.set(...visualPosition) - lastPosition = position - const placement = canPlaceOnFloor(activeLevelId, position, size, [0, previewNode.rotation, 0]) + lastPosition = resolvedPosition + const placement = canPlaceOnFloor(activeLevelId, resolvedPosition, size, [ + 0, + previewNode.rotation, + 0, + ]) setValidPlacement(forcePlacement || placement.valid) const snapKey = movementSfxStepKey({ - coords: [position[0], position[2]], - gridSnapActive: !forcePlacement && gridSnapActive, + coords: [resolvedPosition[0], resolvedPosition[2]], + gridSnapActive, gridStep: useEditor.getState().gridSnapStep, }) if (snapKey !== previousSnapRef.current) { @@ -112,27 +163,23 @@ const CustomMeshTool = () => { const commit = (event: FloorPlacementClickTriggerEvent) => { const forcePlacement = isForcePlacementEvent(event) - const position = forcePlacement - ? getLevelLocalSnappedPosition( - activeLevelId, - event, - useEditor.getState().gridSnapStep, - true, - ) - : (lastPosition ?? - getLevelLocalSnappedPosition( - activeLevelId, - event, - useEditor.getState().gridSnapStep, - !isGridSnapActive(), - )) - const draftNode = CustomMeshNode.parse({ - ...customMeshDefinition.defaults(), - name: 'Custom Mesh', - parentId: activeLevelId, - position, - }) - const placement = canPlaceOnFloor(activeLevelId, position, size, [0, draftNode.rotation, 0]) + const pointed = pointedSurfaceFor(event) ?? supportSurfaceRef.current + supportSurfaceRef.current = pointed + const fallbackPosition = + lastPosition ?? + getLevelLocalSnappedPosition( + activeLevelId, + event, + useEditor.getState().gridSnapStep, + !isGridSnapActive(), + ) + const position: [number, number, number] = [fallbackPosition[0], 0, fallbackPosition[2]] + const { draftNode, patch } = resolvePlacement(position, pointed) + const placement = canPlaceOnFloor(activeLevelId, patch.position, size, [ + 0, + draftNode.rotation, + 0, + ]) setValidPlacement(forcePlacement || placement.valid) if (!(forcePlacement || placement.valid)) { stopPlacementCommitPropagation(event) @@ -140,7 +187,7 @@ const CustomMeshTool = () => { } const node = CustomMeshNode.parse({ ...draftNode, - ...resolveSupportSlabPatch(draftNode, sceneApi.nodes(), { pinSupport: true }), + ...patch, }) sceneApi.upsert(node, activeLevelId) selectNode(node.id) diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index 5eac7859e3..ab9f3587c4 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -32,3 +32,28 @@ describe('wallDefinition floor-plan extension', () => { expect(canCurve?.({ node: { ...wall, children: [] }, nodes })).toBe(true) }) }) + +test('wall top surface follows the effective level-bound height', () => { + const level = { + object: 'node', + id: 'level_test', + type: 'level', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + height: 3.2, + } as AnyNode + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = { [level.id]: level, [wall.id]: wall } + const height = wallDefinition.capabilities.surfaces?.top?.height + + expect(typeof height).toBe('function') + expect(typeof height === 'function' ? height(wall, { nodes }) : height).toBe(3.2) +}) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 1612341287..71d4b265d3 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,6 +1,7 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, type NodeDefinition, type WallNode as WallNodeType, } from '@pascal-app/core' @@ -78,7 +79,14 @@ export const wallDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, // Front + back faces host items (paintings, shelves, switches). surfaces: { - top: { height: (node) => (node as WallNodeType).height ?? DEFAULT_WALL_HEIGHT }, + top: { + height: (node, { nodes }) => { + const wall = node as WallNodeType + return ( + getWallBaseElevationForNodes(wall, nodes) + getWallEffectiveHeightForNodes(wall, nodes) + ) + }, + }, sides: { faces: 'all' }, }, duplicable: true, diff --git a/wiki/architecture/events.md b/wiki/architecture/events.md index bba5fa2381..75a3c5162f 100644 --- a/wiki/architecture/events.md +++ b/wiki/architecture/events.md @@ -12,12 +12,16 @@ The event bus (`emitter`) is a global `mitt` instance typed with `EditorEvents`. ``` : +node: ``` -Example keys: `wall:click`, `item:enter`, `door:double-click`, `grid:pointerdown` +Example keys: `wall:click`, `custom-mesh:enter`, `node:click`, `grid:pointerdown` ### Node Types -`wall` `item` `site` `building` `level` `zone` `slab` `ceiling` `roof` `window` `door` +Every registered `AnyNode` discriminator is available as a typed node-event +prefix, including `custom-mesh`. `node:*` is the cross-kind channel for +consumers that intentionally handle every node kind without maintaining a +parallel list. ### Suffixes ```ts @@ -39,7 +43,8 @@ interface NodeEvent { } ``` -Grid events only carry `position` and `nativeEvent` (no `node`). +Grid events carry `position`, `localPosition`, optional hit metadata, and +`nativeEvent` (but no `node`). ## Selection Intent Events @@ -62,7 +67,9 @@ const events = useNodeEvents(node, 'wall') return ``` -`useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits `wall:click`, `wall:enter`, etc. It suppresses events while the camera is dragging. +`useNodeEvents` converts R3F `ThreeEvent` into a `NodeEvent` and emits both the +kind-specific event (`wall:click`, `custom-mesh:enter`, etc.) and its generic +`node:*` counterpart. It suppresses events while the camera is dragging. ## Listening diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 4ea84bd29c..35a2c8f5f3 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -22,7 +22,7 @@ scope is exactly one interaction at a time, and `idle` carries no payload. | `kind` | Payload | What | | -------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `idle` | — | Nothing in flight. The only state where selection/hover picking is meaningful. | -| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. | +| `placing` | `node`, `nodeId`, `nodeType`, `view`, `pressDrag`, `driver` | Placing a fresh node (catalog/preset/build tool). `node` carries the not-yet-committed draft; `pressDrag` = gizmo press-drag (commit on release) vs click-to-place. `driver` identifies the sole interaction body that owns preview and commit. | | `moving` | `node`, `nodeId`, `nodeType`, `view` | Moving an existing node. | | `handle-drag` | `nodeId`, `handle` | Dragging a resize/translate/rotate handle of a selected node. | | `mesh-editing` | `nodeId`, `phase`, `operator?` | Editing one node's internal mesh components. Held for the complete edit-mode session; `phase` distinguishes component selection from an in-flight operator. |