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..0a91fc9fc --- /dev/null +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -0,0 +1,523 @@ +'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' +import { EDITOR_LAYER } from '../../../lib/constants' + +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/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/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/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/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/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/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 ( { 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/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/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/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/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/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 ( { + 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] } // ============================================================================