From 86e79d3ddda02a1c29de1a5eae223ba9f32ddd0d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 5 Jun 2026 00:15:56 -0400 Subject: [PATCH 1/4] feat(editor): live floor-stacking previews, unified handle system + hit areas, NaN-safe node mutations Floor-placed kinds (item, shelf, spawn, column, stair) preview their slab-stacking Y offset live during placement and both move pathways, via a shared core resolver (getFloorPlacedElevation/getFloorStackedPosition; composite footprints for stairs) consumed by the tools and the viewer FloorElevationSystem. Committed positions stay canonical (no double-apply via store/live-transforms/overrides). Unifies the 3D handle system behind one drag pipeline (handles/use-handle-drag) and one visual primitive (handles/handle-arrow); the descriptor renderers and the bespoke group-rotate/wall handles delegate to them, and every handle gains a forgiving invisible hit area. Validates node mutations at the store boundary (sanitizing non-finite/out-of-range numeric fields, incl. the shelf-NaN that produced NaN geometry) and guards the viewer's shadow-light bounds against non-finite values so one bad node can't black out the scene. Composes cleanly with #373: #373 owns X/Z alignment, this owns Y floor-stack preview. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../floor-placed-elevation.test.ts | 365 +++++ .../spatial-grid/floor-placed-elevation.ts | 93 ++ .../hooks/spatial-grid/spatial-grid-sync.ts | 28 +- packages/core/src/index.ts | 12 + packages/core/src/registry/handles.ts | 4 +- packages/core/src/registry/index.ts | 5 + packages/core/src/registry/types.ts | 32 +- .../core/src/services/alignment-anchors.ts | 5 +- .../core/src/store/actions/node-actions.ts | 490 +++++- .../actions/node-mutation-sanitize.test.ts | 175 +++ packages/core/src/store/use-scene.ts | 50 +- .../renderers/floorplan-registry-layer.tsx | 60 +- .../components/editor/group-move-handle.tsx | 2 +- .../components/editor/group-rotate-handle.tsx | 33 +- .../editor/handles/handle-arrow.tsx | 521 ++++++ .../editor/handles/use-handle-drag.ts | 183 +++ .../components/editor/node-arrow-handles.tsx | 1394 ++++------------- .../editor/wall-move-side-handles.tsx | 122 +- .../tools/item/placement-strategies.ts | 15 +- .../tools/item/use-placement-coordinator.tsx | 102 +- .../registry/move-registry-node-tool.tsx | 83 +- .../tools/shared/floor-stack-preview.ts | 25 + .../src/components/tools/stair/stair-tool.tsx | 132 +- packages/editor/src/index.tsx | 1 + packages/nodes/src/column/definition.ts | 26 +- packages/nodes/src/column/move-tool.tsx | 32 +- packages/nodes/src/column/tool.tsx | 13 +- packages/nodes/src/shared/move-roof-tool.tsx | 48 +- packages/nodes/src/shelf/definition.ts | 35 +- packages/nodes/src/shelf/dimensions.ts | 18 + packages/nodes/src/shelf/floorplan-move.ts | 9 +- packages/nodes/src/shelf/floorplan.ts | 26 +- packages/nodes/src/shelf/geometry.ts | 11 +- packages/nodes/src/shelf/tool.tsx | 19 +- packages/nodes/src/spawn/definition.ts | 18 +- packages/nodes/src/spawn/renderer.tsx | 19 +- packages/nodes/src/spawn/tool.tsx | 29 +- packages/nodes/src/stair/definition.ts | 117 +- packages/nodes/src/stair/floor-stack.test.ts | 177 +++ packages/nodes/src/stair/floor-stack.ts | 114 ++ .../viewer/src/components/viewer/lights.tsx | 25 +- .../floor-elevation-system.tsx | 75 +- .../viewer/src/systems/stair/stair-system.tsx | 70 +- 43 files changed, 3416 insertions(+), 1397 deletions(-) create mode 100644 packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts create mode 100644 packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts create mode 100644 packages/core/src/store/actions/node-mutation-sanitize.test.ts create mode 100644 packages/editor/src/components/editor/handles/handle-arrow.tsx create mode 100644 packages/editor/src/components/editor/handles/use-handle-drag.ts create mode 100644 packages/editor/src/components/tools/shared/floor-stack-preview.ts create mode 100644 packages/nodes/src/shelf/dimensions.ts create mode 100644 packages/nodes/src/stair/floor-stack.test.ts create mode 100644 packages/nodes/src/stair/floor-stack.ts diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts new file mode 100644 index 000000000..563003956 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -0,0 +1,365 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, SlabNode } from '../../schema' +import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' + +const LEVEL_ID = 'level_test' + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) { + const slab = { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + } as SlabNode + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +describe('floor-placed elevation resolver', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + }) + + test('returns 0 without a floorPlaced capability', () => { + registerNode(makeDefinition('item')) + addSlab( + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + 0.4, + ) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) + + test('returns 0 when applies returns false', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + applies: () => false, + }, + }), + ) + addSlab( + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + 0.4, + ) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) + + test('clamps non-finite slab elevation to 0', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) + + const original = spatialGridManager.getSlabElevationForItem + spatialGridManager.getSlabElevationForItem = (() => Number.NaN) as typeof original + + try { + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + } finally { + spatialGridManager.getSlabElevationForItem = original + } + }) + + test('returns 0 for a non-level direct parent', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) + addSlab( + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + 0.4, + ) + + const level = makeLevel() + const shelf = { + id: 'shelf_test', + type: 'shelf', + parentId: LEVEL_ID, + } as unknown as AnyNode + const node = makeFloorNode({ parentId: shelf.id }) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, shelf, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) + + test('returns 0 when the declared parent is missing', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) + addSlab( + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + 0.4, + ) + + const node = makeFloorNode({ parentId: 'missing_level' }) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(node), + position: [0, 0, 0], + rotation: [0, 0, 0], + levelId: LEVEL_ID, + }), + ).toBe(0) + }) + + test('uses the pending rotated footprint', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: (node) => ({ + dimensions: [4, 1, 1], + rotation: (node as { rotation: [number, number, number] }).rotation, + }), + }, + }), + ) + addSlab( + [ + [-0.2, 1.2], + [0.2, 1.2], + [0.2, 1.8], + [-0.2, 1.8], + ], + 0.45, + ) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, Math.PI / 2, 0], + }), + ).toBeCloseTo(0.45) + }) + + test('returns slab overlap elevation and stacks Y onto canonical position', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: (node) => ({ + dimensions: [1, 1, 1], + rotation: (node as { rotation: [number, number, number] }).rotation, + }), + }, + }), + ) + addSlab( + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + 0.35, + ) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0.1, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.35) + const stacked = getFloorStackedPosition({ + node, + nodes: nodesFor(level, node), + position: [0, 0.1, 0], + rotation: [0, 0, 0], + }) + expect(stacked[0]).toBe(0) + expect(stacked[1]).toBeCloseTo(0.45) + expect(stacked[2]).toBe(0) + }) + + test('takes the max elevation across composite footprints', () => { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprints: () => [ + { position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }, + { position: [3, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }, + ], + }, + }), + ) + addSlab( + [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + 0.2, + 'slab_low', + ) + addSlab( + [ + [2.5, -0.5], + [3.5, -0.5], + [3.5, 0.5], + [2.5, 0.5], + ], + 0.8, + 'slab_high', + ) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts new file mode 100644 index 000000000..b0b8315d7 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -0,0 +1,93 @@ +import { nodeRegistry } from '../../registry' +import type { + FloorPlacedConfig, + FloorPlacedFootprint, + FloorPlacedFootprintContext, + FloorPlacedFootprintsResolver, +} from '../../registry/types' +import type { AnyNode, AnyNodeId } from '../../schema' +import { spatialGridManager } from './spatial-grid-manager' + +export type FloorPlacedElevationArgs = { + node: AnyNode + nodes: Record + position: [number, number, number] + rotation?: unknown + levelId?: string | null +} + +function finiteSlabElevation(elevation: number): number { + return Number.isFinite(elevation) ? elevation : 0 +} + +function withPositionAndRotation({ + node, + position, + rotation, +}: Pick): AnyNode { + return { + ...(node as Record), + position, + ...(rotation !== undefined ? { rotation } : {}), + } as AnyNode +} + +export function getFloorPlacedFootprints( + floorPlaced: FloorPlacedConfig, + node: AnyNode, + ctx?: FloorPlacedFootprintContext, +): FloorPlacedFootprint[] { + const rawFootprints = floorPlaced.footprints?.(node, ctx) + if (rawFootprints) return [...rawFootprints] + + const footprint = floorPlaced.footprint?.(node, ctx) + return footprint ? [footprint] : [] +} + +export function getFloorPlacedElevation({ + node, + nodes, + position, + rotation, + levelId, +}: FloorPlacedElevationArgs): number { + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced) return 0 + + const effectiveNode = withPositionAndRotation({ node, position, rotation }) + if (floorPlaced.applies && !floorPlaced.applies(effectiveNode)) return 0 + + const parentId = (effectiveNode as { parentId?: AnyNodeId | null }).parentId ?? null + const parent = parentId ? nodes[parentId] : null + if (parentId && !parent) return 0 + if (parent && parent.type !== 'level') return 0 + if (!parent && !levelId) return 0 + + const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId + if (!resolvedLevelId) return 0 + + let maxElevation = Number.NEGATIVE_INFINITY + for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) { + const footprintPosition = footprint.position ?? position + const elevation = finiteSlabElevation( + spatialGridManager.getSlabElevationForItem( + resolvedLevelId, + footprintPosition, + footprint.dimensions, + footprint.rotation, + ), + ) + if (elevation > maxElevation) { + maxElevation = elevation + } + } + + return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation +} + +export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] { + const [x, y, z] = args.position + return [x, y + getFloorPlacedElevation(args), z] +} + +export type { FloorPlacedFootprint, FloorPlacedFootprintContext, FloorPlacedFootprintsResolver } diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index ba669c218..bf4574c37 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,6 +1,7 @@ import { nodeRegistry } from '../../registry' import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import useScene from '../../store/use-scene' +import { getFloorPlacedFootprints } from './floor-placed-elevation' import { itemOverlapsPolygon, spatialGridManager, @@ -152,7 +153,7 @@ function arraysEqual(a: number[], b: number[]): boolean { } /** - * Mark all floor items, walls, and stairs that may be affected by a slab change as dirty. + * Mark all floor items and walls that may be affected by a slab change as dirty. */ function markNodesOverlappingSlab( slab: SlabNode, @@ -181,12 +182,6 @@ function markNodesOverlappingSlab( } continue } - if (node.type === 'stair') { - if (resolveLevelId(node, nodes) !== slabLevelId) continue - markDirty(node.id) - continue - } - // Generic floor-placed sweep: any registry kind that opts in via // `capabilities.floorPlaced` (item / shelf / column / spawn / …) // re-elevates through `` when a slab below @@ -196,12 +191,25 @@ function markNodesOverlappingSlab( const floorPlaced = def?.capabilities?.floorPlaced if (!floorPlaced) continue if (floorPlaced.applies && !floorPlaced.applies(node)) continue + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent && parent.type !== 'level') continue if (resolveLevelId(node, nodes) !== slabLevelId) continue const position = (node as { position?: [number, number, number] }).position if (!position) continue - const { dimensions, rotation } = floorPlaced.footprint(node) - if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) { - markDirty(node.id) + for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) { + if ( + itemOverlapsPolygon( + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + slab.polygon, + 0.01, + ) + ) { + markDirty(node.id) + break + } } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 344eada88..15ba572b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,6 +38,12 @@ export { sceneRegistry, useRegistry, } from './hooks/scene-registry/scene-registry' +export { + type FloorPlacedElevationArgs, + getFloorPlacedElevation, + getFloorPlacedFootprints, + getFloorStackedPosition, +} from './hooks/spatial-grid/floor-placed-elevation' export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager' export { initSpatialGridSync, @@ -79,6 +85,12 @@ export { type MaterialCategory, toLibraryMaterialRef, } from './material-library' +export type { + FloorPlacedFootprint, + FloorPlacedFootprintContext, + FloorPlacedFootprintResolver, + FloorPlacedFootprintsResolver, +} from './registry' export * from './registry' export * from './schema' export * from './services' diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index f16f69a16..154e7b4cd 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -346,8 +346,10 @@ export type TranslateHandle = { * `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and * Y for 'node-normal'. Used to align the node's edges to the grid (rotation- * aware: swap the pair at 90°). Omit / return null for free movement. + * `sceneApi` is supplied for composite nodes whose footprint depends on + * children, such as straight stairs. */ - snapExtents?: (node: N) => readonly [number, number] | null + snapExtents?: (node: N, sceneApi: SceneApi) => readonly [number, number] | null portal?: HandlePortal } diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index baf7c7494..8dcfc58fd 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -57,6 +57,11 @@ export type { CuttableConfig, DragAction, EditorCtx, + FloorPlacedConfig, + FloorPlacedFootprint, + FloorPlacedFootprintContext, + FloorPlacedFootprintResolver, + FloorPlacedFootprintsResolver, FloorplanAffordance, FloorplanAffordanceModifiers, FloorplanAffordancePoint, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4f0bb611b..c1ce08565 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1235,20 +1235,40 @@ export type SelectableConfig = { override?: (ctx: CapabilityCtx) => SelectableConfig | null } +export type FloorPlacedFootprint = { + dimensions: [number, number, number] + rotation: [number, number, number] + position?: [number, number, number] +} + +export type FloorPlacedFootprintContext = { + nodes: Readonly> +} + +export type FloorPlacedFootprintResolver = ( + node: AnyNode, + ctx?: FloorPlacedFootprintContext, +) => FloorPlacedFootprint + +export type FloorPlacedFootprintsResolver = ( + node: AnyNode, + ctx?: FloorPlacedFootprintContext, +) => readonly FloorPlacedFootprint[] + /** * Floor-placed kinds rest directly on a level and need their Y lifted by * any slab the footprint overlaps. The generic `` * computes `slabElevation + node.position[1]` and writes it onto the - * registered mesh on every dirty mark. `footprint` returns the world-space - * footprint the spatial-grid manager uses to find overlapping slabs; + * registered mesh on every dirty mark. `footprint` returns the default + * world-space footprint the spatial-grid manager uses to find overlapping + * slabs; `footprints` lets composite kinds expose multiple footprint + * segments, with the canonical resolver taking the max slab elevation; * `applies` is an optional predicate to skip nodes that share a kind but * are mounted off-floor (items attached to a wall / ceiling). */ export type FloorPlacedConfig = { - footprint: (node: AnyNode) => { - dimensions: [number, number, number] - rotation: [number, number, number] - } + footprint?: FloorPlacedFootprintResolver + footprints?: FloorPlacedFootprintsResolver applies?: (node: AnyNode) => boolean } diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index 03fd3dce5..86d229f67 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -74,7 +74,10 @@ function floorFootprint( ): { dimensions: [number, number, number]; rotation: [number, number, number] } | null { const capabilities = nodeRegistry.get(node.type)?.capabilities const floorPlaced = capabilities?.floorPlaced - if (floorPlaced) { + // `footprint` is optional now that floor-placed kinds may instead declare + // composite `footprints` (e.g. stairs); those have no single centred box + // here, so fall through to `alignmentFootprint`. + if (floorPlaced?.footprint) { if (floorPlaced.applies && !floorPlaced.applies(node)) return null return floorPlaced.footprint(node) } diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 2c5768a36..bc401e6d2 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + AnyNode as AnyNodeSchema, getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, type WallNode, @@ -22,6 +23,478 @@ type WallMergePlan = { attachmentUpdates: WallAttachmentUpdate[] } +type ZodCheckLike = { + _zod?: { + def?: { + check?: string + value?: unknown + inclusive?: boolean + format?: string + } + } +} + +type ZodSchemaDefLike = { + type?: string + innerType?: ZodSchemaLike + shape?: Record + options?: readonly ZodSchemaLike[] + items?: readonly ZodSchemaLike[] + rest?: ZodSchemaLike | null + element?: ZodSchemaLike + checks?: readonly ZodCheckLike[] + defaultValue?: unknown + values?: readonly unknown[] + entries?: Record +} + +type ZodSchemaLike = { + _zod?: { + def?: ZodSchemaDefLike + bag?: { + minimum?: number + maximum?: number + exclusiveMinimum?: number + exclusiveMaximum?: number + } + values?: Set + } + def?: ZodSchemaDefLike + shape?: Record + minValue?: number | null + maxValue?: number | null +} + +type NumericLimit = { + value: number + inclusive: boolean +} + +type NumericConstraints = { + min?: NumericLimit + max?: NumericLimit + integer: boolean +} + +type NumericSanitizeIssue = { + path: PropertyKey[] + from: number + to?: number + action: 'clamped' | 'dropped' | 'rounded' +} + +type NumericSanitizeResult = { + value: unknown + issues: NumericSanitizeIssue[] + omit?: boolean +} + +const NUMBER_FORMAT_BOUNDS: Record = { + safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], + int32: [-2147483648, 2147483647], + uint32: [0, 4294967295], + float32: [-3.4028234663852886e38, 3.4028234663852886e38], +} + +const INTEGER_NUMBER_FORMATS = new Set(['safeint', 'int32', 'uint32']) + +function getSchemaDef(schema: ZodSchemaLike | null | undefined): ZodSchemaDefLike | undefined { + return schema?._zod?.def ?? schema?.def +} + +function getSchemaDefault(schema: ZodSchemaLike): unknown { + const def = getSchemaDef(schema) + if (!(def?.type === 'default' || def?.type === 'prefault')) return undefined + return def.defaultValue +} + +function unwrapSchema(schema: ZodSchemaLike | null | undefined): ZodSchemaLike | null { + let current = schema ?? null + while (current) { + const def = getSchemaDef(current) + if ( + !( + def?.type === 'default' || + def?.type === 'prefault' || + def?.type === 'optional' || + def?.type === 'nullable' || + def?.type === 'catch' || + def?.type === 'readonly' || + def?.type === 'nonoptional' + ) + ) { + return current + } + current = def.innerType ?? null + } + + return null +} + +function getObjectShape(schema: ZodSchemaLike | null | undefined) { + const unwrapped = unwrapSchema(schema) + const def = getSchemaDef(unwrapped) + if (def?.type !== 'object') return null + + return unwrapped?.shape ?? def.shape ?? null +} + +function schemaAllowsValue(schema: ZodSchemaLike | null | undefined, value: unknown): boolean { + const unwrapped = unwrapSchema(schema) + if (!unwrapped) return false + + const def = getSchemaDef(unwrapped) + if (unwrapped._zod?.values?.has(value)) return true + if (Array.isArray(def?.values) && def.values.includes(value)) return true + if (def?.entries && Object.values(def.entries).includes(value)) return true + + return false +} + +function getNodeSchemaForType(type: unknown): ZodSchemaLike | null { + const schema = AnyNodeSchema as unknown as ZodSchemaLike + const options = getSchemaDef(schema)?.options + if (!options) return null + + for (const option of options) { + const shape = getObjectShape(option) + if (shape?.type && schemaAllowsValue(shape.type, type)) { + return option + } + } + + return null +} + +function applyLowerLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit { + if (!current) return candidate + if (candidate.value > current.value) return candidate + if (candidate.value === current.value && !candidate.inclusive) return candidate + return current +} + +function applyUpperLimit(current: NumericLimit | undefined, candidate: NumericLimit): NumericLimit { + if (!current) return candidate + if (candidate.value < current.value) return candidate + if (candidate.value === current.value && !candidate.inclusive) return candidate + return current +} + +function getNumberConstraints(schema: ZodSchemaLike): NumericConstraints { + const unwrapped = unwrapSchema(schema) ?? schema + const def = getSchemaDef(unwrapped) + const constraints: NumericConstraints = { integer: false } + + const minValue = unwrapped.minValue + if (typeof minValue === 'number') { + constraints.min = applyLowerLimit(constraints.min, { value: minValue, inclusive: true }) + } + + const maxValue = unwrapped.maxValue + if (typeof maxValue === 'number') { + constraints.max = applyUpperLimit(constraints.max, { value: maxValue, inclusive: true }) + } + + for (const check of def?.checks ?? []) { + const checkDef = check._zod?.def + if (!checkDef) continue + + if (checkDef.check === 'greater_than' && typeof checkDef.value === 'number') { + constraints.min = applyLowerLimit(constraints.min, { + value: checkDef.value, + inclusive: checkDef.inclusive !== false, + }) + } else if (checkDef.check === 'less_than' && typeof checkDef.value === 'number') { + constraints.max = applyUpperLimit(constraints.max, { + value: checkDef.value, + inclusive: checkDef.inclusive !== false, + }) + } else if (checkDef.check === 'number_format' && checkDef.format) { + constraints.integer ||= INTEGER_NUMBER_FORMATS.has(checkDef.format) + const bounds = NUMBER_FORMAT_BOUNDS[checkDef.format] + if (bounds) { + constraints.min = applyLowerLimit(constraints.min, { + value: bounds[0], + inclusive: true, + }) + constraints.max = applyUpperLimit(constraints.max, { + value: bounds[1], + inclusive: true, + }) + } + } + } + + const bag = unwrapped._zod?.bag + if (typeof bag?.minimum === 'number') { + constraints.min = applyLowerLimit(constraints.min, { value: bag.minimum, inclusive: true }) + } + if (typeof bag?.exclusiveMinimum === 'number') { + constraints.min = applyLowerLimit(constraints.min, { + value: bag.exclusiveMinimum, + inclusive: false, + }) + } + if (typeof bag?.maximum === 'number') { + constraints.max = applyUpperLimit(constraints.max, { value: bag.maximum, inclusive: true }) + } + if (typeof bag?.exclusiveMaximum === 'number') { + constraints.max = applyUpperLimit(constraints.max, { + value: bag.exclusiveMaximum, + inclusive: false, + }) + } + + return constraints +} + +function nextAbove(value: number) { + return value === 0 ? Number.EPSILON : value + Math.abs(value) * Number.EPSILON +} + +function nextBelow(value: number) { + return value === 0 ? -Number.EPSILON : value - Math.abs(value) * Number.EPSILON +} + +function clampNumber(value: number, constraints: NumericConstraints) { + let next = value + let rounded = false + let clamped = false + + if (constraints.integer && !Number.isInteger(next)) { + next = Math.round(next) + rounded = true + } + + if (constraints.min) { + const min = constraints.min.inclusive ? constraints.min.value : nextAbove(constraints.min.value) + if (next < min) { + next = min + clamped = true + } + } + + if (constraints.max) { + const max = constraints.max.inclusive ? constraints.max.value : nextBelow(constraints.max.value) + if (next > max) { + next = max + clamped = true + } + } + + return { + value: next, + action: clamped ? 'clamped' : rounded ? 'rounded' : undefined, + } satisfies { value: number; action?: NumericSanitizeIssue['action'] } +} + +function getFiniteFallbackNumber(fallback: unknown, constraints: NumericConstraints) { + if (typeof fallback !== 'number' || !Number.isFinite(fallback)) return undefined + return clampNumber(fallback, constraints).value +} + +function sanitizeNumber( + schema: ZodSchemaLike | null, + value: number, + fallback: unknown, + path: PropertyKey[], +): NumericSanitizeResult { + const constraints = schema ? getNumberConstraints(schema) : { integer: false } + + if (!Number.isFinite(value)) { + const replacement = getFiniteFallbackNumber(fallback, constraints) + if (replacement === undefined) { + return { + value, + omit: true, + issues: [{ path, from: value, action: 'dropped' }], + } + } + + return { + value: replacement, + issues: [{ path, from: value, to: replacement, action: 'dropped' }], + } + } + + const clamped = clampNumber(value, constraints) + if (!Object.is(clamped.value, value)) { + return { + value: clamped.value, + issues: [ + { + path, + from: value, + to: clamped.value, + action: clamped.action ?? 'clamped', + }, + ], + } + } + + return { value, issues: [] } +} + +function sanitizeNumericValue( + schema: ZodSchemaLike | null, + value: unknown, + fallback: unknown, + path: PropertyKey[], +): NumericSanitizeResult { + const defaultFallback = schema ? getSchemaDefault(schema) : undefined + const effectiveFallback = fallback === undefined ? defaultFallback : fallback + const unwrapped = unwrapSchema(schema) + const def = getSchemaDef(unwrapped) + + if (def?.type === 'number') { + if (typeof value !== 'number') return { value, issues: [] } + return sanitizeNumber(unwrapped, value, effectiveFallback, path) + } + + if (typeof value === 'number') { + return sanitizeNumber(null, value, effectiveFallback, path) + } + + if (def?.type === 'tuple' && Array.isArray(value)) { + const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : [] + const next = [...value] + const issues: NumericSanitizeIssue[] = [] + + for (let index = 0; index < next.length; index += 1) { + const itemSchema = def.items?.[index] ?? def.rest ?? null + const child = sanitizeNumericValue(itemSchema, next[index], fallbackItems[index], [ + ...path, + index, + ]) + issues.push(...child.issues) + + if (child.omit) { + return { value, omit: true, issues } + } + + next[index] = child.value + } + + return { value: issues.length > 0 ? next : value, issues } + } + + if (def?.type === 'array' && Array.isArray(value)) { + const fallbackItems = Array.isArray(effectiveFallback) ? effectiveFallback : [] + const next: unknown[] = [] + const issues: NumericSanitizeIssue[] = [] + let omitted = false + + for (let index = 0; index < value.length; index += 1) { + const child = sanitizeNumericValue(def.element ?? null, value[index], fallbackItems[index], [ + ...path, + index, + ]) + issues.push(...child.issues) + if (child.omit) { + omitted = true + continue + } + next.push(child.value) + } + + return { value: issues.length > 0 || omitted ? next : value, issues } + } + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const shape = def?.type === 'object' ? (unwrapped?.shape ?? def.shape ?? {}) : {} + const fallbackObject = + effectiveFallback && + typeof effectiveFallback === 'object' && + !Array.isArray(effectiveFallback) + ? (effectiveFallback as Record) + : {} + const input = value as Record + const next: Record = { ...input } + const issues: NumericSanitizeIssue[] = [] + + for (const key of Object.keys(input)) { + const child = sanitizeNumericValue(shape[key] ?? null, input[key], fallbackObject[key], [ + ...path, + key, + ]) + issues.push(...child.issues) + + if (child.omit) { + delete next[key] + } else { + next[key] = child.value + } + } + + return { value: issues.length > 0 ? next : value, issues } + } + + return { value, issues: [] } +} + +function formatNumericValue(value: number) { + if (Number.isNaN(value)) return 'NaN' + if (value === Infinity) return 'Infinity' + if (value === -Infinity) return '-Infinity' + return String(value) +} + +function numericSanitizeIssuesToMessage(issues: NumericSanitizeIssue[]): string { + return issues + .map((issue) => { + const path = issue.path.map(String).join('.') || '' + const to = issue.to === undefined ? '' : ` -> ${formatNumericValue(issue.to)}` + return `${path}: ${formatNumericValue(issue.from)} ${issue.action}${to}` + }) + .join('; ') +} + +function warnSanitizedNodeMutation( + mutation: 'create' | 'update', + nodeId: AnyNodeId, + issues: NumericSanitizeIssue[], +) { + console.warn( + `[Scene] Sanitized invalid numeric node ${mutation}`, + nodeId, + numericSanitizeIssuesToMessage(issues), + ) +} + +function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode { + const candidate = { ...node, parentId } + const parsed = AnyNodeSchema.safeParse(candidate) + if (parsed.success) return parsed.data + + const schema = getNodeSchemaForType(candidate.type) + const sanitized = sanitizeNumericValue(schema, candidate, undefined, []) + + if (sanitized.issues.length === 0) { + return candidate as AnyNode + } + + warnSanitizedNodeMutation('create', node.id, sanitized.issues) + + return sanitized.value as AnyNode +} + +function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode { + const candidate = { ...currentNode, ...data } + const parsed = AnyNodeSchema.safeParse(candidate) + if (parsed.success) return parsed.data + + const schema = getNodeSchemaForType(candidate.type) + const sanitized = sanitizeNumericValue(schema, data, currentNode, []) + + if (sanitized.issues.length === 0) { + return candidate as AnyNode + } + + warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues) + + return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode +} + // Track pending RAF for updateNodesAction to prevent multiple queued callbacks let pendingRafId: number | null = null let pendingUpdates: Set = new Set() @@ -245,11 +718,7 @@ export const createNodesAction = ( for (const { node, parentId } of ops) { const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null - // 1. Assign parentId to the child (Safe because BaseNode has parentId) - const newNode = { - ...node, - parentId: effectiveParentId, - } + const newNode = parseCreatedNode(node, effectiveParentId) nextNodes[newNode.id] = newNode @@ -313,6 +782,7 @@ export const applyNodeChangesAction = ( for (const { id, data } of updateOps) { const currentNode = nextNodes[id] if (!currentNode) continue + const updatedNode = parseUpdatedNode(currentNode, data) if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { const oldParentId = currentNode.parentId as AnyNodeId | null @@ -336,16 +806,13 @@ export const applyNodeChangesAction = ( } } - nextNodes[id] = { ...currentNode, ...data } as AnyNode + nextNodes[id] = updatedNode nodesToMarkDirty.add(id) } for (const { node, parentId } of createOps) { const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null - const newNode = { - ...node, - parentId: effectiveParentId, - } as AnyNode + const newNode = parseCreatedNode(node, effectiveParentId) nextNodes[newNode.id as AnyNodeId] = newNode nodesToMarkDirty.add(newNode.id as AnyNodeId) @@ -442,6 +909,7 @@ export const updateNodesAction = ( for (const { id, data } of updates) { const currentNode = nextNodes[id] if (!currentNode) continue + const updatedNode = parseUpdatedNode(currentNode, data) // Handle Reparenting Logic if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { @@ -480,7 +948,7 @@ export const updateNodesAction = ( } // Apply the update - nextNodes[id] = { ...nextNodes[id], ...data } as AnyNode + nextNodes[id] = updatedNode } return { nodes: nextNodes } diff --git a/packages/core/src/store/actions/node-mutation-sanitize.test.ts b/packages/core/src/store/actions/node-mutation-sanitize.test.ts new file mode 100644 index 000000000..1863b5385 --- /dev/null +++ b/packages/core/src/store/actions/node-mutation-sanitize.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../../schema/types' +import useScene from '../use-scene' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const SHELF_ID = 'shelf_sanitize' as AnyNodeId +const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId + +function makeShelf(overrides: Partial = {}): AnyNode { + return { + id: SHELF_ID, + type: 'shelf', + parentId: null, + object: 'node', + visible: true, + name: 'Shelf', + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 1.2, + depth: 0.3, + thickness: 0.04, + height: 0.9, + style: 'wall-shelf', + rows: 1, + columns: 1, + withBack: false, + withSides: true, + withBottom: false, + bracketStyle: 'minimal', + ...overrides, + } as unknown as AnyNode +} + +function makeSolarPanel(): AnyNode { + return { + id: SOLAR_PANEL_ID, + type: 'solar-panel', + parentId: null, + object: 'node', + visible: true, + name: 'Panel', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + rows: 2, + columns: 3, + panelWidth: 1, + panelHeight: 1.65, + gapX: 0.02, + gapY: 0.02, + mountingType: 'flush', + tiltAngle: 15, + standoffHeight: 0.05, + frameThickness: 0.04, + frameDepth: 0.04, + } as unknown as AnyNode +} + +function shelf() { + return useScene.getState().nodes[SHELF_ID] as Extract +} + +describe('node mutation numeric sanitization', () => { + beforeEach(() => { + useScene.setState({ + nodes: { + [SHELF_ID]: makeShelf(), + [SOLAR_PANEL_ID]: makeSolarPanel(), + }, + rootNodeIds: [SHELF_ID, SOLAR_PANEL_ID], + dirtyNodes: new Set(), + collections: {}, + readOnly: false, + } as never) + useScene.temporal.getState().clear() + }) + + test('drops NaN numeric updates while preserving other fields in the patch', () => { + useScene.getState().updateNode(SHELF_ID, { + thickness: Number.NaN, + name: 'Renamed after NaN', + } as Partial) + + expect(shelf().thickness).toBe(0.04) + expect(Number.isFinite(shelf().thickness)).toBe(true) + expect(shelf().name).toBe('Renamed after NaN') + }) + + test('drops Infinity numeric updates while preserving later normal updates', () => { + useScene.getState().updateNode(SHELF_ID, { + width: Infinity, + name: 'Renamed after Infinity', + } as Partial) + + expect(shelf().width).toBe(1.2) + expect(Number.isFinite(shelf().width)).toBe(true) + expect(shelf().name).toBe('Renamed after Infinity') + + useScene.getState().updateNode(SHELF_ID, { + name: 'Clean rename', + } as Partial) + + expect(shelf().name).toBe('Clean rename') + }) + + test('clamps out-of-range numeric updates to the node schema bounds', () => { + useScene.getState().updateNode(SHELF_ID, { + width: 99, + thickness: -1, + } as Partial) + + expect(shelf().width).toBe(3) + expect(shelf().thickness).toBe(0.01) + }) + + test('preserves extra fields while sanitizing numeric updates', () => { + useScene.setState({ + nodes: { + [SHELF_ID]: { + ...makeShelf(), + legacyField: 'current', + } as unknown as AnyNode, + }, + rootNodeIds: [SHELF_ID], + } as never) + + useScene.getState().updateNode(SHELF_ID, { + width: Infinity, + legacyPatch: 'patch', + } as Partial) + + const node = useScene.getState().nodes[SHELF_ID] as Record + expect(node.width).toBe(1.2) + expect(node.legacyField).toBe('current') + expect(node.legacyPatch).toBe('patch') + }) + + test('allows non-canonical ids to receive updates', () => { + useScene.getState().updateNode(SOLAR_PANEL_ID, { + name: 'Updated panel', + } as Partial) + + const panel = useScene.getState().nodes[SOLAR_PANEL_ID] as { name?: string } + expect(panel.name).toBe('Updated panel') + }) + + test('sanitizes non-finite numeric values during create', () => { + const createdId = 'shelf_created' as AnyNodeId + + useScene.getState().createNode( + makeShelf({ + id: createdId, + width: Infinity, + thickness: Number.NaN, + } as Partial), + ) + + const created = useScene.getState().nodes[createdId] as Extract + expect(created.width).toBe(1.2) + expect(created.thickness).toBe(0.04) + expect(Number.isFinite(created.width)).toBe(true) + expect(Number.isFinite(created.thickness)).toBe(true) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 6bf0ab7c4..b253fcaf4 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -13,6 +13,7 @@ import { type RoofSegmentNode, type RoofType, } from '../schema/nodes/roof-segment' +import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf' import { SiteNode } from '../schema/nodes/site' import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' @@ -24,6 +25,11 @@ function getFiniteNumber(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback } +function getFiniteNumberInRange(value: unknown, fallback: number, min: number, max: number) { + const finite = getFiniteNumber(value, fallback) + return Math.min(Math.max(finite, min), max) +} + function getBoolean(value: unknown, fallback: boolean) { return typeof value === 'boolean' ? value : fallback } @@ -112,6 +118,37 @@ function normalizeDoorNode(node: Record) { return parsed.success ? { ...node, ...parsed.data } : null } +function normalizeShelfNode(node: Record) { + const sanitized = { + ...node, + children: getStringArray(node.children), + position: getVector3(node.position, [0, 0, 0]), + rotation: getVector3(node.rotation, [0, 0, 0]), + width: getFiniteNumberInRange(node.width, 1.2, 0.3, 3.0), + depth: getFiniteNumberInRange(node.depth, 0.3, 0.1, 1.0), + thickness: getFiniteNumberInRange(node.thickness, 0.04, 0.01, 0.1), + height: getFiniteNumberInRange(node.height, 0.9, 0.05, 2.5), + rows: Math.round(getFiniteNumberInRange(node.rows, 1, 1, 8)), + columns: Math.round(getFiniteNumberInRange(node.columns, 1, 1, 6)), + style: getEnumValue( + node.style, + ['wall-shelf', 'bookshelf', 'open-rack', 'cubby'] as const, + 'wall-shelf', + ), + withBack: getBoolean(node.withBack, false), + withSides: getBoolean(node.withSides, true), + withBottom: getBoolean(node.withBottom, false), + bracketStyle: getEnumValue( + node.bracketStyle, + ['minimal', 'industrial', 'hidden'] as const, + 'minimal', + ), + } + + const parsed = ShelfNodeSchema.safeParse(sanitized) + return parsed.success ? parsed.data : null +} + function migrateWallSurfaceMaterials(node: Record) { const hasInterior = node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' @@ -396,14 +433,11 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) } - // Shelf v2: hosting was added in this migration cycle. Older shelves - // (saved before the schema gained `children`) need the field - // initialised so `createNode(item, shelfId)` finds an array to - // append the child id to — without this the host item ends up - // orphaned (parented in scene state but not in the shelf's - // children list, so the renderer doesn't mount it). - if (node.type === 'shelf' && !Array.isArray(node.children)) { - patchedNodes[id] = { ...node, children: [] } + if (node.type === 'shelf') { + const normalized = normalizeShelfNode(node) + if (normalized) { + patchedNodes[id] = normalized + } } // Roof-segment hosting was added in this migration cycle (the same diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 9a3be98fc..716729efa 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -287,39 +287,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // // The live-transform contract varies per kind (see // wiki/architecture/tools.md "useLiveTransforms contract is - // per-kind, not generic"); we narrow per kind here: - // - item: world-plan position frame. Override `position` + - // `rotation` and force `parentId: null` so the resolver - // treats them as world coords directly. - // - slab / ceiling: position is a translation **delta** - // (`[Δx, 0, Δz]`). Translate the polygon + holes by the - // delta — the floor-plan builder draws the polygon at its - // new location, mirroring the 3D `` - // visual without forcing per-tick CSG scene writes. + // per-kind, not generic"); position-carrying floor-placed kinds + // publish canonical X/Z, while slab / ceiling publish a polygon + // translation delta. const live = liveTransforms.get(id) let effectiveNode: AnyNode = node if (live) { - if (node.type === 'item' || node.type === 'shelf') { - // World-plan position kinds: the live transform carries the - // node's intended position/rotation in level-local coords. - // Override both and force `parentId: null` so the floor-plan - // resolver treats `position` as world plan coords directly - // (skipping the parent-chain transform composition). - effectiveNode = { - ...node, - position: live.position, - rotation: [0, live.rotation, 0] as [number, number, number], - parentId: null, - } as AnyNode - } else if (node.type === 'column') { - // Same world-plan override as item/shelf, but column stores its - // Y rotation as a scalar (not a tuple). - effectiveNode = { - ...node, - position: live.position, - rotation: live.rotation, - parentId: null, - } as AnyNode + const floorPlaced = def?.capabilities?.floorPlaced + const hasPosition = Array.isArray((node as { position?: unknown }).position) + if (floorPlaced && hasPosition) { + effectiveNode = applyPositionLiveTransform(node, live) } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') { const dx = live.position[0] const dz = live.position[2] @@ -1591,6 +1568,29 @@ function InteractiveGeometry({ // ── Helpers ────────────────────────────────────────────────────────── +function applyPositionLiveTransform( + node: AnyNode, + live: { position: [number, number, number]; rotation: number }, +): AnyNode { + const currentRotation = (node as { rotation?: unknown }).rotation + const rotation = Array.isArray(currentRotation) + ? ([ + (currentRotation[0] as number) ?? 0, + live.rotation, + (currentRotation[2] as number) ?? 0, + ] as [number, number, number]) + : typeof currentRotation === 'number' + ? live.rotation + : currentRotation + + return { + ...node, + position: live.position, + ...(rotation !== undefined ? { rotation } : {}), + parentId: null, + } as AnyNode +} + function buildContext( node: AnyNode, nodes: Record, diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 1ecf71a9d..d9b30c1f8 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -8,10 +8,10 @@ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { + CORNER_OFFSET, classifyParticipant, collectParticipants, computeGroupBox, - CORNER_OFFSET, expandToComponent, type Vec2, } from './group-transform-shared' diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 8a0736b59..f58d26d8c 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -21,11 +21,15 @@ import { ARROW_HOVER_COLOR, ARROW_SCALE, createRotateArrowHandleGeometry, + createRotateArrowHitAreaGeometry, GuideRing, + InvisibleHandleHitArea, + NO_RAYCAST, RotationGuide, type RotationGuideData, swallowNextClick, useArrowMaterial, + useInvisibleHitAreaMaterial, } from './node-arrow-handles' const ROTATE_SNAP = Math.PI / 12 // 15° @@ -74,7 +78,9 @@ export function GroupRotateHandle() { function GroupRotateHandleInner({ ids }: { ids: string[] }) { const { camera, raycaster, gl, scene } = useThree() const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), []) const arrowMaterial = useArrowMaterial() + const hitMaterial = useInvisibleHitAreaMaterial() const [isHovered, setIsHovered] = useState(false) const [isDragging, setIsDragging] = useState(false) const [guide, setGuide] = useState(null) @@ -85,11 +91,13 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) }, [arrowMaterial, isHovered]) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => dragCleanupRef.current?.(), []) const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE * 1.05 + const baseScale = zoom * ARROW_SCALE * 1.05 + const scale = (isHovered ? 1.12 : 1) * baseScale // World-space bounding box of the selected meshes. Levels are axis-aligned in // XZ, so world XZ coincides with each node's level-local placement — letting @@ -100,11 +108,7 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { const rest = useMemo(() => { const box = computeGroupBox(ids) if (!box) return null - const pivot = new Vector3( - (box.min.x + box.max.x) / 2, - box.min.y, - (box.min.z + box.max.z) / 2, - ) + const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) const corner = new Vector3( box.max.x + CORNER_OFFSET, (box.min.y + box.max.y) / 2, @@ -307,11 +311,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { )} - - + { event.stopPropagation() @@ -323,7 +326,15 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { setIsHovered(false) if (document.body.style.cursor === 'grab') document.body.style.cursor = '' }} + scale={baseScale} + /> + {guide ? : null} diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx new file mode 100644 index 000000000..531e7fece --- /dev/null +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -0,0 +1,521 @@ +'use client' + +import { type Cursor, emitter } from '@pascal-app/core' +import type { ThreeEvent } from '@react-three/fiber' +import { type ReactNode, useEffect, useMemo, useRef } from 'react' +import { + BoxGeometry, + type BufferGeometry, + CircleGeometry, + Color, + CylinderGeometry, + DoubleSide, + ExtrudeGeometry, + type Group, + Shape, + TorusGeometry, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { MeshBasicNodeMaterial } from 'three/webgpu' + +export const ARROW_SCALE = 0.65 +export const ARROW_COLOR = '#8381ed' +export const ARROW_HOVER_COLOR = '#a5b4fc' +export const NO_RAYCAST = () => null +export const HIT_AREA_MARGIN = 0.035 + +const HIT_AREA_RENDER_ORDER = 1011 +const HIT_AREA_THICKNESS = 0.08 +const CHEVRON_MIN_X = -0.2 +const CHEVRON_MAX_X = 0.22 +const CHEVRON_HALF_WIDTH = 0.12 +const CHEVRON_NOTCH_X = -0.04 +const CHEVRON_SHAFT_HALF_WIDTH = 0.035 +const CHEVRON_DEPTH = 0.08 +const CHEVRON_BEVEL_THICKNESS = 0.035 +const CHEVRON_BEVEL_SIZE = 0.03 +const CHEVRON_BEVEL_SEGMENTS = 10 +const MOVE_CROSS_HALF_LENGTH = 0.36 +const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03 +const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12 +const MOVE_CROSS_HEAD_INSET = 0.2 +const MOVE_CROSS_DEPTH = 0.06 +const MOVE_CROSS_BEVEL_THICKNESS = 0.018 +const MOVE_CROSS_BEVEL_SIZE = 0.012 +const MOVE_CROSS_BEVEL_SEGMENTS = 6 +const ROTATE_HANDLE_RADIUS = 0.2 +const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3 +const ROTATE_RIBBON_HALF_WIDTH = 0.02 +const ROTATE_HEAD_HALF_WIDTH = 0.045 +const TRACKER_CUBE_SIZE = 0.16 +export const CORNER_HEX_RADIUS = 0.16 + +export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker' +export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross' + +export type HandleArrowPlacement = { + position: readonly [number, number, number] + rotation?: readonly [number, number, number] + baseScale: number +} + +type PointerHandler = (event: ThreeEvent) => void + +export type HandleArrowProps = { + shape: HandleArrowInputShape + placement: HandleArrowPlacement + hover: boolean + cursor: Cursor + onHoverChange: (hovered: boolean) => void + onPointerDown: PointerHandler + activeCursor?: Cursor + children?: ReactNode + hoverScale?: number + indicatorRotation?: readonly [number, number, number] + onPointerEnter?: PointerHandler + onPointerLeave?: PointerHandler +} + +function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { + if (shape === 'arrow') return 'chevron' + if (shape === 'move-cross') return 'cross' + if (shape === 'chevron' && cursor === 'move') return 'cross' + return shape +} + +// Two-headed curved-arrow silhouette for whole-node rotation handles +// (today: the elevator's corner rotate gizmo). Symmetric arc centred on +// +X with sweeps to +/-halfSweep, arrowhead wings + tangentially-extended +// tips at each end. Drawn in 2D then extruded and rotated to lie in the +// XZ plane - same final-orientation contract as the chevron, so the +// outer rotation Y and inner-rotation chain in the renderer are reused +// unchanged. +export function createRotateArrowHandleGeometry() { + const R = 0.2 + const ribbonHalfWidth = 0.02 + const halfSweep = Math.PI / 3 + const headHalfWidth = 0.045 + const headOvershoot = 0.075 + const rIn = R - ribbonHalfWidth + const rOut = R + ribbonHalfWidth + const a1 = halfSweep + const a2 = -halfSweep + + const tip1: [number, number] = [ + R * Math.cos(a1) - headOvershoot * Math.sin(a1), + R * Math.sin(a1) + headOvershoot * Math.cos(a1), + ] + const tip2: [number, number] = [ + R * Math.cos(a2) + headOvershoot * Math.sin(a2), + R * Math.sin(a2) - headOvershoot * Math.cos(a2), + ] + const innerWing1: [number, number] = [ + (rIn - headHalfWidth) * Math.cos(a1), + (rIn - headHalfWidth) * Math.sin(a1), + ] + const outerWing1: [number, number] = [ + (rOut + headHalfWidth) * Math.cos(a1), + (rOut + headHalfWidth) * Math.sin(a1), + ] + const innerWing2: [number, number] = [ + (rIn - headHalfWidth) * Math.cos(a2), + (rIn - headHalfWidth) * Math.sin(a2), + ] + const outerWing2: [number, number] = [ + (rOut + headHalfWidth) * Math.cos(a2), + (rOut + headHalfWidth) * Math.sin(a2), + ] + const innerCorner1: [number, number] = [rIn * Math.cos(a1), rIn * Math.sin(a1)] + const outerCorner1: [number, number] = [rOut * Math.cos(a1), rOut * Math.sin(a1)] + const innerCorner2: [number, number] = [rIn * Math.cos(a2), rIn * Math.sin(a2)] + const outerCorner2: [number, number] = [rOut * Math.cos(a2), rOut * Math.sin(a2)] + + const shape = new Shape() + shape.moveTo(innerCorner1[0], innerCorner1[1]) + shape.lineTo(innerWing1[0], innerWing1[1]) + shape.lineTo(tip1[0], tip1[1]) + shape.lineTo(outerWing1[0], outerWing1[1]) + shape.lineTo(outerCorner1[0], outerCorner1[1]) + shape.absarc(0, 0, rOut, a1, a2, true) + shape.lineTo(outerWing2[0], outerWing2[1]) + shape.lineTo(tip2[0], tip2[1]) + shape.lineTo(innerWing2[0], innerWing2[1]) + shape.lineTo(innerCorner2[0], innerCorner2[1]) + shape.absarc(0, 0, rIn, a2, a1, false) + shape.closePath() + + const geometry = new ExtrudeGeometry(shape, { + depth: 0.06, + bevelEnabled: true, + bevelThickness: 0.018, + bevelSize: 0.012, + bevelOffset: 0, + bevelSegments: 6, + curveSegments: 24, + steps: 1, + }) + geometry.translate(0, 0, -0.03) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +// Reused chevron+shaft silhouette. The chevron points along +X by default; +// callers rotate it around Y for Z-axis handles and into a vertical frame for +// Y-axis handles. +export function createArrowHandleGeometry() { + const shape = new Shape() + shape.moveTo(CHEVRON_MAX_X, 0) + shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH) + shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_MIN_X, CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_MIN_X, -CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_SHAFT_HALF_WIDTH) + shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH) + shape.lineTo(CHEVRON_MAX_X, 0) + const geometry = new ExtrudeGeometry(shape, { + depth: CHEVRON_DEPTH, + bevelEnabled: true, + bevelThickness: CHEVRON_BEVEL_THICKNESS, + bevelSize: CHEVRON_BEVEL_SIZE, + bevelOffset: 0, + bevelSegments: CHEVRON_BEVEL_SEGMENTS, + curveSegments: 16, + steps: 1, + }) + geometry.translate(0, 0, -CHEVRON_DEPTH / 2) + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +function createDoubleArrowShape(): Shape { + const shape = new Shape() + shape.moveTo(MOVE_CROSS_HALF_LENGTH, 0) + shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH) + shape.lineTo(MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, MOVE_CROSS_HEAD_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HALF_LENGTH, 0) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH) + shape.lineTo(-MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_SHAFT_HALF_WIDTH) + shape.lineTo(MOVE_CROSS_HEAD_INSET, -MOVE_CROSS_HEAD_HALF_WIDTH) + shape.closePath() + return shape +} + +export function createMoveCrossHandleGeometry() { + const shape = createDoubleArrowShape() + const extrudeOpts = { + depth: MOVE_CROSS_DEPTH, + bevelEnabled: true, + bevelThickness: MOVE_CROSS_BEVEL_THICKNESS, + bevelSize: MOVE_CROSS_BEVEL_SIZE, + bevelOffset: 0, + bevelSegments: MOVE_CROSS_BEVEL_SEGMENTS, + curveSegments: 8, + steps: 1, + } + const armX = new ExtrudeGeometry(shape, extrudeOpts) + armX.translate(0, 0, -MOVE_CROSS_DEPTH / 2) + armX.rotateX(-Math.PI / 2) + const armZ = armX.clone() + armZ.rotateY(Math.PI / 2) + const merged = mergeGeometries([armX, armZ], false) + if (!merged) { + armZ.dispose() + armX.computeVertexNormals() + armX.computeBoundingSphere() + return armX + } + armX.dispose() + armZ.dispose() + merged.computeVertexNormals() + merged.computeBoundingSphere() + return merged +} + +export function createArrowHitAreaGeometry() { + const length = CHEVRON_MAX_X - CHEVRON_MIN_X + HIT_AREA_MARGIN * 2 + const centerX = (CHEVRON_MIN_X + CHEVRON_MAX_X) / 2 + const geometry = new CylinderGeometry( + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + CHEVRON_HALF_WIDTH + HIT_AREA_MARGIN, + length, + 16, + ) + geometry.rotateZ(-Math.PI / 2) + geometry.translate(centerX, 0, 0) + geometry.computeBoundingSphere() + return geometry +} + +function createMoveCrossHitAreaGeometry() { + const geometry = new CylinderGeometry( + MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, + MOVE_CROSS_HALF_LENGTH + HIT_AREA_MARGIN, + HIT_AREA_THICKNESS, + 32, + ) + geometry.computeBoundingSphere() + return geometry +} + +export function createRotateArrowHitAreaGeometry() { + const halfSweep = ROTATE_HANDLE_HALF_SWEEP + HIT_AREA_MARGIN / ROTATE_HANDLE_RADIUS + const geometry = new TorusGeometry( + ROTATE_HANDLE_RADIUS, + ROTATE_RIBBON_HALF_WIDTH + ROTATE_HEAD_HALF_WIDTH + HIT_AREA_MARGIN, + 10, + 64, + halfSweep * 2, + ) + geometry.rotateZ(-halfSweep) + geometry.rotateX(-Math.PI / 2) + geometry.computeBoundingSphere() + return geometry +} + +function createTrackerHitAreaGeometry() { + const size = TRACKER_CUBE_SIZE + HIT_AREA_MARGIN * 2 + const geometry = new BoxGeometry(size, size, size) + geometry.computeBoundingSphere() + return geometry +} + +export function createEndpointHitAreaGeometry(radius: number) { + const geometry = new CylinderGeometry( + radius + HIT_AREA_MARGIN, + radius + HIT_AREA_MARGIN, + HIT_AREA_THICKNESS, + 24, + ) + geometry.rotateX(Math.PI / 2) + geometry.computeBoundingSphere() + return geometry +} + +function createHandleArrowGeometry(shape: HandleArrowShape) { + if (shape === 'chevron') return createArrowHandleGeometry() + if (shape === 'cross') return createMoveCrossHandleGeometry() + if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() + if (shape === 'tracker') { + const geometry = new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE) + geometry.computeBoundingSphere() + return geometry + } + const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6) + geometry.computeBoundingSphere() + return geometry +} + +function createHandleArrowHitGeometry(shape: HandleArrowShape) { + if (shape === 'chevron') return createArrowHitAreaGeometry() + if (shape === 'cross') return createMoveCrossHitAreaGeometry() + if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry() + if (shape === 'tracker') return createTrackerHitAreaGeometry() + const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6) + geometry.computeBoundingSphere() + return geometry +} + +let sharedHitAreaMaterial: MeshBasicNodeMaterial | null = null +let sharedHitAreaMaterialRefs = 0 + +function createInvisibleHitAreaMaterial() { + return new MeshBasicNodeMaterial({ + color: new Color('#ffffff'), + colorWrite: false, + depthTest: false, + depthWrite: false, + opacity: 0, + side: DoubleSide, + transparent: true, + }) +} + +export function useInvisibleHitAreaMaterial(): MeshBasicNodeMaterial { + const materialRef = useRef(null) + if (!materialRef.current) { + sharedHitAreaMaterial ??= createInvisibleHitAreaMaterial() + materialRef.current = sharedHitAreaMaterial + } + useEffect(() => { + sharedHitAreaMaterialRefs += 1 + return () => { + sharedHitAreaMaterialRefs -= 1 + if (sharedHitAreaMaterialRefs <= 0 && sharedHitAreaMaterial) { + sharedHitAreaMaterial.dispose() + sharedHitAreaMaterial = null + sharedHitAreaMaterialRefs = 0 + } + } + }, []) + return materialRef.current +} + +export function InvisibleHandleHitArea({ + geometry, + material, + onPointerDown, + onPointerEnter, + onPointerLeave, + scale, +}: { + geometry: BufferGeometry + material: MeshBasicNodeMaterial + onPointerDown: PointerHandler + onPointerEnter: PointerHandler + onPointerLeave: PointerHandler + scale: number +}) { + return ( + + ) +} + +export function useArrowMaterial(): MeshBasicNodeMaterial { + return useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + // `depthTest: false` keeps the chevron drawing on top of any + // geometry under it; `depthWrite: true` puts the chevron's depth + // into the scenePass buffer so the ink-edge shader's depth + // Laplacian fires on its silhouette from every angle. Without + // depthWrite, only the normal-discontinuity branch can detect + // the chevron, and that signal collapses when the arrow's faces + // happen to align with whatever sits behind them in screen space + // - which is why the lines used to drop out depending on the view. + depthTest: false, + depthWrite: true, + transparent: true, + opacity: 1, + }), + [], + ) +} + +function useHandleArrowMaterial(shape: HandleArrowShape): MeshBasicNodeMaterial { + return useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(ARROW_COLOR), + side: DoubleSide, + transparent: true, + opacity: shape === 'corner-picker' ? 0.95 : 1, + depthTest: false, + depthWrite: shape !== 'corner-picker', + }), + [shape], + ) +} + +function indicatorRenderOrder(shape: HandleArrowShape) { + return shape === 'tracker' || shape === 'corner-picker' ? 1003 : 1010 +} + +export function HandleArrow({ + shape, + placement, + hover, + cursor, + activeCursor, + children, + hoverScale = 1.12, + indicatorRotation, + onHoverChange, + onPointerDown, + onPointerEnter, + onPointerLeave, +}: HandleArrowProps) { + const visualShape = normalizeHandleArrowShape(shape, cursor) + const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape]) + const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) + const indicatorMaterial = useHandleArrowMaterial(visualShape) + const hitMaterial = useInvisibleHitAreaMaterial() + const rootRef = useRef(null) + const rotation: [number, number, number] = placement.rotation + ? [placement.rotation[0], placement.rotation[1], placement.rotation[2]] + : [0, 0, 0] + const localRotation: [number, number, number] = indicatorRotation + ? [indicatorRotation[0], indicatorRotation[1], indicatorRotation[2]] + : [0, 0, 0] + const scale = (hover ? hoverScale : 1) * placement.baseScale + const hitScale = visualShape === 'corner-picker' ? scale : placement.baseScale + + useEffect(() => { + indicatorMaterial.color.set(hover ? ARROW_HOVER_COLOR : ARROW_COLOR) + }, [indicatorMaterial, hover]) + useEffect(() => { + const hideForCapture = () => { + if (rootRef.current) rootRef.current.visible = false + } + const restoreAfterCapture = () => { + if (rootRef.current) rootRef.current.visible = true + } + emitter.on('thumbnail:before-capture', hideForCapture) + emitter.on('thumbnail:after-capture', restoreAfterCapture) + return () => { + emitter.off('thumbnail:before-capture', hideForCapture) + emitter.off('thumbnail:after-capture', restoreAfterCapture) + } + }, []) + useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => indicatorMaterial.dispose(), [indicatorMaterial]) + + const handleEnter: PointerHandler = (event) => { + event.stopPropagation() + onHoverChange(true) + if (!activeCursor || document.body.style.cursor !== activeCursor) { + document.body.style.cursor = cursor + } + onPointerEnter?.(event) + } + const handleLeave: PointerHandler = (event) => { + event.stopPropagation() + onHoverChange(false) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + onPointerLeave?.(event) + } + + return ( + + {children} + + + + + + ) +} diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts new file mode 100644 index 000000000..3be0748ba --- /dev/null +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -0,0 +1,183 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type Cursor, + createSceneApi, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type ThreeEvent, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Camera, type Object3D, type Plane, Vector2, type Vector3 } from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' + +export type HandleDragControls = { + onStart: (index: number, snapshot: AnyNode) => void + onEnd: () => void +} + +type IntersectPlane = ( + clientX: number, + clientY: number, + plane: Plane, + target: Vector3, +) => Vector3 | null + +export type HandleDragStartContext = { + event: ThreeEvent + camera: Camera + intersectPlane: IntersectPlane + initialNode: AnyNode + node: AnyNode + nodeId: AnyNodeId + rideObject: Object3D + sceneApi: ReturnType +} + +export type HandleDragMoveContext = { + event: PointerEvent + intersectPlane: IntersectPlane +} + +type HandleDragSession = { + move: (context: HandleDragMoveContext) => Partial | null + onBegin?: () => void + onEnd?: () => void + overrideId?: AnyNodeId +} + +type UseHandleDragArgs = + | { + kind: 'drag' + cursor: Cursor + dragControls: HandleDragControls + handleIndex: number + node: AnyNode + onStart: (context: HandleDragStartContext) => HandleDragSession | null + rideObject: Object3D + setIsDragging: (dragging: boolean) => void + } + | { + kind: 'tap' + onTap: (event: ThreeEvent) => void + } + +export function swallowNextClick() { + const swallow = (clickEvent: Event) => { + clickEvent.stopPropagation() + clickEvent.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => { + window.removeEventListener('click', swallow, { capture: true }) + }, 300) +} + +export function useHandleDrag(args: UseHandleDragArgs) { + const { camera, raycaster, gl } = useThree() + const dragCleanupRef = useRef<(() => void) | null>(null) + + useEffect(() => () => dragCleanupRef.current?.(), []) + + return (event: ThreeEvent) => { + event.stopPropagation() + + if (args.kind === 'tap') { + sfxEmitter.emit('sfx:item-pick') + document.body.style.cursor = '' + args.onTap(event) + return + } + + const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args + rideObject.updateMatrixWorld() + + const ndc = new Vector2() + const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { + const rect = gl.domElement.getBoundingClientRect() + ndc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ) + raycaster.setFromCamera(ndc, camera) + return raycaster.ray.intersectPlane(plane, target) + } + + const nodeId = node.id as AnyNodeId + const sceneApi = createSceneApi(useScene) + const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode + const session = args.onStart({ + event, + camera, + intersectPlane, + initialNode, + node, + nodeId, + rideObject, + sceneApi, + }) + if (!session) return + + const overrideId = session.overrideId ?? nodeId + document.body.style.cursor = cursor + sfxEmitter.emit('sfx:item-pick') + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + setIsDragging(true) + dragControls.onStart(handleIndex, initialNode) + session.onBegin?.() + + let lastPatch: Partial | null = null + + const onMove = (moveEvent: PointerEvent) => { + const patch = session.move({ event: moveEvent, intersectPlane }) + if (!patch) return + lastPatch = patch + useLiveNodeOverrides.getState().set(overrideId, patch as Record) + useScene.getState().markDirty(overrideId) + } + + const cleanup = () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if (document.body.style.cursor === cursor) { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useViewer.getState().setInputDragging(false) + setIsDragging(false) + session.onEnd?.() + dragControls.onEnd() + dragCleanupRef.current = null + } + + const clearOverride = () => { + useLiveNodeOverrides.getState().clear(overrideId) + useScene.getState().markDirty(overrideId) + } + + const onUp = () => { + swallowNextClick() + sfxEmitter.emit('sfx:item-place') + if (lastPatch) { + sceneApi.update(overrideId, lastPatch) + } + clearOverride() + cleanup() + } + + const onCancel = () => { + clearOverride() + cleanup() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } +} diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index b18f61759..869841dc1 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -6,7 +6,6 @@ import { type ArcResizeHandle, type Cursor, createSceneApi, - emitter, type HandleDescriptor, type HandlePortal, type LinearResizeHandle, @@ -23,12 +22,10 @@ import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { - BoxGeometry, BufferGeometry, Color, CylinderGeometry, DoubleSide, - ExtrudeGeometry, Float32BufferAttribute, type Group, Matrix4, @@ -37,21 +34,42 @@ import { Plane, Quaternion, RingGeometry, - Shape, - Vector2, Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' -import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { snapToGrid } from '../tools/item/placement-math' import { formatAngleRadians } from '../tools/shared/segment-angle' +import { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + CORNER_HEX_RADIUS, + HandleArrow, + NO_RAYCAST, +} from './handles/handle-arrow' +import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' + +export { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + createArrowHitAreaGeometry, + createEndpointHitAreaGeometry, + createMoveCrossHandleGeometry, + createRotateArrowHandleGeometry, + createRotateArrowHitAreaGeometry, + HIT_AREA_MARGIN, + InvisibleHandleHitArea, + NO_RAYCAST, + useArrowMaterial, + useInvisibleHitAreaMaterial, +} from './handles/handle-arrow' +export { swallowNextClick } from './handles/use-handle-drag' -export const ARROW_SCALE = 0.65 -export const ARROW_COLOR = '#8381ed' // How far a DOWNWARD tracker's dashed leader pokes past its cube so the // dashes visibly thread through it (the cube sits ON the line, not at // its end). Upward trackers — wall / chimney height — stop at the cube. @@ -100,192 +118,6 @@ function DimensionLabel({ ) } -export const ARROW_HOVER_COLOR = '#a5b4fc' - -// Two-headed curved-arrow silhouette for whole-node rotation handles -// (today: the elevator's corner rotate gizmo). Symmetric arc centred on -// +X with sweeps to ±halfSweep, arrowhead wings + tangentially-extended -// tips at each end. Drawn in 2D then extruded and rotated to lie in the -// XZ plane — same final-orientation contract as the chevron, so the -// outer rotation Y and inner-rotation chain in the renderer are reused -// unchanged. -export function createRotateArrowHandleGeometry() { - const R = 0.2 - const ribbonHalfWidth = 0.028 // ribbon thickness / 2 - const halfSweep = Math.PI / 3 // 60° per side → 120° total arc - const headHalfWidth = 0.05 // arrowhead wings extend this far past ribbon - const headOvershoot = 0.075 // tangential reach of the arrowhead tip - const rIn = R - ribbonHalfWidth - const rOut = R + ribbonHalfWidth - const a1 = halfSweep - const a2 = -halfSweep - - // Tip positions: at radius R, displaced tangentially past the ribbon end. - // CCW tangent at a1: (-sin a1, cos a1) → push past a1. - // CW tangent at a2: (+sin a2, -cos a2) → push past a2 the other way. - const tip1: [number, number] = [ - R * Math.cos(a1) - headOvershoot * Math.sin(a1), - R * Math.sin(a1) + headOvershoot * Math.cos(a1), - ] - const tip2: [number, number] = [ - R * Math.cos(a2) + headOvershoot * Math.sin(a2), - R * Math.sin(a2) - headOvershoot * Math.cos(a2), - ] - const innerWing1: [number, number] = [ - (rIn - headHalfWidth) * Math.cos(a1), - (rIn - headHalfWidth) * Math.sin(a1), - ] - const outerWing1: [number, number] = [ - (rOut + headHalfWidth) * Math.cos(a1), - (rOut + headHalfWidth) * Math.sin(a1), - ] - const innerWing2: [number, number] = [ - (rIn - headHalfWidth) * Math.cos(a2), - (rIn - headHalfWidth) * Math.sin(a2), - ] - const outerWing2: [number, number] = [ - (rOut + headHalfWidth) * Math.cos(a2), - (rOut + headHalfWidth) * Math.sin(a2), - ] - const innerCorner1: [number, number] = [rIn * Math.cos(a1), rIn * Math.sin(a1)] - const outerCorner1: [number, number] = [rOut * Math.cos(a1), rOut * Math.sin(a1)] - const innerCorner2: [number, number] = [rIn * Math.cos(a2), rIn * Math.sin(a2)] - const outerCorner2: [number, number] = [rOut * Math.cos(a2), rOut * Math.sin(a2)] - - const shape = new Shape() - // Trace: top inner-corner → top inner-wing → top tip → top outer-wing → - // top outer-corner → outer arc CW → bot outer-corner → - // bot outer-wing → bot tip → bot inner-wing → bot inner-corner → - // inner arc CCW → back to top inner-corner. - shape.moveTo(innerCorner1[0], innerCorner1[1]) - shape.lineTo(innerWing1[0], innerWing1[1]) - shape.lineTo(tip1[0], tip1[1]) - shape.lineTo(outerWing1[0], outerWing1[1]) - shape.lineTo(outerCorner1[0], outerCorner1[1]) - shape.absarc(0, 0, rOut, a1, a2, true) - shape.lineTo(outerWing2[0], outerWing2[1]) - shape.lineTo(tip2[0], tip2[1]) - shape.lineTo(innerWing2[0], innerWing2[1]) - shape.lineTo(innerCorner2[0], innerCorner2[1]) - shape.absarc(0, 0, rIn, a2, a1, false) - shape.closePath() - - const geometry = new ExtrudeGeometry(shape, { - depth: 0.045, - bevelEnabled: true, - bevelThickness: 0.018, - bevelSize: 0.02, - bevelOffset: 0, - bevelSegments: 8, - curveSegments: 24, - steps: 1, - }) - geometry.translate(0, 0, -0.0225) - geometry.rotateX(-Math.PI / 2) - geometry.computeVertexNormals() - geometry.computeBoundingSphere() - return geometry -} - -// Reused chevron+shaft silhouette — matches every other handle file. The -// chevron points along +X by default; descriptors with `axis: 'z'` rotate -// it around Y, and `axis: 'y'` rotates so it points up. -function createArrowHandleGeometry() { - const shape = new Shape() - shape.moveTo(0.22, 0) - shape.lineTo(-0.04, 0.12) - shape.lineTo(-0.04, 0.035) - shape.lineTo(-0.2, 0.035) - shape.lineTo(-0.2, -0.035) - shape.lineTo(-0.04, -0.035) - shape.lineTo(-0.04, -0.12) - shape.lineTo(0.22, 0) - const geometry = new ExtrudeGeometry(shape, { - depth: 0.045, - bevelEnabled: true, - bevelThickness: 0.018, - bevelSize: 0.02, - bevelOffset: 0, - bevelSegments: 8, - curveSegments: 16, - steps: 1, - }) - geometry.translate(0, 0, -0.0225) - geometry.rotateX(-Math.PI / 2) - geometry.computeVertexNormals() - geometry.computeBoundingSphere() - return geometry -} - -// Double-headed straight arrow silhouette, drawn in 2D pointing along ±X. -// A thin ribbon between two arrowheads. Two of these (one rotated 90°) -// merge into the 4-way move cross. -function createDoubleArrowShape(): Shape { - const L = 0.36 // half-length to each tip - const rw = 0.042 // ribbon half-width - const hw = 0.13 // arrowhead half-width - // Long inner ribbon so opposing arrowheads sit well apart rather than - // meeting in a cramped knot at the centre. - const hx = 0.2 // where each arrowhead meets the ribbon - const shape = new Shape() - shape.moveTo(L, 0) // right tip - shape.lineTo(hx, hw) - shape.lineTo(hx, rw) - shape.lineTo(-hx, rw) - shape.lineTo(-hx, hw) - shape.lineTo(-L, 0) // left tip - shape.lineTo(-hx, -hw) - shape.lineTo(-hx, -rw) - shape.lineTo(hx, -rw) - shape.lineTo(hx, -hw) - shape.closePath() - return shape -} - -// 4-way move cross: two double-headed arrows (±X and ±Z) lying flat in the -// XZ plane. Drawn on top (depthTest off, shared arrow material) so it reads -// as a floor-move grip centred on the item. -export function createMoveCrossHandleGeometry() { - const shape = createDoubleArrowShape() - const extrudeOpts = { - depth: 0.045, - bevelEnabled: true, - bevelThickness: 0.018, - bevelSize: 0.02, - bevelOffset: 0, - bevelSegments: 8, - curveSegments: 8, - steps: 1, - } - const armX = new ExtrudeGeometry(shape, extrudeOpts) - armX.translate(0, 0, -0.0225) - armX.rotateX(-Math.PI / 2) // lay flat → points along ±X in XZ - const armZ = armX.clone() - armZ.rotateY(Math.PI / 2) // second arm → points along ±Z - const merged = mergeGeometries([armX, armZ], false) - if (!merged) { - armZ.dispose() - armX.computeVertexNormals() - armX.computeBoundingSphere() - return armX - } - armX.dispose() - armZ.dispose() - merged.computeVertexNormals() - merged.computeBoundingSphere() - return merged -} - -export function swallowNextClick() { - const swallow = (clickEvent: Event) => { - clickEvent.stopPropagation() - clickEvent.preventDefault() - } - window.addEventListener('click', swallow, { capture: true, once: true }) - setTimeout(() => { - window.removeEventListener('click', swallow, { capture: true }) - }, 300) -} export function NodeArrowHandles() { const selectedIds = useViewer((state) => state.selection.selectedIds) @@ -434,25 +266,9 @@ function NodeArrowHandlesForNode({ // shader, and it's the reason the wall height arrow (which also stays on // SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting // them on EDITOR_LAYER hides them from scenePass and the chevron renders - // flat. Because they live on SCENE_LAYER, the thumbnail camera (which only - // filters EDITOR_LAYER + GRID_LAYER) would otherwise capture them when a - // node is selected at capture time — so the rig hides itself during a - // snapshot via the same before/after-capture handshake SelectionManager - // uses, then reappears. - useEffect(() => { - const hide = () => { - if (outerRef.current) outerRef.current.visible = false - } - const show = () => { - if (outerRef.current) outerRef.current.visible = true - } - emitter.on('thumbnail:before-capture', hide) - emitter.on('thumbnail:after-capture', show) - return () => { - emitter.off('thumbnail:before-capture', hide) - emitter.off('thumbnail:after-capture', show) - } - }, []) + // flat. Arrows are only mounted while a node is selected, so thumbnail + // captures (which never have selection) don't need the layer-based + // exclusion the wall arrow also goes without. useFrame(() => { if (outerRef.current && outerRide) { @@ -479,7 +295,7 @@ function NodeArrowHandlesForNode({ // hook count between renders and trip React's rules-of-hooks check. const [activeIndex, setActiveIndex] = useState(null) const [preDragNode, setPreDragNode] = useState(null) - const dragControls = useMemo( + const dragControls = useMemo( () => ({ onStart: (index: number, snapshot: AnyNode) => { setActiveIndex(index) @@ -533,11 +349,6 @@ function NodeArrowHandlesForNode({ ) } -type DragControls = { - onStart: (index: number, snapshot: AnyNode) => void - onEnd: () => void -} - // Offset, in node-local frame, that compensates for `position` drift on // the mesh during an asymmetric resize. Width/length L+R recompute // `position` so the anchored edge stays world-fixed — the renderer @@ -586,7 +397,7 @@ function ArrowHandle({ preDragNode: AnyNode | null activeIndex: number | null handleIndex: number - dragControls: DragControls + dragControls: HandleDragControls rideObject: Object3D /** When the active drag is a translate, non-active arrows ride the moving * mesh instead of freezing at their pre-drag world position. */ @@ -652,29 +463,6 @@ function ArrowHandle({ return null } -export function useArrowMaterial(): MeshBasicNodeMaterial { - return useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - // `depthTest: false` keeps the chevron drawing on top of any - // geometry under it; `depthWrite: true` puts the chevron's depth - // into the scenePass buffer so the ink-edge shader's depth - // Laplacian fires on its silhouette from every angle. Without - // depthWrite, only the normal-discontinuity branch can detect - // the chevron, and that signal collapses when the arrow's faces - // happen to align with whatever sits behind them in screen space - // — which is why the lines used to drop out depending on the view. - depthTest: false, - depthWrite: true, - transparent: true, - opacity: 1, - }), - [], - ) -} - function pickCursor(descriptor: LinearResizeHandle | RadialResizeHandle): Cursor { if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize' @@ -710,26 +498,16 @@ function LinearArrow({ /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */ freezeOffset: [number, number, number] | null handleIndex: number - dragControls: DragControls + dragControls: HandleDragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) const [isDragging, setIsDragging] = useState(false) - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const arrowMaterial = useArrowMaterial() - const { camera, raycaster, gl } = useThree() + const { camera } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE - const dragCleanupRef = useRef<(() => void) | null>(null) + const baseScale = zoom * ARROW_SCALE const unit = useViewer((s) => s.unit) - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - useEffect(() => () => dragCleanupRef.current?.(), []) - // Suppress "declared but unused" for `liveNode` — LinearArrow's apply // operates on `initialNode` (snapshot inside activate) and reads value // updates back via `useLiveNodeOverrides`. The prop is required for @@ -764,176 +542,85 @@ function LinearArrow({ // Y-rotation chains via the parent below. const rotationY = baseRotationY + axisRotationY - const activate = (event: ThreeEvent) => { - event.stopPropagation() - - rideObject.updateMatrixWorld() - // Freeze the ride frame at drag-start. Some kinds park their mesh - // position on the field being dragged (ceiling: mesh.position.y = - // height) — using the *live* matrix in `onMove` would chase that - // moving frame and the value stalls or jitters. The inverse is - // captured once so local-coord math stays anchored to the pre-drag - // pose for the duration of the drag. - const initialFrameInverse = new Matrix4().copy(rideObject.matrixWorld).invert() - const worldOrigin = new Vector3(...position).applyMatrix4(rideObject.matrixWorld) - // Drag plane MUST contain the handle's axis. The resize value is read off - // the hit point's component along that axis, so a plane that merely faces - // the camera (the old `setY(0)` normal) collapses when the axis points - // toward the viewer: the axis lies near the plane normal, screen motion - // barely changes the axis component, and the resize crawls / stops - // following the cursor. Build the world-space axis from the frozen ride - // frame, then take the view direction with its along-axis part removed — - // that plane contains the axis yet faces the camera as squarely as - // possible for a stable intersection. (For axis='y' this reduces to the - // old vertical plane, since the view's vertical component is dropped.) - const axisIndex = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2 - const worldAxis = new Vector3().setFromMatrixColumn(rideObject.matrixWorld, axisIndex).normalize() - const viewDir = new Vector3().subVectors(worldOrigin, camera.position) - const planeNormal = viewDir.addScaledVector(worldAxis, -viewDir.dot(worldAxis)) - if (planeNormal.lengthSq() < 1e-10) return - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) - - const ndc = new Vector2() - const setNDC = (clientX: number, clientY: number) => { - const rect = gl.domElement.getBoundingClientRect() - ndc.set( - ((clientX - rect.left) / rect.width) * 2 - 1, - -((clientY - rect.top) / rect.height) * 2 + 1, - ) - } - - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - const hitWorld = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hitWorld)) return - const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse) - - // Capture node + initial value at drag start so `apply` can reference - // pre-drag state (e.g. door right-width anchors the LEFT edge at - // `initial.position[0] - initial.width/2` — using the live node would - // drift as width updates each frame). - const nodeId = node.id as AnyNodeId - const sceneApi = createSceneApi(useScene) - const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode - // Cross-node handles (a downspout sliding its gutter's outlet) redirect - // the live override + commit to another node; defaults to the selected - // node. `currentValue` / `apply` still see the selected node. - const overrideId = - (descriptor.kind === 'linear-resize' - ? descriptor.overrideTarget?.(initialNode as never, sceneApi) - : undefined) ?? nodeId - const initialValue = descriptor.currentValue(initialNode) - const initialPointer = - descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z - - const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) - const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) - - // Anchor factor maps pointer delta to value delta: - // center: ×2 (both edges move ±delta, total span grows by 2·delta) - // min: ×1 ( +axis edge moves with pointer; -axis edge anchored) - // max: ×−1 (-axis edge moves with pointer; +axis edge anchored) - // radial: ×1 (the visible edge follows the pointer 1:1) - const factor = - descriptor.kind === 'radial-resize' - ? 1 - : descriptor.anchor === 'center' - ? 2 - : descriptor.anchor === 'min' - ? 1 - : -1 - - document.body.style.cursor = cursor - sfxEmitter.emit('sfx:item-pick') - useViewer.getState().setInputDragging(true) - useScene.temporal.getState().pause() - setIsDragging(true) - // Claim active-drag status — `NodeArrowHandlesForNode` will pass the - // snapshot to every OTHER arrow so they render at their pre-drag - // world positions while this drag runs. The snapshot must be the - // pre-override store node (not the merged `liveNode`) so subsequent - // re-renders don't pollute it with this drag's own patch. - dragControls.onStart(handleIndex, initialNode) - // Publish the dimension being steered so the floating pill can show it. - if (measureLabel) { - useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel }) - } - - let lastPatch: Partial | null = null - - // Drag publishes the patch (e.g. door `{ width, position }`, wall - // `{ height }`) to `useLiveNodeOverrides` + markDirty. The node's - // system reads via `getEffectiveNode` and rebuilds the mesh - // imperatively, so zustand stays at the pre-drag values until - // commit — no per-frame React tree re-renders, no history churn. - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) - const intersection = new Vector3() - if (!raycaster.ray.intersectPlane(plane, intersection)) return - // Use the frozen drag-start frame, not the live one. See the - // `initialFrameInverse` comment above. - const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse) - const currentPointer = - descriptor.axis === 'x' - ? intersectionLocal.x - : descriptor.axis === 'y' - ? intersectionLocal.y - : intersectionLocal.z - const delta = currentPointer - initialPointer - const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor)) - // apply sees the node-at-drag-start so it can compute anchors from - // pre-drag geometry (door-width re-centers on the opposite edge). - const patch = descriptor.apply(initialNode as never, next, sceneApi) - lastPatch = patch as Partial - useLiveNodeOverrides.getState().set(overrideId, patch as Record) - useScene.getState().markDirty(overrideId) - } - - const cleanup = () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onCancel) - if (document.body.style.cursor === cursor) { - document.body.style.cursor = '' + const activate = useHandleDrag({ + kind: 'drag', + cursor, + dragControls, + handleIndex, + node, + rideObject, + setIsDragging, + onStart: ({ + camera: dragCamera, + event, + initialNode, + intersectPlane, + nodeId, + rideObject: dragRideObject, + sceneApi, + }) => { + const initialFrameInverse = new Matrix4().copy(dragRideObject.matrixWorld).invert() + const worldOrigin = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld) + const planeNormal = new Vector3().subVectors(dragCamera.position, worldOrigin).setY(0) + if (planeNormal.lengthSq() === 0) return null + planeNormal.normalize() + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) + + const hitWorld = new Vector3() + if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) { + return null } - useScene.temporal.getState().resume() - useViewer.getState().setInputDragging(false) - setIsDragging(false) - if (measureLabel) { - useEditor.getState().setActiveHandleDrag(null) + const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse) + + const overrideId = + (descriptor.kind === 'linear-resize' + ? descriptor.overrideTarget?.(initialNode as never, sceneApi) + : undefined) ?? nodeId + const initialValue = descriptor.currentValue(initialNode) + const initialPointer = + descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z + const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) + const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) + const factor = + descriptor.kind === 'radial-resize' + ? 1 + : descriptor.anchor === 'center' + ? 2 + : descriptor.anchor === 'min' + ? 1 + : -1 + + return { + overrideId, + onBegin: () => { + if (measureLabel) { + useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel }) + } + }, + onEnd: () => { + if (measureLabel) { + useEditor.getState().setActiveHandleDrag(null) + } + }, + move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { + const intersection = new Vector3() + if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, intersection)) { + return null + } + const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse) + const currentPointer = + descriptor.axis === 'x' + ? intersectionLocal.x + : descriptor.axis === 'y' + ? intersectionLocal.y + : intersectionLocal.z + const delta = currentPointer - initialPointer + const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor)) + return descriptor.apply(initialNode as never, next, sceneApi) as Partial + }, } - // Release the active-drag claim so non-active arrows return to - // live-tracking (and so the next drag can claim its own snapshot). - dragControls.onEnd() - dragCleanupRef.current = null - } - const onUp = () => { - swallowNextClick() - sfxEmitter.emit('sfx:item-place') - // Commit: one tracked write to the scene store, then drop the - // override so subscribers read from scene again. - if (lastPatch) { - sceneApi.update(overrideId, lastPatch) - } - useLiveNodeOverrides.getState().clear(overrideId) - useScene.getState().markDirty(overrideId) - cleanup() - } - const onCancel = () => { - // Revert: drop the override + mark dirty so the system rebuilds - // against the original scene values. - useLiveNodeOverrides.getState().clear(overrideId) - useScene.getState().markDirty(overrideId) - cleanup() - } - dragCleanupRef.current = cleanup - - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) - } + }, + }) // For axis === 'y' (vertical handles), tilt the chevron up via local // X+Z rotation chain matching DoorHeightArrowHandle. When the handle @@ -1000,23 +687,13 @@ function LinearArrow({ ) : null} { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = cursor - }} - onLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === cursor) { - document.body.style.cursor = '' - } - }} - zoom={zoom} + onHoverChange={setIsHovered} + onPointerDown={activate} /> ) @@ -1030,30 +707,17 @@ function LinearArrow({ y={decoration.y?.(node as never) ?? 0} /> ) : null} - + {showLabel ? : null} - - { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = cursor - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === cursor) { - document.body.style.cursor = '' - } - }} - renderOrder={1010} - /> - - + ) } @@ -1098,7 +762,6 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) { const ROTATION_GUIDE_COLOR = ARROW_COLOR const ROTATION_GUIDE_SEGMENTS = 48 -const NO_RAYCAST = () => null // Live rotation readout shown while a whole-node rotate gizmo is dragged. // Mirrors the wall-draft angle arc: a filled wedge + outline swept from the @@ -1288,7 +951,7 @@ function ArcArrow({ /** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */ freezeOffset: [number, number, number] | null handleIndex: number - dragControls: DragControls + dragControls: HandleDragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) @@ -1302,29 +965,16 @@ function ArcArrow({ // tilt into that plane, and the horizontal-only wedge/ring readout is // suppressed. const isNodeNormalRot = descriptor.rotationPlane === 'node-normal' - const arrowGeometry = useMemo( - () => (isRotateShape ? createRotateArrowHandleGeometry() : createArrowHandleGeometry()), - [isRotateShape], - ) - const arrowMaterial = useArrowMaterial() - const { camera, raycaster, gl } = useThree() + const { camera } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 // The rotate icon is denser than the chevron; pump scale a touch so the // ribbon reads at the same on-screen size as the other handles. const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE - const scale = (isHovered ? 1.12 : 1) * zoom * arrowScale - const dragCleanupRef = useRef<(() => void) | null>(null) + const baseScale = zoom * arrowScale // Live rotation amount (radians swept since grab) — non-null only while a // `shape: 'rotate'` gizmo is mid-drag. Drives the in-frame wedge readout. const [rotationDelta, setRotationDelta] = useState(null) - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - useEffect(() => () => dragCleanupRef.current?.(), []) - const placementSceneApi = useMemo(() => createSceneApi(useScene), []) const basePosition = descriptor.placement.position(node, placementSceneApi) // See the LinearArrow note on freezeOffset — for rotation drags the @@ -1351,172 +1001,79 @@ function ArcArrow({ const decoration = descriptor.decoration const showDecoration = Boolean(decoration) && (isHovered || isDragging) - const activate = (event: ThreeEvent) => { - event.stopPropagation() - - // Horizontal drag plane at the arrow's world Y. Atan2 around the - // rotation pivot gives the cursor's bearing — delta between samples - // is the angular drag. - // - // Default pivot is the rideObject's world origin (= node-local - // origin) which is correct when the mesh origin coincides with the - // shape being rotated (roof-segment, elevator). Nodes that bake - // pose into their geometry (chimney) can override via - // `descriptor.rotationCenter`, which we apply through the - // rideObject's matrixWorld so the descriptor stays in node-local - // coordinates. - rideObject.updateMatrixWorld() - const centerWorld = - descriptor.rotationCenter !== undefined - ? new Vector3( - ...descriptor.rotationCenter(node as never, createSceneApi(useScene)), - ).applyMatrix4(rideObject.matrixWorld) - : new Vector3().setFromMatrixPosition(rideObject.matrixWorld) - const arrowWorld = new Vector3(...position).applyMatrix4(rideObject.matrixWorld) - const planeY = arrowWorld.y - - // Rotation axis + drag plane. 'horizontal' spins about world-Y on a flat - // plane; 'node-normal' spins about the node's local +Z (the wall normal) - // on the plane perpendicular to it. The 2D basis (u, v) lets us measure a - // consistent bearing in either plane: for horizontal it collapses to the - // original atan2(z, x). - const axis = isNodeNormalRot - ? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize() - : new Vector3(0, 1, 0) - const plane = isNodeNormalRot - ? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld) - : new Plane(new Vector3(0, 1, 0), -planeY) - let basisU: Vector3 - if (isNodeNormalRot) { - // In-plane reference: world-up projected onto the plane (falls back to - // world-X if the axis is near-vertical, e.g. a ceiling item). - const up = new Vector3(0, 1, 0) - basisU = up.clone().addScaledVector(axis, -up.dot(axis)) - if (basisU.lengthSq() < 1e-6) { - const x = new Vector3(1, 0, 0) - basisU = x.addScaledVector(axis, -x.dot(axis)) - } - basisU.normalize() - } else { - basisU = new Vector3(1, 0, 0) - } - const basisV = isNodeNormalRot - ? new Vector3().crossVectors(axis, basisU).normalize() - : new Vector3(0, 0, 1) - const angleOf = (p: Vector3) => { - const d = new Vector3().subVectors(p, centerWorld) - return Math.atan2(d.dot(basisV), d.dot(basisU)) - } - - const ndc = new Vector2() - const setNDC = (clientX: number, clientY: number) => { - const rect = gl.domElement.getBoundingClientRect() - ndc.set( - ((clientX - rect.left) / rect.width) * 2 - 1, - -((clientY - rect.top) / rect.height) * 2 + 1, - ) - } - - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - const hitWorld = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hitWorld)) return - - const initialAngle = angleOf(hitWorld) - const nodeId = node.id as AnyNodeId - const sceneApi = createSceneApi(useScene) - const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode - - document.body.style.cursor = dragCursor - sfxEmitter.emit('sfx:item-pick') - useViewer.getState().setInputDragging(true) - useScene.temporal.getState().pause() - setIsDragging(true) - // Claim active-drag status — see LinearArrow's onStart note. - dragControls.onStart(handleIndex, initialNode) - - let lastPatch: Partial | null = null - - // Mirrors LinearArrow: drag publishes the patch (sweepAngle + rotation - // for curved-stair sweep handles) to `useLiveNodeOverrides` and marks - // the node dirty. The StairRenderer subscribes to that store and re- - // renders the curved/spiral mesh with the effective node, so zustand - // stays at the pre-drag values until commit on release. - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) - const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return - const currentAngle = angleOf(hit) - // Normalise so a drag that crosses ±π doesn't flip sign mid-gesture. - let delta = currentAngle - initialAngle - while (delta > Math.PI) delta -= 2 * Math.PI - while (delta < -Math.PI) delta += 2 * Math.PI - - // Shift snaps whole-node rotation gizmos (stair, elevator, column…) to - // 15° increments. Scoped to `shape: 'rotate'` so curved-stair sweep - // handles keep their continuous feel. - if (e.shiftKey && descriptor.shape === 'rotate') { - const step = Math.PI / 12 - delta = Math.round(delta / step) * step + const activate = useHandleDrag({ + kind: 'drag', + cursor: dragCursor, + dragControls, + handleIndex, + node, + rideObject, + setIsDragging, + onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => { + const centerWorld = + descriptor.rotationCenter !== undefined + ? new Vector3(...descriptor.rotationCenter(node as never, sceneApi)).applyMatrix4( + dragRideObject.matrixWorld, + ) + : new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld) + const arrowWorld = new Vector3(...position).applyMatrix4(dragRideObject.matrixWorld) + const planeY = arrowWorld.y + const axis = isNodeNormalRot + ? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize() + : new Vector3(0, 1, 0) + const plane = isNodeNormalRot + ? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld) + : new Plane(new Vector3(0, 1, 0), -planeY) + + let basisU: Vector3 + if (isNodeNormalRot) { + const up = new Vector3(0, 1, 0) + basisU = up.clone().addScaledVector(axis, -up.dot(axis)) + if (basisU.lengthSq() < 1e-6) { + const x = new Vector3(1, 0, 0) + basisU = x.addScaledVector(axis, -x.dot(axis)) + } + basisU.normalize() + } else { + basisU = new Vector3(1, 0, 0) } - - const patch = descriptor.apply(initialNode as never, delta, sceneApi) - lastPatch = patch as Partial - useLiveNodeOverrides.getState().set(nodeId, patch as Record) - useScene.getState().markDirty(nodeId) - - // Whole-node rotate gizmos report how far the node has turned since - // grab. We hand the live `delta` (the snapped amount, so it tracks the - // 15° steps under Shift) to a wedge that renders as a CHILD of the node - // frame — concentric and coplanar with the guide ring. Suppressed below - // ~0.5° so a fresh grab doesn't flash a zero-width sliver. Horizontal-axis - // rotation only (the wall-normal spin has no in-plane ring readout). - if (isRotateShape && !isNodeNormalRot) { - setRotationDelta(Math.abs(delta) < 0.0087 ? null : delta) + const basisV = isNodeNormalRot + ? new Vector3().crossVectors(axis, basisU).normalize() + : new Vector3(0, 0, 1) + const angleOf = (p: Vector3) => { + const d = new Vector3().subVectors(p, centerWorld) + return Math.atan2(d.dot(basisV), d.dot(basisU)) } - } - const cleanup = () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onCancel) - if (document.body.style.cursor === dragCursor) { - document.body.style.cursor = '' + const hitWorld = new Vector3() + if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) { + return null } - useScene.temporal.getState().resume() - useViewer.getState().setInputDragging(false) - setIsDragging(false) - setRotationDelta(null) - // Release the active-drag claim — see LinearArrow's onEnd note. - dragControls.onEnd() - dragCleanupRef.current = null - } - const onUp = () => { - swallowNextClick() - sfxEmitter.emit('sfx:item-place') - // Commit the final patch to zustand, then drop the override so the - // store is the single source of truth again. - if (lastPatch) { - sceneApi.update(nodeId, lastPatch) + const initialAngle = angleOf(hitWorld) + + return { + onEnd: () => setRotationDelta(null), + move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { + const hit = new Vector3() + if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null + const currentAngle = angleOf(hit) + let delta = currentAngle - initialAngle + while (delta > Math.PI) delta -= 2 * Math.PI + while (delta < -Math.PI) delta += 2 * Math.PI + + if (moveEvent.shiftKey && descriptor.shape === 'rotate') { + const step = Math.PI / 12 + delta = Math.round(delta / step) * step + } + + if (isRotateShape && !isNodeNormalRot) { + setRotationDelta(Math.abs(delta) < 0.0087 ? null : delta) + } + return descriptor.apply(initialNode as never, delta, sceneApi) as Partial + }, } - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) - cleanup() - } - const onCancel = () => { - // Revert: drop the override + mark dirty so the renderer rebuilds - // against the original scene values. - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) - cleanup() - } - dragCleanupRef.current = cleanup - - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) - } + }, + }) // Suppress "declared but unused" for `liveNode` — ArcArrow's apply // operates entirely on `initialNode` (snapshot taken inside activate) @@ -1545,37 +1102,21 @@ function ArcArrow({ y={decoration?.y?.(node as never) ?? 0} /> ) : null} - - { - event.stopPropagation() - setIsHovered(true) - // Only show the hover cursor if no drag is already in flight — - // otherwise we'd stomp `grabbing` back to `grab` mid-gesture. - if (document.body.style.cursor !== dragCursor) { - document.body.style.cursor = hoverCursor - } - }} - onPointerLeave={(event) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === hoverCursor) { - document.body.style.cursor = '' - } - }} - renderOrder={1010} - /> - + ) } @@ -1584,9 +1125,10 @@ function ArcArrow({ // the horizontal plane at the node's base, convert the hit into the node's // parent-local frame, add the delta to the node's drag-start position, grid- // snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides` -// each move — committing one write to the store on release. Same live-preview -// contract as LinearArrow / ArcArrow; the renderer's mesh follows the override -// so the item slides under the cursor in real time. +// each move — committing one write to the store on release. The override stays +// at base Y; `` reads that effective node and owns the +// presentation-only slab lift so the handle path shares the menu-move stacking +// contract without storing lifted positions. function TranslateArrow({ descriptor, node, @@ -1597,24 +1139,14 @@ function TranslateArrow({ descriptor: TranslateHandle node: AnyNode handleIndex: number - dragControls: DragControls + dragControls: HandleDragControls rideObject: Object3D }) { const [isHovered, setIsHovered] = useState(false) const [isDragging, setIsDragging] = useState(false) - const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), []) - const arrowMaterial = useArrowMaterial() - const { camera, raycaster, gl } = useThree() + const { camera } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE - const dragCleanupRef = useRef<(() => void) | null>(null) - - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - useEffect(() => () => dragCleanupRef.current?.(), []) + const baseScale = zoom * ARROW_SCALE const placementSceneApi = useMemo(() => createSceneApi(useScene), []) const position = descriptor.placement.position(node, placementSceneApi) @@ -1623,134 +1155,62 @@ function TranslateArrow({ // local +Z). Its cross icon stands up into that plane (tilt about X). const isWallPlane = descriptor.plane === 'node-normal' - const activate = (event: ThreeEvent) => { - event.stopPropagation() - - // Drag plane through the node origin. 'horizontal' uses the world-up - // normal (slide on the floor); 'node-normal' uses the node's facing - // direction (its local +Z in world) so the item slides on the wall face. - // Hits map into the parent frame so the delta composes with `position` - // (which lives in parent-local space). - rideObject.updateMatrixWorld() - const worldOrigin = new Vector3().setFromMatrixPosition(rideObject.matrixWorld) - const planeNormal = isWallPlane - ? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize() - : new Vector3(0, 1, 0) - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) - const parent = rideObject.parent - const parentInverse = new Matrix4() - if (parent) { - parent.updateMatrixWorld() - parentInverse.copy(parent.matrixWorld).invert() - } - - const ndc = new Vector2() - const setNDC = (clientX: number, clientY: number) => { - const rect = gl.domElement.getBoundingClientRect() - ndc.set( - ((clientX - rect.left) / rect.width) * 2 - 1, - -((clientY - rect.top) / rect.height) * 2 + 1, - ) - } - - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - const hitWorld = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hitWorld)) return - const startLocal = hitWorld.clone().applyMatrix4(parentInverse) - - const nodeId = node.id as AnyNodeId - const sceneApi = createSceneApi(useScene) - const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode - const initialPos = (initialNode as { position?: readonly [number, number, number] }) - .position ?? [0, 0, 0] - - document.body.style.cursor = cursor - sfxEmitter.emit('sfx:item-pick') - useViewer.getState().setInputDragging(true) - useScene.temporal.getState().pause() - setIsDragging(true) - dragControls.onStart(handleIndex, initialNode) - - let lastPatch: Partial | null = null - - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) - const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return - const curLocal = hit.applyMatrix4(parentInverse) - // Add the in-plane delta to the drag-start position; the off-plane axis - // (Y on the floor, Z/depth on a wall) keeps its value. Snap / clamp is - // the descriptor's job in `apply`. - const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]] - newPos[0] += curLocal.x - startLocal.x - if (isWallPlane) { - newPos[1] += curLocal.y - startLocal.y - } else { - newPos[2] += curLocal.z - startLocal.z + const activate = useHandleDrag({ + kind: 'drag', + cursor, + dragControls, + handleIndex, + node, + rideObject, + setIsDragging, + onStart: ({ event, initialNode, intersectPlane, rideObject: dragRideObject, sceneApi }) => { + const worldOrigin = new Vector3().setFromMatrixPosition(dragRideObject.matrixWorld) + const planeNormal = isWallPlane + ? new Vector3().setFromMatrixColumn(dragRideObject.matrixWorld, 2).normalize() + : new Vector3(0, 1, 0) + const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin) + const parent = dragRideObject.parent + const parentInverse = new Matrix4() + if (parent) { + parent.updateMatrixWorld() + parentInverse.copy(parent.matrixWorld).invert() } - // Grid-snap the two in-plane axes (X + the plane's other free axis). - const extents = descriptor.snapExtents?.(initialNode as never) - if (extents) { - newPos[0] = snapToGrid(newPos[0], extents[0]) - if (isWallPlane) { - newPos[1] = snapToGrid(newPos[1], extents[1]) - } else { - newPos[2] = snapToGrid(newPos[2], extents[1]) - } - } - const patch = descriptor.apply(initialNode as never, newPos, sceneApi) - lastPatch = patch as Partial - useLiveNodeOverrides.getState().set(nodeId, patch as Record) - useScene.getState().markDirty(nodeId) - } - const cleanup = () => { - window.removeEventListener('pointermove', onMove) - window.removeEventListener('pointerup', onUp) - window.removeEventListener('pointercancel', onCancel) - if (document.body.style.cursor === cursor) { - document.body.style.cursor = '' + const hitWorld = new Vector3() + if (!intersectPlane(event.nativeEvent.clientX, event.nativeEvent.clientY, plane, hitWorld)) { + return null } - useScene.temporal.getState().resume() - useViewer.getState().setInputDragging(false) - setIsDragging(false) - dragControls.onEnd() - dragCleanupRef.current = null - } - const onUp = () => { - swallowNextClick() - sfxEmitter.emit('sfx:item-place') - if (lastPatch) { - sceneApi.update(nodeId, lastPatch) + const startLocal = hitWorld.clone().applyMatrix4(parentInverse) + const initialPos = (initialNode as { position?: readonly [number, number, number] }) + .position ?? [0, 0, 0] + + return { + move: ({ event: moveEvent, intersectPlane: intersectMovePlane }) => { + const hit = new Vector3() + if (!intersectMovePlane(moveEvent.clientX, moveEvent.clientY, plane, hit)) return null + const curLocal = hit.applyMatrix4(parentInverse) + const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]] + newPos[0] += curLocal.x - startLocal.x + if (isWallPlane) { + newPos[1] += curLocal.y - startLocal.y + } else { + newPos[2] += curLocal.z - startLocal.z + } + + const extents = descriptor.snapExtents?.(initialNode as never, sceneApi) + if (extents) { + newPos[0] = snapToGrid(newPos[0], extents[0]) + if (isWallPlane) { + newPos[1] = snapToGrid(newPos[1], extents[1]) + } else { + newPos[2] = snapToGrid(newPos[2], extents[1]) + } + } + return descriptor.apply(initialNode as never, newPos, sceneApi) as Partial + }, } - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) - cleanup() - } - const onCancel = () => { - useLiveNodeOverrides.getState().clear(nodeId) - useScene.getState().markDirty(nodeId) - cleanup() - } - dragCleanupRef.current = cleanup - - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) - } - - const onEnter = (event: ThreeEvent) => { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = cursor - } - const onLeave = (event: ThreeEvent) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === cursor) document.body.style.cursor = '' - } + }, + }) // Suppress the unused `isDragging` lint — it only drives the React re-render // that keeps hover/drag cursor state in sync. @@ -1761,17 +1221,14 @@ function TranslateArrow({ const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0] return ( - - - + ) } @@ -1794,162 +1251,49 @@ function TapActionArrow({ const position = descriptor.placement.position(node, placementSceneApi) const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 const shape = descriptor.shape ?? 'arrow' - const cursor: Cursor = - descriptor.cursor ?? (shape === 'corner-picker' || shape === 'move-cross' ? 'move' : 'ew-resize') - - const onActivate = (event: ThreeEvent) => { - event.stopPropagation() - sfxEmitter.emit('sfx:item-pick') - document.body.style.cursor = '' - setIsHovered(false) - descriptor.onActivate(node as never, createSceneApi(useScene), createEditorApi()) - } - - const onEnter = (event: ThreeEvent) => { - event.stopPropagation() - setIsHovered(true) - document.body.style.cursor = cursor - } - const onLeave = (event: ThreeEvent) => { - event.stopPropagation() - setIsHovered(false) - if (document.body.style.cursor === cursor) document.body.style.cursor = '' - } + const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize') + + const onActivate = useHandleDrag({ + kind: 'tap', + onTap: () => { + setIsHovered(false) + descriptor.onActivate(node as never, createSceneApi(useScene), createEditorApi()) + }, + }) if (shape === 'corner-picker') { const height = descriptor.nodeHeight?.(node) ?? 1 return ( - ) - } - - if (shape === 'move-cross') { - return ( - ) } // Default 'arrow' shape — the standard chevron. + const baseScale = zoom * ARROW_SCALE return ( - ) } -// 4-way move cross for tap-action handles — same visual as the translate -// gizmo's cross, but a click target that hands the node to its move tool. The -// cross is built flat in XZ; `tilt` stands it up into a wall face (XY plane). -function MoveCrossShape({ - position, - zoom, - isHovered, - tilt, - onActivate, - onEnter, - onLeave, -}: { - position: readonly [number, number, number] - zoom: number - isHovered: boolean - tilt: boolean - onActivate: (event: ThreeEvent) => void - onEnter: (event: ThreeEvent) => void - onLeave: (event: ThreeEvent) => void -}) { - const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), []) - const arrowMaterial = useArrowMaterial() - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE - const iconRotation: [number, number, number] = tilt ? [Math.PI / 2, 0, 0] : [0, 0, 0] - return ( - - - - ) -} - -function ArrowShape({ - position, - rotationY, - zoom, - isHovered, - onActivate, - onEnter, - onLeave, -}: { - position: readonly [number, number, number] - rotationY: number - zoom: number - isHovered: boolean - onActivate: (event: ThreeEvent) => void - onEnter: (event: ThreeEvent) => void - onLeave: (event: ThreeEvent) => void -}) { - const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) - const arrowMaterial = useArrowMaterial() - useEffect(() => { - arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) - }, [arrowMaterial, isHovered]) - useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) - useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) - - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE - return ( - - - - ) -} - // Wall corner-picker visual: dashed vertical leader from floor up to // `height` + billboarded hex disc (the click target) + outer ring. The // hex disc is the only mesh with a pointer-down handler; the dashes and // ring are decorative. -const CORNER_HEX_RADIUS = 0.16 const CORNER_DASH_SIZE = 0.1 const CORNER_GAP_SIZE = 0.07 const CORNER_DASH_THICKNESS = 0.006 @@ -1974,19 +1318,19 @@ function buildDashedVerticalGeometry(height: number) { function CornerPickerShape({ position, height, - zoom, - isHovered, - onActivate, - onEnter, - onLeave, + baseScale, + cursor, + hover, + onHoverChange, + onPointerDown, }: { position: readonly [number, number, number] height: number - zoom: number - isHovered: boolean - onActivate: (event: ThreeEvent) => void - onEnter: (event: ThreeEvent) => void - onLeave: (event: ThreeEvent) => void + baseScale: number + cursor: Cursor + hover: boolean + onHoverChange: (hovered: boolean) => void + onPointerDown: (event: ThreeEvent) => void }) { const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height]) useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) @@ -2002,18 +1346,6 @@ function CornerPickerShape({ }), [], ) - const hexMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: 0.95, - depthTest: false, - depthWrite: false, - }), - [], - ) const ringMaterial = useMemo( () => new MeshBasicNodeMaterial({ @@ -2027,13 +1359,11 @@ function CornerPickerShape({ [], ) useEffect(() => { - const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR + const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR dashMaterial.color.set(next) - hexMaterial.color.set(next) ringMaterial.color.set(next) - }, [dashMaterial, hexMaterial, ringMaterial, isHovered]) + }, [dashMaterial, ringMaterial, hover]) useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) - useEffect(() => () => hexMaterial.dispose(), [hexMaterial]) useEffect(() => () => ringMaterial.dispose(), [ringMaterial]) const billboardRef = useRef(null) @@ -2059,7 +1389,7 @@ function CornerPickerShape({ } }) - const scale = (isHovered ? 1.25 : 1) * zoom + const scale = (hover ? 1.25 : 1) * baseScale return ( <> @@ -2070,21 +1400,17 @@ function CornerPickerShape({ position={position} renderOrder={1001} /> - - - - - + + + @@ -2098,26 +1424,24 @@ function CornerPickerShape({ // sits at the TOP of the leader rather than the floor — the visual reads as // "this cube is the wall top; drag it to raise/lower." All interactivity // (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`. -const TRACKER_CUBE_SIZE = 0.16 - function TrackerShape({ basePosition, + baseScale, cubePosition, + cursor, + hover, leaderHeight, - zoom, - isHovered, - onActivate, - onEnter, - onLeave, + onHoverChange, + onPointerDown, }: { basePosition: readonly [number, number, number] + baseScale: number cubePosition: readonly [number, number, number] + cursor: Cursor + hover: boolean leaderHeight: number - zoom: number - isHovered: boolean - onActivate: (event: ThreeEvent) => void - onEnter: (event: ThreeEvent) => void - onLeave: (event: ThreeEvent) => void + onHoverChange: (hovered: boolean) => void + onPointerDown: (event: ThreeEvent) => void }) { // `leaderHeight === 0` (wallHeight collapsed to floor) would make the // dashed builder return an empty geometry — skip the mesh entirely in @@ -2129,12 +1453,6 @@ function TrackerShape({ ) useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry]) - const cubeGeometry = useMemo( - () => new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE), - [], - ) - useEffect(() => () => cubeGeometry.dispose(), [cubeGeometry]) - const dashMaterial = useMemo( () => new MeshBasicNodeMaterial({ @@ -2146,31 +1464,11 @@ function TrackerShape({ }), [], ) - const cubeMaterial = useMemo( - () => - new MeshBasicNodeMaterial({ - color: new Color(ARROW_COLOR), - side: DoubleSide, - transparent: true, - opacity: 1, - // depthTest off keeps the cube visible through any geometry sitting - // between camera and wall top; depthWrite on so the ink-edge pass - // catches the cube silhouette from every angle (same reasoning as - // the chevron — without it the lines fade in/out by view angle). - depthTest: false, - depthWrite: true, - }), - [], - ) useEffect(() => { - const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR + const next = hover ? ARROW_HOVER_COLOR : ARROW_COLOR dashMaterial.color.set(next) - cubeMaterial.color.set(next) - }, [dashMaterial, cubeMaterial, isHovered]) + }, [dashMaterial, hover]) useEffect(() => () => dashMaterial.dispose(), [dashMaterial]) - useEffect(() => () => cubeMaterial.dispose(), [cubeMaterial]) - - const cubeScale = (isHovered ? 1.25 : 1) * zoom return ( <> @@ -2183,16 +1481,14 @@ function TrackerShape({ renderOrder={1001} /> ) : null} - ) diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 0cff52286..40d0beace 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -34,6 +34,13 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { + createArrowHitAreaGeometry, + createEndpointHitAreaGeometry, + InvisibleHandleHitArea, + NO_RAYCAST, + useInvisibleHitAreaMaterial, +} from './node-arrow-handles' const HANDLE_OFFSET = 0.27 const HANDLE_MIN_OFFSET = 0.33 @@ -227,7 +234,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const billboardRef = useRef(null) const parentWorldQuaternionRef = useRef(new Quaternion()) const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.25 : 1) * zoom + const baseScale = zoom + const visualScale = isHovered ? 1.25 : 1 const corner = endpoint === 'start' ? wall.start : wall.end const x = corner[0] @@ -235,7 +243,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) + const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) + const hitMaterial = useInvisibleHitAreaMaterial() useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) // Node materials matched to the rest of the file — mixing plain // `meshBasicMaterial` with WebGPU node materials trips @@ -332,9 +343,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: position={[x, 0, z]} renderOrder={1001} /> - - + { event.stopPropagation() @@ -348,13 +360,16 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: document.body.style.cursor = '' } }} - renderOrder={1003} - > - - - - - + scale={1} + /> + + + + + + + + ) @@ -363,6 +378,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const [isHovered, setIsHovered] = useState(false) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() const arrowMaterial = useMemo( () => new MeshBasicNodeMaterial({ @@ -377,7 +394,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { ) const { camera, raycaster, gl } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale const dragCleanupRef = useRef<(() => void) | null>(null) useEffect(() => { @@ -394,6 +412,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { }, []) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) // Sit on the visual centre of the wall — for curved walls that's the @@ -509,12 +528,10 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { return ( - - + { event.stopPropagation() @@ -528,7 +545,16 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { document.body.style.cursor = '' } }} + scale={baseScale} + /> + @@ -538,6 +564,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { const [isHovered, setIsHovered] = useState(false) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() const arrowMaterial = useMemo( () => new MeshBasicNodeMaterial({ @@ -554,7 +582,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale useEffect(() => { arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) @@ -569,6 +598,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov }, []) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) const activateWallMove = (event: ThreeEvent) => { @@ -586,16 +616,10 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov } return ( - - `) - // so the mesh is never rendered with R3F's default empty - // `BufferGeometry`. Combined with `frustumCulled={false}`, the - // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first - // frame and WebGPU flags "Vertex buffer slot 0 ... was not set". - frustumCulled={false} - geometry={arrowGeometry} - material={arrowMaterial} + + { event.stopPropagation() @@ -609,7 +633,20 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov document.body.style.cursor = '' } }} + scale={baseScale} + /> + `) + // so the mesh is never rendered with R3F's default empty + // `BufferGeometry`. Combined with `frustumCulled={false}`, the + // primitive-attach path emits a `Draw(0, 1, 0, 0)` on the first + // frame and WebGPU flags "Vertex buffer slot 0 ... was not set". + frustumCulled={false} + geometry={arrowGeometry} + material={arrowMaterial} + raycast={NO_RAYCAST} renderOrder={1002} + scale={scale} /> ) @@ -618,6 +655,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { const [isHovered, setIsHovered] = useState(false) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) + const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), []) + const hitMaterial = useInvisibleHitAreaMaterial() const arrowMaterial = useMemo( () => new MeshBasicNodeMaterial({ @@ -633,7 +672,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal const { camera } = useThree() const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 - const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE + const baseScale = zoom * ARROW_SCALE + const scale = (isHovered ? 1.12 : 1) * baseScale useEffect(() => { arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) @@ -648,6 +688,7 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal }, []) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) const activateFenceMove = (event: ThreeEvent) => { @@ -664,13 +705,10 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal } return ( - - + { event.stopPropagation() @@ -684,7 +722,17 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal document.body.style.cursor = '' } }} + scale={baseScale} + /> + ) diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 724821df7..191e49075 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -103,12 +103,13 @@ export const floorStrategy = { // building-local ToolManager group, so local coords are correct for both data and visuals. const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) + const y = ctx.gridPosition.y return { - gridPosition: [x, 0, z], - cursorPosition: [x, event.localPosition[1], z], + gridPosition: [x, y, z], + cursorPosition: [x, y, z], cursorRotationY: rotY, - nodeUpdate: { position: [x, 0, z] }, + nodeUpdate: { position: [x, y, z] }, stopPropagation: false, dirtyNodeId: null, } @@ -126,7 +127,11 @@ export const floorStrategy = { if (ctx.state.surface !== 'floor') return null if (!(ctx.levelId && ctx.draftItem)) return null - const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z] + const pos: [number, number, number] = [ + ctx.gridPosition.x, + ctx.gridPosition.y, + ctx.gridPosition.z, + ] const valid = validators.canPlaceOnFloor( ctx.levelId, pos, @@ -793,7 +798,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato // Floor (no attachTo) return validators.canPlaceOnFloor( ctx.levelId, - [ctx.gridPosition.x, 0, ctx.gridPosition.z], + [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], alignedDims, ctx.draftItem.rotation, [ctx.draftItem.id], 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 ae00f981e..f854338d1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -1,4 +1,4 @@ -import type { AssetInput } from '@pascal-app/core' +import type { AssetInput, ItemNode } from '@pascal-app/core' import { type AlignmentAnchor, type AnyNode, @@ -14,7 +14,6 @@ import { resolveLevelId, type ShelfEvent, sceneRegistry, - spatialGridManager, useAlignmentGuides, useLiveTransforms, useScene, @@ -44,6 +43,7 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { createLineGeometry, getBoxEdgePoints, @@ -141,6 +141,22 @@ function getFallbackPreviewBounds( } } +function getGridAlignedPreviewNode(item: ItemNode): ItemNode { + const scaled = getScaledDimensions(item) + const aligned = getGridAlignedDimensions(scaled, item.asset.attachTo) + if (scaled[0] === aligned[0] && scaled[1] === aligned[1] && scaled[2] === aligned[2]) { + return item + } + + const scaleAxis = (axis: 0 | 1 | 2) => + scaled[axis] === 0 ? item.scale[axis] : item.scale[axis] * (aligned[axis] / scaled[axis]) + + return { + ...item, + scale: [scaleAxis(0), scaleAxis(1), scaleAxis(2)] as [number, number, number], + } +} + // Shared materials for placement cursor - we just change colors, not swap materials // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines const edgeMaterial = new LineBasicNodeMaterial({ @@ -359,6 +375,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea applyPoints(measurementHeightRef, heightPoints) }, []) + const getFloorVisualPosition = useCallback( + ( + position: [number, number, number], + nodeUpdate?: Partial, + ): [number, number, number] => { + const draft = draftNode.current + if (!(draft && !asset?.attachTo)) return position + const previewNode = getGridAlignedPreviewNode({ ...draft, ...nodeUpdate } as ItemNode) + return getFloorStackPreviewPosition({ + node: previewNode, + position, + rotation: previewNode.rotation, + }) + }, + [asset?.attachTo, draftNode], + ) + useEffect(() => { if (!asset) return useScene.temporal.getState().pause() @@ -661,11 +694,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea result.gridPosition[1], result.gridPosition[2] + alignZ, ] - const cursorPos: [number, number, number] = [ - result.cursorPosition[0] + alignX, - result.cursorPosition[1], - result.cursorPosition[2] + alignZ, - ] // Play snap sound when grid position changes if ( @@ -677,7 +705,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea previousGridPos = [...gridPos] gridPosition.current.set(...gridPos) - cursorGroupRef.current.position.set(cursorPos[0], cursorPos[1], cursorPos[2]) + const cursorPosition = getFloorVisualPosition(gridPos) + cursorGroupRef.current.position.set(cursorPosition[0], cursorPosition[1], cursorPosition[2]) // Floor items only rotate on Y; keep the preview box (and the live // transform the 2D floorplan mirrors) aligned with the draft's // rotation. Without this the box stays at its seed rotation until a @@ -946,8 +975,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea shelfId: null, }) gridPosition.current.set(wx, 0, wz) + const levelId = useViewer.getState().selection.levelId as AnyNodeId | null + const floorVisualPosition = getFloorVisualPosition( + floorPos, + levelId ? { parentId: levelId } : undefined, + ) if (cursorGroupRef.current) { - cursorGroupRef.current.position.set(wx, 0, wz) + cursorGroupRef.current.position.set(...floorVisualPosition) } const draft = draftNode.current @@ -1466,12 +1500,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(x, gridPosition.current.y, z) draft.position = [x, gridPosition.current.y, z] if (cursorGroupRef.current) { - cursorGroupRef.current.position.x = x - cursorGroupRef.current.position.z = z + if (surface === 'floor') { + cursorGroupRef.current.position.set( + ...getFloorVisualPosition([x, gridPosition.current.y, z]), + ) + } else { + cursorGroupRef.current.position.x = x + cursorGroupRef.current.position.z = z + } } if (mesh) { mesh.position.x = x mesh.position.z = z + if (surface === 'floor') { + mesh.position.y = getFloorVisualPosition([x, gridPosition.current.y, z])[1] + } } } else if (surface === 'item-surface' && placementState.current.surfaceItemId) { const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId) @@ -1501,13 +1544,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Update live transform for 2D floorplan with post-snap position const currentLive = useLiveTransforms.getState().get(draft.id) if (currentLive) { - const livePosition: [number, number, number] = cursorGroupRef.current - ? [ - cursorGroupRef.current.position.x, - cursorGroupRef.current.position.y, - cursorGroupRef.current.position.z, - ] - : [draft.position[0], draft.position[1], draft.position[2]] + const livePosition: [number, number, number] = + surface === 'floor' + ? [draft.position[0], draft.position[1], draft.position[2]] + : cursorGroupRef.current + ? [ + cursorGroupRef.current.position.x, + cursorGroupRef.current.position.y, + cursorGroupRef.current.position.z, + ] + : [draft.position[0], draft.position[1], draft.position[2]] useLiveTransforms.getState().set(draft.id, { ...currentLive, position: livePosition, @@ -1647,6 +1693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea canPlaceOnWall, canPlaceOnCeiling, draftNode, + getFloorVisualPosition, gridSnapStep, updateDimensionGuides, updatePreviewGeometry, @@ -1747,18 +1794,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Adjust Y for slab elevation (floor items on top of slabs) if (!asset.attachTo) { - const nodes = useScene.getState().nodes - const levelId = resolveLevelId(draftNode.current, nodes) - const slabElevation = spatialGridManager.getSlabElevationForItem( - levelId, - [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - getGridAlignedDimensions( - getScaledDimensions(draftNode.current), - draftNode.current.asset.attachTo, - ), - draftNode.current.rotation, - ) - mesh.position.y = slabElevation + const visualPosition = getFloorVisualPosition([ + gridPosition.current.x, + gridPosition.current.y, + gridPosition.current.z, + ]) + mesh.position.y = visualPosition[1] + cursorGroupRef.current.position.y = visualPosition[1] } } }) 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 84a3e5a36..827578f08 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 @@ -25,6 +25,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' +import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { PlacementBox } from '../shared/placement-box' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 @@ -174,11 +175,34 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // fresh clone after a drop, or the user picks a different catalog tile — // and `useState` only honours its initial value, so without this the box // would keep the previous clone's rotation/position until the next R/T. - setCursorPosition(originalPosition) setCursorRotationY(originalRotationY) lastCursorRef.current = originalPosition let committed = false + const baseRotation = (node as { rotation?: unknown }).rotation + const toCommitRotation = (y: number): number | [number, number, number] => + Array.isArray(baseRotation) + ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0] + : y + + const getVisualPosition = ( + position: [number, number, number], + rotationY = rotationRef.current, + ): [number, number, number] => { + return getFloorStackPreviewPosition({ + node, + position, + rotation: toCommitRotation(rotationY), + }) + } + const markMovedNodeDirty = () => { + if (useScene.getState().nodes[node.id]) { + useScene.getState().markDirty(node.id as AnyNodeId) + } + } + + setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) + // Re-run the floor-collision check at the live cursor + rotation and push // the result to the box colour. Shift forces a valid (green) override so // the user can drop on top of an existing item on purpose. Only shelves @@ -196,10 +220,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setValid(true) return } - const [x, , z] = lastCursorRef.current + const [x, y, z] = lastCursorRef.current const { valid: placeable } = spatialGridManager.canPlaceOnFloor( levelId, - [x, 0, z], + [x, y, z], boxDimensions, [0, rotationRef.current, 0], [node.id], @@ -209,14 +233,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } recomputeValidity() - // The node's rotation shape (tuple vs scalar) is preserved on commit; - // only the Y angle changes. Most registry kinds use a `[x, y, z]` tuple. - const baseRotation = (node as { rotation?: unknown }).rotation - const toCommitRotation = (y: number): number | [number, number, number] => - Array.isArray(baseRotation) - ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0] - : y - // Disable raycast on the moved node's meshes for the duration of // the drag. As the shelf follows the cursor, the cursor ray would // otherwise hit the moved mesh first → only `${kind}:move` fires → @@ -275,13 +291,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useAlignmentGuides.getState().clear() } + const position: [number, number, number] = [x, originalPosition[1], z] + const visualPosition = getVisualPosition(position) hasMovedRef.current = true - setCursorPosition([x, 0, z]) - lastCursorRef.current = [x, 0, z] + setCursorPosition(visualPosition) + lastCursorRef.current = position recomputeValidity() // Pure imperative: move the mesh via its registered Object3D ref. - sceneRegistry.nodes.get(node.id)?.position.set(x, 0, z) + sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition) // Publish to `useLiveTransforms` so the 2D floor plan can mirror // the drag in real-time (the floor-plan layer subscribes to this // store and overrides the node's rendered position when an entry @@ -293,9 +311,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // (slab / ceiling / fence) follow a different delta contract — // their floor-plan move-targets handle the override themselves. useLiveTransforms.getState().set(node.id, { - position: [x, 0, z], + position, rotation: rotationRef.current, }) + markMovedNodeDirty() const prev = previousSnapRef.current if (!prev || prev[0] !== x || prev[1] !== z) { @@ -331,6 +350,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const position: [number, number, number] = [...lastCursorRef.current] const rotation = toCommitRotation(rotationRef.current) + const visualPosition = getVisualPosition(position) if (useScene.getState().nodes[node.id]) { useScene.temporal.getState().resume() @@ -355,18 +375,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } - // Keep mesh.position/rotation aligned with the just-committed scene - // values so the next R3F frame paints correctly even if React's - // reconciliation lags by a tick. + // Clear after the scene write so React reconciles against the new + // canonical position, then restamp the lifted presentation Y for the + // current frame. + useLiveTransforms.getState().clear(node.id) const mesh = sceneRegistry.nodes.get(node.id) if (mesh) { - mesh.position.set(position[0], position[1], position[2]) + mesh.position.set(...visualPosition) mesh.rotation.y = rotationRef.current } - // Now safe to clear — node.position is already the new value, so - // `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`. - useLiveTransforms.getState().clear(node.id) useAlignmentGuides.getState().clear() sfxEmitter.emit('sfx:item-place') @@ -405,12 +423,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { sfxEmitter.emit('sfx:item-rotate') rotationRef.current += delta setCursorRotationY(rotationRef.current) + const position = lastCursorRef.current + const visualPosition = getVisualPosition(position) + setCursorPosition(visualPosition) const m = sceneRegistry.nodes.get(node.id) - if (m) m.rotation.y = rotationRef.current + if (m) { + m.position.set(...visualPosition) + m.rotation.y = rotationRef.current + } useLiveTransforms.getState().set(node.id, { - position: lastCursorRef.current, + position, rotation: rotationRef.current, }) + markMovedNodeDirty() // Rotation changes the footprint's collision span — re-check validity. recomputeValidity() } @@ -437,13 +462,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } const onCancel = () => { + useLiveTransforms.getState().clear(node.id) const m = sceneRegistry.nodes.get(node.id) if (m) { - m.position.set(originalPosition[0], originalPosition[1], originalPosition[2]) + m.position.set(...getVisualPosition(originalPosition, originalRotationY)) m.rotation.y = originalRotationY } - useLiveTransforms.getState().clear(node.id) useAlignmentGuides.getState().clear() + markMovedNodeDirty() useScene.temporal.getState().resume() markToolCancelConsumed() exitMoveMode() @@ -467,10 +493,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // unmount / commit paths uniformly. useAlignmentGuides.getState().clear() if (!committed) { + useLiveTransforms.getState().clear(node.id) sceneRegistry.nodes .get(node.id) - ?.position.set(originalPosition[0], originalPosition[1], originalPosition[2]) - useLiveTransforms.getState().clear(node.id) + ?.position.set(...getVisualPosition(originalPosition, originalRotationY)) + markMovedNodeDirty() useScene.temporal.getState().resume() } } diff --git a/packages/editor/src/components/tools/shared/floor-stack-preview.ts b/packages/editor/src/components/tools/shared/floor-stack-preview.ts new file mode 100644 index 000000000..f29b608be --- /dev/null +++ b/packages/editor/src/components/tools/shared/floor-stack-preview.ts @@ -0,0 +1,25 @@ +import { type AnyNode, type AnyNodeId, getFloorStackedPosition, useScene } from '@pascal-app/core' + +type FloorStackPreviewArgs = { + node: AnyNode + position: [number, number, number] + rotation?: unknown + levelId?: string | null + nodes?: Record +} + +export function getFloorStackPreviewPosition({ + node, + position, + rotation, + levelId, + nodes, +}: FloorStackPreviewArgs): [number, number, number] { + return getFloorStackedPosition({ + node, + nodes: nodes ?? useScene.getState().nodes, + position, + rotation, + levelId, + }) +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 15d8d971e..d152b2b46 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, DEFAULT_CURVED_STAIR_SWEEP_ANGLE, @@ -73,19 +74,10 @@ function createStairPreviewGeometry(): THREE.BufferGeometry { } /** - * Creates a stair group with one default stair segment at the given position/rotation. + * Creates a default straight stair segment. */ -function commitStairPlacement( - levelId: LevelNode['id'], - position: [number, number, number], - rotation: number, -): void { - const { createNodes, nodes } = useScene.getState() - - const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length - const name = `Staircase ${stairCount + 1}` - - const segment = StairSegmentNode.parse({ +function createDefaultStairSegment() { + return StairSegmentNode.parse({ segmentType: 'stair', width: DEFAULT_STAIR_WIDTH, length: DEFAULT_STAIR_LENGTH, @@ -96,14 +88,24 @@ function commitStairPlacement( thickness: DEFAULT_STAIR_THICKNESS, position: [0, 0, 0], }) +} - const sortedLevels = Object.values(nodes) - .filter((node): node is LevelNode => node.type === 'level') - .sort((left, right) => left.level - right.level) - const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) - const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId - - const stair = StairNode.parse({ +function createDefaultStairNode({ + name, + levelId, + nextLevelId, + position, + rotation, + segmentId, +}: { + name: string + levelId: LevelNode['id'] + nextLevelId: LevelNode['id'] + position: [number, number, number] + rotation: number + segmentId: StairSegmentNode['id'] +}) { + return StairNode.parse({ name, position, rotation, @@ -125,7 +127,37 @@ function commitStairPlacement( showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, railingHeight: DEFAULT_STAIR_RAILING_HEIGHT, railingMode: DEFAULT_STAIR_RAILING_MODE, - children: [segment.id], + children: [segmentId], + }) +} + +/** + * Creates a stair group with one default stair segment at the given position/rotation. + */ +function commitStairPlacement( + levelId: LevelNode['id'], + position: [number, number, number], + rotation: number, +): void { + const { createNodes, nodes } = useScene.getState() + + const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length + const name = `Staircase ${stairCount + 1}` + const segment = createDefaultStairSegment() + + const sortedLevels = Object.values(nodes) + .filter((node): node is LevelNode => node.type === 'level') + .sort((left, right) => left.level - right.level) + const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) + const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId + + const stair = createDefaultStairNode({ + name, + levelId, + nextLevelId, + position, + rotation, + segmentId: segment.id, }) createNodes([ @@ -141,6 +173,7 @@ export const StairTool: React.FC = () => { const previewRef = useRef(null) const rotationRef = useRef(0) const previousGridPosRef = useRef<[number, number] | null>(null) + const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) const previewGeometry = useMemo(() => createStairPreviewGeometry(), []) @@ -151,6 +184,49 @@ export const StairTool: React.FC = () => { // Reset rotation when tool activates rotationRef.current = 0 if (previewRef.current) previewRef.current.rotation.y = 0 + lastCanonicalPositionRef.current = null + + const getPreviewPosition = ( + position: [number, number, number], + rotation: number, + ): [number, number, number] => { + const segment = createDefaultStairSegment() + const stair = createDefaultStairNode({ + name: 'Staircase Preview', + levelId: currentLevelId, + nextLevelId: currentLevelId, + position, + rotation, + segmentId: segment.id, + }) + return getFloorStackPreviewPosition({ + node: stair, + position, + rotation, + levelId: currentLevelId, + nodes: { + ...useScene.getState().nodes, + [stair.id]: stair, + [segment.id]: segment, + }, + }) + } + + const applyPreview = (position: [number, number, number], rotation: number) => { + const visualPosition = getPreviewPosition(position, rotation) + if (cursorRef.current) { + cursorRef.current.position.set( + visualPosition[0], + visualPosition[1] + GRID_OFFSET, + visualPosition[2], + ) + } + + if (previewRef.current) { + previewRef.current.position.set(...visualPosition) + previewRef.current.rotation.y = rotation + } + } // Alignment candidates — anchors of every alignable object; refreshed // after each placement. The stair aligns by its ORIGIN point. @@ -199,15 +275,9 @@ export const StairTool: React.FC = () => { event.localPosition[2], event.nativeEvent?.altKey === true, ) - const y = event.localPosition[1] - - if (cursorRef.current) { - cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ) - } - - if (previewRef.current) { - previewRef.current.position.set(gridX, y, gridZ) - } + const position: [number, number, number] = [gridX, 0, gridZ] + lastCanonicalPositionRef.current = position + applyPreview(position, rotationRef.current) if ( previousGridPosRef.current && @@ -248,7 +318,9 @@ export const StairTool: React.FC = () => { event.preventDefault() sfxEmitter.emit('sfx:item-rotate') rotationRef.current += rotationDelta - if (previewRef.current) { + if (lastCanonicalPositionRef.current) { + applyPreview(lastCanonicalPositionRef.current, rotationRef.current) + } else if (previewRef.current) { previewRef.current.rotation.y = rotationRef.current } } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 046f9b5f4..d688a4e13 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -60,6 +60,7 @@ export { } from './components/tools/item/use-placement-coordinator' 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' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 2aa8f3121..81c366b8d 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -19,6 +19,7 @@ const BRACE_HANDLE_OFFSET = 0.3 const SPREAD_HANDLE_OFFSET = 0.22 const ROTATE_CORNER_OFFSET = 0.32 const ROTATE_RING_OFFSET = 0.04 +const MOVE_FRONT_OFFSET = 0.35 const MIN_COLUMN_HEIGHT = 0.2 const MIN_COLUMN_WIDTH = 0.1 const MIN_COLUMN_DEPTH = 0.1 @@ -241,6 +242,29 @@ function columnRotateHandle(): HandleDescriptor { } } +function columnMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + // Low to the floor at the front edge (matches the item move grip) so it + // reads as a floor-move grip and stays clear of the body resize / rotate + // handles that sit at mid-height. + position: (n) => { + const { halfZ } = columnFootprintHalf(n) + return [0, 0.02, halfZ + MOVE_FRONT_OFFSET] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const { halfX, halfZ } = columnFootprintHalf(n) + const dimX = Math.max(halfX * 2, MIN_COLUMN_WIDTH) + const dimZ = Math.max(halfZ * 2, MIN_COLUMN_DEPTH) + const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9 + return [swap ? dimZ : dimX, swap ? dimX : dimZ] + }, + } +} + function columnHandles(node: ColumnNodeType): HandleDescriptor[] { // 1. Height (universal). // 2. Footprint arrows depending on supportStyle + crossSection: @@ -265,7 +289,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] } else { handles.push(columnAxisHandle('x'), columnAxisHandle('z')) } - handles.push(columnRotateHandle()) + handles.push(columnRotateHandle(), columnMoveHandle()) return handles } diff --git a/packages/nodes/src/column/move-tool.tsx b/packages/nodes/src/column/move-tool.tsx index 37cc905c3..995425eb1 100644 --- a/packages/nodes/src/column/move-tool.tsx +++ b/packages/nodes/src/column/move-tool.tsx @@ -17,6 +17,7 @@ import { import { CursorSphere, DragBoundingBox, + getFloorStackPreviewPosition, markToolCancelConsumed, triggerSFX, useEditor, @@ -74,6 +75,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { ? (node.metadata as Record) : {} const isNew = !!meta.isNew + const getVisualPosition = ( + position: [number, number, number], + rotation = rotationY, + ): [number, number, number] => + getFloorStackPreviewPosition({ + node, + position, + rotation, + levelId: node.parentId ?? null, + }) // Alignment candidates — every other alignable object's anchors, gathered // once (the scene graph is stable during the imperative drag). @@ -81,19 +92,23 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { const applyPreview = (position: [number, number, number]) => { lastPosition = position - setPreviewPosition(position) + const visualPosition = getVisualPosition(position) + setPreviewPosition(visualPosition) setPreviewRotation(rotationY) useLiveTransforms.getState().set(node.id, { position, rotation: rotationY, }) + useScene.getState().markDirty(node.id as AnyNodeId) const m = sceneRegistry.nodes.get(node.id) if (m) { - m.position.set(position[0], position[1], position[2]) + m.position.set(...visualPosition) m.rotation.y = rotationY } } + setPreviewPosition(getVisualPosition(node.position, node.rotation)) + const onGridMove = (event: GridEvent) => { hasMoved = true let x = snapToGridStep(event.localPosition[0]) @@ -145,11 +160,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { if (nodeId && useScene.getState().nodes[nodeId]) { committed = true - useLiveTransforms.getState().clear(nodeId) useScene.temporal.getState().resume() useScene .getState() .updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) }) + useLiveTransforms.getState().clear(nodeId) + const m = sceneRegistry.nodes.get(nodeId) + if (m) { + m.position.set(...getVisualPosition(position, rotationY)) + m.rotation.y = rotationY + } } else if (node.parentId) { const column = ColumnNodeSchema.parse({ ...node, @@ -174,9 +194,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { useAlignmentGuides.getState().clear() const m = sceneRegistry.nodes.get(node.id) if (m) { - m.position.set(node.position[0], node.position[1], node.position[2]) + m.position.set(...getVisualPosition(node.position, node.rotation)) m.rotation.y = node.rotation } + useScene.getState().markDirty(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() exitMoveMode() @@ -197,9 +218,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) { if (!committed) { const m = sceneRegistry.nodes.get(node.id) if (m) { - m.position.set(node.position[0], node.position[1], node.position[2]) + m.position.set(...getVisualPosition(node.position, node.rotation)) m.rotation.y = node.rotation } + useScene.getState().markDirty(node.id as AnyNodeId) useScene.temporal.getState().resume() } } diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 73f5eaa2e..fc2db9d15 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -13,7 +13,7 @@ import { useAlignmentGuides, useScene, } from '@pascal-app/core' -import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' +import { getFloorStackPreviewPosition, triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import type { Group } from 'three' @@ -91,13 +91,20 @@ const ColumnTool = () => { useAlignmentGuides.getState().clear() } - cursorRef.current?.position.set(ax, event.localPosition[1], az) + const position: [number, number, number] = [ax, 0, az] + const visualPosition = getFloorStackPreviewPosition({ + node: previewNode, + position, + rotation: previewNode.rotation, + levelId: activeLevelId, + }) + cursorRef.current?.position.set(...visualPosition) // Publish a transient, positioned preview node for the 2D floor-plan // ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The floor-plan // placement-preview layer renders this node's footprint at the snapped, // aligned cursor so users see the pillar before they click. - usePlacementPreview.getState().set({ ...previewNode, position: [ax, 0, az] }) + usePlacementPreview.getState().set({ ...previewNode, position }) const prev = previousSnapRef.current if (!prev || prev[0] !== ax || prev[1] !== az) { diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 11f7356ac..5ece73966 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -5,6 +5,7 @@ import { type FenceNode, type GridEvent, type LevelNode, + nodeRegistry, type RoofNode, type RoofSegmentNode, resolveAlignment, @@ -19,6 +20,7 @@ import { import { CursorSphere, clearRoofDuplicateMetadata, + getFloorStackPreviewPosition, snapFenceDraftPoint, triggerSFX, useEditor, @@ -113,6 +115,11 @@ export const MoveRoofTool: React.FC<{ // Track pending rotation — no store updates during drag let pendingRotation: number = movingNode.rotation as number + let lastLocalPosition: [number, number, number] = [ + movingNode.position[0], + movingNode.position[1], + movingNode.position[2], + ] // For roof-segment moves: the selection was cleared before entering move mode, // so isSelected=false on the parent roof, hiding individual segment meshes and @@ -150,6 +157,20 @@ export const MoveRoofTool: React.FC<{ } const levelId = resolveLevelId() + const isFloorPlaced = nodeRegistry.get(movingNode.type)?.capabilities?.floorPlaced !== undefined + const getPreviewPosition = ( + position: [number, number, number], + rotation = pendingRotation, + ): [number, number, number] => { + if (!isFloorPlaced) return position + return getFloorStackPreviewPosition({ + node: movingNode, + position, + rotation, + levelId, + nodes: useScene.getState().nodes, + }) + } const levelNode = levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level' ? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode) @@ -260,20 +281,28 @@ export const MoveRoofTool: React.FC<{ } previousGridPosRef.current = [gridX, gridZ] - setCursorWorldPos([lx, event.localPosition[1], lz]) const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz) + lastLocalPosition = [localX, movingNode.position[1], localZ] + const previewPosition = getPreviewPosition(lastLocalPosition) + setCursorWorldPos(isFloorPlaced ? previewPosition : [lx, event.localPosition[1], lz]) // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) if (mesh) { - mesh.position.x = localX - mesh.position.z = localZ + if (isFloorPlaced) { + mesh.position.set(...previewPosition) + } else { + mesh.position.x = localX + mesh.position.z = localZ + } } - // Publish world-space position so the 2D floorplan can track the drag + // Publish canonical position so the 2D floorplan can track the drag. + // Floor-placed parents (stairs) stay in their committed local frame; + // the lifted Y remains presentation-only in the 3D view. useLiveTransforms.getState().set(movingNode.id, { - position: [gridX, y, gridZ], + position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ], rotation: pendingRotation, }) } @@ -359,7 +388,14 @@ export const MoveRoofTool: React.FC<{ // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) - if (mesh) mesh.rotation.y = pendingRotation + if (mesh) { + mesh.rotation.y = pendingRotation + if (isFloorPlaced) { + const previewPosition = getPreviewPosition(lastLocalPosition, pendingRotation) + mesh.position.set(...previewPosition) + setCursorWorldPos(previewPosition) + } + } // Update live transform rotation for 2D floorplan const currentLive = useLiveTransforms.getState().get(movingNode.id) diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index b30967b53..ff0d0bc34 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -1,4 +1,5 @@ import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core' +import { sanitizeShelfDimensions } from './dimensions' import { buildShelfFloorplan } from './floorplan' import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances' import { shelfFloorplanMoveTarget } from './floorplan-move' @@ -10,6 +11,7 @@ const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 const ROTATE_CORNER_OFFSET = 0.32 const ROTATE_RING_OFFSET = 0.04 +const MOVE_FRONT_OFFSET = 0.35 const MIN_SHELF_WIDTH = 0.3 const MIN_SHELF_DEPTH = 0.1 const MIN_SHELF_HEIGHT = 0.05 @@ -95,8 +97,35 @@ function shelfRotateHandle(): HandleDescriptor { } } +function shelfMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + // Low to the floor at the front edge (matches the item move grip) so it + // reads as a floor-move grip and stays clear of the body resize / rotate + // handles that sit at mid-height. + position: (n) => { + const shelf = sanitizeShelfDimensions(n as ShelfNode) + return [0, 0.02, shelf.depth / 2 + MOVE_FRONT_OFFSET] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n) => { + const shelf = sanitizeShelfDimensions(n as ShelfNode) + const swap = Math.abs(Math.sin(shelf.rotation[1] ?? 0)) > 0.9 + return [swap ? shelf.depth : shelf.width, swap ? shelf.width : shelf.depth] + }, + } +} + function shelfHandles(_node: ShelfNodeType): HandleDescriptor[] { - return [shelfWidthHandle(), shelfDepthHandle(), shelfHeightHandle(), shelfRotateHandle()] + return [ + shelfWidthHandle(), + shelfDepthHandle(), + shelfHeightHandle(), + shelfRotateHandle(), + shelfMoveHandle(), + ] } export const shelfDefinition: NodeDefinition = { @@ -158,7 +187,7 @@ export const shelfDefinition: NodeDefinition = { // shelf sitting over a raised slab visually rests on top of it. floorPlaced: { footprint: (node) => { - const shelf = node as ShelfNode + const shelf = sanitizeShelfDimensions(node as ShelfNode) return { dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number], rotation: shelf.rotation, @@ -189,7 +218,7 @@ export const shelfDefinition: NodeDefinition = { // `children`. Lets skip the dispose+rebuild (and the // pointer enter/leave churn it causes) when an item reparents onto a row. geometryKey: (n) => { - const s = n as ShelfNodeType + const s = sanitizeShelfDimensions(n as ShelfNode) return JSON.stringify([ s.style, s.width, diff --git a/packages/nodes/src/shelf/dimensions.ts b/packages/nodes/src/shelf/dimensions.ts new file mode 100644 index 000000000..d12ad29f7 --- /dev/null +++ b/packages/nodes/src/shelf/dimensions.ts @@ -0,0 +1,18 @@ +import type { ShelfNode } from './schema' + +function clampShelfDim(value: unknown, lo: number, hi: number, fallback: number): number { + const v = typeof value === 'number' && Number.isFinite(value) ? value : fallback + return Math.min(Math.max(v, lo), hi) +} + +export function sanitizeShelfDimensions(node: ShelfNode): ShelfNode { + return { + ...node, + width: clampShelfDim(node.width, 0.3, 3.0, 1.2), + depth: clampShelfDim(node.depth, 0.1, 1.0, 0.3), + thickness: clampShelfDim(node.thickness, 0.01, 0.1, 0.04), + height: clampShelfDim(node.height, 0.05, 2.5, 0.9), + rows: Math.round(clampShelfDim(node.rows, 1, 8, 1)), + columns: Math.round(clampShelfDim(node.columns, 1, 6, 1)), + } +} diff --git a/packages/nodes/src/shelf/floorplan-move.ts b/packages/nodes/src/shelf/floorplan-move.ts index 21715cc49..658ecd34c 100644 --- a/packages/nodes/src/shelf/floorplan-move.ts +++ b/packages/nodes/src/shelf/floorplan-move.ts @@ -10,6 +10,7 @@ import { } from '@pascal-app/core' import { applyFloorplanAlignment, + getFloorStackPreviewPosition, snapPointToGrid, triggerSFX, type WallPlanPoint, @@ -82,6 +83,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, triggerSFX('sfx:grid-snap') lastSnapKey = snapKey } + const visualPosition = getFloorStackPreviewPosition({ + node, + position: next, + rotation: node.rotation, + levelId: node.parentId ?? null, + }) // Single source of truth — write the absolute position straight to // the scene (history is paused by the overlay). Both the 2D SVG and // the 3D group transform read `node.position` reactively, so they @@ -90,7 +97,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget = ({ node, useScene.getState().updateNodes([ { id: shelfId, - data: { position: next }, + data: { position: visualPosition }, }, ]) }, diff --git a/packages/nodes/src/shelf/floorplan.ts b/packages/nodes/src/shelf/floorplan.ts index 3230bd0e3..7f05ab3b7 100644 --- a/packages/nodes/src/shelf/floorplan.ts +++ b/packages/nodes/src/shelf/floorplan.ts @@ -1,4 +1,5 @@ import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import { sanitizeShelfDimensions } from './dimensions' import type { ShelfResizePayload } from './floorplan-affordances' import type { ShelfNode } from './schema' @@ -25,15 +26,16 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22 * (engaged from the action-menu Move button, not from these arrows). */ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry { - const [px, , pz] = node.position - const ry = node.rotation[1] ?? 0 + const shelf = sanitizeShelfDimensions(node) + const [px, , pz] = shelf.position + const ry = shelf.rotation[1] ?? 0 // Floor-plan plots at `-ry` so SVG's CW-with-y-down `rotate` direction // ends up visually matching Three.js Y-rotation (CCW from a top-down // view) — same `rotation` value rotates the same way in both views. // Stair already does this; column / shelf / roof-segment now do too. const planRy = -ry - const halfW = node.width / 2 - const halfD = node.depth / 2 + const halfW = shelf.width / 2 + const halfD = shelf.depth / 2 const isSelected = ctx?.viewState?.selected ?? false // Floor-plan fill: a single neutral fill regardless of `material`. @@ -44,8 +46,8 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo kind: 'rect', x: -halfW, y: -halfD, - width: node.width, - height: node.depth, + width: shelf.width, + height: shelf.depth, fill: '#d6d3d1', stroke: '#1f2937', strokeWidth: 0.015, @@ -55,17 +57,17 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo // Show column dividers for grid-style shelves so the cubby / bookshelf // grid is visible from above. - if ((node.style === 'bookshelf' || node.style === 'cubby') && node.columns > 1) { - const innerWidth = node.width - 2 * node.thickness - const colStep = innerWidth / node.columns - for (let c = 1; c < node.columns; c++) { + if ((shelf.style === 'bookshelf' || shelf.style === 'cubby') && shelf.columns > 1) { + const innerWidth = shelf.width - 2 * shelf.thickness + const colStep = innerWidth / shelf.columns + for (let c = 1; c < shelf.columns; c++) { const x = -innerWidth / 2 + c * colStep footprintChildren.push({ kind: 'line', x1: x, - y1: -halfD + node.thickness, + y1: -halfD + shelf.thickness, x2: x, - y2: halfD - node.thickness, + y2: halfD - shelf.thickness, stroke: '#1f2937', strokeWidth: 0.012, opacity: 0.7, diff --git a/packages/nodes/src/shelf/geometry.ts b/packages/nodes/src/shelf/geometry.ts index 63d3841d5..f71a3d470 100644 --- a/packages/nodes/src/shelf/geometry.ts +++ b/packages/nodes/src/shelf/geometry.ts @@ -7,6 +7,7 @@ import { type RenderShading, } from '@pascal-app/viewer' import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three' +import { sanitizeShelfDimensions } from './dimensions' import type { ShelfNode } from './schema' /** @@ -69,10 +70,11 @@ function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material { } export function buildShelfGeometry( - node: ShelfNode, + rawNode: ShelfNode, _ctx?: unknown, shading: RenderShading = 'rendered', ): Group { + const node = sanitizeShelfDimensions(rawNode) const group = new Group() group.name = 'shelf-geometry' @@ -343,8 +345,9 @@ function addCornerPosts( * can host in the lowest cell. */ export function shelfRowSurfaceYs(node: ShelfNode): number[] { - const ys = boardCenterYs(node).map((y) => y + node.thickness / 2) - const bottomApplies = node.style === 'cubby' || node.style === 'bookshelf' - if (node.withBottom && bottomApplies) ys.unshift(node.thickness) + const safe = sanitizeShelfDimensions(node) + const ys = boardCenterYs(safe).map((y) => y + safe.thickness / 2) + const bottomApplies = safe.style === 'cubby' || safe.style === 'bookshelf' + if (safe.withBottom && bottomApplies) ys.unshift(safe.thickness) return ys } diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index dd19c3dc7..740feaf4f 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -15,7 +15,7 @@ import { useAlignmentGuides, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import { type Group, Vector3 } from 'three' @@ -70,16 +70,16 @@ function getLevelLocalPosition( const local = (event as GridEvent).localPosition if (local) { const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP) - return [sx, local[1] ?? 0, sz] + return [sx, 0, sz] } const [sx, sz] = snapPointToGrid([event.position[0], event.position[2]], GRID_STEP) - return [sx, event.position[1], sz] + return [sx, 0, sz] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) levelObject.worldToLocal(worldVector) const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP) - return [sx, worldVector.y, sz] + return [sx, 0, sz] } const ShelfTool = () => { @@ -150,8 +150,15 @@ const ShelfTool = () => { useAlignmentGuides.getState().clear() } - cursorRef.current?.position.set(ax, event.localPosition[1], az) - lastCursorRef.current = [ax, event.localPosition[1], az] + const position: [number, number, number] = [ax, 0, az] + const visualPosition = getFloorStackPreviewPosition({ + node: previewNode, + position, + rotation: previewNode.rotation, + levelId: activeLevelId, + }) + cursorRef.current?.position.set(...visualPosition) + lastCursorRef.current = position const prev = previousSnapRef.current if (!prev || prev[0] !== ax || prev[1] !== az) { diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index 543f98699..0f7109df4 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -1,8 +1,23 @@ -import type { NodeDefinition } from '@pascal-app/core' +import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core' import { buildSpawnFloorplan } from './floorplan' import { spawnParametrics } from './parametrics' import { SpawnNode } from './schema' +const SPAWN_FOOTPRINT = 0.6 +const MOVE_FRONT_OFFSET = 0.35 + +function spawnMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + // Low to the floor at the front edge (matches the item move grip). + position: () => [0, 0.02, SPAWN_FOOTPRINT / 2 + MOVE_FRONT_OFFSET], + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: () => [SPAWN_FOOTPRINT, SPAWN_FOOTPRINT], + } +} + export const spawnDefinition: NodeDefinition = { kind: 'spawn', schemaVersion: 1, @@ -37,6 +52,7 @@ export const spawnDefinition: NodeDefinition = { }, parametrics: spawnParametrics, + handles: [spawnMoveHandle()], renderer: { kind: 'parametric', diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx index 5b6aedd35..9088ac875 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -1,6 +1,12 @@ 'use client' -import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { + type AnyNodeId, + type SpawnNode, + useLiveNodeOverrides, + useLiveTransforms, + useRegistry, +} from '@pascal-app/core' import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useMemo, useRef } from 'react' import { Color, type Group, Shape } from 'three' @@ -20,6 +26,11 @@ const SPAWN_COLOR = new Color('#22c55e') const SpawnRenderer = ({ node }: { node: SpawnNode }) => { const ref = useRef(null!) const handlers = useNodeEvents(node, 'spawn') + const liveOverride = useLiveNodeOverrides((state) => state.get(node.id as AnyNodeId)) + const effectiveNode = useMemo( + () => (liveOverride ? ({ ...node, ...liveOverride } as SpawnNode) : node), + [node, liveOverride], + ) const liveTransform = useLiveTransforms((state) => state.get(node.id)) const walkthroughMode = useViewer((state) => state.walkthroughMode) const shading = useViewer((state) => state.shading) @@ -52,10 +63,10 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => { return ( diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 21f98a232..6bb9dc00c 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -1,7 +1,12 @@ 'use client' import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core' -import { CursorSphere, triggerSFX, useEditor } from '@pascal-app/editor' +import { + CursorSphere, + getFloorStackPreviewPosition, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import { type Group, Vector3 } from 'three' @@ -20,16 +25,12 @@ function getExistingSpawnIds() { function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { const levelObject = sceneRegistry.nodes.get(levelId) if (!levelObject) { - return [ - roundToHalf(event.localPosition[0]), - event.localPosition[1], - roundToHalf(event.localPosition[2]), - ] + return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) levelObject.worldToLocal(worldVector) - return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] + return [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)] } /** @@ -53,7 +54,19 @@ const SpawnTool = () => { // same half-meter snap the legacy tool uses. const nextX = roundToHalf(event.localPosition[0]) const nextZ = roundToHalf(event.localPosition[2]) - cursorRef.current?.position.set(nextX, event.localPosition[1], nextZ) + const position: [number, number, number] = [nextX, 0, nextZ] + const previewNode = SpawnNode.parse({ + name: 'Spawn Point', + position, + rotation: 0, + }) + const visualPosition = getFloorStackPreviewPosition({ + node: previewNode, + position, + rotation: 0, + levelId: activeLevelId, + }) + cursorRef.current?.position.set(...visualPosition) // Fire grid-snap SFX only when the snapped position crosses a cell, // not every frame the mouse moves within the same cell. Matches the diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 7fc032d78..7c991cb64 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -1,8 +1,10 @@ import { type HandleDescriptor, type NodeDefinition, + type SceneApi, StairNode as StairNodeSchema, type StairNode as StairNodeType, + type StairSegmentNode, stairFootprintAABB, } from '@pascal-app/core' @@ -27,6 +29,7 @@ const CURVED_INNER_RING_MIN = 0.05 // the footprint. Same pattern as elevator / column / shelf / roof-segment. const STAIR_ROTATE_CORNER_OFFSET = 0.4 const STAIR_ROTATE_RING_OFFSET = 0.08 +const STAIR_MOVE_FRONT_OFFSET = 0.35 type CurvedStairGeom = { isSpiral: boolean @@ -42,6 +45,14 @@ type CurvedStairGeom = { minInnerRadius: number } +type StairMoveBounds = { + minX: number + maxX: number + minZ: number + maxZ: number + height: number +} + function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom { const isSpiral = node.stairType === 'spiral' const stepCount = Math.max(2, Math.round(node.stepCount ?? 10)) @@ -71,6 +82,79 @@ function isCurvedOrSpiral(node: StairNodeType): boolean { return node.stairType === 'curved' || node.stairType === 'spiral' } +function rotateLocalXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds { + const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH) + const depth = Math.max(width, 1) + return { + minX: -width / 2, + maxX: width / 2, + minZ: 0, + maxZ: depth, + height: Math.max(node.totalRise ?? 2.5, 0.1), + } +} + +function readStraightStairMoveBounds(node: StairNodeType, sceneApi: SceneApi): StairMoveBounds { + const segments = (node.children ?? []) + .map((childId) => sceneApi.get(childId as never)) + .filter((child): child is StairSegmentNode => child?.type === 'stair-segment') + + if (segments.length === 0) return fallbackStraightStairMoveBounds(node) + + const transforms = computeStairSegmentFloorStackTransforms(segments) + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + let height = 0 + + segments.forEach((segment, index) => { + const transform = transforms[index] + if (!transform) return + const halfWidth = segment.width / 2 + const corners = [ + [-halfWidth, 0], + [halfWidth, 0], + [-halfWidth, segment.length], + [halfWidth, segment.length], + ] as const + for (const [x, z] of corners) { + const [rx, rz] = rotateLocalXZ(x, z, transform.rotation) + minX = Math.min(minX, transform.position[0] + rx) + maxX = Math.max(maxX, transform.position[0] + rx) + minZ = Math.min(minZ, transform.position[2] + rz) + maxZ = Math.max(maxZ, transform.position[2] + rz) + } + height = Math.max( + height, + transform.position[1] + Math.max(segment.height, segment.thickness, 0.01), + ) + }) + + if (![minX, maxX, minZ, maxZ].every(Number.isFinite)) { + return fallbackStraightStairMoveBounds(node) + } + return { minX, maxX, minZ, maxZ, height: Math.max(height, 0.1) } +} + +function readStairMoveBounds(node: StairNodeType, sceneApi: SceneApi): StairMoveBounds { + if (!isCurvedOrSpiral(node)) return readStraightStairMoveBounds(node, sceneApi) + const g = readCurvedStairGeometry(node) + return { + minX: -g.outerRadius, + maxX: g.outerRadius, + minZ: -g.outerRadius, + maxZ: g.outerRadius, + height: g.totalRise, + } +} + function curvedRiseHandle(): HandleDescriptor { return { kind: 'linear-resize', @@ -266,6 +350,29 @@ function stairRotateHandle(): HandleDescriptor { } } +function stairMoveHandle(): HandleDescriptor { + return { + kind: 'translate', + placement: { + // Low to the floor at the front edge (matches the item move grip) so it + // reads as a floor-move grip and stays clear of the body resize / rotate + // handles that sit at mid-height. + position: (n, sceneApi) => { + const bounds = readStairMoveBounds(n, sceneApi) + return [(bounds.minX + bounds.maxX) / 2, 0.02, bounds.maxZ + STAIR_MOVE_FRONT_OFFSET] + }, + }, + apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }), + snapExtents: (n, sceneApi) => { + const bounds = readStairMoveBounds(n, sceneApi) + const dimX = Math.max(bounds.maxX - bounds.minX, MIN_CURVED_WIDTH) + const dimZ = Math.max(bounds.maxZ - bounds.minZ, MIN_CURVED_WIDTH) + const swap = Math.abs(Math.sin(n.rotation ?? 0)) > 0.9 + return [swap ? dimZ : dimX, swap ? dimX : dimZ] + }, + } +} + function stairHandles(node: StairNodeType): HandleDescriptor[] { // Straight stairs have no parent-level shape arrows — the segment // children each render their own (width / length / height). Curved + @@ -282,10 +389,14 @@ function stairHandles(node: StairNodeType): HandleDescriptor[] { curvedSweepHandle('end'), ) } - handles.push(stairRotateHandle()) + handles.push(stairRotateHandle(), stairMoveHandle()) return handles } +import { + computeStairSegmentFloorStackTransforms, + getStairFloorPlacedFootprints, +} from './floor-stack' import { buildStairFloorplan } from './floorplan' import { curvedStairInnerRadiusAffordance, @@ -330,6 +441,10 @@ export const stairDefinition: NodeDefinition = { }, duplicable: true, deletable: true, + floorPlaced: { + footprints: (node, ctx) => + ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [], + }, }, // Bespoke move shared with roof / roof-segment / stair-segment via diff --git a/packages/nodes/src/stair/floor-stack.test.ts b/packages/nodes/src/stair/floor-stack.test.ts new file mode 100644 index 000000000..4a822512b --- /dev/null +++ b/packages/nodes/src/stair/floor-stack.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + getFloorPlacedElevation, + nodeRegistry, + registerNode, + type SlabNode, + StairNode, + StairSegmentNode, + spatialGridManager, +} from '@pascal-app/core' +import { stairDefinition } from './definition' +import { getStairSegmentFloorPlacedFootprints } from './floor-stack' + +const LEVEL_ID = 'level_test' + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab_${elevation}`) { + const slab = { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + } as SlabNode + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +describe('stair floor-stack footprints', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + }) + + test('derives a rotated segment footprint from stair position and rotation', () => { + const segment = StairSegmentNode.parse({ + id: 'sseg_single', + width: 2, + length: 4, + height: 1, + thickness: 0.25, + }) + const stair = StairNode.parse({ + id: 'stair_single', + parentId: LEVEL_ID, + position: [10, 0, 20], + rotation: Math.PI / 2, + children: [segment.id], + }) + + const [footprint] = getStairSegmentFloorPlacedFootprints(stair, [segment]) + + expect(footprint?.position?.[0]).toBeCloseTo(12) + expect(footprint?.position?.[1]).toBeCloseTo(0) + expect(footprint?.position?.[2]).toBeCloseTo(20) + expect(footprint?.dimensions).toEqual([2, 1, 4]) + expect(footprint?.rotation[1]).toBeCloseTo(Math.PI / 2) + }) + + test('emits one footprint per chained stair segment', () => { + const first = StairSegmentNode.parse({ + id: 'sseg_first', + width: 2, + length: 4, + height: 1, + thickness: 0.25, + }) + const second = StairSegmentNode.parse({ + id: 'sseg_second', + attachmentSide: 'left', + width: 1.5, + length: 3, + height: 0.8, + thickness: 0.2, + }) + const stair = StairNode.parse({ + id: 'stair_multi', + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: [first.id, second.id], + }) + + const footprints = getStairSegmentFloorPlacedFootprints(stair, [first, second]) + + expect(footprints).toHaveLength(2) + expect(footprints[0]?.position).toEqual([0, 0, 2]) + expect(footprints[0]?.rotation[1]).toBeCloseTo(0) + expect(footprints[1]?.position?.[0]).toBeCloseTo(2.5) + expect(footprints[1]?.position?.[1]).toBeCloseTo(1) + expect(footprints[1]?.position?.[2]).toBeCloseTo(2) + expect(footprints[1]?.rotation[1]).toBeCloseTo(Math.PI / 2) + }) + + test('uses the max slab elevation across stair segment footprints', () => { + registerNode(stairDefinition as unknown as AnyNodeDefinition) + + addSlab( + [ + [-0.6, 1.4], + [0.6, 1.4], + [0.6, 2.6], + [-0.6, 2.6], + ], + 0.25, + 'slab_low', + ) + addSlab( + [ + [2.2, 1.7], + [2.8, 1.7], + [2.8, 2.3], + [2.2, 2.3], + ], + 0.75, + 'slab_high', + ) + + const level = makeLevel() + const first = StairSegmentNode.parse({ + id: 'sseg_resolver_first', + width: 2, + length: 4, + height: 1, + }) + const second = StairSegmentNode.parse({ + id: 'sseg_resolver_second', + attachmentSide: 'left', + width: 1.5, + length: 3, + height: 0.8, + }) + const stair = StairNode.parse({ + id: 'stair_resolver', + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: [first.id, second.id], + }) + const nodes = { + [level.id]: level, + [stair.id]: stair, + [first.id]: first, + [second.id]: second, + } + + expect( + getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: LEVEL_ID, + }), + ).toBeCloseTo(0.75) + }) +}) diff --git a/packages/nodes/src/stair/floor-stack.ts b/packages/nodes/src/stair/floor-stack.ts new file mode 100644 index 000000000..c21d4c85d --- /dev/null +++ b/packages/nodes/src/stair/floor-stack.ts @@ -0,0 +1,114 @@ +import type { + AnyNode, + AnyNodeId, + FloorPlacedFootprint, + StairNode, + StairSegmentNode, +} from '@pascal-app/core' + +type SegmentTransform = { + position: [number, number, number] + rotation: number +} + +export function getStairFloorPlacedFootprints( + stair: StairNode, + nodes: Readonly>, +): FloorPlacedFootprint[] { + const segments = (stair.children ?? []) + .map((childId) => nodes[childId as AnyNodeId]) + .filter((node): node is StairSegmentNode => node?.type === 'stair-segment') + + return getStairSegmentFloorPlacedFootprints(stair, segments) +} + +export function getStairSegmentFloorPlacedFootprints( + stair: StairNode, + segments: readonly StairSegmentNode[], +): FloorPlacedFootprint[] { + const transforms = computeStairSegmentFloorStackTransforms(segments) + + return segments.map((segment, index) => { + const transform = transforms[index]! + const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation) + const centerInGroupX = transform.position[0] + centerOffsetX + const centerInGroupZ = transform.position[2] + centerOffsetZ + const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ( + centerInGroupX, + centerInGroupZ, + stair.rotation, + ) + + return { + position: [ + stair.position[0] + centerOffsetWorldX, + stair.position[1] + transform.position[1], + stair.position[2] + centerOffsetWorldZ, + ], + dimensions: [ + segment.width, + Math.max(segment.height, segment.thickness, 0.01), + segment.length, + ], + rotation: [0, stair.rotation + transform.rotation, 0], + } + }) +} + +export function computeStairSegmentFloorStackTransforms( + segments: readonly StairSegmentNode[], +): SegmentTransform[] { + const transforms: SegmentTransform[] = [] + let currentX = 0 + let currentY = 0 + let currentZ = 0 + let currentRot = 0 + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]! + + if (index > 0) { + const previous = segments[index - 1]! + let attachX = 0 + let attachZ = 0 + let rotationDelta = 0 + + switch (segment.attachmentSide) { + case 'front': + attachX = 0 + attachZ = previous.length + rotationDelta = 0 + break + case 'left': + attachX = previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = Math.PI / 2 + break + case 'right': + attachX = -previous.width / 2 + attachZ = previous.length / 2 + rotationDelta = -Math.PI / 2 + break + } + + const [rotatedX, rotatedZ] = rotateXZ(attachX, attachZ, currentRot) + currentX += rotatedX + currentY += previous.height + currentZ += rotatedZ + currentRot += rotationDelta + } + + transforms.push({ + position: [currentX, currentY, currentZ], + rotation: currentRot, + }) + } + + return transforms +} + +function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index df4ef80d9..27dbf6c45 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -103,15 +103,26 @@ export function Lights() { if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue box.expandByObject(obj) } - if (box.isEmpty()) { - // Empty scene: fall back to the origin with a default radius so the - // ground still receives a sensible shadow region. + box.getBoundingSphere(boundsSphere.current) + const center = boundsSphere.current.center + const radius = boundsSphere.current.radius + // Empty scene OR a node with a NaN position/geometry poisoning the union + // box: fall back to the origin with a default radius. The directional + // light's position is derived from `focus`, so a single non-finite mesh + // must NOT be allowed to make `focus`/`radius` NaN — that breaks every + // shadow-casting light's position and renders the whole scene black. + const finiteBounds = + !box.isEmpty() && + Number.isFinite(center.x) && + Number.isFinite(center.y) && + Number.isFinite(center.z) && + Number.isFinite(radius) + if (finiteBounds) { + shadowFocus.current.copy(center) + shadowRadius.current = radius + } else { shadowFocus.current.set(0, 0, 0) shadowRadius.current = SHADOW_FALLBACK_RADIUS - } else { - box.getBoundingSphere(boundsSphere.current) - shadowFocus.current.copy(boundsSphere.current.center) - shadowRadius.current = boundsSphere.current.radius } } diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 01b52c2bd..2098875e7 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -1,15 +1,43 @@ import { type AnyNode, type AnyNodeId, + getEffectiveNode, + getFloorStackedPosition, nodeRegistry, - resolveLevelId, sceneRegistry, - spatialGridManager, + useLiveTransforms, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import type * as THREE from 'three' +type PositionedNode = AnyNode & { + position?: [number, number, number] + rotation?: [number, number, number] | number +} + +function withLiveTransform(node: AnyNode, id: string): AnyNode { + const liveTransform = useLiveTransforms.getState().get(id) + if (!liveTransform) return node + + const currentRotation = (node as PositionedNode).rotation + const rotation = Array.isArray(currentRotation) + ? ([currentRotation[0] ?? 0, liveTransform.rotation, currentRotation[2] ?? 0] as [ + number, + number, + number, + ]) + : typeof currentRotation === 'number' + ? liveTransform.rotation + : currentRotation + + return { + ...(node as Record), + position: liveTransform.position, + ...(rotation !== undefined ? { rotation } : {}), + } as AnyNode +} + /** * Generic floor-elevation system. * @@ -25,11 +53,12 @@ import type * as THREE from 'three' * * Runs at priority 1 — before the priority-2 systems (`GeometrySystem`, * `ItemSystem`) so the dirty mark survives long enough for those to do - * their own work. Doesn't clear dirty; the per-kind system (or the - * generic geometry rebuild) is responsible for that. + * their own work. Kinds with no geometry/system have no downstream dirty + * consumer, so this system clears their dirty mark after applying the lift. */ export const FloorElevationSystem = () => { const dirtyNodes = useScene((s) => s.dirtyNodes) + const clearDirty = useScene((s) => s.clearDirty) useFrame(() => { if (dirtyNodes.size === 0) return @@ -43,31 +72,31 @@ export const FloorElevationSystem = () => { const floorPlaced = def?.capabilities?.floorPlaced if (!floorPlaced) return - if (floorPlaced.applies && !floorPlaced.applies(node as AnyNode)) return - - // Only nodes parented directly to a level get the lift. Children of - // walls / ceilings / other items inherit Y from the parent group. - const parentId = node.parentId as AnyNodeId | null - const parent = parentId ? nodes[parentId] : null - if (parent && parent.type !== 'level') return - const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined if (!mesh) return - const position = (node as { position?: [number, number, number] }).position + const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), id) + const position = (effectiveNode as PositionedNode).position if (!position) return - const levelId = resolveLevelId(node, nodes) - if (!levelId) return - - const { dimensions, rotation } = floorPlaced.footprint(node as AnyNode) - const slabElevation = spatialGridManager.getSlabElevationForItem( - levelId, + // This system is the single drag-time authority for floor-stack mesh Y: + // tools publish base positions to live stores, renderers may + // reconcile that base Y onto the group, then this presentation system + // reapplies the resolver-derived visual Y before render. Because the + // override/store position remains base-height, the slab lift is never + // committed or applied twice. + const resolverNodes = + effectiveNode === node ? nodes : { ...nodes, [effectiveNode.id]: effectiveNode } + const visualPosition = getFloorStackedPosition({ + node: effectiveNode, + nodes: resolverNodes, position, - dimensions, - rotation, - ) - mesh.position.y = slabElevation + position[1] + }) + mesh.position.y = visualPosition[1] + + if (!(def.geometry || def.system)) { + clearDirty(id as AnyNodeId) + } }) }, 1) diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 545df0457..242a6e5e7 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -2,11 +2,10 @@ import { type AnyNode, type AnyNodeId, getEffectiveNode, - resolveLevelId, + getFloorStackedPosition, type StairNode, type StairSegmentNode, sceneRegistry, - spatialGridManager, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -96,9 +95,9 @@ export const StairSystem = () => { // so slab-elevation spatial queries match where the segments are // actually being rendered. Without this, dragging the rotate gizmo // looks up slabs at the pre-drag world XZ — if rotation carries a - // segment off the original slab footprint, getStairSlabElevation - // returns 0 and `group.position.y` collapses, dropping the flight - // or landing below the floor and out of view mid-drag. + // segment off the original slab footprint, the floor-stack + // resolver would otherwise read the pre-drag footprint and drop + // the flight or landing below the floor mid-drag. const stairNode = getEffectiveNode(baseStairNode as StairNode) const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined if (group) { @@ -273,57 +272,20 @@ function syncStairGroupElevation( group: THREE.Group, nodes: Record, ) { - const levelId = resolveLevelId(stairNode, nodes) - const slabElevation = getStairSlabElevation(levelId, stairNode, nodes) - group.position.y = stairNode.position[1] + slabElevation -} - -function getStairSlabElevation( - levelId: string, - stairNode: StairNode, - nodes: Record, -): number { - // Merge live overrides so slab queries match the visual chain during a drag. - const segments = (stairNode.children ?? []) - .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) - .filter((n): n is StairSegmentNode => n?.type === 'stair-segment') - .map((n) => getEffectiveNode(n)) - - if (segments.length === 0) return 0 - - const transforms = computeSegmentTransforms(segments) - let maxElevation = Number.NEGATIVE_INFINITY - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i]! - const transform = transforms[i]! - - const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation) - const centerInGroupX = transform.position[0] + centerOffsetX - const centerInGroupZ = transform.position[2] + centerOffsetZ - const [centerOffsetWorldX, centerOffsetWorldZ] = rotateXZ( - centerInGroupX, - centerInGroupZ, - stairNode.rotation, - ) - - const slabElevation = spatialGridManager.getSlabElevationForItem( - levelId, - [ - stairNode.position[0] + centerOffsetWorldX, - stairNode.position[1] + transform.position[1], - stairNode.position[2] + centerOffsetWorldZ, - ], - [segment.width, Math.max(segment.height, segment.thickness, 0.01), segment.length], - [0, stairNode.rotation + transform.rotation, 0], - ) - - if (slabElevation > maxElevation) { - maxElevation = slabElevation + const effectiveNodes: Record = { ...nodes, [stairNode.id]: stairNode } + for (const childId of stairNode.children ?? []) { + const segment = nodes[childId as AnyNodeId] + if (segment?.type === 'stair-segment') { + effectiveNodes[segment.id] = getEffectiveNode(segment as StairSegmentNode) } } - - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + const visualPosition = getFloorStackedPosition({ + node: stairNode, + nodes: effectiveNodes, + position: stairNode.position, + rotation: stairNode.rotation, + }) + group.position.y = visualPosition[1] } // ============================================================================ From dc3e7864850ae7eef06eea8371c2f22b81e05432 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 5 Jun 2026 10:00:24 -0400 Subject: [PATCH 2/4] fix(editor): keep invisible handle hit-areas on EDITOR_LAYER, not the MRT scene pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forgiving invisible hit-areas added with the unified handle system used a colorWrite:false + DoubleSide MeshBasicNodeMaterial with no layer, so they defaulted to SCENE_LAYER and entered the post-processing MRT scene pass — unsafe for such NodeMaterials (no color outputs for the 3-target MRT). That poisoned front/back rendering for FrontSide scene geometry, making floor-slab side walls (and other FrontSide meshes) render see-through. Setting the hit mesh to EDITOR_LAYER inside the shared InvisibleHandleHitArea covers all call sites; raycasting is unaffected (the raycaster already enables EDITOR_LAYER). Visible indicators stay on SCENE_LAYER by design. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/components/editor/handles/handle-arrow.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 531e7fece..0a91fc9fc 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -17,6 +17,7 @@ import { } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../../lib/constants' export const ARROW_SCALE = 0.65 export const ARROW_COLOR = '#8381ed' @@ -376,6 +377,7 @@ export function InvisibleHandleHitArea({ Date: Fri, 5 Jun 2026 14:16:10 -0400 Subject: [PATCH 3/4] feat(editor): hover + click-to-edit slab holes in 3D Selecting a slab highlights its hole cutouts (indigo, matching wall openings); hovering a hole emphasizes it and clicking it opens the hole editor for manual holes or selects the owning stair/elevator for auto holes. The SlabHoleHighlights overlay portals into the slab's object (so R3F delivers pointer events and the holes inherit the slab transform). Hover/edit state lives in useEditor (hoveredHole alongside editingHole); ToolManager only opens the hole editor for manual holes. The hit box caps its top at the slab surface so the centre thickness handle keeps click priority, and the visible fill/outline render in the scene pass (SCENE_LAYER) so handles sort in front while the invisible hit mesh stays on EDITOR_LAYER (out of the MRT scene pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/src/components/editor/index.tsx | 2 + .../editor/slab-hole-highlights.tsx | 456 ++++++++++++++++++ .../src/components/tools/tool-manager.tsx | 12 +- packages/editor/src/lib/scene.ts | 1 + packages/editor/src/store/use-editor.tsx | 16 +- packages/nodes/src/slab/hole-editor.tsx | 3 +- 6 files changed, 485 insertions(+), 5 deletions(-) create mode 100644 packages/editor/src/components/editor/slab-hole-highlights.tsx diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 9fb83be78..5c6e5d758 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -65,6 +65,7 @@ import { GroupRotateHandle } from './group-rotate-handle' import { NodeArrowHandles } from './node-arrow-handles' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' +import { SlabHoleHighlights } from './slab-hole-highlights' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { WallMeasurementLabel } from './wall-measurement-label' @@ -610,6 +611,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } + {!noEditing && } {!noEditing && } {!noEditing && } {!noEditing && } diff --git a/packages/editor/src/components/editor/slab-hole-highlights.tsx b/packages/editor/src/components/editor/slab-hole-highlights.tsx new file mode 100644 index 000000000..564689046 --- /dev/null +++ b/packages/editor/src/components/editor/slab-hole-highlights.tsx @@ -0,0 +1,456 @@ +'use client' + +import { + type AnyNodeId, + type BuildingNode, + type LevelNode, + resolveBuildingForLevel, + resolveLevelId, + type SlabNode, + type SurfaceHoleMetadata, + sceneRegistry, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + BoxGeometry, + BufferGeometry, + DoubleSide, + Float32BufferAttribute, + type Object3D, + ShapeUtils, + Vector2, +} from 'three' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' +import useEditor from '../../store/use-editor' +import { swallowNextClick } from './handles/use-handle-drag' + +const ACCENT = 0x83_81_ed +const SURFACE_OFFSET = 0.01 +const HIT_PADDING = 0.08 +const MIN_HIT_HEIGHT = 0.16 + +const NO_RAYCAST = () => null +let restoreNodeClickSuppression: (() => void) | null = null + +const outlineMaterial = new LineBasicNodeMaterial({ + color: ACCENT, + depthTest: false, + depthWrite: false, +}) + +const fillMaterial = new MeshBasicNodeMaterial({ + color: ACCENT, + transparent: true, + opacity: 0.5, + depthTest: false, + depthWrite: false, + side: DoubleSide, +}) + +// Invisible hit targets must stay out of the scene MRT pass; keep them on +// EDITOR_LAYER even though they never write color. +const hitMaterial = new MeshBasicNodeMaterial({ + color: ACCENT, + colorWrite: false, + depthTest: false, + depthWrite: false, + opacity: 0, + side: DoubleSide, + transparent: true, +}) + +type HolePolygon = Array<[number, number]> + +function makeFillGeometry(hole: HolePolygon, y: number): BufferGeometry { + const contour2d = hole.map(([x, z]) => new Vector2(x, z)) + const positions: number[] = [] + const indices: number[] = [] + + for (const point of contour2d) { + positions.push(point.x, y, point.y) + } + + const triangles = ShapeUtils.triangulateShape(contour2d, []) + for (const tri of triangles) { + indices.push(tri[0]!, tri[2]!, tri[1]!) + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setIndex(indices) + geometry.computeVertexNormals() + geometry.computeBoundingSphere() + return geometry +} + +function makeOutlineGeometry(hole: HolePolygon, y: number): BufferGeometry { + const positions: number[] = [] + + for (let index = 0; index < hole.length; index += 1) { + const [ax, az] = hole[index]! + const [bx, bz] = hole[(index + 1) % hole.length]! + positions.push(ax, y, az, bx, y, bz) + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeBoundingSphere() + return geometry +} + +function makeHitGeometry(hole: HolePolygon, centerY: number, height: number): BufferGeometry { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [x, z] of hole) { + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + + const width = Math.max(maxX - minX + HIT_PADDING * 2, HIT_PADDING * 2) + const depth = Math.max(maxZ - minZ + HIT_PADDING * 2, HIT_PADDING * 2) + const centerX = (minX + maxX) / 2 + const centerZ = (minZ + maxZ) / 2 + const geometry = new BoxGeometry(width, height, depth) + geometry.translate(centerX, centerY, centerZ) + geometry.computeBoundingSphere() + return geometry +} + +function getSurfaceY(slab: SlabNode): number { + const elevation = slab.elevation ?? 0.05 + return (elevation > 0 ? elevation : 0) + SURFACE_OFFSET +} + +function getHitCenterY(slab: SlabNode): number { + const elevation = slab.elevation ?? 0.05 + const surfaceTop = elevation > 0 ? elevation : 0 + // Keep the hit box's TOP at the slab surface (never poke above it) so the + // slab's centre thickness/height handle, which sits on top, always wins the + // raycast over the hole's selectable area. + return surfaceTop - getHitHeight(slab) / 2 +} + +function getHitHeight(slab: SlabNode): number { + return Math.max(Math.abs(slab.elevation ?? 0.05), MIN_HIT_HEIGHT) +} + +function clearHoveredHoleIfMatches(nodeId: string, holeIndex: number) { + const { hoveredHole, setHoveredHole } = useEditor.getState() + if (hoveredHole?.nodeId === nodeId && hoveredHole.holeIndex === holeIndex) { + setHoveredHole(null) + } +} + +function selectOwnedNode(ownerId: string, expectedType: 'stair' | 'elevator') { + const nodes = useScene.getState().nodes + const owner = nodes[ownerId as AnyNodeId] + if (owner?.type !== expectedType) return + + const selectedId = owner.id as AnyNodeId + + if (owner.type === 'elevator') { + const buildingId = + owner.parentId && nodes[owner.parentId as AnyNodeId]?.type === 'building' + ? (owner.parentId as BuildingNode['id']) + : null + + useViewer + .getState() + .setSelection( + buildingId ? { buildingId, selectedIds: [selectedId] } : { selectedIds: [selectedId] }, + ) + return + } + + const levelId = resolveLevelId(owner, nodes) + const buildingId = + levelId && levelId !== 'default' ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null + + useViewer.getState().setSelection({ + ...(buildingId ? { buildingId: buildingId as BuildingNode['id'] } : {}), + ...(levelId && levelId !== 'default' ? { levelId: levelId as LevelNode['id'] } : {}), + selectedIds: [selectedId], + }) +} + +function resetPointerCursor() { + if (document.body.style.cursor === 'pointer') { + document.body.style.cursor = '' + } +} + +function stopPointerPropagation(event: ThreeEvent) { + event.stopPropagation() + event.nativeEvent.stopPropagation() + event.nativeEvent.stopImmediatePropagation() +} + +function suppressNodeClickUntilPointerUp() { + restoreNodeClickSuppression?.() + const previousInputDragging = useViewer.getState().inputDragging + useViewer.getState().setInputDragging(true) + swallowNextClick() + + const restore = () => { + if (restoreNodeClickSuppression !== restore) return + useViewer.getState().setInputDragging(previousInputDragging) + window.removeEventListener('pointerup', restore) + window.removeEventListener('pointercancel', restore) + restoreNodeClickSuppression = null + } + + restoreNodeClickSuppression = restore + window.addEventListener('pointerup', restore, { once: true }) + window.addEventListener('pointercancel', restore, { once: true }) +} + +function releaseNodeClickSuppression() { + restoreNodeClickSuppression?.() +} + +export function SlabHoleHighlights() { + const selectedIds = useViewer((state) => state.selection.selectedIds) + const hoveredHole = useEditor((state) => state.hoveredHole) + const setHoveredHole = useEditor((state) => state.setHoveredHole) + + useEffect(() => { + if (hoveredHole && !selectedIds.includes(hoveredHole.nodeId as AnyNodeId)) { + setHoveredHole(null) + } + }, [hoveredHole, selectedIds, setHoveredHole]) + + if (selectedIds.length === 0) return null + + return ( + <> + {selectedIds.map((id) => ( + + ))} + + ) +} + +function SelectedSlabHoleHighlights({ slabId }: { slabId: string }) { + const node = useScene((state) => state.nodes[slabId as AnyNodeId]) + const override = useLiveNodeOverrides((state) => state.overrides.get(slabId)) + const hoveredHole = useEditor((state) => state.hoveredHole) + const editingHole = useEditor((state) => state.editingHole) + const setHoveredHole = useEditor((state) => state.setHoveredHole) + + const slab = node?.type === 'slab' ? (node as SlabNode) : null + const effectiveSlab = slab ? ({ ...slab, ...(override ?? {}) } as SlabNode) : null + const holes = effectiveSlab?.holes ?? [] + const holeMetadata = effectiveSlab?.holeMetadata ?? [] + + // Portal the highlights into the slab's OWN object — exactly how + // NodeArrowHandles portals into a node object. This is what makes R3F deliver + // pointer events to the hit meshes (portaling into the raw scene renders but + // never receives hover/click), and the children inherit the slab's world + // transform so the holes sit in slab-local space with no manual math. + const [slabObject, setSlabObject] = useState( + () => sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null, + ) + useEffect(() => { + let frameId = 0 + const resolve = () => { + const next = sceneRegistry.nodes.get(slabId as AnyNodeId) ?? null + setSlabObject((cur) => (cur === next ? cur : next)) + if (!next) { + frameId = window.requestAnimationFrame(resolve) + } + } + resolve() + return () => { + if (frameId) window.cancelAnimationFrame(frameId) + } + }, [slabId]) + + useEffect(() => { + if (hoveredHole?.nodeId !== slabId) return + if (!effectiveSlab || hoveredHole.holeIndex >= holes.length) { + setHoveredHole(null) + } + }, [effectiveSlab, holes.length, hoveredHole, slabId, setHoveredHole]) + + if (!effectiveSlab || holes.length === 0 || !slabObject) return null + + const surfaceY = getSurfaceY(effectiveSlab) + const hitCenterY = getHitCenterY(effectiveSlab) + const hitHeight = getHitHeight(effectiveSlab) + + return createPortal( + + {holes.map((hole, holeIndex) => { + const metadata = holeMetadata[holeIndex] + if (hole.length < 3) return null + + const isActive = + (hoveredHole?.nodeId === slabId && hoveredHole.holeIndex === holeIndex) || + (editingHole?.nodeId === slabId && editingHole.holeIndex === holeIndex) + + return ( + + ) + })} + , + slabObject, + ) +} + +function SlabHoleHighlight({ + active, + hitCenterY, + hitHeight, + hole, + holeIndex, + metadata, + slabId, + surfaceY, +}: { + active: boolean + hitCenterY: number + hitHeight: number + hole: HolePolygon + holeIndex: number + metadata: SurfaceHoleMetadata | undefined + slabId: string + surfaceY: number +}) { + const fillGeometry = useMemo(() => makeFillGeometry(hole, surfaceY), [hole, surfaceY]) + const outlineGeometry = useMemo(() => makeOutlineGeometry(hole, surfaceY), [hole, surfaceY]) + const hitGeometry = useMemo( + () => makeHitGeometry(hole, hitCenterY, hitHeight), + [hitCenterY, hitHeight, hole], + ) + + useEffect(() => () => fillGeometry.dispose(), [fillGeometry]) + useEffect(() => () => outlineGeometry.dispose(), [outlineGeometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) + useEffect(() => () => clearHoveredHoleIfMatches(slabId, holeIndex), [holeIndex, slabId]) + + const handlePointerEnter = useCallback( + (event: ThreeEvent) => { + event.stopPropagation() + useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex }) + if (document.body.style.cursor !== 'pointer') { + document.body.style.cursor = 'pointer' + } + }, + [holeIndex, slabId], + ) + + const handlePointerLeave = useCallback( + (event: ThreeEvent) => { + event.stopPropagation() + clearHoveredHoleIfMatches(slabId, holeIndex) + resetPointerCursor() + }, + [holeIndex, slabId], + ) + + const handlePointerDown = useCallback( + (event: ThreeEvent) => { + if (event.button !== 0) return + stopPointerPropagation(event) + suppressNodeClickUntilPointerUp() + useEditor.getState().setHoveredHole({ nodeId: slabId, holeIndex }) + + // Auto-managed cutouts (stair / elevator) jump to their owner so the + // user edits the source rather than the synced hole. Everything else — + // manual holes and holes that predate holeMetadata — opens the editor. + if (metadata?.source === 'stair' && metadata.stairId) { + useEditor.getState().setEditingHole(null) + useEditor.getState().setHoveredHole(null) + resetPointerCursor() + selectOwnedNode(metadata.stairId, 'stair') + return + } + if (metadata?.source === 'elevator' && metadata.elevatorId) { + useEditor.getState().setEditingHole(null) + useEditor.getState().setHoveredHole(null) + resetPointerCursor() + selectOwnedNode(metadata.elevatorId, 'elevator') + return + } + + useEditor.getState().setEditingHole({ nodeId: slabId, holeIndex }) + useViewer.getState().setSelection({ selectedIds: [slabId as AnyNodeId] }) + }, + [holeIndex, metadata, slabId], + ) + + const handlePointerUp = useCallback((event: ThreeEvent) => { + releaseNodeClickSuppression() + stopPointerPropagation(event) + }, []) + + return ( + <> + + + {active && ( + <> + + + + )} + + + ) +} + +export default SlabHoleHighlights diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 2a0cdee9f..e5f8e01fe 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -78,6 +78,11 @@ export const ToolManager: React.FC = () => { const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as | SlabNode['id'] | undefined + const selectedSlab = selectedSlabId ? (nodes[selectedSlabId as AnyNodeId] as SlabNode) : null + const editingSlabHoleIsManual = + selectedSlabId !== undefined && + editingHole?.nodeId === selectedSlabId && + selectedSlab?.holeMetadata?.[editingHole.holeIndex]?.source === 'manual' // Check if a ceiling is selected const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as @@ -92,11 +97,14 @@ export const ToolManager: React.FC = () => { phase === 'structure' && mode === 'select' && selectedSlabId !== undefined && - (!editingHole || editingHole.nodeId !== selectedSlabId) + !editingSlabHoleIsManual // Show slab hole editor when editing a hole on the selected slab const showSlabHoleEditor = - selectedSlabId !== undefined && editingHole !== null && editingHole.nodeId === selectedSlabId + selectedSlabId !== undefined && + editingHole !== null && + editingHole.nodeId === selectedSlabId && + editingSlabHoleIsManual // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole) const showCeilingBoundaryEditor = diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 4304241d4..a6ff88618 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -355,6 +355,7 @@ function resetEditorInteractionState() { selectedReferenceId: null, spaces: {}, editingHole: null, + hoveredHole: null, isPreviewMode: false, }) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 0c61bc82d..91c00d56f 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -158,6 +158,8 @@ type MaterialPaintSelectionSnapshot = { activePaintMaterial: ActivePaintMaterial | null } +export type SurfaceHoleTarget = { nodeId: string; holeIndex: number } + export type GuideUiState = { locked?: boolean scaleReferenceVisible?: boolean @@ -296,8 +298,10 @@ type EditorState = { spaces: Record setSpaces: (spaces: Record) => void // Generic hole editing (works for slabs, ceilings, and any future polygon nodes) - editingHole: { nodeId: string; holeIndex: number } | null - setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void + editingHole: SurfaceHoleTarget | null + setEditingHole: (hole: SurfaceHoleTarget | null) => void + hoveredHole: SurfaceHoleTarget | null + setHoveredHole: (hole: SurfaceHoleTarget | null) => void // Preview mode (viewer-like experience inside the editor) isPreviewMode: boolean setPreviewMode: (preview: boolean) => void @@ -808,6 +812,14 @@ const useEditor = create()( setSpaces: (spaces) => set({ spaces }), editingHole: null, setEditingHole: (hole) => set({ editingHole: hole }), + hoveredHole: null, + setHoveredHole: (hole) => + set((state) => + state.hoveredHole?.nodeId === hole?.nodeId && + state.hoveredHole?.holeIndex === hole?.holeIndex + ? state + : { hoveredHole: hole }, + ), isPreviewMode: false, setPreviewMode: (preview) => { if (preview) { diff --git a/packages/nodes/src/slab/hole-editor.tsx b/packages/nodes/src/slab/hole-editor.tsx index 7b4d0eb02..966e0ed12 100644 --- a/packages/nodes/src/slab/hole-editor.tsx +++ b/packages/nodes/src/slab/hole-editor.tsx @@ -24,6 +24,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null const holes = slab?.holes || [] const hole = holes[holeIndex] + const metadata = slab?.holeMetadata?.[holeIndex] const handlePolygonChange = useCallback( (newPolygon: Array<[number, number]>) => { @@ -60,7 +61,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe } }, [slabId]) - if (!(slab && hole) || hole.length < 3) return null + if (!(slab && hole) || hole.length < 3 || metadata?.source !== 'manual') return null return ( Date: Fri, 5 Jun 2026 15:04:18 -0400 Subject: [PATCH 4/4] refactor(editor): normalize handle interaction colors + cross-arrow polygon move + hole click-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PolygonEditor (slab boundary/edge, slab + ceiling hole, zone/site editors) handles now use the generic indigo interaction states (ARROW_COLOR idle / ARROW_HOVER_COLOR on hover+drag) instead of bespoke green/blue, matching the descriptor, wall, and group handles. The whole-polygon move grip is now the generic cross-arrow with an invisible cylinder hit area (was a sphere). Clicking any node (e.g. the slab surface outside a hole) now clears editingHole, so clicking off a hole onto the slab deselects the hole and keeps the slab selected — the hole's handles/hit mesh stopPropagation, so genuine hole interactions are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/editor/selection-manager.tsx | 7 +++ .../tools/shared/polygon-editor.tsx | 56 ++++++++++++------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index bf8af2d35..83758d551 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1263,6 +1263,13 @@ export const SelectionManager = () => { } } + // Clicking any node (e.g. the slab surface outside a hole) exits slab + // hole-edit mode. The hole handles + hit mesh stopPropagation, so a + // click reaching here means the user clicked outside the hole. + if (useEditor.getState().editingHole) { + useEditor.getState().setEditingHole(null) + } + activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) let nextMaterialTargetHandled = false diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 676514799..32bcf0d9b 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -11,16 +11,17 @@ import { type Line, type Object3D, Shape, - SphereGeometry, } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { + createMoveCrossHandleGeometry, ARROW_COLOR as EDGE_ARROW_COLOR, ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR, ARROW_SCALE as EDGE_ARROW_SCALE, useArrowMaterial, + useInvisibleHitAreaMaterial, } from '../../editor/node-arrow-handles' import { snapToHalf } from '../item/placement-math' @@ -185,7 +186,11 @@ function OutlinedCylinderHandle({ ) } -function OutlinedSphereHandle({ +// Whole-polygon move grip — the generic 4-way cross-arrow (matching the node +// move handles) with an invisible cylinder hit area, replacing the old sphere. +// The cross sits on SCENE_LAYER (so the ink pass outlines it) while the +// cylinder hit mesh is on EDITOR_LAYER (grabbable, out of the MRT scene pass). +function OutlinedCrossHandle({ color, position, ...handlers @@ -193,20 +198,33 @@ function OutlinedSphereHandle({ color: string position: [number, number, number] } & HandleHandlers) { - const geometry = useMemo(() => new SphereGeometry(0.09, 20, 20), []) + const geometry = useMemo(() => createMoveCrossHandleGeometry(), []) const material = usePolygonNodeMaterial(color) + const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), []) + const hitMaterial = useInvisibleHitAreaMaterial() useEffect(() => () => geometry.dispose(), [geometry]) + useEffect(() => () => hitGeometry.dispose(), [hitGeometry]) return ( - + + + + ) } @@ -651,7 +669,7 @@ export const PolygonEditor: React.FC = ({ return ( { @@ -693,8 +711,8 @@ export const PolygonEditor: React.FC = ({ })} {allowPolygonMove && ( - { if (e.button !== 0) return e.stopPropagation() @@ -760,7 +778,7 @@ export const PolygonEditor: React.FC = ({ > @@ -769,9 +787,7 @@ export const PolygonEditor: React.FC = ({ Points outward from the edge; dragging it translates only this edge's two vertices along the outward normal. */} { if (e.button !== 0) return @@ -807,7 +823,7 @@ export const PolygonEditor: React.FC = ({ return ( {