feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)

- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged.
- Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass.
- Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors.
- NaN-safe node mutations + non-finite shadow-light bounds guard.
- Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
This commit is contained in:
Aymeric Rabot
2026-06-05 16:24:48 -04:00
committed by GitHub
parent d1b40aa98d
commit 0b338cf647
51 changed files with 3942 additions and 1418 deletions
@@ -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> = {}): 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<string, AnyNode> {
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)
})
})
@@ -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<string, AnyNode>
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<FloorPlacedElevationArgs, 'node' | 'position' | 'rotation'>): AnyNode {
return {
...(node as Record<string, unknown>),
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 }
@@ -1,6 +1,7 @@
import { nodeRegistry } from '../../registry' import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { import {
itemOverlapsPolygon, itemOverlapsPolygon,
spatialGridManager, 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( function markNodesOverlappingSlab(
slab: SlabNode, slab: SlabNode,
@@ -181,12 +182,6 @@ function markNodesOverlappingSlab(
} }
continue 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 // Generic floor-placed sweep: any registry kind that opts in via
// `capabilities.floorPlaced` (item / shelf / column / spawn / …) // `capabilities.floorPlaced` (item / shelf / column / spawn / …)
// re-elevates through `<FloorElevationSystem>` when a slab below // re-elevates through `<FloorElevationSystem>` when a slab below
@@ -196,12 +191,25 @@ function markNodesOverlappingSlab(
const floorPlaced = def?.capabilities?.floorPlaced const floorPlaced = def?.capabilities?.floorPlaced
if (!floorPlaced) continue if (!floorPlaced) continue
if (floorPlaced.applies && !floorPlaced.applies(node)) 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 if (resolveLevelId(node, nodes) !== slabLevelId) continue
const position = (node as { position?: [number, number, number] }).position const position = (node as { position?: [number, number, number] }).position
if (!position) continue if (!position) continue
const { dimensions, rotation } = floorPlaced.footprint(node) for (const footprint of getFloorPlacedFootprints(floorPlaced, node, { nodes })) {
if (itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) { if (
itemOverlapsPolygon(
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
slab.polygon,
0.01,
)
) {
markDirty(node.id) markDirty(node.id)
break
}
} }
} }
} }
+12
View File
@@ -38,6 +38,12 @@ export {
sceneRegistry, sceneRegistry,
useRegistry, useRegistry,
} from './hooks/scene-registry/scene-registry' } 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 { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
export { export {
initSpatialGridSync, initSpatialGridSync,
@@ -79,6 +85,12 @@ export {
type MaterialCategory, type MaterialCategory,
toLibraryMaterialRef, toLibraryMaterialRef,
} from './material-library' } from './material-library'
export type {
FloorPlacedFootprint,
FloorPlacedFootprintContext,
FloorPlacedFootprintResolver,
FloorPlacedFootprintsResolver,
} from './registry'
export * from './registry' export * from './registry'
export * from './schema' export * from './schema'
export * from './services' export * from './services'
+3 -1
View File
@@ -346,8 +346,10 @@ export type TranslateHandle<N = any> = {
* `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and * `[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- * 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. * 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 portal?: HandlePortal
} }
+5
View File
@@ -57,6 +57,11 @@ export type {
CuttableConfig, CuttableConfig,
DragAction, DragAction,
EditorCtx, EditorCtx,
FloorPlacedConfig,
FloorPlacedFootprint,
FloorPlacedFootprintContext,
FloorPlacedFootprintResolver,
FloorPlacedFootprintsResolver,
FloorplanAffordance, FloorplanAffordance,
FloorplanAffordanceModifiers, FloorplanAffordanceModifiers,
FloorplanAffordancePoint, FloorplanAffordancePoint,
+26 -6
View File
@@ -1235,20 +1235,40 @@ export type SelectableConfig = {
override?: (ctx: CapabilityCtx) => SelectableConfig | null 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<Record<AnyNodeId, AnyNode>>
}
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 * Floor-placed kinds rest directly on a level and need their Y lifted by
* any slab the footprint overlaps. The generic `<FloorElevationSystem>` * any slab the footprint overlaps. The generic `<FloorElevationSystem>`
* computes `slabElevation + node.position[1]` and writes it onto the * computes `slabElevation + node.position[1]` and writes it onto the
* registered mesh on every dirty mark. `footprint` returns the world-space * registered mesh on every dirty mark. `footprint` returns the default
* footprint the spatial-grid manager uses to find overlapping slabs; * 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 * `applies` is an optional predicate to skip nodes that share a kind but
* are mounted off-floor (items attached to a wall / ceiling). * are mounted off-floor (items attached to a wall / ceiling).
*/ */
export type FloorPlacedConfig = { export type FloorPlacedConfig = {
footprint: (node: AnyNode) => { footprint?: FloorPlacedFootprintResolver
dimensions: [number, number, number] footprints?: FloorPlacedFootprintsResolver
rotation: [number, number, number]
}
applies?: (node: AnyNode) => boolean applies?: (node: AnyNode) => boolean
} }
@@ -74,7 +74,10 @@ function floorFootprint(
): { dimensions: [number, number, number]; rotation: [number, number, number] } | null { ): { dimensions: [number, number, number]; rotation: [number, number, number] } | null {
const capabilities = nodeRegistry.get(node.type)?.capabilities const capabilities = nodeRegistry.get(node.type)?.capabilities
const floorPlaced = capabilities?.floorPlaced 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 if (floorPlaced.applies && !floorPlaced.applies(node)) return null
return floorPlaced.footprint(node) return floorPlaced.footprint(node)
} }
+479 -11
View File
@@ -1,6 +1,7 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
AnyNode as AnyNodeSchema,
getEffectiveWallSurfaceMaterial, getEffectiveWallSurfaceMaterial,
getWallSurfaceMaterialSignature, getWallSurfaceMaterialSignature,
type WallNode, type WallNode,
@@ -22,6 +23,478 @@ type WallMergePlan = {
attachmentUpdates: WallAttachmentUpdate[] attachmentUpdates: WallAttachmentUpdate[]
} }
type ZodCheckLike = {
_zod?: {
def?: {
check?: string
value?: unknown
inclusive?: boolean
format?: string
}
}
}
type ZodSchemaDefLike = {
type?: string
innerType?: ZodSchemaLike
shape?: Record<string, ZodSchemaLike>
options?: readonly ZodSchemaLike[]
items?: readonly ZodSchemaLike[]
rest?: ZodSchemaLike | null
element?: ZodSchemaLike
checks?: readonly ZodCheckLike[]
defaultValue?: unknown
values?: readonly unknown[]
entries?: Record<string, unknown>
}
type ZodSchemaLike = {
_zod?: {
def?: ZodSchemaDefLike
bag?: {
minimum?: number
maximum?: number
exclusiveMinimum?: number
exclusiveMaximum?: number
}
values?: Set<unknown>
}
def?: ZodSchemaDefLike
shape?: Record<string, ZodSchemaLike>
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<string, [number, number] | undefined> = {
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<string, unknown>)
: {}
const input = value as Record<string, unknown>
const next: Record<string, unknown> = { ...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('.') || '<root>'
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>): 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<AnyNode>) } as AnyNode
}
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks // Track pending RAF for updateNodesAction to prevent multiple queued callbacks
let pendingRafId: number | null = null let pendingRafId: number | null = null
let pendingUpdates: Set<AnyNodeId> = new Set() let pendingUpdates: Set<AnyNodeId> = new Set()
@@ -245,11 +718,7 @@ export const createNodesAction = (
for (const { node, parentId } of ops) { for (const { node, parentId } of ops) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
// 1. Assign parentId to the child (Safe because BaseNode has parentId) const newNode = parseCreatedNode(node, effectiveParentId)
const newNode = {
...node,
parentId: effectiveParentId,
}
nextNodes[newNode.id] = newNode nextNodes[newNode.id] = newNode
@@ -313,6 +782,7 @@ export const applyNodeChangesAction = (
for (const { id, data } of updateOps) { for (const { id, data } of updateOps) {
const currentNode = nextNodes[id] const currentNode = nextNodes[id]
if (!currentNode) continue if (!currentNode) continue
const updatedNode = parseUpdatedNode(currentNode, data)
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
const oldParentId = currentNode.parentId as AnyNodeId | null 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) nodesToMarkDirty.add(id)
} }
for (const { node, parentId } of createOps) { for (const { node, parentId } of createOps) {
const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null const effectiveParentId = parentId ?? (node.parentId as AnyNodeId | null) ?? null
const newNode = { const newNode = parseCreatedNode(node, effectiveParentId)
...node,
parentId: effectiveParentId,
} as AnyNode
nextNodes[newNode.id as AnyNodeId] = newNode nextNodes[newNode.id as AnyNodeId] = newNode
nodesToMarkDirty.add(newNode.id as AnyNodeId) nodesToMarkDirty.add(newNode.id as AnyNodeId)
@@ -442,6 +909,7 @@ export const updateNodesAction = (
for (const { id, data } of updates) { for (const { id, data } of updates) {
const currentNode = nextNodes[id] const currentNode = nextNodes[id]
if (!currentNode) continue if (!currentNode) continue
const updatedNode = parseUpdatedNode(currentNode, data)
// Handle Reparenting Logic // Handle Reparenting Logic
if (data.parentId !== undefined && data.parentId !== currentNode.parentId) { if (data.parentId !== undefined && data.parentId !== currentNode.parentId) {
@@ -480,7 +948,7 @@ export const updateNodesAction = (
} }
// Apply the update // Apply the update
nextNodes[id] = { ...nextNodes[id], ...data } as AnyNode nextNodes[id] = updatedNode
} }
return { nodes: nextNodes } return { nodes: nextNodes }
@@ -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> = {}): 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<AnyNode, { type: 'shelf' }>
}
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<AnyNode>)
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<AnyNode>)
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<AnyNode>)
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<AnyNode>)
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<AnyNode>)
const node = useScene.getState().nodes[SHELF_ID] as Record<string, unknown>
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<AnyNode>)
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<AnyNode>),
)
const created = useScene.getState().nodes[createdId] as Extract<AnyNode, { type: 'shelf' }>
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)
})
})
+42 -8
View File
@@ -13,6 +13,7 @@ import {
type RoofSegmentNode, type RoofSegmentNode,
type RoofType, type RoofType,
} from '../schema/nodes/roof-segment' } from '../schema/nodes/roof-segment'
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
import { SiteNode } from '../schema/nodes/site' import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair' import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' 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 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) { function getBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback return typeof value === 'boolean' ? value : fallback
} }
@@ -112,6 +118,37 @@ function normalizeDoorNode(node: Record<string, unknown>) {
return parsed.success ? { ...node, ...parsed.data } : null return parsed.success ? { ...node, ...parsed.data } : null
} }
function normalizeShelfNode(node: Record<string, unknown>) {
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<string, any>) { function migrateWallSurfaceMaterials(node: Record<string, any>) {
const hasInterior = const hasInterior =
node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string' node.interiorMaterial !== undefined || typeof node.interiorMaterialPreset === 'string'
@@ -396,14 +433,11 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
} }
// Shelf v2: hosting was added in this migration cycle. Older shelves if (node.type === 'shelf') {
// (saved before the schema gained `children`) need the field const normalized = normalizeShelfNode(node)
// initialised so `createNode(item, shelfId)` finds an array to if (normalized) {
// append the child id to — without this the host item ends up patchedNodes[id] = normalized
// 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: [] }
} }
// Roof-segment hosting was added in this migration cycle (the same // Roof-segment hosting was added in this migration cycle (the same
@@ -287,39 +287,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// //
// The live-transform contract varies per kind (see // The live-transform contract varies per kind (see
// wiki/architecture/tools.md "useLiveTransforms contract is // wiki/architecture/tools.md "useLiveTransforms contract is
// per-kind, not generic"); we narrow per kind here: // per-kind, not generic"); position-carrying floor-placed kinds
// - item: world-plan position frame. Override `position` + // publish canonical X/Z, while slab / ceiling publish a polygon
// `rotation` and force `parentId: null` so the resolver // translation delta.
// 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 `<group position={delta}>`
// visual without forcing per-tick CSG scene writes.
const live = liveTransforms.get(id) const live = liveTransforms.get(id)
let effectiveNode: AnyNode = node let effectiveNode: AnyNode = node
if (live) { if (live) {
if (node.type === 'item' || node.type === 'shelf') { const floorPlaced = def?.capabilities?.floorPlaced
// World-plan position kinds: the live transform carries the const hasPosition = Array.isArray((node as { position?: unknown }).position)
// node's intended position/rotation in level-local coords. if (floorPlaced && hasPosition) {
// Override both and force `parentId: null` so the floor-plan effectiveNode = applyPositionLiveTransform(node, live)
// 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
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') { } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const dx = live.position[0] const dx = live.position[0]
const dz = live.position[2] const dz = live.position[2]
@@ -1591,6 +1568,29 @@ function InteractiveGeometry({
// ── Helpers ────────────────────────────────────────────────────────── // ── 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( function buildContext(
node: AnyNode, node: AnyNode,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
@@ -8,10 +8,10 @@ import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { import {
CORNER_OFFSET,
classifyParticipant, classifyParticipant,
collectParticipants, collectParticipants,
computeGroupBox, computeGroupBox,
CORNER_OFFSET,
expandToComponent, expandToComponent,
type Vec2, type Vec2,
} from './group-transform-shared' } from './group-transform-shared'
@@ -21,11 +21,15 @@ import {
ARROW_HOVER_COLOR, ARROW_HOVER_COLOR,
ARROW_SCALE, ARROW_SCALE,
createRotateArrowHandleGeometry, createRotateArrowHandleGeometry,
createRotateArrowHitAreaGeometry,
GuideRing, GuideRing,
InvisibleHandleHitArea,
NO_RAYCAST,
RotationGuide, RotationGuide,
type RotationGuideData, type RotationGuideData,
swallowNextClick, swallowNextClick,
useArrowMaterial, useArrowMaterial,
useInvisibleHitAreaMaterial,
} from './node-arrow-handles' } from './node-arrow-handles'
const ROTATE_SNAP = Math.PI / 12 // 15° const ROTATE_SNAP = Math.PI / 12 // 15°
@@ -74,7 +78,9 @@ export function GroupRotateHandle() {
function GroupRotateHandleInner({ ids }: { ids: string[] }) { function GroupRotateHandleInner({ ids }: { ids: string[] }) {
const { camera, raycaster, gl, scene } = useThree() const { camera, raycaster, gl, scene } = useThree()
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), []) const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
const hitGeometry = useMemo(() => createRotateArrowHitAreaGeometry(), [])
const arrowMaterial = useArrowMaterial() const arrowMaterial = useArrowMaterial()
const hitMaterial = useInvisibleHitAreaMaterial()
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [guide, setGuide] = useState<RotationGuideData | null>(null) const [guide, setGuide] = useState<RotationGuideData | null>(null)
@@ -85,11 +91,13 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered]) }, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
useEffect(() => () => dragCleanupRef.current?.(), []) useEffect(() => () => dragCleanupRef.current?.(), [])
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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 // 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 // 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 rest = useMemo(() => {
const box = computeGroupBox(ids) const box = computeGroupBox(ids)
if (!box) return null if (!box) return null
const pivot = new Vector3( const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
(box.min.x + box.max.x) / 2,
box.min.y,
(box.min.z + box.max.z) / 2,
)
const corner = new Vector3( const corner = new Vector3(
box.max.x + CORNER_OFFSET, box.max.x + CORNER_OFFSET,
(box.min.y + box.max.y) / 2, (box.min.y + box.max.y) / 2,
@@ -307,11 +311,10 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
<GuideRing radius={0.2 * scale} y={0} /> <GuideRing radius={0.2 * scale} y={0} />
</group> </group>
)} )}
<group position={[corner.x, corner.y, corner.z]} scale={scale}> <group position={[corner.x, corner.y, corner.z]}>
<mesh <InvisibleHandleHitArea
frustumCulled={false} geometry={hitGeometry}
geometry={arrowGeometry} material={hitMaterial}
material={arrowMaterial}
onPointerDown={activate} onPointerDown={activate}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -323,7 +326,15 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) {
setIsHovered(false) setIsHovered(false)
if (document.body.style.cursor === 'grab') document.body.style.cursor = '' if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
}} }}
scale={baseScale}
/>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
raycast={NO_RAYCAST}
renderOrder={1010} renderOrder={1010}
scale={scale}
/> />
</group> </group>
{guide ? <RotationGuide data={guide} /> : null} {guide ? <RotationGuide data={guide} /> : null}
@@ -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<PointerEvent>) => 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<MeshBasicNodeMaterial | null>(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 (
<mesh
frustumCulled={false}
geometry={geometry}
layers={EDITOR_LAYER}
material={material}
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
renderOrder={HIT_AREA_RENDER_ORDER}
scale={scale}
/>
)
}
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<Group>(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 (
<group position={placement.position} ref={rootRef} rotation={rotation}>
{children}
<group rotation={localRotation}>
<InvisibleHandleHitArea
geometry={hitGeometry}
material={hitMaterial}
onPointerDown={onPointerDown}
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
scale={hitScale}
/>
<mesh
frustumCulled={false}
geometry={geometry}
material={indicatorMaterial}
raycast={NO_RAYCAST}
renderOrder={indicatorRenderOrder(visualShape)}
scale={scale}
/>
</group>
</group>
)
}
@@ -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<PointerEvent>
camera: Camera
intersectPlane: IntersectPlane
initialNode: AnyNode
node: AnyNode
nodeId: AnyNodeId
rideObject: Object3D
sceneApi: ReturnType<typeof createSceneApi>
}
export type HandleDragMoveContext = {
event: PointerEvent
intersectPlane: IntersectPlane
}
type HandleDragSession = {
move: (context: HandleDragMoveContext) => Partial<AnyNode> | 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<PointerEvent>) => 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<PointerEvent>) => {
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<AnyNode> | 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<string, unknown>)
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)
}
}
@@ -65,6 +65,7 @@ import { GroupRotateHandle } from './group-rotate-handle'
import { NodeArrowHandles } from './node-arrow-handles' import { NodeArrowHandles } from './node-arrow-handles'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { SlabHoleHighlights } from './slab-hole-highlights'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
@@ -610,6 +611,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
{!noEditing && <GroupRotateHandle />} {!noEditing && <GroupRotateHandle />}
{!noEditing && <GroupMoveHandle />} {!noEditing && <GroupMoveHandle />}
{!noEditing && <WallOpeningHighlights />} {!noEditing && <WallOpeningHighlights />}
{!noEditing && <SlabHoleHighlights />}
{!noEditing && <WallMoveSideHandles />} {!noEditing && <WallMoveSideHandles />}
{!noEditing && <FloatingActionMenu />} {!noEditing && <FloatingActionMenu />}
{!noEditing && <FloatingBuildingActionMenu />} {!noEditing && <FloatingBuildingActionMenu />}
File diff suppressed because it is too large Load Diff
@@ -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) activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
let nextMaterialTargetHandled = false let nextMaterialTargetHandled = false
@@ -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<PointerEvent>) {
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) => (
<SelectedSlabHoleHighlights key={id} slabId={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<Object3D | null>(
() => 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(
<group>
{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 (
<SlabHoleHighlight
active={isActive}
hitCenterY={hitCenterY}
hitHeight={hitHeight}
hole={hole}
holeIndex={holeIndex}
key={`${slabId}:${holeIndex}`}
metadata={metadata}
slabId={slabId}
surfaceY={surfaceY}
/>
)
})}
</group>,
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<PointerEvent>) => {
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<PointerEvent>) => {
event.stopPropagation()
clearHoveredHoleIfMatches(slabId, holeIndex)
resetPointerCursor()
},
[holeIndex, slabId],
)
const handlePointerDown = useCallback(
(event: ThreeEvent<PointerEvent>) => {
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<PointerEvent>) => {
releaseNodeClickSuppression()
stopPointerPropagation(event)
}, [])
return (
<>
<mesh
frustumCulled={false}
geometry={fillGeometry}
material={fillMaterial}
raycast={NO_RAYCAST}
renderOrder={1004}
/>
<lineSegments
frustumCulled={false}
geometry={outlineGeometry}
material={outlineMaterial}
raycast={NO_RAYCAST}
renderOrder={1005}
/>
{active && (
<>
<mesh
frustumCulled={false}
geometry={fillGeometry}
material={fillMaterial}
raycast={NO_RAYCAST}
renderOrder={1006}
/>
<lineSegments
frustumCulled={false}
geometry={outlineGeometry}
material={outlineMaterial}
raycast={NO_RAYCAST}
renderOrder={1007}
/>
</>
)}
<mesh
frustumCulled={false}
geometry={hitGeometry}
layers={EDITOR_LAYER}
material={hitMaterial}
onPointerDown={handlePointerDown}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onPointerUp={handlePointerUp}
renderOrder={1006}
/>
</>
)
}
export default SlabHoleHighlights
@@ -34,6 +34,13 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import {
createArrowHitAreaGeometry,
createEndpointHitAreaGeometry,
InvisibleHandleHitArea,
NO_RAYCAST,
useInvisibleHitAreaMaterial,
} from './node-arrow-handles'
const HANDLE_OFFSET = 0.27 const HANDLE_OFFSET = 0.27
const HANDLE_MIN_OFFSET = 0.33 const HANDLE_MIN_OFFSET = 0.33
@@ -227,7 +234,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
const billboardRef = useRef<Group>(null) const billboardRef = useRef<Group>(null)
const parentWorldQuaternionRef = useRef(new Quaternion()) const parentWorldQuaternionRef = useRef(new Quaternion())
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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 corner = endpoint === 'start' ? wall.start : wall.end
const x = corner[0] const x = corner[0]
@@ -235,7 +243,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight])
const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), [])
const hitMaterial = useInvisibleHitAreaMaterial()
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
// Node materials matched to the rest of the file — mixing plain // Node materials matched to the rest of the file — mixing plain
// `meshBasicMaterial` with WebGPU node materials trips // `meshBasicMaterial` with WebGPU node materials trips
@@ -332,9 +343,10 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
position={[x, 0, z]} position={[x, 0, z]}
renderOrder={1001} renderOrder={1001}
/> />
<group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={scale}> <group position={[x, CORNER_FLOOR_OFFSET, z]} ref={billboardRef} scale={baseScale}>
<mesh <InvisibleHandleHitArea
material={hexMaterial} geometry={hitGeometry}
material={hitMaterial}
onPointerDown={activateEndpointMove} onPointerDown={activateEndpointMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -348,14 +360,17 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
document.body.style.cursor = '' document.body.style.cursor = ''
} }
}} }}
renderOrder={1003} scale={1}
> />
<group scale={visualScale}>
<mesh material={hexMaterial} raycast={NO_RAYCAST} renderOrder={1003}>
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} /> <circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
</mesh> </mesh>
<mesh material={ringMaterial} renderOrder={1002}> <mesh material={ringMaterial} raycast={NO_RAYCAST} renderOrder={1002}>
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} /> <ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
</mesh> </mesh>
</group> </group>
</group>
</> </>
) )
} }
@@ -363,6 +378,8 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint:
function WallHeightArrowHandle({ wall }: { wall: WallNode }) { function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
const hitMaterial = useInvisibleHitAreaMaterial()
const arrowMaterial = useMemo( const arrowMaterial = useMemo(
() => () =>
new MeshBasicNodeMaterial({ new MeshBasicNodeMaterial({
@@ -377,7 +394,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
) )
const { camera, raycaster, gl } = useThree() const { camera, raycaster, gl } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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) const dragCleanupRef = useRef<(() => void) | null>(null)
useEffect(() => { useEffect(() => {
@@ -394,6 +412,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
}, []) }, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry]) useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
// Sit on the visual centre of the wall — for curved walls that's the // Sit on the visual centre of the wall — for curved walls that's the
@@ -509,12 +528,10 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
return ( return (
<group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}> <group position={[midX, handleY, midZ]} rotation={[0, wallAngle, 0]}>
<group rotation={[0, Math.PI / 2, Math.PI / 2]} scale={scale}> <group rotation={[0, Math.PI / 2, Math.PI / 2]}>
<mesh <InvisibleHandleHitArea
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle. geometry={hitGeometry}
frustumCulled={false} material={hitMaterial}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activateHeightResize} onPointerDown={activateHeightResize}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -528,7 +545,16 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
document.body.style.cursor = '' document.body.style.cursor = ''
} }
}} }}
scale={baseScale}
/>
<mesh
// Geometry-as-prop + frustumCulled={false} — see WallMoveArrowHandle.
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
raycast={NO_RAYCAST}
renderOrder={1002} renderOrder={1002}
scale={scale}
/> />
</group> </group>
</group> </group>
@@ -538,6 +564,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) { function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
const hitMaterial = useInvisibleHitAreaMaterial()
const arrowMaterial = useMemo( const arrowMaterial = useMemo(
() => () =>
new MeshBasicNodeMaterial({ new MeshBasicNodeMaterial({
@@ -554,7 +582,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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(() => { useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) 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(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const activateWallMove = (event: ThreeEvent<PointerEvent>) => { const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
@@ -586,16 +616,10 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
} }
return ( return (
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}> <group position={handle.position} rotation={[0, handle.rotationY, 0]}>
<mesh <InvisibleHandleHitArea
// Pass geometry as a prop (not `<primitive attach="geometry">`) geometry={hitGeometry}
// so the mesh is never rendered with R3F's default empty material={hitMaterial}
// `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}
onPointerDown={activateWallMove} onPointerDown={activateWallMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -609,7 +633,20 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
document.body.style.cursor = '' document.body.style.cursor = ''
} }
}} }}
scale={baseScale}
/>
<mesh
// Pass geometry as a prop (not `<primitive attach="geometry">`)
// 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} renderOrder={1002}
scale={scale}
/> />
</group> </group>
) )
@@ -618,6 +655,8 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) { function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), []) const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const hitGeometry = useMemo(() => createArrowHitAreaGeometry(), [])
const hitMaterial = useInvisibleHitAreaMaterial()
const arrowMaterial = useMemo( const arrowMaterial = useMemo(
() => () =>
new MeshBasicNodeMaterial({ new MeshBasicNodeMaterial({
@@ -633,7 +672,8 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
const { camera } = useThree() const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 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(() => { useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR) 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(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial]) useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => { const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
@@ -664,13 +705,10 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
} }
return ( return (
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}> <group position={handle.position} rotation={[0, handle.rotationY, 0]}>
<mesh <InvisibleHandleHitArea
// Pass geometry as a prop — see WallMoveArrowHandle for the geometry={hitGeometry}
// WebGPU "Vertex buffer slot 0 ... was not set" rationale. material={hitMaterial}
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activateFenceMove} onPointerDown={activateFenceMove}
onPointerEnter={(event) => { onPointerEnter={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -684,7 +722,17 @@ function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: Wal
document.body.style.cursor = '' document.body.style.cursor = ''
} }
}} }}
scale={baseScale}
/>
<mesh
// Pass geometry as a prop — see WallMoveArrowHandle for the
// WebGPU "Vertex buffer slot 0 ... was not set" rationale.
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
raycast={NO_RAYCAST}
renderOrder={1002} renderOrder={1002}
scale={scale}
/> />
</group> </group>
) )
@@ -103,12 +103,13 @@ export const floorStrategy = {
// building-local ToolManager group, so local coords are correct for both data and visuals. // building-local ToolManager group, so local coords are correct for both data and visuals.
const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX) const x = snapToGrid(event.localPosition[0], swapDims ? dimZ : dimX)
const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ) const z = snapToGrid(event.localPosition[2], swapDims ? dimX : dimZ)
const y = ctx.gridPosition.y
return { return {
gridPosition: [x, 0, z], gridPosition: [x, y, z],
cursorPosition: [x, event.localPosition[1], z], cursorPosition: [x, y, z],
cursorRotationY: rotY, cursorRotationY: rotY,
nodeUpdate: { position: [x, 0, z] }, nodeUpdate: { position: [x, y, z] },
stopPropagation: false, stopPropagation: false,
dirtyNodeId: null, dirtyNodeId: null,
} }
@@ -126,7 +127,11 @@ export const floorStrategy = {
if (ctx.state.surface !== 'floor') return null if (ctx.state.surface !== 'floor') return null
if (!(ctx.levelId && ctx.draftItem)) 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( const valid = validators.canPlaceOnFloor(
ctx.levelId, ctx.levelId,
pos, pos,
@@ -793,7 +798,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
// Floor (no attachTo) // Floor (no attachTo)
return validators.canPlaceOnFloor( return validators.canPlaceOnFloor(
ctx.levelId, ctx.levelId,
[ctx.gridPosition.x, 0, ctx.gridPosition.z], [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
alignedDims, alignedDims,
ctx.draftItem.rotation, ctx.draftItem.rotation,
[ctx.draftItem.id], [ctx.draftItem.id],
@@ -1,4 +1,4 @@
import type { AssetInput } from '@pascal-app/core' import type { AssetInput, ItemNode } from '@pascal-app/core'
import { import {
type AlignmentAnchor, type AlignmentAnchor,
type AnyNode, type AnyNode,
@@ -14,7 +14,6 @@ import {
resolveLevelId, resolveLevelId,
type ShelfEvent, type ShelfEvent,
sceneRegistry, sceneRegistry,
spatialGridManager,
useAlignmentGuides, useAlignmentGuides,
useLiveTransforms, useLiveTransforms,
useScene, useScene,
@@ -44,6 +43,7 @@ import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
createLineGeometry, createLineGeometry,
getBoxEdgePoints, 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 // Shared materials for placement cursor - we just change colors, not swap materials
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines // Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
const edgeMaterial = new LineBasicNodeMaterial({ const edgeMaterial = new LineBasicNodeMaterial({
@@ -359,6 +375,23 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
applyPoints(measurementHeightRef, heightPoints) applyPoints(measurementHeightRef, heightPoints)
}, []) }, [])
const getFloorVisualPosition = useCallback(
(
position: [number, number, number],
nodeUpdate?: Partial<ItemNode>,
): [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(() => { useEffect(() => {
if (!asset) return if (!asset) return
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -661,11 +694,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
result.gridPosition[1], result.gridPosition[1],
result.gridPosition[2] + alignZ, 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 // Play snap sound when grid position changes
if ( if (
@@ -677,7 +705,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
previousGridPos = [...gridPos] previousGridPos = [...gridPos]
gridPosition.current.set(...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 // Floor items only rotate on Y; keep the preview box (and the live
// transform the 2D floorplan mirrors) aligned with the draft's // transform the 2D floorplan mirrors) aligned with the draft's
// rotation. Without this the box stays at its seed rotation until a // rotation. Without this the box stays at its seed rotation until a
@@ -946,8 +975,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
shelfId: null, shelfId: null,
}) })
gridPosition.current.set(wx, 0, wz) 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) { if (cursorGroupRef.current) {
cursorGroupRef.current.position.set(wx, 0, wz) cursorGroupRef.current.position.set(...floorVisualPosition)
} }
const draft = draftNode.current const draft = draftNode.current
@@ -1466,12 +1500,21 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.set(x, gridPosition.current.y, z) gridPosition.current.set(x, gridPosition.current.y, z)
draft.position = [x, gridPosition.current.y, z] draft.position = [x, gridPosition.current.y, z]
if (cursorGroupRef.current) { if (cursorGroupRef.current) {
if (surface === 'floor') {
cursorGroupRef.current.position.set(
...getFloorVisualPosition([x, gridPosition.current.y, z]),
)
} else {
cursorGroupRef.current.position.x = x cursorGroupRef.current.position.x = x
cursorGroupRef.current.position.z = z cursorGroupRef.current.position.z = z
} }
}
if (mesh) { if (mesh) {
mesh.position.x = x mesh.position.x = x
mesh.position.z = z 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) { } else if (surface === 'item-surface' && placementState.current.surfaceItemId) {
const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId) const surfaceMesh = sceneRegistry.nodes.get(placementState.current.surfaceItemId)
@@ -1501,7 +1544,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Update live transform for 2D floorplan with post-snap position // Update live transform for 2D floorplan with post-snap position
const currentLive = useLiveTransforms.getState().get(draft.id) const currentLive = useLiveTransforms.getState().get(draft.id)
if (currentLive) { if (currentLive) {
const livePosition: [number, number, number] = cursorGroupRef.current 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.x,
cursorGroupRef.current.position.y, cursorGroupRef.current.position.y,
@@ -1647,6 +1693,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
canPlaceOnWall, canPlaceOnWall,
canPlaceOnCeiling, canPlaceOnCeiling,
draftNode, draftNode,
getFloorVisualPosition,
gridSnapStep, gridSnapStep,
updateDimensionGuides, updateDimensionGuides,
updatePreviewGeometry, updatePreviewGeometry,
@@ -1747,18 +1794,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Adjust Y for slab elevation (floor items on top of slabs) // Adjust Y for slab elevation (floor items on top of slabs)
if (!asset.attachTo) { if (!asset.attachTo) {
const nodes = useScene.getState().nodes const visualPosition = getFloorVisualPosition([
const levelId = resolveLevelId(draftNode.current, nodes) gridPosition.current.x,
const slabElevation = spatialGridManager.getSlabElevationForItem( gridPosition.current.y,
levelId, gridPosition.current.z,
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], ])
getGridAlignedDimensions( mesh.position.y = visualPosition[1]
getScaledDimensions(draftNode.current), cursorGroupRef.current.position.y = visualPosition[1]
draftNode.current.asset.attachTo,
),
draftNode.current.rotation,
)
mesh.position.y = slabElevation
} }
} }
}) })
@@ -25,6 +25,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { PlacementBox } from '../shared/placement-box' import { PlacementBox } from '../shared/placement-box'
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 /** 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 — // 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 // 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. // would keep the previous clone's rotation/position until the next R/T.
setCursorPosition(originalPosition)
setCursorRotationY(originalRotationY) setCursorRotationY(originalRotationY)
lastCursorRef.current = originalPosition lastCursorRef.current = originalPosition
let committed = false 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 // 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 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 // 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) setValid(true)
return return
} }
const [x, , z] = lastCursorRef.current const [x, y, z] = lastCursorRef.current
const { valid: placeable } = spatialGridManager.canPlaceOnFloor( const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
levelId, levelId,
[x, 0, z], [x, y, z],
boxDimensions, boxDimensions,
[0, rotationRef.current, 0], [0, rotationRef.current, 0],
[node.id], [node.id],
@@ -209,14 +233,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
} }
recomputeValidity() 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 // Disable raycast on the moved node's meshes for the duration of
// the drag. As the shelf follows the cursor, the cursor ray would // the drag. As the shelf follows the cursor, the cursor ray would
// otherwise hit the moved mesh first → only `${kind}:move` fires → // otherwise hit the moved mesh first → only `${kind}:move` fires →
@@ -275,13 +291,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
const position: [number, number, number] = [x, originalPosition[1], z]
const visualPosition = getVisualPosition(position)
hasMovedRef.current = true hasMovedRef.current = true
setCursorPosition([x, 0, z]) setCursorPosition(visualPosition)
lastCursorRef.current = [x, 0, z] lastCursorRef.current = position
recomputeValidity() recomputeValidity()
// Pure imperative: move the mesh via its registered Object3D ref. // 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 // Publish to `useLiveTransforms` so the 2D floor plan can mirror
// the drag in real-time (the floor-plan layer subscribes to this // the drag in real-time (the floor-plan layer subscribes to this
// store and overrides the node's rendered position when an entry // 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 — // (slab / ceiling / fence) follow a different delta contract —
// their floor-plan move-targets handle the override themselves. // their floor-plan move-targets handle the override themselves.
useLiveTransforms.getState().set(node.id, { useLiveTransforms.getState().set(node.id, {
position: [x, 0, z], position,
rotation: rotationRef.current, rotation: rotationRef.current,
}) })
markMovedNodeDirty()
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== x || prev[1] !== z) { 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 position: [number, number, number] = [...lastCursorRef.current]
const rotation = toCommitRotation(rotationRef.current) const rotation = toCommitRotation(rotationRef.current)
const visualPosition = getVisualPosition(position)
if (useScene.getState().nodes[node.id]) { if (useScene.getState().nodes[node.id]) {
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
@@ -355,18 +375,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
} }
} }
// Keep mesh.position/rotation aligned with the just-committed scene // Clear after the scene write so React reconciles against the new
// values so the next R3F frame paints correctly even if React's // canonical position, then restamp the lifted presentation Y for the
// reconciliation lags by a tick. // current frame.
useLiveTransforms.getState().clear(node.id)
const mesh = sceneRegistry.nodes.get(node.id) const mesh = sceneRegistry.nodes.get(node.id)
if (mesh) { if (mesh) {
mesh.position.set(position[0], position[1], position[2]) mesh.position.set(...visualPosition)
mesh.rotation.y = rotationRef.current 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() useAlignmentGuides.getState().clear()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -405,12 +423,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += delta rotationRef.current += delta
setCursorRotationY(rotationRef.current) setCursorRotationY(rotationRef.current)
const position = lastCursorRef.current
const visualPosition = getVisualPosition(position)
setCursorPosition(visualPosition)
const m = sceneRegistry.nodes.get(node.id) 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, { useLiveTransforms.getState().set(node.id, {
position: lastCursorRef.current, position,
rotation: rotationRef.current, rotation: rotationRef.current,
}) })
markMovedNodeDirty()
// Rotation changes the footprint's collision span — re-check validity. // Rotation changes the footprint's collision span — re-check validity.
recomputeValidity() recomputeValidity()
} }
@@ -437,13 +462,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
} }
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { if (m) {
m.position.set(originalPosition[0], originalPosition[1], originalPosition[2]) m.position.set(...getVisualPosition(originalPosition, originalRotationY))
m.rotation.y = originalRotationY m.rotation.y = originalRotationY
} }
useLiveTransforms.getState().clear(node.id)
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
markMovedNodeDirty()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
@@ -467,10 +493,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// unmount / commit paths uniformly. // unmount / commit paths uniformly.
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
if (!committed) { if (!committed) {
useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes sceneRegistry.nodes
.get(node.id) .get(node.id)
?.position.set(originalPosition[0], originalPosition[1], originalPosition[2]) ?.position.set(...getVisualPosition(originalPosition, originalRotationY))
useLiveTransforms.getState().clear(node.id) markMovedNodeDirty()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
} }
} }
@@ -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<AnyNodeId, AnyNode>
}
export function getFloorStackPreviewPosition({
node,
position,
rotation,
levelId,
nodes,
}: FloorStackPreviewArgs): [number, number, number] {
return getFloorStackedPosition({
node,
nodes: nodes ?? useScene.getState().nodes,
position,
rotation,
levelId,
})
}
@@ -11,16 +11,17 @@ import {
type Line, type Line,
type Object3D, type Object3D,
Shape, Shape,
SphereGeometry,
} from 'three' } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { import {
createMoveCrossHandleGeometry,
ARROW_COLOR as EDGE_ARROW_COLOR, ARROW_COLOR as EDGE_ARROW_COLOR,
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR, ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
ARROW_SCALE as EDGE_ARROW_SCALE, ARROW_SCALE as EDGE_ARROW_SCALE,
useArrowMaterial, useArrowMaterial,
useInvisibleHitAreaMaterial,
} from '../../editor/node-arrow-handles' } from '../../editor/node-arrow-handles'
import { snapToHalf } from '../item/placement-math' 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, color,
position, position,
...handlers ...handlers
@@ -193,20 +198,33 @@ function OutlinedSphereHandle({
color: string color: string
position: [number, number, number] position: [number, number, number]
} & HandleHandlers) { } & HandleHandlers) {
const geometry = useMemo(() => new SphereGeometry(0.09, 20, 20), []) const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
const material = usePolygonNodeMaterial(color) const material = usePolygonNodeMaterial(color)
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
const hitMaterial = useInvisibleHitAreaMaterial()
useEffect(() => () => geometry.dispose(), [geometry]) useEffect(() => () => geometry.dispose(), [geometry])
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
return ( return (
<group position={position}>
<mesh <mesh
frustumCulled={false} frustumCulled={false}
geometry={geometry} geometry={geometry}
layers={SCENE_LAYER} layers={SCENE_LAYER}
material={material} material={material}
position={position} raycast={NO_RAYCAST}
renderOrder={1010} renderOrder={1010}
scale={EDGE_ARROW_SCALE}
/>
<mesh
frustumCulled={false}
geometry={hitGeometry}
layers={EDITOR_LAYER}
material={hitMaterial}
renderOrder={1011}
{...handlers} {...handlers}
/> />
</group>
) )
} }
@@ -651,7 +669,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return ( return (
<OutlinedCylinderHandle <OutlinedCylinderHandle
color={isDragging ? '#22c55e' : isHovered ? '#60a5fa' : '#3b82f6'} color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
height={height} height={height}
key={`vertex-${index}`} key={`vertex-${index}`}
onClick={(e) => { onClick={(e) => {
@@ -693,8 +711,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
})} })}
{allowPolygonMove && ( {allowPolygonMove && (
<OutlinedSphereHandle <OutlinedCrossHandle
color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} color={dragState?.mode === 'polygon' ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
onClick={(e) => { onClick={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
e.stopPropagation() e.stopPropagation()
@@ -760,7 +778,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
> >
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} /> <boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshBasicMaterial <meshBasicMaterial
color={isDragging ? '#22c55e' : '#60a5fa'} color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14} opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
transparent transparent
/> />
@@ -769,9 +787,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
Points outward from the edge; dragging it translates only this Points outward from the edge; dragging it translates only this
edge's two vertices along the outward normal. */} edge's two vertices along the outward normal. */}
<OutlinedEdgeArrowHandle <OutlinedEdgeArrowHandle
color={ color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR
}
geometry={arrowGeometry} geometry={arrowGeometry}
onClick={(e) => { onClick={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
@@ -807,7 +823,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return ( return (
<OutlinedCylinderHandle <OutlinedCylinderHandle
color={isHovered ? '#4ade80' : '#22c55e'} color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
height={height} height={height}
key={`midpoint-${index}`} key={`midpoint-${index}`}
onClick={(e) => { onClick={(e) => {
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
import { import {
DEFAULT_CURVED_STAIR_INNER_RADIUS, DEFAULT_CURVED_STAIR_INNER_RADIUS,
DEFAULT_CURVED_STAIR_SWEEP_ANGLE, 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( function createDefaultStairSegment() {
levelId: LevelNode['id'], return StairSegmentNode.parse({
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({
segmentType: 'stair', segmentType: 'stair',
width: DEFAULT_STAIR_WIDTH, width: DEFAULT_STAIR_WIDTH,
length: DEFAULT_STAIR_LENGTH, length: DEFAULT_STAIR_LENGTH,
@@ -96,14 +88,24 @@ function commitStairPlacement(
thickness: DEFAULT_STAIR_THICKNESS, thickness: DEFAULT_STAIR_THICKNESS,
position: [0, 0, 0], position: [0, 0, 0],
}) })
}
const sortedLevels = Object.values(nodes) function createDefaultStairNode({
.filter((node): node is LevelNode => node.type === 'level') name,
.sort((left, right) => left.level - right.level) levelId,
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId) nextLevelId,
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId position,
rotation,
const stair = StairNode.parse({ segmentId,
}: {
name: string
levelId: LevelNode['id']
nextLevelId: LevelNode['id']
position: [number, number, number]
rotation: number
segmentId: StairSegmentNode['id']
}) {
return StairNode.parse({
name, name,
position, position,
rotation, rotation,
@@ -125,7 +127,37 @@ function commitStairPlacement(
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT, railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
railingMode: DEFAULT_STAIR_RAILING_MODE, 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([ createNodes([
@@ -141,6 +173,7 @@ export const StairTool: React.FC = () => {
const previewRef = useRef<THREE.Group>(null) const previewRef = useRef<THREE.Group>(null)
const rotationRef = useRef(0) const rotationRef = useRef(0)
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null)
const currentLevelId = useViewer((state) => state.selection.levelId) const currentLevelId = useViewer((state) => state.selection.levelId)
const previewGeometry = useMemo(() => createStairPreviewGeometry(), []) const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
@@ -151,6 +184,49 @@ export const StairTool: React.FC = () => {
// Reset rotation when tool activates // Reset rotation when tool activates
rotationRef.current = 0 rotationRef.current = 0
if (previewRef.current) previewRef.current.rotation.y = 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 // Alignment candidates — anchors of every alignable object; refreshed
// after each placement. The stair aligns by its ORIGIN point. // after each placement. The stair aligns by its ORIGIN point.
@@ -199,15 +275,9 @@ export const StairTool: React.FC = () => {
event.localPosition[2], event.localPosition[2],
event.nativeEvent?.altKey === true, event.nativeEvent?.altKey === true,
) )
const y = event.localPosition[1] const position: [number, number, number] = [gridX, 0, gridZ]
lastCanonicalPositionRef.current = position
if (cursorRef.current) { applyPreview(position, rotationRef.current)
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
}
if (previewRef.current) {
previewRef.current.position.set(gridX, y, gridZ)
}
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -248,7 +318,9 @@ export const StairTool: React.FC = () => {
event.preventDefault() event.preventDefault()
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += rotationDelta rotationRef.current += rotationDelta
if (previewRef.current) { if (lastCanonicalPositionRef.current) {
applyPreview(lastCanonicalPositionRef.current, rotationRef.current)
} else if (previewRef.current) {
previewRef.current.rotation.y = rotationRef.current previewRef.current.rotation.y = rotationRef.current
} }
} }
@@ -78,6 +78,11 @@ export const ToolManager: React.FC = () => {
const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as const selectedSlabId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'slab') as
| SlabNode['id'] | SlabNode['id']
| undefined | 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 // Check if a ceiling is selected
const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as const selectedCeilingId = selectedIds.find((id) => nodes[id as AnyNodeId]?.type === 'ceiling') as
@@ -92,11 +97,14 @@ export const ToolManager: React.FC = () => {
phase === 'structure' && phase === 'structure' &&
mode === 'select' && mode === 'select' &&
selectedSlabId !== undefined && selectedSlabId !== undefined &&
(!editingHole || editingHole.nodeId !== selectedSlabId) !editingSlabHoleIsManual
// Show slab hole editor when editing a hole on the selected slab // Show slab hole editor when editing a hole on the selected slab
const showSlabHoleEditor = 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) // Show ceiling boundary editor when in structure/select mode with a ceiling selected (but not editing a hole)
const showCeilingBoundaryEditor = const showCeilingBoundaryEditor =
+1
View File
@@ -60,6 +60,7 @@ export {
} from './components/tools/item/use-placement-coordinator' } from './components/tools/item/use-placement-coordinator'
export { CursorSphere } from './components/tools/shared/cursor-sphere' export { CursorSphere } from './components/tools/shared/cursor-sphere'
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' 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. // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export { export {
PolygonEditor, PolygonEditor,
+1
View File
@@ -355,6 +355,7 @@ function resetEditorInteractionState() {
selectedReferenceId: null, selectedReferenceId: null,
spaces: {}, spaces: {},
editingHole: null, editingHole: null,
hoveredHole: null,
isPreviewMode: false, isPreviewMode: false,
}) })
} }
+14 -2
View File
@@ -158,6 +158,8 @@ type MaterialPaintSelectionSnapshot = {
activePaintMaterial: ActivePaintMaterial | null activePaintMaterial: ActivePaintMaterial | null
} }
export type SurfaceHoleTarget = { nodeId: string; holeIndex: number }
export type GuideUiState = { export type GuideUiState = {
locked?: boolean locked?: boolean
scaleReferenceVisible?: boolean scaleReferenceVisible?: boolean
@@ -296,8 +298,10 @@ type EditorState = {
spaces: Record<string, Space> spaces: Record<string, Space>
setSpaces: (spaces: Record<string, Space>) => void setSpaces: (spaces: Record<string, Space>) => void
// Generic hole editing (works for slabs, ceilings, and any future polygon nodes) // Generic hole editing (works for slabs, ceilings, and any future polygon nodes)
editingHole: { nodeId: string; holeIndex: number } | null editingHole: SurfaceHoleTarget | null
setEditingHole: (hole: { nodeId: string; holeIndex: number } | null) => void setEditingHole: (hole: SurfaceHoleTarget | null) => void
hoveredHole: SurfaceHoleTarget | null
setHoveredHole: (hole: SurfaceHoleTarget | null) => void
// Preview mode (viewer-like experience inside the editor) // Preview mode (viewer-like experience inside the editor)
isPreviewMode: boolean isPreviewMode: boolean
setPreviewMode: (preview: boolean) => void setPreviewMode: (preview: boolean) => void
@@ -808,6 +812,14 @@ const useEditor = create<EditorState>()(
setSpaces: (spaces) => set({ spaces }), setSpaces: (spaces) => set({ spaces }),
editingHole: null, editingHole: null,
setEditingHole: (hole) => set({ editingHole: hole }), 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, isPreviewMode: false,
setPreviewMode: (preview) => { setPreviewMode: (preview) => {
if (preview) { if (preview) {
+25 -1
View File
@@ -19,6 +19,7 @@ const BRACE_HANDLE_OFFSET = 0.3
const SPREAD_HANDLE_OFFSET = 0.22 const SPREAD_HANDLE_OFFSET = 0.22
const ROTATE_CORNER_OFFSET = 0.32 const ROTATE_CORNER_OFFSET = 0.32
const ROTATE_RING_OFFSET = 0.04 const ROTATE_RING_OFFSET = 0.04
const MOVE_FRONT_OFFSET = 0.35
const MIN_COLUMN_HEIGHT = 0.2 const MIN_COLUMN_HEIGHT = 0.2
const MIN_COLUMN_WIDTH = 0.1 const MIN_COLUMN_WIDTH = 0.1
const MIN_COLUMN_DEPTH = 0.1 const MIN_COLUMN_DEPTH = 0.1
@@ -241,6 +242,29 @@ function columnRotateHandle(): HandleDescriptor<ColumnNodeType> {
} }
} }
function columnMoveHandle(): HandleDescriptor<ColumnNodeType> {
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<ColumnNodeType>[] { function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[] {
// 1. Height (universal). // 1. Height (universal).
// 2. Footprint arrows depending on supportStyle + crossSection: // 2. Footprint arrows depending on supportStyle + crossSection:
@@ -265,7 +289,7 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor<ColumnNodeType>[]
} else { } else {
handles.push(columnAxisHandle('x'), columnAxisHandle('z')) handles.push(columnAxisHandle('x'), columnAxisHandle('z'))
} }
handles.push(columnRotateHandle()) handles.push(columnRotateHandle(), columnMoveHandle())
return handles return handles
} }
+27 -5
View File
@@ -17,6 +17,7 @@ import {
import { import {
CursorSphere, CursorSphere,
DragBoundingBox, DragBoundingBox,
getFloorStackPreviewPosition,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
@@ -74,6 +75,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
? (node.metadata as Record<string, unknown>) ? (node.metadata as Record<string, unknown>)
: {} : {}
const isNew = !!meta.isNew 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 // Alignment candidates — every other alignable object's anchors, gathered
// once (the scene graph is stable during the imperative drag). // 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]) => { const applyPreview = (position: [number, number, number]) => {
lastPosition = position lastPosition = position
setPreviewPosition(position) const visualPosition = getVisualPosition(position)
setPreviewPosition(visualPosition)
setPreviewRotation(rotationY) setPreviewRotation(rotationY)
useLiveTransforms.getState().set(node.id, { useLiveTransforms.getState().set(node.id, {
position, position,
rotation: rotationY, rotation: rotationY,
}) })
useScene.getState().markDirty(node.id as AnyNodeId)
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { if (m) {
m.position.set(position[0], position[1], position[2]) m.position.set(...visualPosition)
m.rotation.y = rotationY m.rotation.y = rotationY
} }
} }
setPreviewPosition(getVisualPosition(node.position, node.rotation))
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
hasMoved = true hasMoved = true
let x = snapToGridStep(event.localPosition[0]) let x = snapToGridStep(event.localPosition[0])
@@ -145,11 +160,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
if (nodeId && useScene.getState().nodes[nodeId]) { if (nodeId && useScene.getState().nodes[nodeId]) {
committed = true committed = true
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
useScene useScene
.getState() .getState()
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) }) .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) { } else if (node.parentId) {
const column = ColumnNodeSchema.parse({ const column = ColumnNodeSchema.parse({
...node, ...node,
@@ -174,9 +194,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { 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 m.rotation.y = node.rotation
} }
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
markToolCancelConsumed() markToolCancelConsumed()
exitMoveMode() exitMoveMode()
@@ -197,9 +218,10 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
if (!committed) { if (!committed) {
const m = sceneRegistry.nodes.get(node.id) const m = sceneRegistry.nodes.get(node.id)
if (m) { 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 m.rotation.y = node.rotation
} }
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
} }
} }
+10 -3
View File
@@ -13,7 +13,7 @@ import {
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } 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 { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import type { Group } from 'three' import type { Group } from 'three'
@@ -91,13 +91,20 @@ const ColumnTool = () => {
useAlignmentGuides.getState().clear() 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 // Publish a transient, positioned preview node for the 2D floor-plan
// ghost (the 3D `ColumnPreview` mesh is hidden in 2D). The 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, // placement-preview layer renders this node's footprint at the snapped,
// aligned cursor so users see the pillar before they click. // 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 const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) { if (!prev || prev[0] !== ax || prev[1] !== az) {
+40 -4
View File
@@ -5,6 +5,7 @@ import {
type FenceNode, type FenceNode,
type GridEvent, type GridEvent,
type LevelNode, type LevelNode,
nodeRegistry,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
resolveAlignment, resolveAlignment,
@@ -19,6 +20,7 @@ import {
import { import {
CursorSphere, CursorSphere,
clearRoofDuplicateMetadata, clearRoofDuplicateMetadata,
getFloorStackPreviewPosition,
snapFenceDraftPoint, snapFenceDraftPoint,
triggerSFX, triggerSFX,
useEditor, useEditor,
@@ -113,6 +115,11 @@ export const MoveRoofTool: React.FC<{
// Track pending rotation — no store updates during drag // Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number 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, // For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and // 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 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 = const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level' levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode) ? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
@@ -260,20 +281,28 @@ export const MoveRoofTool: React.FC<{
} }
previousGridPosRef.current = [gridX, gridZ] previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, 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 // Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id) const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) { if (mesh) {
if (isFloorPlaced) {
mesh.position.set(...previewPosition)
} else {
mesh.position.x = localX mesh.position.x = localX
mesh.position.z = localZ 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, { useLiveTransforms.getState().set(movingNode.id, {
position: [gridX, y, gridZ], position: isFloorPlaced ? lastLocalPosition : [gridX, y, gridZ],
rotation: pendingRotation, rotation: pendingRotation,
}) })
} }
@@ -359,7 +388,14 @@ export const MoveRoofTool: React.FC<{
// Directly update the Three.js mesh — no store update during drag // Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id) 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 // Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(movingNode.id) const currentLive = useLiveTransforms.getState().get(movingNode.id)
+32 -3
View File
@@ -1,4 +1,5 @@
import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core' import type { HandleDescriptor, NodeDefinition, ShelfNode as ShelfNodeType } from '@pascal-app/core'
import { sanitizeShelfDimensions } from './dimensions'
import { buildShelfFloorplan } from './floorplan' import { buildShelfFloorplan } from './floorplan'
import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances' import { shelfResizeAffordance, shelfRotateAffordance } from './floorplan-affordances'
import { shelfFloorplanMoveTarget } from './floorplan-move' import { shelfFloorplanMoveTarget } from './floorplan-move'
@@ -10,6 +11,7 @@ const SIDE_HANDLE_OFFSET = 0.18
const HEIGHT_HANDLE_OFFSET = 0.22 const HEIGHT_HANDLE_OFFSET = 0.22
const ROTATE_CORNER_OFFSET = 0.32 const ROTATE_CORNER_OFFSET = 0.32
const ROTATE_RING_OFFSET = 0.04 const ROTATE_RING_OFFSET = 0.04
const MOVE_FRONT_OFFSET = 0.35
const MIN_SHELF_WIDTH = 0.3 const MIN_SHELF_WIDTH = 0.3
const MIN_SHELF_DEPTH = 0.1 const MIN_SHELF_DEPTH = 0.1
const MIN_SHELF_HEIGHT = 0.05 const MIN_SHELF_HEIGHT = 0.05
@@ -95,8 +97,35 @@ function shelfRotateHandle(): HandleDescriptor<ShelfNodeType> {
} }
} }
function shelfMoveHandle(): HandleDescriptor<ShelfNodeType> {
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<ShelfNodeType>[] { function shelfHandles(_node: ShelfNodeType): HandleDescriptor<ShelfNodeType>[] {
return [shelfWidthHandle(), shelfDepthHandle(), shelfHeightHandle(), shelfRotateHandle()] return [
shelfWidthHandle(),
shelfDepthHandle(),
shelfHeightHandle(),
shelfRotateHandle(),
shelfMoveHandle(),
]
} }
export const shelfDefinition: NodeDefinition<typeof ShelfNode> = { export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
@@ -158,7 +187,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// shelf sitting over a raised slab visually rests on top of it. // shelf sitting over a raised slab visually rests on top of it.
floorPlaced: { floorPlaced: {
footprint: (node) => { footprint: (node) => {
const shelf = node as ShelfNode const shelf = sanitizeShelfDimensions(node as ShelfNode)
return { return {
dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number], dimensions: [shelf.width, shelf.height, shelf.depth] as [number, number, number],
rotation: shelf.rotation, rotation: shelf.rotation,
@@ -189,7 +218,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the // `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the
// pointer enter/leave churn it causes) when an item reparents onto a row. // pointer enter/leave churn it causes) when an item reparents onto a row.
geometryKey: (n) => { geometryKey: (n) => {
const s = n as ShelfNodeType const s = sanitizeShelfDimensions(n as ShelfNode)
return JSON.stringify([ return JSON.stringify([
s.style, s.style,
s.width, s.width,
+18
View File
@@ -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)),
}
}
+8 -1
View File
@@ -10,6 +10,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
applyFloorplanAlignment, applyFloorplanAlignment,
getFloorStackPreviewPosition,
snapPointToGrid, snapPointToGrid,
triggerSFX, triggerSFX,
type WallPlanPoint, type WallPlanPoint,
@@ -82,6 +83,12 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
triggerSFX('sfx:grid-snap') triggerSFX('sfx:grid-snap')
lastSnapKey = snapKey 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 // Single source of truth — write the absolute position straight to
// the scene (history is paused by the overlay). Both the 2D SVG and // the scene (history is paused by the overlay). Both the 2D SVG and
// the 3D group transform read `node.position` reactively, so they // the 3D group transform read `node.position` reactively, so they
@@ -90,7 +97,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
useScene.getState().updateNodes([ useScene.getState().updateNodes([
{ {
id: shelfId, id: shelfId,
data: { position: next }, data: { position: visualPosition },
}, },
]) ])
}, },
+14 -12
View File
@@ -1,4 +1,5 @@
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core' import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { sanitizeShelfDimensions } from './dimensions'
import type { ShelfResizePayload } from './floorplan-affordances' import type { ShelfResizePayload } from './floorplan-affordances'
import type { ShelfNode } from './schema' 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). * (engaged from the action-menu Move button, not from these arrows).
*/ */
export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry { export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): FloorplanGeometry {
const [px, , pz] = node.position const shelf = sanitizeShelfDimensions(node)
const ry = node.rotation[1] ?? 0 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 // 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 // ends up visually matching Three.js Y-rotation (CCW from a top-down
// view) — same `rotation` value rotates the same way in both views. // view) — same `rotation` value rotates the same way in both views.
// Stair already does this; column / shelf / roof-segment now do too. // Stair already does this; column / shelf / roof-segment now do too.
const planRy = -ry const planRy = -ry
const halfW = node.width / 2 const halfW = shelf.width / 2
const halfD = node.depth / 2 const halfD = shelf.depth / 2
const isSelected = ctx?.viewState?.selected ?? false const isSelected = ctx?.viewState?.selected ?? false
// Floor-plan fill: a single neutral fill regardless of `material`. // Floor-plan fill: a single neutral fill regardless of `material`.
@@ -44,8 +46,8 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
kind: 'rect', kind: 'rect',
x: -halfW, x: -halfW,
y: -halfD, y: -halfD,
width: node.width, width: shelf.width,
height: node.depth, height: shelf.depth,
fill: '#d6d3d1', fill: '#d6d3d1',
stroke: '#1f2937', stroke: '#1f2937',
strokeWidth: 0.015, 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 // Show column dividers for grid-style shelves so the cubby / bookshelf
// grid is visible from above. // grid is visible from above.
if ((node.style === 'bookshelf' || node.style === 'cubby') && node.columns > 1) { if ((shelf.style === 'bookshelf' || shelf.style === 'cubby') && shelf.columns > 1) {
const innerWidth = node.width - 2 * node.thickness const innerWidth = shelf.width - 2 * shelf.thickness
const colStep = innerWidth / node.columns const colStep = innerWidth / shelf.columns
for (let c = 1; c < node.columns; c++) { for (let c = 1; c < shelf.columns; c++) {
const x = -innerWidth / 2 + c * colStep const x = -innerWidth / 2 + c * colStep
footprintChildren.push({ footprintChildren.push({
kind: 'line', kind: 'line',
x1: x, x1: x,
y1: -halfD + node.thickness, y1: -halfD + shelf.thickness,
x2: x, x2: x,
y2: halfD - node.thickness, y2: halfD - shelf.thickness,
stroke: '#1f2937', stroke: '#1f2937',
strokeWidth: 0.012, strokeWidth: 0.012,
opacity: 0.7, opacity: 0.7,
+7 -4
View File
@@ -7,6 +7,7 @@ import {
type RenderShading, type RenderShading,
} from '@pascal-app/viewer' } from '@pascal-app/viewer'
import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three' import { BoxGeometry, FrontSide, Group, type Material, Mesh } from 'three'
import { sanitizeShelfDimensions } from './dimensions'
import type { ShelfNode } from './schema' import type { ShelfNode } from './schema'
/** /**
@@ -69,10 +70,11 @@ function getShelfMaterial(node: ShelfNode, shading: RenderShading): Material {
} }
export function buildShelfGeometry( export function buildShelfGeometry(
node: ShelfNode, rawNode: ShelfNode,
_ctx?: unknown, _ctx?: unknown,
shading: RenderShading = 'rendered', shading: RenderShading = 'rendered',
): Group { ): Group {
const node = sanitizeShelfDimensions(rawNode)
const group = new Group() const group = new Group()
group.name = 'shelf-geometry' group.name = 'shelf-geometry'
@@ -343,8 +345,9 @@ function addCornerPosts(
* can host in the lowest cell. * can host in the lowest cell.
*/ */
export function shelfRowSurfaceYs(node: ShelfNode): number[] { export function shelfRowSurfaceYs(node: ShelfNode): number[] {
const ys = boardCenterYs(node).map((y) => y + node.thickness / 2) const safe = sanitizeShelfDimensions(node)
const bottomApplies = node.style === 'cubby' || node.style === 'bookshelf' const ys = boardCenterYs(safe).map((y) => y + safe.thickness / 2)
if (node.withBottom && bottomApplies) ys.unshift(node.thickness) const bottomApplies = safe.style === 'cubby' || safe.style === 'bookshelf'
if (safe.withBottom && bottomApplies) ys.unshift(safe.thickness)
return ys return ys
} }
+13 -6
View File
@@ -15,7 +15,7 @@ import {
useAlignmentGuides, useAlignmentGuides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor' import { getFloorStackPreviewPosition, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { type Group, Vector3 } from 'three' import { type Group, Vector3 } from 'three'
@@ -70,16 +70,16 @@ function getLevelLocalPosition(
const local = (event as GridEvent).localPosition const local = (event as GridEvent).localPosition
if (local) { if (local) {
const [sx, sz] = snapPointToGrid([local[0], local[2]], GRID_STEP) 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) 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]) worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false) levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector) levelObject.worldToLocal(worldVector)
const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP) const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], GRID_STEP)
return [sx, worldVector.y, sz] return [sx, 0, sz]
} }
const ShelfTool = () => { const ShelfTool = () => {
@@ -150,8 +150,15 @@ const ShelfTool = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} }
cursorRef.current?.position.set(ax, event.localPosition[1], az) const position: [number, number, number] = [ax, 0, az]
lastCursorRef.current = [ax, event.localPosition[1], az] const visualPosition = getFloorStackPreviewPosition({
node: previewNode,
position,
rotation: previewNode.rotation,
levelId: activeLevelId,
})
cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
const prev = previousSnapRef.current const prev = previousSnapRef.current
if (!prev || prev[0] !== ax || prev[1] !== az) { if (!prev || prev[0] !== ax || prev[1] !== az) {
+2 -1
View File
@@ -24,6 +24,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const holes = slab?.holes || [] const holes = slab?.holes || []
const hole = holes[holeIndex] const hole = holes[holeIndex]
const metadata = slab?.holeMetadata?.[holeIndex]
const handlePolygonChange = useCallback( const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => { (newPolygon: Array<[number, number]>) => {
@@ -60,7 +61,7 @@ export const SlabHoleEditor: React.FC<{ slabId: SlabNode['id']; holeIndex: numbe
} }
}, [slabId]) }, [slabId])
if (!(slab && hole) || hole.length < 3) return null if (!(slab && hole) || hole.length < 3 || metadata?.source !== 'manual') return null
return ( return (
<PolygonEditor <PolygonEditor
+17 -1
View File
@@ -1,8 +1,23 @@
import type { NodeDefinition } from '@pascal-app/core' import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
import { buildSpawnFloorplan } from './floorplan' import { buildSpawnFloorplan } from './floorplan'
import { spawnParametrics } from './parametrics' import { spawnParametrics } from './parametrics'
import { SpawnNode } from './schema' import { SpawnNode } from './schema'
const SPAWN_FOOTPRINT = 0.6
const MOVE_FRONT_OFFSET = 0.35
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
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<typeof SpawnNode> = { export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
kind: 'spawn', kind: 'spawn',
schemaVersion: 1, schemaVersion: 1,
@@ -37,6 +52,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
}, },
parametrics: spawnParametrics, parametrics: spawnParametrics,
handles: [spawnMoveHandle()],
renderer: { renderer: {
kind: 'parametric', kind: 'parametric',
+15 -4
View File
@@ -1,6 +1,12 @@
'use client' '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 { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three' import { Color, type Group, Shape } from 'three'
@@ -20,6 +26,11 @@ const SPAWN_COLOR = new Color('#22c55e')
const SpawnRenderer = ({ node }: { node: SpawnNode }) => { const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
const handlers = useNodeEvents(node, 'spawn') 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 liveTransform = useLiveTransforms((state) => state.get(node.id))
const walkthroughMode = useViewer((state) => state.walkthroughMode) const walkthroughMode = useViewer((state) => state.walkthroughMode)
const shading = useViewer((state) => state.shading) const shading = useViewer((state) => state.shading)
@@ -52,10 +63,10 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
return ( return (
<group <group
position={liveTransform?.position ?? node.position} position={liveTransform?.position ?? effectiveNode.position}
ref={ref} ref={ref}
rotation={[0, liveTransform?.rotation ?? node.rotation, 0]} rotation={[0, liveTransform?.rotation ?? effectiveNode.rotation, 0]}
visible={!walkthroughMode} visible={!walkthroughMode && effectiveNode.visible !== false}
> >
<mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}> <mesh position={[0, 0.09, 0]} rotation={[-Math.PI / 2, 0, 0]} {...handlers}>
<ringGeometry args={[0.34, 0.48, 48]} /> <ringGeometry args={[0.34, 0.48, 48]} />
+21 -8
View File
@@ -1,7 +1,12 @@
'use client' 'use client'
import { emitter, type GridEvent, SpawnNode, sceneRegistry, useScene } from '@pascal-app/core' 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 { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { type Group, Vector3 } from 'three' import { type Group, Vector3 } from 'three'
@@ -20,16 +25,12 @@ function getExistingSpawnIds() {
function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] { function getLevelLocalPosition(levelId: string, event: GridEvent): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId) const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) { if (!levelObject) {
return [ return [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])]
roundToHalf(event.localPosition[0]),
event.localPosition[1],
roundToHalf(event.localPosition[2]),
]
} }
worldVector.set(event.position[0], event.position[1], event.position[2]) worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false) levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVector) 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. // same half-meter snap the legacy tool uses.
const nextX = roundToHalf(event.localPosition[0]) const nextX = roundToHalf(event.localPosition[0])
const nextZ = roundToHalf(event.localPosition[2]) 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, // Fire grid-snap SFX only when the snapped position crosses a cell,
// not every frame the mouse moves within the same cell. Matches the // not every frame the mouse moves within the same cell. Matches the
+116 -1
View File
@@ -1,8 +1,10 @@
import { import {
type HandleDescriptor, type HandleDescriptor,
type NodeDefinition, type NodeDefinition,
type SceneApi,
StairNode as StairNodeSchema, StairNode as StairNodeSchema,
type StairNode as StairNodeType, type StairNode as StairNodeType,
type StairSegmentNode,
stairFootprintAABB, stairFootprintAABB,
} from '@pascal-app/core' } 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. // the footprint. Same pattern as elevator / column / shelf / roof-segment.
const STAIR_ROTATE_CORNER_OFFSET = 0.4 const STAIR_ROTATE_CORNER_OFFSET = 0.4
const STAIR_ROTATE_RING_OFFSET = 0.08 const STAIR_ROTATE_RING_OFFSET = 0.08
const STAIR_MOVE_FRONT_OFFSET = 0.35
type CurvedStairGeom = { type CurvedStairGeom = {
isSpiral: boolean isSpiral: boolean
@@ -42,6 +45,14 @@ type CurvedStairGeom = {
minInnerRadius: number minInnerRadius: number
} }
type StairMoveBounds = {
minX: number
maxX: number
minZ: number
maxZ: number
height: number
}
function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom { function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom {
const isSpiral = node.stairType === 'spiral' const isSpiral = node.stairType === 'spiral'
const stepCount = Math.max(2, Math.round(node.stepCount ?? 10)) 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' 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<StairSegmentNode>(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<StairNodeType> { function curvedRiseHandle(): HandleDescriptor<StairNodeType> {
return { return {
kind: 'linear-resize', kind: 'linear-resize',
@@ -266,6 +350,29 @@ function stairRotateHandle(): HandleDescriptor<StairNodeType> {
} }
} }
function stairMoveHandle(): HandleDescriptor<StairNodeType> {
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<StairNodeType>[] { function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
// Straight stairs have no parent-level shape arrows — the segment // Straight stairs have no parent-level shape arrows — the segment
// children each render their own (width / length / height). Curved + // children each render their own (width / length / height). Curved +
@@ -282,10 +389,14 @@ function stairHandles(node: StairNodeType): HandleDescriptor<StairNodeType>[] {
curvedSweepHandle('end'), curvedSweepHandle('end'),
) )
} }
handles.push(stairRotateHandle()) handles.push(stairRotateHandle(), stairMoveHandle())
return handles return handles
} }
import {
computeStairSegmentFloorStackTransforms,
getStairFloorPlacedFootprints,
} from './floor-stack'
import { buildStairFloorplan } from './floorplan' import { buildStairFloorplan } from './floorplan'
import { import {
curvedStairInnerRadiusAffordance, curvedStairInnerRadiusAffordance,
@@ -330,6 +441,10 @@ export const stairDefinition: NodeDefinition<typeof StairNode> = {
}, },
duplicable: true, duplicable: true,
deletable: true, deletable: true,
floorPlaced: {
footprints: (node, ctx) =>
ctx ? getStairFloorPlacedFootprints(node as StairNodeType, ctx.nodes) : [],
},
}, },
// Bespoke move shared with roof / roof-segment / stair-segment via // Bespoke move shared with roof / roof-segment / stair-segment via
@@ -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)
})
})
+114
View File
@@ -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<Record<AnyNodeId, AnyNode>>,
): 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]
}
@@ -103,15 +103,26 @@ export function Lights() {
if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue if (SHADOW_EXCLUDED_TYPES.some((t) => sceneRegistry.byType[t]!.has(id))) continue
box.expandByObject(obj) box.expandByObject(obj)
} }
if (box.isEmpty()) { box.getBoundingSphere(boundsSphere.current)
// Empty scene: fall back to the origin with a default radius so the const center = boundsSphere.current.center
// ground still receives a sensible shadow region. 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) shadowFocus.current.set(0, 0, 0)
shadowRadius.current = SHADOW_FALLBACK_RADIUS shadowRadius.current = SHADOW_FALLBACK_RADIUS
} else {
box.getBoundingSphere(boundsSphere.current)
shadowFocus.current.copy(boundsSphere.current.center)
shadowRadius.current = boundsSphere.current.radius
} }
} }
@@ -1,15 +1,43 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveNode,
getFloorStackedPosition,
nodeRegistry, nodeRegistry,
resolveLevelId,
sceneRegistry, sceneRegistry,
spatialGridManager, useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import type * as THREE from 'three' 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<string, unknown>),
position: liveTransform.position,
...(rotation !== undefined ? { rotation } : {}),
} as AnyNode
}
/** /**
* Generic floor-elevation system. * Generic floor-elevation system.
* *
@@ -25,11 +53,12 @@ import type * as THREE from 'three'
* *
* Runs at priority 1 — before the priority-2 systems (`GeometrySystem`, * Runs at priority 1 — before the priority-2 systems (`GeometrySystem`,
* `ItemSystem`) so the dirty mark survives long enough for those to do * `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 * their own work. Kinds with no geometry/system have no downstream dirty
* generic geometry rebuild) is responsible for that. * consumer, so this system clears their dirty mark after applying the lift.
*/ */
export const FloorElevationSystem = () => { export const FloorElevationSystem = () => {
const dirtyNodes = useScene((s) => s.dirtyNodes) const dirtyNodes = useScene((s) => s.dirtyNodes)
const clearDirty = useScene((s) => s.clearDirty)
useFrame(() => { useFrame(() => {
if (dirtyNodes.size === 0) return if (dirtyNodes.size === 0) return
@@ -43,31 +72,31 @@ export const FloorElevationSystem = () => {
const floorPlaced = def?.capabilities?.floorPlaced const floorPlaced = def?.capabilities?.floorPlaced
if (!floorPlaced) return 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 const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined
if (!mesh) return 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 if (!position) return
const levelId = resolveLevelId(node, nodes) // This system is the single drag-time authority for floor-stack mesh Y:
if (!levelId) return // tools publish base positions to live stores, renderers may
// reconcile that base Y onto the group, then this presentation system
const { dimensions, rotation } = floorPlaced.footprint(node as AnyNode) // reapplies the resolver-derived visual Y before render. Because the
const slabElevation = spatialGridManager.getSlabElevationForItem( // override/store position remains base-height, the slab lift is never
levelId, // committed or applied twice.
const resolverNodes =
effectiveNode === node ? nodes : { ...nodes, [effectiveNode.id]: effectiveNode }
const visualPosition = getFloorStackedPosition({
node: effectiveNode,
nodes: resolverNodes,
position, position,
dimensions, })
rotation, mesh.position.y = visualPosition[1]
)
mesh.position.y = slabElevation + position[1] if (!(def.geometry || def.system)) {
clearDirty(id as AnyNodeId)
}
}) })
}, 1) }, 1)
@@ -2,11 +2,10 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveNode, getEffectiveNode,
resolveLevelId, getFloorStackedPosition,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
sceneRegistry, sceneRegistry,
spatialGridManager,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
@@ -96,9 +95,9 @@ export const StairSystem = () => {
// so slab-elevation spatial queries match where the segments are // so slab-elevation spatial queries match where the segments are
// actually being rendered. Without this, dragging the rotate gizmo // actually being rendered. Without this, dragging the rotate gizmo
// looks up slabs at the pre-drag world XZ — if rotation carries a // looks up slabs at the pre-drag world XZ — if rotation carries a
// segment off the original slab footprint, getStairSlabElevation // segment off the original slab footprint, the floor-stack
// returns 0 and `group.position.y` collapses, dropping the flight // resolver would otherwise read the pre-drag footprint and drop
// or landing below the floor and out of view mid-drag. // the flight or landing below the floor mid-drag.
const stairNode = getEffectiveNode(baseStairNode as StairNode) const stairNode = getEffectiveNode(baseStairNode as StairNode)
const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined const group = sceneRegistry.nodes.get(stairId) as THREE.Group | undefined
if (group) { if (group) {
@@ -273,57 +272,20 @@ function syncStairGroupElevation(
group: THREE.Group, group: THREE.Group,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
) { ) {
const levelId = resolveLevelId(stairNode, nodes) const effectiveNodes: Record<string, AnyNode> = { ...nodes, [stairNode.id]: stairNode }
const slabElevation = getStairSlabElevation(levelId, stairNode, nodes) for (const childId of stairNode.children ?? []) {
group.position.y = stairNode.position[1] + slabElevation const segment = nodes[childId as AnyNodeId]
} if (segment?.type === 'stair-segment') {
effectiveNodes[segment.id] = getEffectiveNode(segment as StairSegmentNode)
function getStairSlabElevation(
levelId: string,
stairNode: StairNode,
nodes: Record<string, AnyNode>,
): 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 visualPosition = getFloorStackedPosition({
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation node: stairNode,
nodes: effectiveNodes,
position: stairNode.position,
rotation: stairNode.rotation,
})
group.position.y = visualPosition[1]
} }
// ============================================================================ // ============================================================================