diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b952a0b253..7df199bbfb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -454,6 +454,7 @@ export { getWallPlanFootprint, getWallThickness, } from './systems/wall/wall-footprint' +export { planWallMerge } from './systems/wall/wall-merge' export { calculateLevelMiters, getAdjacentWallIds, @@ -475,6 +476,12 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' +export { + planWallDivision, + planWallDivisions, + planWallRectangle, + wallRectangleCorners, +} from './systems/wall/wall-operations' export { MIN_WALL_HEIGHT, resolveWallEffectiveHeight, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index b8dcd751e2..afe938abd2 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1411,6 +1411,13 @@ export type NodeDefinition> = { */ toolHints?: ToolHint[] + /** + * HUD hints for the kind's own reshapes, keyed by reshape name (see + * `affordanceTools`): shown while a node of this kind is in that `reshaping` + * scope, with the same chips and visibility rules as `toolHints`. + */ + affordanceHints?: Record + /** * Pick-one option rows for this kind's build tool, rendered by the shared * `` in whichever sidebar the host mounts it (see diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index a2d8e93999..44d4970bc9 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -14,8 +14,6 @@ import { type GutterNode, generateId, getDefaultGutterSide, - getEffectiveWallSurfaceMaterial, - getWallSurfaceMaterialSignature, isAutoGutterEnabled, isAutoRidgeVentEnabled, isDefaultDownspoutNode, @@ -29,6 +27,14 @@ import { } from '../../schema' import type { CollectionId } from '../../schema/collections' import { constrainWallCurveOffsetToAvoidIntersections } from '../../systems/wall/wall-curve' +import { + areWallStylesCompatible, + areWallsCollinearAcrossPoint, + buildMergedWallAttachmentUpdates, + getWallEndpointAtPoint, + resolveMergedWallEndpoints, + type WallAttachmentUpdate, +} from '../../systems/wall/wall-merge' import { activeSceneCommitNodeIds, addActiveSceneCommitNodeIds, @@ -40,7 +46,6 @@ type AnyContainerNode = AnyNode & { children: string[] } type NodeCreateOp = { node: AnyNode; parentId?: AnyNodeId } type NodeUpdateOp = { id: AnyNodeId; data: Partial } type NodeDeleteOp = AnyNodeId -type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } type WallMergePlan = { primaryWallId: AnyNodeId secondaryWallId: AnyNodeId @@ -1008,141 +1013,6 @@ function refreshDefaultGuttersForRoofIds( let pendingRafId: number | null = null let pendingUpdates: Set = new Set() -function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { - const dx = a[0] - b[0] - const dz = a[1] - b[1] - return dx * dx + dz * dz <= tolerance * tolerance -} - -function wallLength(wall: Pick) { - return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) -} - -function getWallEndpointAtPoint( - wall: Pick, - point: [number, number], -): 'start' | 'end' | null { - if (pointsEqual(wall.start, point)) return 'start' - if (pointsEqual(wall.end, point)) return 'end' - return null -} - -function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { - return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start -} - -function areWallStylesCompatible(a: WallNode, b: WallNode) { - const aInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'interior')) - const bInterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'interior')) - const aExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, 'exterior')) - const bExterior = getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, 'exterior')) - - return ( - (a.parentId ?? null) === (b.parentId ?? null) && - Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && - Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && - // Absent height means plane-bound (follows the storey), which must never - // merge with an explicit height — even one that currently matches the plane. - (a.height == null) === (b.height == null) && - Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 && - aInterior === bInterior && - aExterior === bExterior && - a.frontSide === b.frontSide && - a.backSide === b.backSide && - a.visible === b.visible - ) -} - -function areWallsCollinearAcrossPoint(a: WallNode, b: WallNode, sharedPoint: [number, number]) { - const freeA = getWallFreeEndpoint(a, sharedPoint) - const freeB = getWallFreeEndpoint(b, sharedPoint) - const ax = freeA[0] - sharedPoint[0] - const az = freeA[1] - sharedPoint[1] - const bx = freeB[0] - sharedPoint[0] - const bz = freeB[1] - sharedPoint[1] - const lenA = Math.hypot(ax, az) - const lenB = Math.hypot(bx, bz) - - if (lenA < 1e-6 || lenB < 1e-6) return false - - const cross = (ax * bz - az * bx) / (lenA * lenB) - const dot = (ax * bx + az * bz) / (lenA * lenB) - return Math.abs(cross) <= 1e-4 && dot < -0.999 -} - -function resolveMergedWallEndpoints( - primary: WallNode, - secondary: WallNode, - sharedPoint: [number, number], -): { start: [number, number]; end: [number, number] } { - const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint) - const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint) - - if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') { - return { start: primary.start, end: secondary.end } - } - if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') { - return { start: secondary.start, end: primary.end } - } - if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') { - return { start: primary.end, end: secondary.end } - } - - return { start: primary.start, end: secondary.start } -} - -function buildMergedWallAttachmentUpdates( - primary: WallNode, - secondary: WallNode, - mergedWallId: AnyNodeId, - mergedStart: [number, number], - mergedEnd: [number, number], - nodes: Record, -): WallAttachmentUpdate[] { - const mergedLength = Math.max( - Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), - 1e-6, - ) - const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength - const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength - const updates: WallAttachmentUpdate[] = [] - - const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] - for (const childId of wallChildren) { - const child = nodes[childId] - if (!(child && 'position' in child && Array.isArray(child.position))) { - continue - } - - const sourceWall = child.parentId === secondary.id ? secondary : primary - const sourceLength = Math.max(wallLength(sourceWall), 1e-6) - const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 - const worldX = - sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength - const worldZ = - sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength - const nextLocalX = Math.max( - 0, - Math.min( - mergedLength, - (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, - ), - ) - - updates.push({ - id: childId, - data: { - parentId: mergedWallId, - wallId: mergedWallId, - position: [nextLocalX, child.position[1], child.position[2]] as typeof child.position, - ...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}), - } as Partial, - }) - } - - return updates -} - function buildWallMergePlans( nodes: Record, idsToDelete: AnyNodeId[], @@ -1162,7 +1032,7 @@ function buildWallMergePlans( if (node?.type !== 'wall') return false if (skippedWallIds.has(node.id) || usedWallIds.has(node.id)) return false if ((node.parentId ?? null) !== (deletedWall.parentId ?? null)) return false - return pointsEqual(node.start, junction) || pointsEqual(node.end, junction) + return getWallEndpointAtPoint(node, junction) !== null }) if (candidates.length !== 2) { diff --git a/packages/core/src/systems/wall/wall-merge.test.ts b/packages/core/src/systems/wall/wall-merge.test.ts new file mode 100644 index 0000000000..e39218f1e4 --- /dev/null +++ b/packages/core/src/systems/wall/wall-merge.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + LevelNode, + WallNode, + WindowNode, + ZoneNode, +} from '../../schema' +import useScene from '../../store/use-scene' +import { planWallMerge } from './wall-merge' +import { planWallDivision } from './wall-operations' +import type { WallTopologyChanges } from './wall-topology' + +const level = LevelNode.parse({ id: 'level_merge', children: [] }) +const map = (nodes: AnyNode[]) => + Object.fromEntries([level, ...nodes].map((n) => [n.id, n])) as Record +const apply = (nodes: Record, changes: WallTopologyChanges) => { + const next = { ...nodes } + for (const { node, parentId } of changes.create) + next[node.id] = (parentId ? { ...node, parentId } : node) as AnyNode + for (const { id, data } of changes.update) next[id] = { ...next[id]!, ...data } as AnyNode + for (const id of changes.delete) delete next[id] + return next +} +const wall = (start: [number, number], end: [number, number], extra: Partial = {}) => + WallNode.parse({ parentId: level.id, start, end, ...extra }) + +describe('explicit wall merge', () => { + test('undoes a split: one wall again, openings back where they were', () => { + const original = wall([0, 0], [8, 0]) + const window = WindowNode.parse({ + parentId: original.id, + wallId: original.id, + position: [7, 1.5, 0], + width: 1, + }) + original.children = [window.id] + const split = apply( + map([original, window]), + planWallDivision(map([original, window]), original.id, 3).changes, + ) + const ids = Object.values(split) + .filter((n): n is WallNode => n.type === 'wall') + .map((w) => w.id) + expect(ids).toHaveLength(2) + + const plan = planWallMerge(split, ids) + const merged = apply(split, plan.changes) + const walls = Object.values(merged).filter((n): n is WallNode => n.type === 'wall') + expect(walls).toHaveLength(1) + expect([walls[0]!.start, walls[0]!.end]).toEqual([ + [0, 0], + [8, 0], + ]) + expect(plan.wallId).toBe(walls[0]!.id) + const moved = merged[window.id] as WindowNode + expect(moved.parentId).toBe(plan.wallId) + expect(moved.position).toEqual([7, 1.5, 0]) + expect(split[ids[0]!]).toBeDefined() + }) + + test('a straight run of three becomes one wall and rooms keep one reference', () => { + const a = wall([0, 0], [2, 0]) + const b = wall([2, 0], [5, 0]) + const c = wall([5, 0], [9, 0]) + const zone = ZoneNode.parse({ + name: 'Room', + parentId: level.id, + polygon: [ + [0, 0], + [9, 0], + [9, 4], + [0, 4], + ], + boundaryWallIds: [a.id, b.id, c.id], + }) + const nodes = map([a, b, c, zone]) + const plan = planWallMerge(nodes, [c.id, a.id, b.id]) + const merged = apply(nodes, plan.changes) + const kept = merged[plan.wallId] as WallNode + expect([kept.start, kept.end].sort()).toEqual([ + [0, 0], + [9, 0], + ]) + expect(plan.changes.delete).toHaveLength(2) + expect((merged[zone.id] as ZoneNode).boundaryWallIds).toEqual([plan.wallId]) + }) + + test('walls that look alike merge even when room sides or height mode differ', () => { + const storeyLevel = { ...level, height: 3 } as AnyNode + const a = wall([0, 0], [4, 0], { frontSide: 'interior', backSide: 'exterior' }) + const b = wall([4, 0], [8, 0], { height: 3 }) + const nodes = { + ...map([a, b]), + [level.id]: storeyLevel, + } as Record + expect(() => planWallMerge(nodes, [a.id, b.id])).not.toThrow() + const taller = wall([4, 0], [8, 0], { height: 3.5 }) + expect(() => + planWallMerge( + { ...map([a, taller]), [level.id]: storeyLevel } as Record, + [a.id, taller.id], + ), + ).toThrow('These walls have a different height.') + }) + + test('refuses anything that would change the layout', () => { + const a = wall([0, 0], [4, 0]) + const b = wall([4, 0], [8, 0]) + const tee = wall([4, 0], [4, 3]) + expect(() => planWallMerge(map([a, b, tee]), [a.id, b.id])).toThrow('Another wall') + const corner = wall([4, 0], [4, 4]) + expect(() => planWallMerge(map([a, corner]), [a.id, corner.id])).toThrow('straight line') + const thick = wall([4, 0], [8, 0], { thickness: 0.3 }) + expect(() => planWallMerge(map([a, thick]), [a.id, thick.id])).toThrow( + 'These walls have a different thickness.', + ) + const curved = wall([4, 0], [8, 0], { curveOffset: 0.5 }) + expect(() => planWallMerge(map([a, curved]), [a.id, curved.id])).toThrow('straight walls') + const apart = wall([5, 0], [8, 0]) + expect(() => planWallMerge(map([a, apart]), [a.id, apart.id])).toThrow('end to end') + expect(() => planWallMerge(map([a]), [a.id])).toThrow('two or more') + }) +}) + +describe('explicit wall merge through the store', () => { + let saved: ReturnType + const raf = globalThis.requestAnimationFrame + beforeEach(() => { + saved = useScene.getState() + globalThis.requestAnimationFrame = () => 0 + }) + afterEach(() => { + useScene.setState(saved) + globalThis.requestAnimationFrame = raf + }) + + test('the absorbed wall is deleted without taking its moved window along', () => { + const kept = wall([0, 0], [4, 0]) + const absorbed = wall([4, 0], [8, 0]) + const windows = [ + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [1, 1.5, 0], width: 0.8 }), + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [3, 1.5, 0], width: 0.8 }), + WindowNode.parse({ + parentId: absorbed.id, + wallId: absorbed.id, + position: [2, 1.5, 0], + width: 0.8, + }), + ] + kept.children = [windows[0]!.id, windows[1]!.id] + absorbed.children = [windows[2]!.id] + const levelNode = { ...level, children: [kept.id, absorbed.id] } + useScene.setState({ + nodes: Object.fromEntries( + [levelNode, kept, absorbed, ...windows].map((n) => [n.id, n]), + ) as Record, + rootNodeIds: [level.id], + readOnly: false, + }) + + const plan = planWallMerge(useScene.getState().nodes, [kept.id, absorbed.id]) + expect(plan.wallId).toBe(kept.id) + useScene.getState().applyNodeChanges(plan.changes) + + const nodes = useScene.getState().nodes + expect(nodes[absorbed.id]).toBeUndefined() + const merged = nodes[kept.id] as WallNode + expect(merged.end).toEqual([8, 0]) + expect(merged.children).toEqual(windows.map((w) => w.id)) + const moved = nodes[windows[2]!.id] as WindowNode + expect(moved.parentId).toBe(kept.id) + expect(moved.position).toEqual([6, 1.5, 0]) + expect((nodes[level.id] as LevelNode).children).toEqual([kept.id]) + }) + test('walls meeting start-to-start or end-to-end keep the kept wall facing; absorbed openings mirror', () => { + for (const [kept, absorbed, absorbedWindowX, expectedX, expectedRun] of [ + // Kept runs 4→8, absorbed runs 4→0: merged must still run 0→8. + [wall([4, 0], [8, 0]), wall([4, 0], [0, 0]), 1, 3, [0, 8]], + // Kept runs 0→4, absorbed runs 8→4. + [wall([0, 0], [4, 0]), wall([8, 0], [4, 0]), 1, 7, [0, 8]], + ] as const) { + const keptWindows = [0.5, 2, 3.5].map((x) => + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [x, 1.5, 0], width: 0.6 }), + ) + const absorbedWindow = WindowNode.parse({ + parentId: absorbed.id, + wallId: absorbed.id, + position: [absorbedWindowX, 1.5, 0], + width: 0.6, + side: 'front', + }) + const absorbedDoor = DoorNode.parse({ + parentId: absorbed.id, + wallId: absorbed.id, + position: [absorbedWindowX + 1.5, 0, 0.05], + width: 0.9, + rotation: [0, 0.25, 0], + hingesSide: 'left', + handleSide: 'right', + }) + kept.children = keptWindows.map((w) => w.id) + absorbed.children = [absorbedWindow.id, absorbedDoor.id] + const graph = map([kept, absorbed, ...keptWindows, absorbedWindow, absorbedDoor]) + const plan = planWallMerge(graph, [kept.id, absorbed.id]) + expect(plan.wallId).toBe(kept.id) + const nodes = apply(graph, plan.changes) + const merged = nodes[kept.id] as WallNode + expect([merged.start[0], merged.end[0]]).toEqual([...expectedRun]) + const moved = nodes[absorbedWindow.id] as WindowNode + expect(moved.position[0]).toBeCloseTo(expectedX) + expect(moved.side).toBe('back') + // A door on the reversed wall turns with its frame — depth and yaw — while + // its hinges and handle, hung off that yaw, keep their stored side. + const door = nodes[absorbedDoor.id] as DoorNode + expect(door.position[2]).toBeCloseTo(-0.05) + expect(door.rotation[1]).toBeCloseTo(0.25 - Math.PI) + expect(door.hingesSide).toBe('left') + expect(door.handleSide).toBe('right') + // The kept wall's own openings keep their side and their world place. + const keptFirst = nodes[keptWindows[0]!.id] as WindowNode + expect(keptFirst.side).toBeUndefined() + expect(keptFirst.position[0]).toBeCloseTo(kept.start[0] + 0.5 - merged.start[0]) + } + }) + test('a child without a position moves to the kept wall too, and stale child ids are dropped', () => { + const kept = wall([0, 0], [4, 0]) + const absorbed = wall([4, 0], [8, 0]) + // The wall with the most attachments is kept; three windows outnumber the two ids below. + const windows = [0.5, 2, 3.5].map((x) => + WindowNode.parse({ parentId: kept.id, wallId: kept.id, position: [x, 1.5, 0], width: 0.8 }), + ) + kept.children = windows.map((w) => w.id) + // Stands in for a hosted kind the planner cannot re-place along the wall. + const tag = { ...ZoneNode.parse({ name: 'Tag', polygon: [] }), parentId: absorbed.id } + absorbed.children = [tag.id, 'window_gone'] + + const graph = map([kept, absorbed, ...windows, tag]) + const plan = planWallMerge(graph, [kept.id, absorbed.id]) + expect(plan.wallId).toBe(kept.id) + expect(plan.changes.delete).toEqual([absorbed.id]) + const nodes = apply(graph, plan.changes) + expect(nodes[tag.id]?.parentId).toBe(kept.id) + expect((nodes[kept.id] as WallNode).children).toEqual([...windows.map((w) => w.id), tag.id]) + }) +}) diff --git a/packages/core/src/systems/wall/wall-merge.ts b/packages/core/src/systems/wall/wall-merge.ts new file mode 100644 index 0000000000..f6f85d0e9b --- /dev/null +++ b/packages/core/src/systems/wall/wall-merge.ts @@ -0,0 +1,316 @@ +import { getWallEffectiveHeightForNodes } from '../../hooks/spatial-grid/spatial-grid-manager' +import { + type AnyNode, + type AnyNodeId, + getEffectiveWallSurfaceMaterial, + getWallSurfaceMaterialSignature, + type WallNode, +} from '../../schema' +import type { WallTopologyChanges } from './wall-topology' + +// Joining two walls that continue each other at a shared end: the delete heal +// (store `deleteNodes`) and the explicit merge share the geometry and style checks. + +export type WallAttachmentUpdate = { id: AnyNodeId; data: Partial } + +function pointsEqual(a: [number, number], b: [number, number], tolerance = 1e-6) { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz <= tolerance * tolerance +} + +function wallLength(wall: Pick) { + return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +export function getWallEndpointAtPoint( + wall: Pick, + point: [number, number], +): 'start' | 'end' | null { + if (pointsEqual(wall.start, point)) return 'start' + if (pointsEqual(wall.end, point)) return 'end' + return null +} + +function getWallFreeEndpoint(wall: Pick, sharedPoint: [number, number]) { + return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start +} + +/** + * The first thing two walls disagree on (for the merge's explanation), or null. + * The delete heal is strict: room sides must match, and a wall following the + * storey never joins one with an explicit height. An explicit merge compares + * what is visible instead — `heightOf` resolves each wall's actual height — and + * leaves the sides to room detection, which reclassifies the merged wall. + */ +export function wallStyleMismatch( + a: WallNode, + b: WallNode, + options: { sides: boolean; heightOf?: (wall: WallNode) => number }, +): string | null { + if ((a.parentId ?? null) !== (b.parentId ?? null)) return 'floor' + if (Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) > 1e-6) return 'curve' + if (Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) > 1e-6) return 'thickness' + const { heightOf } = options + if ( + heightOf + ? Math.abs(heightOf(a) - heightOf(b)) > 1e-6 + : (a.height == null) !== (b.height == null) || + Math.abs((a.height ?? 0) - (b.height ?? 0)) > 1e-6 + ) + return 'height' + for (const side of ['interior', 'exterior'] as const) { + if ( + getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(a, side)) !== + getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(b, side)) + ) + return `${side} finish` + } + if (options.sides && (a.frontSide !== b.frontSide || a.backSide !== b.backSide)) + return 'room sides' + if (a.visible !== b.visible) return 'visibility' + return null +} + +export function areWallStylesCompatible(a: WallNode, b: WallNode) { + return wallStyleMismatch(a, b, { sides: true }) === null +} + +export function areWallsCollinearAcrossPoint( + a: WallNode, + b: WallNode, + sharedPoint: [number, number], +) { + const freeA = getWallFreeEndpoint(a, sharedPoint) + const freeB = getWallFreeEndpoint(b, sharedPoint) + const ax = freeA[0] - sharedPoint[0] + const az = freeA[1] - sharedPoint[1] + const bx = freeB[0] - sharedPoint[0] + const bz = freeB[1] - sharedPoint[1] + const lenA = Math.hypot(ax, az) + const lenB = Math.hypot(bx, bz) + + if (lenA < 1e-6 || lenB < 1e-6) return false + + const cross = (ax * bz - az * bx) / (lenA * lenB) + const dot = (ax * bx + az * bz) / (lenA * lenB) + return Math.abs(cross) <= 1e-4 && dot < -0.999 +} + +export function resolveMergedWallEndpoints( + primary: WallNode, + secondary: WallNode, + sharedPoint: [number, number], +): { start: [number, number]; end: [number, number] } { + const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint) + const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint) + + if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') { + return { start: primary.start, end: secondary.end } + } + if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') { + return { start: secondary.start, end: primary.end } + } + // The kept wall never turns around: its sides, finishes and hosted faces stay + // where they are. Meeting start-to-start or end-to-end, the absorbed wall is + // the one read backwards. + if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') { + return { start: secondary.end, end: primary.end } + } + + return { start: primary.start, end: secondary.start } +} + +/** + * Hosted nodes live in the wall's local frame (+X along it, +Z its front, yaw + * 0 front / π back). A wall read backwards turns that frame by 180° about Y, + * so the face and the yaw swap and the caller mirrors depth. Hinges, handles + * and swing hang off the node's own yaw (the plan symbol flips them from it), + * so they stay as stored. + */ +function reversedWallChildPatch(child: AnyNode): Partial { + const patch: Record = {} + if ('side' in child && (child.side === 'front' || child.side === 'back')) { + patch.side = child.side === 'front' ? 'back' : 'front' + } + if ('rotation' in child && Array.isArray(child.rotation)) { + const yaw = child.rotation[1] + Math.PI + patch.rotation = [ + child.rotation[0], + Math.atan2(Math.sin(yaw), Math.cos(yaw)), + child.rotation[2], + ] + } + return patch as Partial +} + +export function buildMergedWallAttachmentUpdates( + primary: WallNode, + secondary: WallNode, + mergedWallId: AnyNodeId, + mergedStart: [number, number], + mergedEnd: [number, number], + nodes: Record, +): WallAttachmentUpdate[] { + const mergedLength = Math.max( + Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), + 1e-6, + ) + const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength + const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength + const updates: WallAttachmentUpdate[] = [] + + const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[] + for (const childId of wallChildren) { + const child = nodes[childId] + if (!child) continue + // Every child moves to the kept wall: deleting the absorbed wall would + // otherwise take a child left on it along. Only positioned ones re-place. + const rehost = { parentId: mergedWallId, wallId: mergedWallId } + if (!('position' in child && Array.isArray(child.position))) { + updates.push({ id: childId, data: rehost as Partial }) + continue + } + + const sourceWall = child.parentId === secondary.id ? secondary : primary + const sourceLength = Math.max(wallLength(sourceWall), 1e-6) + const reversed = + (sourceWall.end[0] - sourceWall.start[0]) * tangentX + + (sourceWall.end[1] - sourceWall.start[1]) * tangentZ < + 0 + const mirrored = reversed ? reversedWallChildPatch(child) : {} + const localX = typeof child.position[0] === 'number' ? child.position[0] : 0 + const worldX = + sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength + const worldZ = + sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength + const nextLocalX = Math.max( + 0, + Math.min( + mergedLength, + (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ, + ), + ) + + updates.push({ + id: childId, + data: { + ...rehost, + ...mirrored, + position: [ + nextLocalX, + child.position[1], + reversed ? -child.position[2] : child.position[2], + ] as typeof child.position, + ...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}), + } as Partial, + }) + } + + return updates +} + +/** + * Merges a straight run of adjoining walls into one — the inverse of a split. + * Every joint must hold exactly these walls (a T or a cross stays split), and + * neighbours must continue in line and look alike (`wallStyleMismatch`: same + * thickness, visible height and finish). The wall with the most attachments + * keeps its id and height mode; openings and wall items keep their world + * position on it. + */ +export function planWallMerge( + nodes: Record, + wallIds: readonly AnyNodeId[], +): { changes: WallTopologyChanges; wallId: WallNode['id'] } { + const walls = [...new Set(wallIds)] + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + if (walls.length < 2 || walls.length !== new Set(wallIds).size) + throw Error('Select two or more walls to merge.') + if (walls.some((wall) => (wall.curveOffset ?? 0) !== 0)) + throw Error('Only straight walls can be merged.') + const levelId = walls[0]!.parentId ?? null + if (walls.some((wall) => (wall.parentId ?? null) !== levelId)) + throw Error('Merge walls on the same floor.') + + const virtual = { ...nodes } + const [primary, ...rest] = [...walls].sort( + (a, b) => (b.children?.length ?? 0) - (a.children?.length ?? 0) || a.id.localeCompare(b.id), + ) + let merged = primary! + let remaining = rest + while (remaining.length > 0) { + let next: WallNode | undefined + let joint: [number, number] | undefined + for (const wall of remaining) { + joint = [merged.start, merged.end].find((end) => getWallEndpointAtPoint(wall, end) !== null) + if (joint) { + next = wall + break + } + } + if (!(next && joint)) throw Error('Merge walls that touch end to end.') + const atJoint = Object.values(virtual).filter( + (node) => + node?.type === 'wall' && + (node.parentId ?? null) === levelId && + getWallEndpointAtPoint(node, joint) !== null, + ) + if (atJoint.length !== 2) + throw Error('Another wall meets this joint, so merging would disconnect it.') + if (!areWallsCollinearAcrossPoint(merged, next, joint)) + throw Error('Merge walls that continue in a straight line.') + const mismatch = wallStyleMismatch(merged, next, { + sides: false, + heightOf: (wall) => getWallEffectiveHeightForNodes(wall, virtual), + }) + if (mismatch) throw Error(`These walls have a different ${mismatch}.`) + + const { start, end } = resolveMergedWallEndpoints(merged, next, joint) + for (const update of buildMergedWallAttachmentUpdates( + merged, + next, + merged.id, + start, + end, + virtual, + )) { + virtual[update.id] = { ...virtual[update.id]!, ...update.data } as AnyNode + } + merged = { + ...merged, + start, + end, + // Ids without a node are dropped rather than carried onto the kept wall. + children: [ + ...new Set([ + ...(merged.children ?? []), + ...(next.children ?? []).filter((id) => virtual[id as AnyNodeId]), + ]), + ], + } as WallNode + virtual[merged.id] = merged + delete virtual[next.id] + for (const node of Object.values(virtual)) { + if (node?.type !== 'zone' || !node.boundaryWallIds?.includes(next.id)) continue + virtual[node.id] = { + ...node, + boundaryWallIds: [ + ...new Set(node.boundaryWallIds.map((id) => (id === next.id ? merged.id : id))), + ], + } + } + remaining = remaining.filter((wall) => wall !== next) + } + + return { + changes: { + create: [], + update: Object.values(virtual) + .filter((node) => nodes[node.id] && node !== nodes[node.id]) + .map((node) => ({ id: node.id, data: node })), + delete: walls.filter((wall) => !virtual[wall.id]).map((wall) => wall.id), + }, + wallId: merged.id, + } +} diff --git a/packages/core/src/systems/wall/wall-operations.test.ts b/packages/core/src/systems/wall/wall-operations.test.ts new file mode 100644 index 0000000000..83a115c380 --- /dev/null +++ b/packages/core/src/systems/wall/wall-operations.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + LevelNode, + WallNode, + WindowNode, + ZoneNode, +} from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength } from './wall-curve' +import { + planWallDivision, + planWallDivisions, + planWallRectangle, + wallRectangleCorners, +} from './wall-operations' + +const level = LevelNode.parse({ id: 'level_creation', children: [] }) +const map = (nodes: AnyNode[]) => + Object.fromEntries([level, ...nodes].map((n) => [n.id, n])) as Record +const rectangle = (offset = 0) => { + const points = wallRectangleCorners([offset, 0], [offset + 8, 6]) + return points.map((start, i) => + WallNode.parse({ parentId: level.id, start, end: points[(i + 1) % 4] }), + ) +} +describe('wall creation operations', () => { + test('rectangle is closed in every drag direction and the planning graph is untouched', () => { + const nodes = map([]) + const before = JSON.stringify(nodes) + for (const [a, b] of [ + [ + [0, 0], + [8, 6], + ], + [ + [8, 0], + [0, 6], + ], + [ + [8, 6], + [0, 0], + ], + ]) { + const plan = planWallRectangle(nodes, { + levelId: level.id, + start: a as [number, number], + end: b as [number, number], + wallDefaults: { thickness: 0.22 }, + }) + expect(plan.walls).toHaveLength(4) + plan.walls.forEach((w, i) => { + expect(w.end).toEqual(plan.walls[(i + 1) % 4]!.start) + expect(w.thickness).toBe(0.22) + }) + } + expect(JSON.stringify(nodes)).toBe(before) + }) + test('adjacent rectangles reuse their shared wall', () => { + const existing = rectangle() + const plan = planWallRectangle(map(existing), { + levelId: level.id, + start: [8, 0], + end: [12, 6], + }) + expect(plan.changes.create).toHaveLength(3) + expect(plan.changes.delete).toHaveLength(0) + }) + test('a side drawn along an existing wall and past its end adds only the overhang', () => { + const existing = rectangle() + const plan = planWallRectangle(map(existing), { + levelId: level.id, + start: [8, 0], + end: [12, 8], + }) + expect(plan.changes.create).toHaveLength(4) + expect(plan.changes.delete).toHaveLength(0) + const alongSharedLine = plan.walls.filter((w) => w.start[0] === 8 && w.end[0] === 8) + expect(alongSharedLine).toHaveLength(1) + expect([alongSharedLine[0]!.start[1], alongSharedLine[0]!.end[1]].sort()).toEqual([6, 8]) + }) + test('an existing wall in the middle of a side leaves a remainder on each side of it', () => { + const middle = WallNode.parse({ parentId: level.id, start: [2, 0], end: [5, 0] }) + const plan = planWallRectangle(map([middle]), { + levelId: level.id, + start: [0, 0], + end: [8, 6], + }) + expect(plan.changes.create).toHaveLength(5) + const bottom = plan.walls + .filter((w) => w.start[1] === 0 && w.end[1] === 0) + .map((w) => [w.start[0], w.end[0]].sort((a, b) => a - b)) + .sort((a, b) => a[0]! - b[0]!) + expect(bottom).toEqual([ + [0, 2], + [5, 8], + ]) + }) + test('a rectangle side T-joining a room wall keeps the room on the replacement walls', () => { + const existing = rectangle() + const room = ZoneNode.parse({ + parentId: level.id, + name: 'Room', + polygon: existing.map((w) => w.start), + autoFromWalls: true, + boundaryWallIds: existing.map((w) => w.id), + }) + const plan = planWallRectangle(map([...existing, room]), { + levelId: level.id, + start: [4, 6], + end: [8, 10], + }) + const top = existing[2]! + expect(plan.changes.delete).toEqual([top.id]) + const zone = plan.changes.update.find((op) => op.id === room.id)?.data as + | { boundaryWallIds: string[] } + | undefined + expect(zone?.boundaryWallIds).toHaveLength(5) + expect(zone?.boundaryWallIds).not.toContain(top.id) + const createdIds = plan.changes.create.map((op) => op.node.id) + for (const id of zone?.boundaryWallIds ?? []) { + expect(createdIds.includes(id) || existing.some((w) => w.id === id)).toBe(true) + } + }) + test('a gap too short to be a wall counts as covered', () => { + const almost = WallNode.parse({ parentId: level.id, start: [8, 0], end: [8, 5.995] }) + const plan = planWallRectangle(map([almost]), { + levelId: level.id, + start: [8, 0], + end: [12, 6], + }) + expect(plan.changes.create).toHaveLength(3) + }) + test('invalid rectangle has no partial result', () => { + for (const end of [ + [0, 5], + [5, 0.01], + [NaN, 5], + ]) + expect(() => + planWallRectangle(map([]), { + levelId: level.id, + start: [0, 0], + end: end as [number, number], + }), + ).toThrow() + }) + test('split preserves original id and remaps openings without changing their world placement', () => { + const wall = WallNode.parse({ + parentId: level.id, + start: [2, 3], + end: [8, 11], + thickness: 0.3, + slots: { exterior: 'preset:brick' }, + }) + const opening = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [8, 1.5, 0], + width: 1, + }) + wall.children = [opening.id] + const zone = ZoneNode.parse({ + name: 'Room', + polygon: [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ], + parentId: level.id, + boundaryWallIds: [wall.id], + autoFromWalls: true, + }) + const plan = planWallDivision(map([wall, opening, zone]), wall.id, 4) + expect(plan.changes.delete).toEqual([]) + expect(plan.changes.create).toHaveLength(1) + const first = plan.changes.update.find((op) => op.id === wall.id)!.data as WallNode + const second = plan.changes.create[0]!.node as WallNode + expect(first.end).toEqual(second.start) + expect(second.slots).toEqual(wall.slots) + const moved = plan.changes.update.find((op) => op.id === opening.id)!.data as WindowNode + expect(moved.position).toEqual([4, 1.5, 0]) + expect(moved.parentId).toBe(second.id) + expect(plan.changes.update.find((op) => op.id === zone.id)!.data).toMatchObject({ + boundaryWallIds: [wall.id, second.id], + }) + }) + test('split refuses cuts through windows and near endpoints', () => { + const wall = rectangle()[0]! + const opening = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [4, 1.5, 0], + width: 2, + }) + wall.children = [opening.id] + for (const distance of [0, 0.01, 4, 8, NaN]) + expect(() => planWallDivision(map([wall, opening]), wall.id, distance)).toThrow() + }) + test('several cuts commit as one plan: first id kept, openings follow their segment', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + const opening = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [7, 1.5, 0], + width: 1, + }) + wall.children = [opening.id] + const nodes = map([wall, opening]) + const plan = planWallDivisions(nodes, wall.id, [2, 4, 6]) + expect(plan.points).toEqual([ + [2, 0], + [4, 0], + [6, 0], + ]) + expect(plan.changes.delete).toEqual([]) + const walls = [ + plan.changes.update.find((op) => op.id === wall.id)!.data as WallNode, + ...plan.changes.create.map((op) => op.node as WallNode), + ].sort((a, b) => a.start[0] - b.start[0]) + expect(walls.map((w) => [w.start[0], w.end[0]])).toEqual([ + [0, 2], + [2, 4], + [4, 6], + [6, 8], + ]) + expect(walls[0]!.id).toBe(wall.id) + const moved = plan.changes.update.find((op) => op.id === opening.id)!.data as WindowNode + expect(moved.parentId).toBe(walls[3]!.id) + expect(moved.position).toEqual([1, 1.5, 0]) + expect(nodes[wall.id]).toBe(wall) + }) + test('cuts closer than 5 cm apart are refused as a whole', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + expect(() => planWallDivisions(map([wall]), wall.id, [4, 4.02])).toThrow() + }) + test('curved splits preserve total arc length and curve endpoint', () => { + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [8, 0], + curveOffset: 1, + }) + const length = getWallCurveLength(wall) + const expected = getWallCurveFrameAt(wall, 0.4).point + const plan = planWallDivision(map([wall]), wall.id, length * 0.4) + const first = plan.changes.update[0]!.data as WallNode + const second = plan.changes.create[0]!.node as WallNode + expect(first.end[0]).toBeCloseTo(expected.x) + expect(first.end[1]).toBeCloseTo(expected.y) + expect(getWallCurveLength(first) + getWallCurveLength(second)).toBeCloseTo(length) + }) +}) diff --git a/packages/core/src/systems/wall/wall-operations.ts b/packages/core/src/systems/wall/wall-operations.ts new file mode 100644 index 0000000000..516f65fd9b --- /dev/null +++ b/packages/core/src/systems/wall/wall-operations.ts @@ -0,0 +1,176 @@ +import type { AnyNode, AnyNodeId, WallNode } from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength } from './wall-curve' +import type { WallPlanPoint } from './wall-move' +import { + planWallInsertion, + planWallSplitAtPoint, + uncoveredWallSegments, + type WallTopologyChanges, +} from './wall-topology' + +export function wallRectangleCorners(a: WallPlanPoint, b: WallPlanPoint): WallPlanPoint[] { + if ( + ![...a, ...b].every(Number.isFinite) || + Math.abs(a[0] - b[0]) < 0.05 || + Math.abs(a[1] - b[1]) < 0.05 + ) + return [] + const x = Math.min(a[0], b[0]) + const z = Math.min(a[1], b[1]) + const right = Math.max(a[0], b[0]) + const top = Math.max(a[1], b[1]) + return [ + [x, z], + [right, z], + [right, top], + [x, top], + ] +} + +/** Plan against a scratch graph so four sides commit atomically, including junction splits. */ +export function planWallRectangle( + nodes: Record, + args: { + levelId: AnyNodeId + start: WallPlanPoint + end: WallPlanPoint + wallDefaults?: Partial + }, +): { changes: WallTopologyChanges; walls: WallNode[] } { + if (nodes[args.levelId]?.type !== 'level') throw Error('Select an editable floor.') + const corners = wallRectangleCorners(args.start, args.end) + if (!corners.length) throw Error('Both rectangle dimensions must be at least 5 cm.') + const virtual = { ...nodes } + const added = new Set() + for (let i = 0; i < 4; i++) { + // A side that runs along existing walls only adds what they don't cover, + // so snapping to a wall and dragging past its end reuses it rather than + // doubling it. + const levelWalls = Object.values(virtual).filter( + (node): node is WallNode => node.type === 'wall' && node.parentId === args.levelId, + ) + for (const [start, end] of uncoveredWallSegments( + corners[i]!, + corners[(i + 1) % 4]!, + levelWalls, + )) { + const result = planWallInsertion(virtual, { ...args, start, end, joinRadius: 0 }) + if (!result.ok) throw Error('The rectangle cannot be drawn here.') + for (const id of result.plan.changes.delete) delete virtual[id] + for (const { node, parentId } of result.plan.changes.create) { + virtual[node.id] = { ...node, ...(parentId ? { parentId } : {}) } as AnyNode + } + for (const wall of result.plan.insertedWalls) added.add(wall.id) + for (const { id, data } of result.plan.changes.update) { + if (virtual[id]) virtual[id] = { ...virtual[id], ...data } as AnyNode + } + } + } + const created = Object.values(virtual).filter((n) => !nodes[n.id]) + return { + changes: { + create: created.map((node) => ({ node, parentId: node.parentId as AnyNodeId })), + update: Object.values(virtual) + .filter((n) => nodes[n.id] && n !== nodes[n.id]) + .map((n) => ({ id: n.id, data: n })), + delete: Object.keys(nodes).filter((id) => !virtual[id as AnyNodeId]) as AnyNodeId[], + }, + walls: created.filter((n): n is WallNode => n.type === 'wall' && added.has(n.id)), + } +} + +/** Explicit split keeps the first wall's identity and reuses the junction planner's host migration. */ +export function planWallDivision( + nodes: Record, + wallId: WallNode['id'], + distance: number, +) { + const wall = nodes[wallId] + if (wall?.type !== 'wall' || !wall.parentId) throw Error('Select a wall.') + const length = getWallCurveLength(wall) + if (!Number.isFinite(distance) || distance < 0.05 || distance > length - 0.05) + throw Error('Keep at least 5 cm on each side of the split.') + const point = getWallCurveFrameAt(wall, distance / length).point + const result = planWallSplitAtPoint(nodes, { + levelId: wall.parentId as AnyNodeId, + point: [point.x, point.y], + radius: 0.001, + ignoreWallIds: Object.values(nodes) + .filter((n) => n.type === 'wall' && n.id !== wallId) + .map((n) => n.id), + }) + if (!result.ok || result.plan.changes.create.length !== 2) + throw Error('Move the split away from doors, windows and mounted objects.') + const [first, second] = result.plan.changes.create + const firstId = first!.node.id + const changes: WallTopologyChanges = { + create: [second!], + delete: [], + update: [ + { + id: wallId, + data: { ...first!.node, id: wallId, parentId: wall.parentId } as Partial, + }, + ...result.plan.changes.update.map((op) => ({ + ...op, + data: { + ...op.data, + ...(op.data.parentId === firstId ? { parentId: wallId } : {}), + ...('wallId' in op.data && op.data.wallId === firstId ? { wallId } : {}), + } as Partial, + })), + ], + } + for (const node of Object.values(nodes)) { + if ( + node.type === 'zone' && + node.parentId === wall.parentId && + node.boundaryWallIds?.includes(wallId) + ) { + changes.update.push({ + id: node.id, + data: { + boundaryWallIds: node.boundaryWallIds.flatMap((id) => + id === wallId ? [wallId, second!.node.id as WallNode['id']] : [id], + ), + }, + }) + } + } + return { changes, point: [point.x, point.y] as WallPlanPoint } +} + +/** + * Several cuts as one edit. Cutting farthest first keeps the first wall's + * identity at every step, so the nearer distances stay valid on it. + */ +export function planWallDivisions( + nodes: Record, + wallId: WallNode['id'], + distances: readonly number[], +): { changes: WallTopologyChanges; points: WallPlanPoint[] } { + const virtual = { ...nodes } + const points: WallPlanPoint[] = [] + for (const distance of [...distances].sort((a, b) => b - a)) { + const plan = planWallDivision(virtual, wallId, distance) + for (const { node, parentId } of plan.changes.create) { + virtual[node.id] = { ...node, ...(parentId ? { parentId } : {}) } as AnyNode + } + for (const { id, data } of plan.changes.update) { + if (virtual[id]) virtual[id] = { ...virtual[id], ...data } as AnyNode + } + points.push(plan.point) + } + return { + changes: { + create: Object.values(virtual) + .filter((n) => !nodes[n.id]) + .map((node) => ({ node, parentId: node.parentId as AnyNodeId })), + update: Object.values(virtual) + .filter((n) => nodes[n.id] && n !== nodes[n.id]) + .map((n) => ({ id: n.id, data: n })), + delete: [], + }, + points: points.reverse(), + } +} diff --git a/packages/core/src/systems/wall/wall-topology.test.ts b/packages/core/src/systems/wall/wall-topology.test.ts index 5ff3ab8044..a6a8e2ce9f 100644 --- a/packages/core/src/systems/wall/wall-topology.test.ts +++ b/packages/core/src/systems/wall/wall-topology.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { GROUND_SUPPORT_ID } from '../../hooks/spatial-grid/support-host-id' import { encodeTerrainField } from '../../lib/terrain-codec' import { applyHeightPatch, createTerrainField, flattenPatch } from '../../lib/terrain-field' -import { type AnyNode, type AnyNodeId, DoorNode, WallNode } from '../../schema' +import { type AnyNode, type AnyNodeId, DoorNode, WallNode, ZoneNode } from '../../schema' import { getWallArcData, getWallCurveFrameAt } from './wall-curve' import { planWallInsertion, planWallSplitAtPoint } from './wall-topology' @@ -106,6 +106,36 @@ describe('planWallInsertion', () => { ]) }) + test('a room keeps naming its boundary when a T-join splits one of its walls', () => { + const host = WallNode.parse({ parentId: LEVEL_ID, start: [0, 0], end: [8, 0] }) + const other = WallNode.parse({ parentId: LEVEL_ID, start: [8, 0], end: [8, 6] }) + const room = ZoneNode.parse({ + parentId: LEVEL_ID, + name: 'Room', + polygon: [ + [0, 0], + [8, 0], + [8, 6], + [0, 6], + ], + autoFromWalls: true, + boundaryWallIds: [host.id, other.id], + }) + const result = planWallInsertion(nodeMap([host, other, room]), { + levelId: LEVEL_ID, + start: [4, 0], + end: [4, -3], + joinRadius: 0.1, + }) + expect(result.ok).toBe(true) + if (!result.ok) return + const replacementIds = result.plan.changes.create + .map((op) => op.node.id) + .filter((id) => !result.plan.insertedWalls.some((wall) => wall.id === id)) + expect(replacementIds).toHaveLength(2) + const zoneUpdate = result.plan.changes.update.find((op) => op.id === room.id) + expect(zoneUpdate?.data).toEqual({ boundaryWallIds: [...replacementIds, other.id] }) + }) test('moves an attached opening to the replacement wall that contains it', () => { const door = DoorNode.parse({ id: 'door_attached', diff --git a/packages/core/src/systems/wall/wall-topology.ts b/packages/core/src/systems/wall/wall-topology.ts index f4db5ae81c..d97477a9ed 100644 --- a/packages/core/src/systems/wall/wall-topology.ts +++ b/packages/core/src/systems/wall/wall-topology.ts @@ -64,11 +64,16 @@ function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint) { return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } -function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, walls: WallNode[]) { +/** Where straight walls run along `start→end`, as sorted parameter intervals of it. */ +function collinearWallIntervals( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): { intervals: Array<[number, number]>; length: number } | null { const dx = end[0] - start[0] const dz = end[1] - start[1] const lengthSquared = dx * dx + dz * dz - if (lengthSquared <= WALL_INTERSECTION_EPSILON * WALL_INTERSECTION_EPSILON) return false + if (lengthSquared <= WALL_INTERSECTION_EPSILON * WALL_INTERSECTION_EPSILON) return null const length = Math.sqrt(lengthSquared) const intervals: Array<[number, number]> = [] @@ -89,11 +94,16 @@ function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, wall const intervalEnd = Math.min(1, Math.max(wallStartT, wallEndT)) if (intervalEnd >= intervalStart) intervals.push([intervalStart, intervalEnd]) } - intervals.sort((left, right) => left[0] - right[0]) - const parameterTolerance = WALL_INTERSECTION_EPSILON / length + return { intervals, length } +} + +function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, walls: WallNode[]) { + const collinear = collinearWallIntervals(start, end, walls) + if (!collinear) return false + const parameterTolerance = WALL_INTERSECTION_EPSILON / collinear.length let coveredUntil = 0 - for (const [intervalStart, intervalEnd] of intervals) { + for (const [intervalStart, intervalEnd] of collinear.intervals) { if (intervalStart > coveredUntil + parameterTolerance) return false coveredUntil = Math.max(coveredUntil, intervalEnd) if (coveredUntil >= 1 - parameterTolerance) return true @@ -101,6 +111,35 @@ function wallSegmentsCoverSegment(start: WallPlanPoint, end: WallPlanPoint, wall return false } +/** + * The parts of `start→end` that no straight wall already runs along, in order. + * A side drawn along an existing wall and past its end yields the overhang + * only, so the existing wall is reused instead of doubled. Gaps too short to + * be a wall are treated as covered. + */ +export function uncoveredWallSegments( + start: WallPlanPoint, + end: WallPlanPoint, + walls: WallNode[], +): Array<[WallPlanPoint, WallPlanPoint]> { + const collinear = collinearWallIntervals(start, end, walls) + if (!collinear) return [] + const minimumGap = WALL_MIN_LENGTH / collinear.length + const at = (t: number): WallPlanPoint => [ + start[0] + (end[0] - start[0]) * t, + start[1] + (end[1] - start[1]) * t, + ] + const segments: Array<[WallPlanPoint, WallPlanPoint]> = [] + let coveredUntil = 0 + for (const [intervalStart, intervalEnd] of collinear.intervals) { + if (intervalStart > coveredUntil + minimumGap) + segments.push([at(coveredUntil), at(intervalStart)]) + coveredUntil = Math.max(coveredUntil, intervalEnd) + } + if (coveredUntil < 1 - minimumGap) segments.push([at(coveredUntil), end]) + return segments +} + function projectPointOntoWallCenterline( point: WallPlanPoint, wall: WallNode, @@ -527,13 +566,32 @@ export function planWallInsertion( return split ? [[wallId, split] as const] : [] }) const replacementWalls = splitPlans.flatMap(([, split]) => split.create) + // Rooms name the walls that enclose them; a split wall's replacements take + // its place, as they do for an explicit division or a merge. + const replacements = new Map( + splitPlans.map(([wallId, split]) => [wallId, split.create.map((wall) => wall.id)] as const), + ) + const zoneUpdates = Object.values(nodes).flatMap((node) => + node.type === 'zone' && + node.parentId === args.levelId && + node.boundaryWallIds.some((id) => replacements.has(id)) + ? [ + { + id: node.id, + data: { + boundaryWallIds: node.boundaryWallIds.flatMap((id) => replacements.get(id) ?? [id]), + }, + }, + ] + : [], + ) const plan: WallInsertionPlan = { changes: { create: [...replacementWalls, ...insertedWalls].map((node) => ({ node, parentId: args.levelId, })), - update: splitPlans.flatMap(([, split]) => split.update), + update: [...splitPlans.flatMap(([, split]) => split.update), ...zoneUpdates], delete: splitPlans.map(([wallId]) => wallId as AnyNodeId), }, insertedWalls, diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 1c63b3a3cf..f3f36eab40 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -37,8 +37,7 @@ import useInteractionScope, { useMovingNode } from '../../store/use-interaction- import { classifyParticipant, collectParticipants, - computeGroupBox, - expandToComponent, + computeGroupPlanBox, type GroupPlanBounds, groupPlanBounds, levelFrame, @@ -128,9 +127,8 @@ export function startFloorplanGroupMove( const participantIds = selectedIds.filter( (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) - // Move the full connected wall/fence component, mirroring the 3D gizmo. - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + // Only the selection moves; connected walls stretch through `links`, as in 3D. + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return null const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] @@ -144,10 +142,9 @@ export function startFloorplanGroupMove( const candidates = collectAlignmentAnchors(staticNodes, '', levelId) // The group aligns as one rigid footprint: its bbox corners + center are - // the moving anchors. `computeGroupBox` is world-space (the 3D scene stays - // mounted under every view mode); plan coords are level-frame, so convert. + // the moving anchors, measured in the level frame like the plan itself. const { inverse: frameInv } = levelFrame(levelId) - const restBounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + const restBounds = groupPlanBounds(starts, frameInv) if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', @@ -441,12 +438,11 @@ export function startFloorplanGroupRotate(event: { (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) if (participantIds.length === 0) return false - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return false const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] const { inverse: frameInv } = levelFrame(levelId) - const bounds = groupPlanBounds(computeGroupBox(fullIds), starts, frameInv) + const bounds = groupPlanBounds(starts, frameInv) if (!bounds) return false // Same pivot as the dashed box the handles hang off (and as the 3D rotate // gizmo): its centre, not the anchor points' centre. @@ -605,8 +601,9 @@ const GROUP_BOX_ROTATE_CURSOR_STYLE = { /** * Dashed bounding box around the current multi-selection's transformable - * participants (expanded to the welded wall/fence component) — shows what a - * group drag will carry along, and IS the group's drag handle: press anywhere + * participants — what a group drag carries (connected walls outside the + * selection stretch at their shared ends to stay joined) — and IS the group's + * drag handle: press anywhere * inside it to slide the group, click to pick it up. Holding a selection * modifier (Cmd/Ctrl/Shift) lets pointer events pass through so members under * the box can still be toggled in and out. Rides the live drag delta so it @@ -653,7 +650,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB }, []) // `meshEpoch` re-runs the measurement once the meshes settle after a scene - // change (undo/redo included) — `computeGroupBox` reads mesh world bounds, + // change (undo/redo included) — `computeGroupPlanBox` reads mesh bounds, // which lag the `nodes` commit by a frame or two. const meshEpoch = useMeshSettleEpoch(nodes) const box = useMemo(() => { @@ -663,17 +660,13 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) if (participantIds.length === 0) return null - const fullIds = expandToComponent(participantIds, nodes, levelId) - const world = computeGroupBox(fullIds) - if (!world) return null - const { inverse } = levelFrame(levelId) - const min = world.min.clone().applyMatrix4(inverse) - const max = world.max.clone().applyMatrix4(inverse) + const plan = computeGroupPlanBox(participantIds, levelId) + if (!plan) return null return { - x: Math.min(min.x, max.x), - z: Math.min(min.z, max.z), - width: Math.abs(max.x - min.x), - depth: Math.abs(max.z - min.z), + x: plan.minX, + z: plan.minZ, + width: plan.maxX - plan.minX, + depth: plan.maxZ - plan.minZ, } }, [selectedIds, levelId, nodes, meshEpoch]) diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx index 12de92939d..c30bdcf6fc 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx @@ -14,8 +14,19 @@ import { } from '../../lib/floorplan/floorplan-mode' import useEditor from '../../store/use-editor' import useFloorplanMode from '../../store/use-floorplan-mode' +import useInteractionScope, { useReshapingNode } from '../../store/use-interaction-scope' -const lazyToolCache = new WeakMap<() => Promise, ComponentType>() +type Loader = () => Promise<{ default: ComponentType }> +const lazyToolCache = new WeakMap>() + +function lazyTool(loader: Loader | undefined): ComponentType | null { + if (!loader) return null + const cached = lazyToolCache.get(loader) + if (cached) return cached + const component = lazy(loader) + lazyToolCache.set(loader, component) + return component +} function registeredFloorplanTool( tool: string | null, @@ -24,13 +35,16 @@ function registeredFloorplanTool( if (!tool) return null const extension = getFloorplanNodeExtension(nodeRegistry.get(tool)) if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null - const loader = extension?.tool - if (!loader) return null - const cached = lazyToolCache.get(loader) - if (cached) return cached - const component = lazy(loader) - lazyToolCache.set(loader, component) - return component + return lazyTool(extension?.tool) +} + +/** The plan sibling of `def.affordanceTools[reshape]`: a kind's layer for its own reshape. */ +function registeredReshapeLayer( + kind: string | null, + reshape: string | null, +): ComponentType | null { + if (!(kind && reshape)) return null + return lazyTool(getFloorplanNodeExtension(nodeRegistry.get(kind))?.reshapeLayers?.[reshape]) } export function FloorplanRegisteredToolLayer() { @@ -42,6 +56,10 @@ export function FloorplanRegisteredToolLayer() { const toolDefaults = useEditor((state) => state.tool ? (state.toolDefaults[state.tool] ?? null) : null, ) + const reshape = useInteractionScope((state) => + state.scope.kind === 'reshaping' ? state.scope.reshape : null, + ) + const reshapingNode = useReshapingNode() const activeLevelId = useViewer((state) => state.selection.levelId) const unit = useViewer((state) => state.unit) const metricNotation = useViewer((state) => state.metricNotation) @@ -55,11 +73,14 @@ export function FloorplanRegisteredToolLayer() { useEditor.getState().setTool(null) useEditor.getState().setMode('select') }, []) - if (mode !== 'build') return null - const Tool = registeredToolEnabled ? registeredFloorplanTool(tool, floorplanMode) : null - return Tool ? ( + const Active = + registeredReshapeLayer(reshapingNode?.type ?? null, reshape) ?? + (mode === 'build' && registeredToolEnabled + ? registeredFloorplanTool(tool, floorplanMode) + : null) + return Active ? ( - 90) labelAngleDeg -= 180 + else if (screenDeg <= -90) labelAngleDeg += 180 + + // Push the plate perpendicular to the wall so the dashed footprint + // stays visible underneath. + const perpX = -measurement.direction[1] + const perpY = measurement.direction[0] + const offset = upx * 18 + const cx = measurement.midpoint[0] + perpX * offset + const cy = measurement.midpoint[1] + perpY * offset + + const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2 + const lengthPlateW = lengthTextWidth + padX * 2 + const lengthPlateH = fontSize + padY * 2 + + const arcSampleCount = 32 + + return ( + + + + + {measurement.lengthLabel} + + + + {measurement.angleLabels.map((arc) => { + // Sample the arc as a polyline — avoids the SVG arc command's + // sweep-flag direction quirks across negative/positive sweeps. + const points: string[] = [] + for (let i = 0; i <= arcSampleCount; i += 1) { + const t = i / arcSampleCount + const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t + const px = arc.center[0] + Math.cos(a) * arc.radius + const py = arc.center[1] + Math.sin(a) * arc.radius + points.push(`${px},${py}`) + } + + const aFontSize = Math.max(upx * 9, 0.075) + const aPadX = upx * 5 + const aPadY = upx * 2.5 + const aTextWidth = arc.label.length * upx * 6.2 + const aPlateW = aTextWidth + aPadX * 2 + const aPlateH = aFontSize + aPadY * 2 + + const labelDist = arc.radius + upx * 16 + const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist + const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist + + return ( + + + + + + {arc.label} + + + + ) + })} + + ) +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index e89a86edcf..40af99c705 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -23,6 +23,7 @@ import { collectFloorplanLinkedLevelNodes, computeAffectedSiblingIds, floorplanAffordanceReshapeScope, + floorplanEntryYieldsToTool, floorplanHandleDoubleClickAffordance, InteractiveGeometry, isFloorplanOpeningPlacementState, @@ -780,3 +781,12 @@ describe('collectFloorplanLinkedLevelNodes', () => { ).toEqual([]) }) }) + +describe('floorplan entry routing while a tool is active', () => { + test('build tools get presses on entries, as in 3D; select and delete keep selecting', () => { + expect(floorplanEntryYieldsToTool({ mode: 'build', openingPlacement: false })).toBe(true) + expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: true })).toBe(true) + expect(floorplanEntryYieldsToTool({ mode: 'select', openingPlacement: false })).toBe(false) + expect(floorplanEntryYieldsToTool({ mode: 'delete', openingPlacement: false })).toBe(false) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 8f66c21bbb..2c711dd105 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -91,7 +91,7 @@ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import { paintZoneMembership } from '../../../lib/units' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useDrawingView from '../../../store/use-drawing-view' -import useEditor, { isAngleSnapActive } from '../../../store/use-editor' +import useEditor, { isAngleSnapActive, type Mode } from '../../../store/use-editor' import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' import useFloorplanMode from '../../../store/use-floorplan-mode' import useInteractionScope, { @@ -456,15 +456,32 @@ export function isFloorplanOpeningPlacementState({ ) } -function isFloorplanOpeningPlacementActiveNow(): boolean { +/** + * Whether a press on an entry belongs to the active tool instead of selecting + * the entry. Build tools own the plan the way their 3D tools own the canvas, + * where selection only runs in select mode: a wall drawn onto another wall + * must place its point (T-junction), not select the wall under the cursor. + */ +export function floorplanEntryYieldsToTool(state: { + mode: Mode + openingPlacement: boolean +}): boolean { + return state.mode === 'build' || state.openingPlacement +} + +function floorplanEntryYieldsToToolNow(): boolean { const { phase, mode, tool } = useEditor.getState() const movingNode = getMovingNode() - return isFloorplanOpeningPlacementState({ - phase, + return floorplanEntryYieldsToTool({ mode, - tool, - movingNodeHasWallOpeningPlacement: - movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement, + openingPlacement: isFloorplanOpeningPlacementState({ + phase, + mode, + tool, + movingNodeHasWallOpeningPlacement: + movingNode != null && + !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement, + }), }) } @@ -558,13 +575,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const sceneRotationDeg = renderCtx?.getSceneRotationDeg() ?? 0 const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) - // Door / window placement (both build and move) needs the SVG's - // background click handler to run — it finds the closest wall via - // `findClosestWallPoint` and emits `wall:click` for the door / window - // tool. When the user clicks *on top of* a wall in this mode, the + // Build tools and door / window placement (both build and move) need the + // SVG's background handlers to run — wall drafting places its point there, + // and opening placement finds the closest wall via `findClosestWallPoint` + // and emits `wall:click`. When the user clicks *on top of* a wall, the // wall's registry entry would otherwise swallow the click via - // `handleClickStop` / `handleSelect`, so the placement never fires. - // Pass clicks through in that case. + // `handleClickStop` / `handleSelect`, so the tool never sees it. Pass + // clicks through in that case (`floorplanEntryYieldsToToolNow`). const editorMode = useEditor((s) => s.mode) const structureLayer = useEditor((s) => s.structureLayer) const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) @@ -682,9 +699,23 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const currentSelectedIds = useViewer.getState().selection.selectedIds let nextSelectedIds: string[] if (options.shouldToggle) { - nextSelectedIds = currentSelectedIds.includes(id) - ? currentSelectedIds.filter((selectedId) => selectedId !== id) - : [...currentSelectedIds, id] + if (currentSelectedIds.includes(id)) { + // A room's slab and ceiling overlap in the plan and only the slab + // takes the click, so removing one removes the other too (a marquee + // picks both). 3D keeps single toggles: each is clickable there. + const nodes = useScene.getState().nodes + const node = nodes[id as AnyNodeId] + const counterparts = node + ? (getFloorplanNodeExtension(nodeRegistry.get(node.type))?.selectionCounterparts?.({ + node, + nodes, + }) ?? []) + : [] + const removed = new Set([id, ...counterparts]) + nextSelectedIds = currentSelectedIds.filter((selectedId) => !removed.has(selectedId)) + } else { + nextSelectedIds = [...currentSelectedIds, id] + } } else if (options.isolateMember) { nextSelectedIds = [id] } else { @@ -722,7 +753,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) const handleClickStop = useCallback((event: React.MouseEvent) => { - if (isFloorplanOpeningPlacementActiveNow()) return + if (floorplanEntryYieldsToToolNow()) return event.stopPropagation() }, []) @@ -983,7 +1014,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // the stores at event time. Commit clears the interaction scope before // React paints the next frame; a render-time `undefined` handler leaves // a short dead zone where the first post-placement selection is lost. - if (isFloorplanOpeningPlacementActiveNow()) return + if (floorplanEntryYieldsToToolNow()) return if (startDirectMoveDrag(id, event)) return if (startDirectRotateDrag(id, event)) return if (startGroupMoveDrag(id, event)) return diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index b50630e191..67bbb0f555 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -31,6 +31,17 @@ import { withCameraPoseDistance, } from '../../lib/camera-pose' import { EDITOR_LAYER } from '../../lib/constants' +import { + acceptsKeyboardPan, + clearKeyboardPanKeys, + hasKeyboardPanInput, + isEditableKeyboardTarget, + isKeyboardPanKey, + type KeyboardPanState, + keyboardPanDirection, + keyboardPanSpeed, + setKeyboardPanKey, +} from '../../lib/keyboard-pan' import { editorOwnsOneFingerDrag } from '../../lib/touch-gesture-priority' import { publishCameraPose } from '../../store/camera-pose-store' import useEditor from '../../store/use-editor' @@ -51,11 +62,11 @@ const tempTarget = new Vector3() const transitionFreezePosition = new Vector3() const transitionFreezeTarget = new Vector3() const keyboardPanSpherical = new Spherical() +// In 2D-only view the canvas is paused, so the floor plan drives WASD, orbit and +// top view itself (`floorplan-panel.tsx`) and the camera stands down. +const planOwnsNavigation = () => useEditor.getState().viewMode === '2d' const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 -const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65 -const KEYBOARD_PAN_MIN_SPEED = 2 -const KEYBOARD_PAN_MAX_SPEED = 55 type CameraMode = ReturnType['cameraMode'] type CameraPoseSnapshot = { mode: CameraMode @@ -114,54 +125,6 @@ function freezeCameraControlTransition(control: CameraControlsImpl) { ) } -function isEditableKeyboardTarget(target: EventTarget | null) { - return ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable) - ) -} - -type KeyboardPanState = { - forward: boolean - backward: boolean - left: boolean - right: boolean -} - -function setKeyboardPanKey(state: KeyboardPanState, code: string, pressed: boolean): boolean { - if (code === 'KeyW') { - const changed = state.forward !== pressed - state.forward = pressed - return changed - } - if (code === 'KeyS') { - const changed = state.backward !== pressed - state.backward = pressed - return changed - } - if (code === 'KeyA') { - const changed = state.left !== pressed - state.left = pressed - return changed - } - if (code === 'KeyD') { - const changed = state.right !== pressed - state.right = pressed - return changed - } - return false -} - -function isKeyboardPanKey(code: string): boolean { - return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' -} - -function hasKeyboardPanInput(state: KeyboardPanState): boolean { - return state.forward || state.backward || state.left || state.right -} - type CameraViewportSize = { width: number height: number @@ -667,19 +630,14 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } } - const panKeys = keyboardPanKeys.current - const horizontal = (panKeys.right ? 1 : 0) - (panKeys.left ? 1 : 0) - const vertical = (panKeys.forward ? 1 : 0) - (panKeys.backward ? 1 : 0) + const { horizontal, vertical } = keyboardPanDirection(keyboardPanKeys.current) if (horizontal === 0 && vertical === 0) return const control = controls.current control.getSpherical(keyboardPanSpherical, false) const viewWidth = getCameraViewWidth(camera, keyboardPanSpherical.radius, viewportSize) - const speed = Math.min( - Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED), - KEYBOARD_PAN_MAX_SPEED, - ) + const speed = keyboardPanSpeed(viewWidth) const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical) if (horizontal !== 0) control.truck(horizontal * step, 0, true) @@ -766,13 +724,6 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = let panPointerId: number | null = null let panPointerButton: number | null = null - const clearKeyboardPanKeys = () => { - keyboardPanKeys.current.forward = false - keyboardPanKeys.current.backward = false - keyboardPanKeys.current.left = false - keyboardPanKeys.current.right = false - } - const setNavigationCursor = (cursor: 'grab' | 'grabbing') => { document.body.style.cursor = cursor gl.domElement.style.cursor = cursor @@ -835,10 +786,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = const onKeyDown = (event: KeyboardEvent) => { if (isKeyboardPanKey(event.code)) { - if ( - !(event.metaKey || event.ctrlKey || event.altKey) && - !isEditableKeyboardTarget(event.target) - ) { + if (acceptsKeyboardPan(event) && !planOwnsNavigation()) { const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, true) if (changed) beginLocalCameraInteraction() event.preventDefault() @@ -930,7 +878,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = const onBlur = () => { keyState.space = false - clearKeyboardPanKeys() + clearKeyboardPanKeys(keyboardPanKeys.current) panPointerId = null panPointerButton = null clearNavigationCursor() @@ -955,7 +903,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = window.removeEventListener('pointercancel', onPointerUp, true) window.removeEventListener('blur', onBlur) gl.domElement.removeEventListener('wheel', onWheel, true) - clearKeyboardPanKeys() + clearKeyboardPanKeys(keyboardPanKeys.current) clearNavigationCursor() cameraDraggingLifecycle.end() } @@ -1196,7 +1144,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } const handleTopView = () => { - if (isFirstPersonMode || !controls.current) return + if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return const currentPolarAngle = controls.current.polarAngle @@ -1208,7 +1156,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } const handleOrbitCW = () => { - if (isFirstPersonMode || !controls.current) return + if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle @@ -1220,7 +1168,7 @@ export const CustomCameraControls = ({ paused = false }: { paused?: boolean }) = } const handleOrbitCCW = () => { - if (isFirstPersonMode || !controls.current) return + if (isFirstPersonMode || !controls.current || planOwnsNavigation()) return const currentAzimuth = controls.current.azimuthAngle const currentPolar = controls.current.polarAngle diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx index 093a2f9fd1..07a1444603 100644 --- a/packages/editor/src/components/editor/editor-layout-mobile.tsx +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -236,6 +236,7 @@ export function EditorLayoutMobile({
{viewerContent}
{overlays && (
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 91e4aa0816..fc56af0736 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -55,6 +55,7 @@ import { type WindowNode, WindowNode as WindowNodeSchema, wallClosesRoom, + wallRectangleCorners, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' @@ -94,6 +95,15 @@ import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid- import type { EditorGridEvent } from '../../lib/grid-event-presentation' import { groundHeightAt } from '../../lib/ground-surface' import { guideEmitter } from '../../lib/guide-events' +import { + acceptsKeyboardPan, + clearKeyboardPanKeys, + isKeyboardPanKey, + type KeyboardPanState, + keyboardPanDirection, + keyboardPanSpeed, + setKeyboardPanKey, +} from '../../lib/keyboard-pan' import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements' import { sfxEmitter } from '../../lib/sfx-bus' @@ -137,6 +147,7 @@ import { import { FloorplanSnapBeaconLayer } from '../editor-2d/floorplan-snap-beacon-layer' import { FloorplanWallMoveGhostLayer } from '../editor-2d/floorplan-wall-move-ghost-layer' import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer' +import { FloorplanDraftWallMeasurement } from '../editor-2d/renderers/floorplan-draft-wall-measurement' import { FloorplanGeometryRenderer } from '../editor-2d/renderers/floorplan-geometry-renderer' import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer' import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer' @@ -2320,168 +2331,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin } } -type DraftWallMeasurement = { - lengthLabel: string - midpoint: WallPlanPoint - direction: WallPlanPoint - angleLabels: { - id: string - label: string - center: WallPlanPoint - radius: number - startAngle: number - endAngle: number - midAngle: number - }[] -} - -function FloorplanDraftWallMeasurement({ - measurement, - measurementStroke, - labelBackground, - labelText, - sceneRotationDeg, - unitsPerPixel, -}: { - measurement: DraftWallMeasurement - measurementStroke: string - labelBackground: string - labelText: string - sceneRotationDeg: number - unitsPerPixel: number -}) { - const stroke = measurementStroke - const labelBg = labelBackground - - const upx = unitsPerPixel - const fontSize = Math.max(upx * 10, 0.08) - const padX = upx * 6 - const padY = upx * 3 - - // Length plate: rotates to follow the wall direction, but flips 180° - // when its on-screen orientation would read upside-down (same trick as - // `floorplan-registry-layer.tsx` for dimension labels). - const wallAngleDeg = - (Math.atan2(measurement.direction[1], measurement.direction[0]) * 180) / Math.PI - let labelAngleDeg = wallAngleDeg - let screenDeg = wallAngleDeg + sceneRotationDeg - screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180 - if (screenDeg > 90) labelAngleDeg -= 180 - else if (screenDeg <= -90) labelAngleDeg += 180 - - // Push the plate perpendicular to the wall so the dashed footprint - // stays visible underneath. - const perpX = -measurement.direction[1] - const perpY = measurement.direction[0] - const offset = upx * 18 - const cx = measurement.midpoint[0] + perpX * offset - const cy = measurement.midpoint[1] + perpY * offset - - const lengthTextWidth = measurement.lengthLabel.length * upx * 6.2 - const lengthPlateW = lengthTextWidth + padX * 2 - const lengthPlateH = fontSize + padY * 2 - - const arcSampleCount = 32 - - return ( - - - - - {measurement.lengthLabel} - - - - {measurement.angleLabels.map((arc) => { - // Sample the arc as a polyline — avoids the SVG arc command's - // sweep-flag direction quirks across negative/positive sweeps. - const points: string[] = [] - for (let i = 0; i <= arcSampleCount; i += 1) { - const t = i / arcSampleCount - const a = arc.startAngle + (arc.endAngle - arc.startAngle) * t - const px = arc.center[0] + Math.cos(a) * arc.radius - const py = arc.center[1] + Math.sin(a) * arc.radius - points.push(`${px},${py}`) - } - - const aFontSize = Math.max(upx * 9, 0.075) - const aPadX = upx * 5 - const aPadY = upx * 2.5 - const aTextWidth = arc.label.length * upx * 6.2 - const aPlateW = aTextWidth + aPadX * 2 - const aPlateH = aFontSize + aPadY * 2 - - const labelDist = arc.radius + upx * 16 - const lx = arc.center[0] + Math.cos(arc.midAngle) * labelDist - const ly = arc.center[1] + Math.sin(arc.midAngle) * labelDist - - return ( - - - - - - {arc.label} - - - - ) - })} - - ) -} - function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } @@ -4570,7 +4419,7 @@ function FloorplanDraftCursorLayer({ )} {cursorPoint && ( - + s.fenceDraftEnd) const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd) const roofDraftQuarterTurn = useFloorplanDraftPreview((s) => s.roofDraftQuarterTurn) + const wallRectangleDraftStart = useFloorplanDraftPreview((s) => s.wallRectangleDraftStart) + // The live cursor is the rectangle's opposite corner; select it only while a + // rectangle draft is open so idle moves don't re-render this layer. + const wallRectangleDraftEnd = useFloorplanDraftPreview((s) => + s.wallRectangleDraftStart ? s.cursorPoint : null, + ) const draftPolygon = useMemo(() => { if ( @@ -4697,6 +4552,53 @@ function FloorplanLinearDraftLayer({ return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA) }, [levelId, wallDraftStart, wallDraftEnd]) + // Rectangle mode drafts four walls at once; they read exactly like a line + // draft — same mitered footprints, measurement plates and axis guides. + const rectangleDraft = useMemo(() => { + if (!(levelId && isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd)) { + return null + } + const corners = wallRectangleCorners(wallRectangleDraftStart, wallRectangleDraftEnd) + if (corners.length !== 4) return null + const draftWalls = corners.map((start, index) => + getSharedFloorplanWall({ + ...buildDraftWall(levelId, start, corners[(index + 1) % 4]!), + id: `wall_draft_${index}` as WallNode['id'], + }), + ) + const miterData = calculateLevelMiters(draftWalls) + const polygons = draftWalls.map((wall) => + formatPolygonPoints(getWallPlanFootprint(wall, miterData)), + ) + // Measure the two sides meeting at the cursor corner. Corners wind + // counter-clockwise, so each side is measured end → start to push its + // plate outside the rectangle. + const cursorIndex = corners.findIndex( + ([x, z]) => x === wallRectangleDraftEnd[0] && z === wallRectangleDraftEnd[1], + ) + const measurements = [cursorIndex - 1, cursorIndex].map((side) => { + const from = corners[(side + 5) % 4]! + const to = corners[(side + 4) % 4]! + const dx = to[0] - from[0] + const dy = to[1] - from[1] + const length = Math.hypot(dx, dy) + return { + lengthLabel: formatMeasurement(length, unit, null, metricNotation), + midpoint: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as WallPlanPoint, + direction: [dx / length, dy / length] as WallPlanPoint, + angleLabels: [], + } + }) + return { polygons, measurements } + }, [ + isWallBuildActive, + levelId, + metricNotation, + unit, + wallRectangleDraftEnd, + wallRectangleDraftStart, + ]) + const draftPolygonPoints = useMemo(() => { if (isRoofBuildActive && roofDraftStart && roofDraftEnd) { const minX = Math.min(roofDraftStart[0], roofDraftEnd[0]) @@ -4864,6 +4766,10 @@ function FloorplanLinearDraftLayer({ if (isWallBuildActive && wallDraftStart && wallDraftEnd) { pushDraft(wallDraftStart, wallDraftEnd) } + if (isWallBuildActive && wallRectangleDraftStart && wallRectangleDraftEnd) { + pushCross(wallRectangleDraftStart) + pushCross(wallRectangleDraftEnd) + } if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) { pushDraft(fenceDraftStart, fenceDraftEnd) } @@ -4875,6 +4781,8 @@ function FloorplanLinearDraftLayer({ fenceDraftStart, wallDraftEnd, wallDraftStart, + wallRectangleDraftEnd, + wallRectangleDraftStart, ]) return ( @@ -4932,6 +4840,34 @@ function FloorplanLinearDraftLayer({ unitsPerPixel={unitsPerPixel} /> )} + + {rectangleDraft?.polygons.map((points, index) => ( + + ))} + + {rectangleDraft?.measurements.map((measurement, index) => ( + + ))} ) } @@ -5036,6 +4972,7 @@ export function FloorplanPanel({ // the user closes and re-opens the 2D editor instead of restoring the // stale viewport from before they closed it. const isFloorplanOpen = useEditor((state) => state.isFloorplanOpen) + const viewMode = useEditor((state) => state.viewMode) // Mirror for callbacks that fire outside React's render (the per-frame // navigation-pose subscriber): when the 2D panel is hidden (`display:none` in // 3D mode) it must NOT re-render on every camera-zoom frame. @@ -7784,6 +7721,116 @@ export function FloorplanPanel({ [publishFloorplanNavigationPose], ) + // WASD and the orbit buttons in 2D-only view. The 3D canvas is paused there + // (`renderPaused`), so the plan drives them itself — same keys and speed as + // the camera — and hands the pose to 3D when the move ends. + const keyboardPanKeysRef = useRef({ + forward: false, + backward: false, + left: false, + right: false, + }) + useEffect(() => { + if (viewMode !== '2d') return + const keys = keyboardPanKeysRef.current + let frame: number | null = null + let lastTime: number | null = null + const sceneRotationDeg = (userRotationDeg: number) => + FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - buildingRotationDeg + const step = (time: number) => { + const { horizontal, vertical } = keyboardPanDirection(keys) + const viewport = latestViewportRef.current ?? latestFittedViewportRef.current + if ((horizontal === 0 && vertical === 0) || !viewport) { + frame = null + lastTime = null + commitFloorplanPan() + return + } + const elapsed = lastTime === null ? 0 : Math.min((time - lastTime) / 1000, 0.05) + lastTime = time + const distance = + (keyboardPanSpeed(viewport.width) * elapsed) / Math.hypot(horizontal, vertical) + const next = { + centerX: viewport.centerX + horizontal * distance, + centerY: viewport.centerY - vertical * distance, + width: viewport.width, + } + const userRotationDeg = latestFloorplanUserRotationDegRef.current + floorplanViewportInteractionInProgressRef.current = true + applyFloorplanViewportImperatively(next) + floorplanPanPoseRef.current = { + localCenter: rotateSvgPoint( + { x: next.centerX, y: next.centerY }, + -sceneRotationDeg(userRotationDeg), + ), + userRotationDeg, + viewWidth: next.width, + } + frame = requestAnimationFrame(step) + } + const start = () => { + if (frame === null) frame = requestAnimationFrame(step) + } + const onKeyDown = (event: KeyboardEvent) => { + if (!(isKeyboardPanKey(event.code) && acceptsKeyboardPan(event))) return + if (setKeyboardPanKey(keys, event.code, true)) start() + event.preventDefault() + event.stopPropagation() + } + const onKeyUp = (event: KeyboardEvent) => { + if (isKeyboardPanKey(event.code) && setKeyboardPanKey(keys, event.code, false)) { + event.preventDefault() + event.stopPropagation() + } + } + const onBlur = () => clearKeyboardPanKeys(keys) + // Same rule as the 3D buttons: snap to the nearest quarter turn, then turn one more. + const orbit = (clockwise: boolean) => { + const viewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!viewport) return + const userRotationDeg = latestFloorplanUserRotationDegRef.current + const quarter = Math.PI / 2 + const azimuth = + Math.round(cameraAzimuthFromFloorplanRotation(userRotationDeg) / quarter) * quarter + + (clockwise ? -quarter : quarter) + const nextRotationDeg = floorplanRotationFromCameraAzimuth(azimuth, userRotationDeg) + const localCenter = rotateSvgPoint( + { x: viewport.centerX, y: viewport.centerY }, + -sceneRotationDeg(userRotationDeg), + ) + smoothFloorplanNavigationView(localCenter, nextRotationDeg, viewport.width) + publishFloorplanNavigationPose(localCenter, nextRotationDeg, viewport.width) + } + const orbitClockwise = () => orbit(true) + const orbitCounterClockwise = () => orbit(false) + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) + emitter.on('camera-controls:orbit-cw', orbitClockwise) + emitter.on('camera-controls:orbit-ccw', orbitCounterClockwise) + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) + emitter.off('camera-controls:orbit-cw', orbitClockwise) + emitter.off('camera-controls:orbit-ccw', orbitCounterClockwise) + // A key released while 3D owns the keys would otherwise still read as + // held when the plan takes them back; the camera clears its own the same way. + clearKeyboardPanKeys(keys) + if (frame !== null) { + cancelAnimationFrame(frame) + commitFloorplanPan() + } + } + }, [ + applyFloorplanViewportImperatively, + buildingRotationDeg, + commitFloorplanPan, + publishFloorplanNavigationPose, + smoothFloorplanNavigationView, + viewMode, + ]) + useLayoutEffect(() => { flushFloorplanRotationPresentationRestore(pendingFloorplanRotationRestoreRef) }) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index e2c0d68765..f41db4cb8c 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -39,7 +39,6 @@ import { classifyParticipant, collectParticipants, computeGroupBox, - expandToComponent, groupPlanBounds, levelFrame, planBoundsCenter, @@ -83,8 +82,8 @@ export function canGroupPickUp(): boolean { * until a click commits, mirroring the single-node `movingNode` flow. Returns * false when the selection holds no transformable participants. * - * `scopeToSelection` limits the moving set to the selected participants — - * no connected-component expansion and no welded-neighbor endpoints. The + * `scopeToSelection` drops the welded-neighbor endpoints, so connected walls + * don't stretch along with the move. The * Duplicate flow needs this: its clones sit EXACTLY on the originals, so * junction coincidence would otherwise weld the originals into the pick-up * and drag them along with the copies. @@ -96,10 +95,7 @@ export function startGroupPickUp( const participantIds = groupParticipantIds() if (participantIds.length === 0) return false const nodes = useScene.getState().nodes - const fullIds = opts.scopeToSelection - ? participantIds - : expandToComponent(participantIds, nodes, levelId) - const collected = collectParticipants(fullIds, nodes, levelId) + const collected = collectParticipants(participantIds, nodes, levelId) // Mutable: mid-carry R/T rotates these snapshots in place. let starts = collected.starts let links = opts.scopeToSelection ? [] : collected.links @@ -107,8 +103,8 @@ export function startGroupPickUp( const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] const { inverse: frameInv } = levelFrame(levelId) - const restBox = computeGroupBox(fullIds) - const startBounds = groupPlanBounds(restBox, starts, frameInv) + const restBox = computeGroupBox(participantIds) + const startBounds = groupPlanBounds(starts, frameInv) if (!startBounds) return false // Mutable: mid-carry R/T re-seeds the footprint around the same pivot. let restBounds = startBounds diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index 6f755add3c..6f978664e4 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -15,7 +15,12 @@ import useSessionGroups, { ungroupCurrentSelection, } from '../../store/use-session-groups' import { deleteSelection, duplicateSelectionAndPickUp, startGroupPickUp } from './group-actions' -import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared' +import { + classifyParticipant, + computeGroupBox, + computeGroupPlanBox, + levelFrame, +} from './group-transform-shared' import { NodeActionMenu } from './node-action-menu' import { useMeshSettleEpoch } from './use-mesh-settle-epoch' @@ -69,15 +74,20 @@ export function GroupFloatingActionMenu() { const anchor = useMemo(() => { void meshEpoch if (participantIds.length === 0) return null - const fullIds = expandToComponent(participantIds, nodes, levelId) - const box = computeGroupBox(fullIds) + const box = computeGroupBox(participantIds) if (!box) return null - return new THREE.Vector3( - (box.min.x + box.max.x) / 2, - box.max.y + MENU_Y_OFFSET, - (box.min.z + box.max.z) / 2, - ) - }, [participantIds, nodes, levelId, meshEpoch]) + // Over the level-frame box's centre (the dashed boxes' and gizmo's), so the + // pill stays on the box under a rotated building; the mesh box gives the top. + const levelId = useViewer.getState().selection.levelId + const plan = computeGroupPlanBox(participantIds, levelId) + const anchor = plan + ? new THREE.Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4( + levelFrame(levelId).matrix, + ) + : new THREE.Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2) + anchor.y = box.max.y + MENU_Y_OFFSET + return anchor + }, [participantIds, meshEpoch]) useFrame((state) => { if (!(menuScaleRef.current && groupRef.current)) return diff --git a/packages/editor/src/components/editor/group-move-3d.ts b/packages/editor/src/components/editor/group-move-3d.ts index 3a26251e5d..53f6bf0e57 100644 --- a/packages/editor/src/components/editor/group-move-3d.ts +++ b/packages/editor/src/components/editor/group-move-3d.ts @@ -31,7 +31,6 @@ import { classifyParticipant, collectParticipants, computeGroupBox, - expandToComponent, type GroupPlanBounds, groupPlanBounds, levelFrame, @@ -107,16 +106,15 @@ export function armGroupMove3d(args: { const participantIds = selectedIds.filter( (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, ) - // Move the full connected wall/fence component, mirroring the 2D session. - const fullIds = expandToComponent(participantIds, nodes, levelId) - const { starts, links } = collectParticipants(fullIds, nodes, levelId) + // Only the selection moves; connected walls stretch through `links`, as in 2D. + const { starts, links } = collectParticipants(participantIds, nodes, levelId) if (starts.length === 0) return null const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] // Horizontal drag plane at the group's base; placements live in the level // frame, so world-space plane hits convert through it (a rotated building // would otherwise drift off-axis from the cursor). - const restBox = computeGroupBox(fullIds) + const restBox = computeGroupBox(participantIds) if (!restBox) return null const plane = new Plane(new Vector3(0, 1, 0), -restBox.min.y) const { inverse: frameInv } = levelFrame(levelId) @@ -136,7 +134,7 @@ export function armGroupMove3d(args: { if (n && !movingIdSet.has(nid)) staticNodes[nid] = n } const candidates = collectAlignmentAnchors(staticNodes, '', levelId) - const restBounds = groupPlanBounds(restBox, starts, frameInv) + const restBounds = groupPlanBounds(starts, frameInv) if (!restBounds) return null const restAnchors = bboxCornerAnchors( 'group-move', diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 0503b4335e..939f17b192 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -28,7 +28,7 @@ import { classifyParticipant, collectParticipants, computeGroupBox, - expandToComponent, + computeGroupPlanBox, levelFrame, rotateGroupPatches, type Vec3, @@ -79,14 +79,6 @@ export function GroupRotateHandle() { [selectedIds, levelId, nodes], ) - // Gate on the explicit selection (so a single connected wall still gets the - // per-node handles), but transform the full connected wall/fence component so - // attached structure rotates rigidly as one piece. - const fullIds = useMemo( - () => expandToComponent(participantIds, nodes, levelId), - [participantIds, levelId, nodes], - ) - const shouldRender = participantIds.length >= 2 && mode !== 'delete' && @@ -98,7 +90,13 @@ export function GroupRotateHandle() { if (!shouldRender) return null // Remount when the moving set changes so the rest pivot re-seeds cleanly. - return + return ( + + ) } function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: number }) { @@ -125,22 +123,28 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: const baseScale = zoom * ARROW_SCALE * 1.05 const scale = (isHovered ? 1.12 : 1) * baseScale - // World-space bounding box of the selected meshes. Levels are axis-aligned in - // XZ, so world XZ coincides with each node's level-local placement — letting - // us rotate `position` / `start` / `end` directly against the pivot without - // per-node frame conversion. - // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin - // - `corner` = front-right bbox corner at mid-height → where the gizmo sits + // Both rest points come from the level-frame box (the dashed boxes' box, also + // keyboard R/T's pivot), carried to world so they stay on it under a rotated + // building; the world mesh box only supplies heights. + // - `pivot` = box center, Y at the group's base → the rotation origin + // - `corner` = far box corner at mid-height → where the gizmo sits const rest = useMemo(() => { void meshEpoch const box = computeGroupBox(ids) if (!box) return null - const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) - const corner = new Vector3( - box.max.x + CORNER_OFFSET, - (box.min.y + box.max.y) / 2, - box.max.z + CORNER_OFFSET, - ) + const levelId = useViewer.getState().selection.levelId + const { matrix } = levelFrame(levelId) + const plan = computeGroupPlanBox(ids, levelId) + const pivot = plan + ? new Vector3((plan.minX + plan.maxX) / 2, 0, (plan.minZ + plan.maxZ) / 2).applyMatrix4( + matrix, + ) + : new Vector3((box.min.x + box.max.x) / 2, 0, (box.min.z + box.max.z) / 2) + pivot.y = box.min.y + const corner = plan + ? new Vector3(plan.maxX + CORNER_OFFSET, 0, plan.maxZ + CORNER_OFFSET).applyMatrix4(matrix) + : new Vector3(box.max.x + CORNER_OFFSET, 0, box.max.z + CORNER_OFFSET) + corner.y = (box.min.y + box.max.y) / 2 return { pivot, corner } }, [ids, meshEpoch]) diff --git a/packages/editor/src/components/editor/group-selection-box-3d.tsx b/packages/editor/src/components/editor/group-selection-box-3d.tsx index 29d78739a0..6e33fe67ed 100644 --- a/packages/editor/src/components/editor/group-selection-box-3d.tsx +++ b/packages/editor/src/components/editor/group-selection-box-3d.tsx @@ -12,7 +12,7 @@ import { armGroupMove3d } from './group-move-3d' import { classifyParticipant, computeGroupBox, - expandToComponent, + computeGroupPlanBox, levelFrame, } from './group-transform-shared' import { useMeshSettleEpoch } from './use-mesh-settle-epoch' @@ -24,8 +24,8 @@ const BOX_PAD = 0.06 /** * 3D sibling of the 2D dashed group selection box: a dashed wireframe around - * the multi-selection's transformable participants (expanded to the welded - * wall/fence component) that doubles as the group's whole-volume drag + * the multi-selection's transformable participants that doubles as the + * group's whole-volume drag * handle — move cursor across it, press-drag anywhere on it slides the group, * a plain click picks it up. Holding a selection modifier passes the press * through so members inside can still be toggled. Rides the live drag delta @@ -55,22 +55,30 @@ export function GroupSelectionBox3D() { const box = useMemo(() => { void meshEpoch if (participantIds.length === 0) return null - const fullIds = expandToComponent(participantIds, nodes, levelId) - const world = computeGroupBox(fullIds) - if (!world) return null - const size = new THREE.Vector3() - world.getSize(size) - const center = new THREE.Vector3() - world.getCenter(center) + // Aligned with the building like the 2D box: level-frame footprint, world + // height span, turned by the level's rotation. + const world = computeGroupBox(participantIds) + const { matrix } = levelFrame(levelId) + const plan = computeGroupPlanBox(participantIds, levelId) + if (!(world && plan)) return null + const center = new THREE.Vector3( + (plan.minX + plan.maxX) / 2, + 0, + (plan.minZ + plan.maxZ) / 2, + ).applyMatrix4(matrix) + center.y = (world.min.y + world.max.y) / 2 + const quaternion = new THREE.Quaternion() + matrix.decompose(new THREE.Vector3(), quaternion, new THREE.Vector3()) return { - size: [size.x + 2 * BOX_PAD, size.y + 2 * BOX_PAD, size.z + 2 * BOX_PAD] as [ - number, - number, - number, - ], + size: [ + plan.maxX - plan.minX + 2 * BOX_PAD, + world.max.y - world.min.y + 2 * BOX_PAD, + plan.maxZ - plan.minZ + 2 * BOX_PAD, + ] as [number, number, number], center, + quaternion, } - }, [participantIds, nodes, levelId, meshEpoch]) + }, [participantIds, levelId, meshEpoch]) // Dashed wireframe. Built per box size (rare — selection / commit changes) // because LineDashedMaterial measures dashes along the line, so scaling a @@ -156,7 +164,7 @@ export function GroupSelectionBox3D() { ] return ( - + {/* Invisible whole-volume hit target — the box IS the drag handle. */} { ]) }) }) + +describe('group plan bounds', () => { + const withNodes = (nodes: AnyNode[], run: () => void) => { + const saved = useScene.getState().nodes + useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved }) + try { + run() + } finally { + useScene.setState({ nodes: saved }) + } + } + + test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => { + const level = new Group() + level.position.set(10, 0, -4) + level.rotation.y = Math.PI / 5 + const item = new Mesh(new BoxGeometry(1, 1, 1)) + item.position.set(2, 0.5, 0) + level.add(item) + level.updateWorldMatrix(true, true) + const registry = sceneRegistry.nodes as unknown as Map + registry.set('item_plan_bounds_test', item) + const start = { + id: 'item_plan_bounds_test' as AnyNodeId, + kind: 'vec3' as const, + position: [2, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } + try { + const frameInv = level.matrixWorld.clone().invert() + const built = groupPlanBounds([start], frameInv) + expect(built?.minX).toBeCloseTo(1.5) + expect(built?.maxX).toBeCloseTo(2.5) + expect(built?.minZ).toBeCloseTo(-0.5) + expect(built?.maxZ).toBeCloseTo(0.5) + // Unbuilt while the 3D scene is paused: the placeholder must not count. + item.geometry.userData.placeholder = true + expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 }) + } finally { + registry.delete('item_plan_bounds_test') + } + }) + + test('hugs the selected wall outlines; a connected wall stays outside', () => { + const level = LevelNode.parse({ children: [] }) + const bottom = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [2.4, 0], + thickness: 0.2, + }) + const side = WallNode.parse({ + parentId: level.id, + start: [2.4, 0], + end: [2.4, 5.3], + thickness: 0.2, + }) + const neighbour = WallNode.parse({ + parentId: level.id, + start: [2.4, 5.3], + end: [8, 5.3], + thickness: 0.2, + }) + withNodes([level, bottom, side, neighbour], () => { + const plan = computeGroupPlanBox([bottom.id, side.id], level.id) + expect(plan?.minX).toBeCloseTo(0) + expect(plan?.maxX).toBeCloseTo(2.5) + expect(plan?.minZ).toBeCloseTo(-0.1) + expect(plan?.maxZ).toBeLessThan(5.5) + expect(plan?.maxX).toBeLessThan(8) + }) + }) + + test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => { + // A user's selection whose box came out 13 m wide in 2D-only view. + const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] }) + const room: [number, number][] = [ + [-3.6221682640435353, -9.037533979512114], + [-6.21504662384804, -8.29671159099654], + [-5.712152486161543, -6.5365821090938], + [-5.712152486161543, -5], + [-8.223564365687542, -5], + [-8.223564365687542, -6.5365821090938], + [-10.712152486161543, -6.5365821090938], + [-11.712152486161543, -8.5365821090938], + [-4.119274126357038, -10.777404497609373], + ] + const wall15 = WallNode.parse({ + parentId: level.id, + start: [-10.712152486161543, -6.5365821090938], + end: [-11.712152486161543, -8.5365821090938], + }) + const wall12 = WallNode.parse({ + parentId: level.id, + start: [-11.712152486161543, -8.5365821090938], + end: [-6.712152486161543, -10.0365821090938], + }) + const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 }) + const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 }) + withNodes([level, wall15, wall12, slab, ceiling], () => { + const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)! + expect(plan.minX).toBeLessThan(-11.71) + expect(plan.minX).toBeGreaterThan(-11.95) + expect(plan.maxX).toBeCloseTo(-3.62, 1) + expect(plan.minZ).toBeCloseTo(-10.78, 1) + expect(plan.maxZ).toBeCloseTo(-5, 1) + expect(plan.maxX - plan.minX).toBeLessThan(8.4) + }) + }) +}) diff --git a/packages/editor/src/components/editor/group-transform-shared.ts b/packages/editor/src/components/editor/group-transform-shared.ts index 408eb2cc65..954f3dbaf0 100644 --- a/packages/editor/src/components/editor/group-transform-shared.ts +++ b/packages/editor/src/components/editor/group-transform-shared.ts @@ -1,11 +1,15 @@ import { type AnyNode, type AnyNodeId, + calculateLevelMiters, + getWallPlanFootprint, nodeRegistry, resolveBuildingForLevel, sceneRegistry, + useScene, + type WallNode, } from '@pascal-app/core' -import { Box3, Matrix4 } from 'three' +import { Box3, type BufferGeometry, Matrix4, type Object3D, Vector3 } from 'three' // Shared plumbing for the group transform gizmos (rotate + move). Both operate // on the same multi-selection: classify each participant by how its placement @@ -242,48 +246,6 @@ export function collectParticipants( return { starts, links } } -// Grow a selection to the full connected component of walls/fences: any -// endpoint node transitively reachable through shared junctions from a selected -// endpoint node joins in, so the whole rigid structure transforms as one piece -// (rather than tearing/stretching at the boundary). Non-endpoint selections -// (items, columns) pass through unchanged. -export function expandToComponent( - selectedIds: string[], - sceneNodes: Record, - levelId: string | null, -): string[] { - const endpoints: { id: string; start: Vec2; end: Vec2 }[] = [] - for (const [id, node] of Object.entries(sceneNodes)) { - if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') { - const n = node as AnyNode & { start: Vec2; end: Vec2 } - endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] }) - } - } - const included = new Set(selectedIds) - if (!endpoints.some((e) => included.has(e.id))) return selectedIds - - let changed = true - while (changed) { - changed = false - for (const e of endpoints) { - if (included.has(e.id)) continue - const touches = endpoints.some( - (o) => - included.has(o.id) && - (nearPoint(e.start, o.start) || - nearPoint(e.start, o.end) || - nearPoint(e.end, o.start) || - nearPoint(e.end, o.end)), - ) - if (touches) { - included.add(e.id) - changed = true - } - } - } - return Array.from(included) -} - // Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live // preview and for the single batched `updateNodes` on commit. export type GroupPatch = readonly [AnyNodeId, Record] @@ -389,9 +351,24 @@ export function rotateGroupSnapshots( export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number } -// Level-frame XZ extents of the participant DATA — the mesh-free sibling of -// `computeGroupBox`, used when meshes aren't mounted yet. -function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null { +const planCorner = new Vector3() +const planMatrix = new Matrix4() + +/** + * The selection's footprint in the level frame — the plan's own coordinates, + * measured from node data wherever the plan draws from data: walls by their + * mitered outline, polygon hosts (slab, ceiling, zone) by their polygon, fences + * by their run. Only placed objects (items, columns, stairs…) measure their + * meshes: bounds go through `frameInv × matrixWorld` (a world box can't be + * carried into a rotated building's frame), placeholders — unbuilt while the + * 3D scene is paused in 2D-only view — are skipped, and a node with no built + * mesh falls back to its anchor. + */ +export function groupPlanBounds( + starts: ParticipantStart[], + frameInv: Matrix4, +): GroupPlanBounds | null { + const nodes = useScene.getState().nodes let minX = Number.POSITIVE_INFINITY let minZ = Number.POSITIVE_INFINITY let maxX = Number.NEGATIVE_INFINITY @@ -402,43 +379,72 @@ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null maxX = Math.max(maxX, x) maxZ = Math.max(maxZ, z) } + const miters = new Map>() + const wallOutline = (wall: WallNode) => { + const levelId = wall.parentId ?? null + let levelMiters = miters.get(levelId) + if (!levelMiters) { + levelMiters = calculateLevelMiters( + Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId, + ), + ) + miters.set(levelId, levelMiters) + } + return getWallPlanFootprint(wall, levelMiters) + } + const measureMeshes = (id: string) => { + const obj = sceneRegistry.nodes.get(id as AnyNodeId) + if (!obj) return false + obj.updateWorldMatrix(true, true) + let found = false + obj.traverse((child: Object3D) => { + const geometry = (child as Object3D & { geometry?: BufferGeometry }).geometry + if (!child.visible || !geometry || geometry.userData.placeholder) return + // Always fresh: geometries rebuilt in place keep a stale cached box. + geometry.computeBoundingBox() + const bounds = geometry.boundingBox + if (!bounds || bounds.isEmpty()) return + planMatrix.multiplyMatrices(frameInv, child.matrixWorld) + for (let corner = 0; corner < 8; corner++) { + planCorner + .set( + corner & 1 ? bounds.max.x : bounds.min.x, + corner & 2 ? bounds.max.y : bounds.min.y, + corner & 4 ? bounds.max.z : bounds.min.z, + ) + .applyMatrix4(planMatrix) + reach(planCorner.x, planCorner.z) + } + found = true + }) + return found + } for (const s of starts) { + const node = nodes[s.id] if (s.kind === 'endpoint') { + const outline = node?.type === 'wall' ? wallOutline(node) : [] + if (outline.length > 0) { + for (const point of outline) reach(point.x, point.y) + continue + } reach(s.start[0], s.start[1]) reach(s.end[0], s.end[1]) + const path = (node as { path?: unknown } | undefined)?.path + if (isVec2Array(path)) for (const [x, z] of path) reach(x, z) } else if (s.kind === 'polygon') { - for (const [x, z] of s.polygon) { - reach(x, z) - } - } else { + for (const [x, z] of s.polygon) reach(x, z) + } else if (!measureMeshes(s.id)) { reach(s.position[0], s.position[2]) } } - if (!Number.isFinite(minX)) return null - return { minX, minZ, maxX, maxZ } + return Number.isFinite(minX) ? { minX, minZ, maxX, maxZ } : null } -// The one footprint every group transform measures itself against: the -// selection's mounted meshes (world box, converted into the level frame) with -// the participant DATA extents as the fallback when the meshes aren't up yet -// (Duplicate picks up its clones a frame before their renderers mount). Anchor -// points alone sit metres inside a wide selection's real footprint, so a -// gesture that pivots on the data extents orbits a different point than the -// idle keyboard rotate and the rotate gizmos, which both use the mesh box. -export function groupPlanBounds( - box: Box3 | null, - starts: ParticipantStart[], - frameInv: Matrix4, -): GroupPlanBounds | null { - if (!box) return participantExtents(starts) - const min = box.min.clone().applyMatrix4(frameInv) - const max = box.max.clone().applyMatrix4(frameInv) - return { - minX: Math.min(min.x, max.x), - minZ: Math.min(min.z, max.z), - maxX: Math.max(min.x, max.x), - maxZ: Math.max(min.z, max.z), - } +/** `groupPlanBounds` for a selection: the dashed boxes and gizmos measure this. */ +export function computeGroupPlanBox(ids: string[], levelId: string | null): GroupPlanBounds | null { + const { starts } = collectParticipants(ids, useScene.getState().nodes, levelId) + return groupPlanBounds(starts, levelFrame(levelId).inverse) } export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [ diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index bb1ca78dad..8c8eea59f1 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -3,6 +3,7 @@ import { Icon } from '@iconify/react' import { Copy, Group, Move, PencilRuler, Search, Spline, Trash2, Ungroup } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' +import { RegistryActionContributions } from './registry-action-contributions' type NodeActionMenuProps = { onFind?: MouseEventHandler @@ -111,6 +112,7 @@ export function NodeActionMenu({ )} + {onDuplicate && ( + ) +} + +/** Joins selected walls that continue each other into one — the inverse of Split. */ +function MergeWallsAction() { + const selected = useViewer((s) => s.selection.selectedIds) as AnyNodeId[] + const nodes = useScene((s) => s.nodes) + const readOnly = useScene((s) => s.readOnly) + // null: not a wall selection (render nothing); otherwise why it can't merge, or null. + const merge = useMemo(() => { + if (selected.length < 2 || selected.some((id) => nodes[id]?.type !== 'wall')) return null + try { + planWallMerge(nodes, selected) + return { reason: null } + } catch (error) { + return { reason: error instanceof Error ? error.message : 'These walls cannot be merged.' } + } + }, [nodes, selected]) + if (!merge) return null + const { reason } = merge + const button = ( + + ) + if (!reason) return button + // A disabled button gets no hover events, so the reason hangs off a wrapper. + return ( + + + {button} + + {reason} + + ) +} diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 1b3e3a6c1a..88a880db80 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -12,6 +12,7 @@ import { } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { hasWallCurveBlockingChildren } from './curve-eligibility' +import { useWallDrawingMode } from './drawing-mode' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' import { wallCurveAffordance, @@ -30,6 +31,9 @@ import { wallParametrics } from './parametrics' import { wallQuickMeasurement } from './quick-measurement' import { WallNode } from './schema' import { wallSlots } from './slots' +import { WALL_SPLIT_MAX_CUTS } from './split-preview' +import { setWallSplitCuts } from './split-session' +import { useWallSplit } from './split-store' /** * Wall — the Phase 3 stress test of the registry-driven node model. @@ -46,6 +50,10 @@ import { wallSlots } from './slots' * floorplan-panel.tsx's `wallPolygons` short-circuits to [] when * wall is registered. */ +const SPLIT_CUT_COUNTS = Array.from({ length: WALL_SPLIT_MAX_CUTS }, (_, index) => + String(index + 1), +) + export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', @@ -71,8 +79,11 @@ export const wallDefinition: NodeDefinition = { }, } satisfies DraftingSurfaceExtension, 'pascal:editor/floorplan': { + tool: () => import('./floorplan-tool'), + reshapeLayers: { split: () => import('./split-floorplan-layer') }, contextualDimensions: buildWallContextualDimensions, actionMenu: { + actions: () => import('./actions'), canCurve: ({ node, nodes }) => !hasWallCurveBlockingChildren( node.children.flatMap((childId) => { @@ -152,6 +163,7 @@ export const wallDefinition: NodeDefinition = { curve: () => import('./curve-tool'), 'move-endpoint': () => import('./move-endpoint-tool'), move: () => import('./move-tool'), + split: () => import('./split-tool'), }, renderer: { @@ -188,8 +200,57 @@ export const wallDefinition: NodeDefinition = { floorplanSiblingOverrides: wallFloorplanSiblingOverrides, toolHints: [ { key: 'Left click', label: 'Set wall start / end' }, + { + key: 'R', + label: 'Shape', + chip: { + subscribe: (onChange) => useWallDrawingMode.subscribe(onChange), + value: () => useWallDrawingMode.getState().mode, + cycle: () => useWallDrawingMode.getState().toggle(), + labels: { line: 'Shape: Line', rectangle: 'Shape: Rectangle' }, + icons: { line: 'lucide:minus', rectangle: 'lucide:square' }, + tooltip: 'Wall shape — click or press R to toggle', + }, + }, { key: 'Esc', label: 'Cancel' }, ], + // The split session (`split-session.ts`) is the wall's own reshape; the HUD + // shows these while it runs. The snapping chip comes from the scope. + affordanceHints: { + split: [ + { key: 'Left click', label: 'Split at the marks' }, + { + key: 'Scroll', + label: 'Cuts', + chip: { + subscribe: (onChange) => useWallSplit.subscribe(onChange), + value: () => String(useWallSplit.getState().draft?.cuts ?? 1), + cycle: () => { + const cuts = useWallSplit.getState().draft?.cuts ?? 1 + setWallSplitCuts(cuts >= WALL_SPLIT_MAX_CUTS ? 1 : cuts + 1) + }, + labels: Object.fromEntries( + SPLIT_CUT_COUNTS.map((count) => [ + count, + count === '1' ? 'Cuts: 1' : `Cuts: ${count}, even`, + ]), + ), + icons: Object.fromEntries(SPLIT_CUT_COUNTS.map((count) => [count, 'lucide:scissors'])), + tooltip: 'Number of cuts — scroll or click to change', + }, + }, + { + key: 'Alt', + label: 'Free placement', + // Several cuts are evenly spaced, so there is nothing to place freely. + visible: { + subscribe: (onChange) => useWallSplit.subscribe(onChange), + value: () => (useWallSplit.getState().draft?.cuts ?? 1) === 1, + }, + }, + { key: 'Esc', label: 'Cancel' }, + ], + }, presentation: { label: 'Wall', diff --git a/packages/nodes/src/wall/drawing-mode.ts b/packages/nodes/src/wall/drawing-mode.ts new file mode 100644 index 0000000000..19ee6508a3 --- /dev/null +++ b/packages/nodes/src/wall/drawing-mode.ts @@ -0,0 +1,42 @@ +import { emitter } from '@pascal-app/core' +import { isEditableKeyboardTarget } from '@pascal-app/editor' +import { useEffect } from 'react' +import { create } from 'zustand' + +export const useWallDrawingMode = create<{ + mode: 'line' | 'rectangle' + toggle: () => void +}>((set, get) => ({ + mode: 'line', + toggle: () => { + emitter.emit('tool:cancel') + set({ mode: get().mode === 'rectangle' ? 'line' : 'rectangle' }) + }, +})) + +const onKeyDown = (e: KeyboardEvent) => { + if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return + if (e.key !== 'r' && e.key !== 'R') return + if (isEditableKeyboardTarget(e.target)) return + e.preventDefault() + useWallDrawingMode.getState().toggle() +} +let owners = 0 + +/** + * R toggles line / rectangle drawing (the HUD's Shape chip cycles the same + * store). Both wall tools mount this: the 3D tool only exists once the canvas + * does, and split view mounts both, so one listener serves whichever is up and + * the mode returns to line when the last one closes. + */ +export function useWallDrawingModeKeys() { + useEffect(() => { + if (owners++ === 0) window.addEventListener('keydown', onKeyDown) + return () => { + if (--owners === 0) { + window.removeEventListener('keydown', onKeyDown) + useWallDrawingMode.setState({ mode: 'line' }) + } + } + }, []) +} diff --git a/packages/nodes/src/wall/floorplan-tool.tsx b/packages/nodes/src/wall/floorplan-tool.tsx new file mode 100644 index 0000000000..1e1f8b22e9 --- /dev/null +++ b/packages/nodes/src/wall/floorplan-tool.tsx @@ -0,0 +1,123 @@ +'use client' +import { + emitter, + resolveTerrainWallConstructionOptions, + useScene, + type WallPlanPoint, +} from '@pascal-app/core' +import { + type FloorplanToolContext, + markToolCancelConsumed, + triggerSFX, + useEditor, + useFloorplanDraftPreview, + useFloorplanRender, + useInteractionScope, +} from '@pascal-app/editor' +import { useEffect, useRef, useState } from 'react' +import { useWallDrawingMode, useWallDrawingModeKeys } from './drawing-mode' +import { createWallRectangle } from './rectangle-command' + +/** The wall's plan tool: line drafting stays with the panel; rectangle mode mounts its own tool. */ +export default function WallFloorplanTool(props: FloorplanToolContext) { + useWallDrawingModeKeys() + const mode = useWallDrawingMode((s) => s.mode) + return mode === 'rectangle' ? : null +} + +/** + * Rectangle mode in the floor plan. Pointer moves stay with the panel, so the + * cursor, snapping, alignment guides and snap beacon are the line wall's own; + * this tool only claims clicks and publishes the first corner, and the panel's + * linear draft layer draws the four draft walls from it. + */ +function RectangleFloorplanTool({ activeLevelId }: FloorplanToolContext) { + const group = useRef(null) + const renderContext = useFloorplanRender() + const [error, setError] = useState<{ message: string; at: WallPlanPoint } | null>(null) + useEffect(() => { + const svg = group.current?.ownerSVGElement + if (!svg || !activeLevelId) return + const draft = useFloorplanDraftPreview.getState + let down: [number, number] | null = null + let construction: ReturnType | undefined + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'wall' }) + const claim = (e: Event) => { + e.preventDefault() + e.stopImmediatePropagation() + } + const onDown = (e: PointerEvent) => { + if (e.button !== 0 || e.metaKey || e.ctrlKey) return + claim(e) + down = [e.clientX, e.clientY] + } + const cancel = () => { + if (draft().wallRectangleDraftStart) markToolCancelConsumed() + draft().setWallRectangleDraftStart(null) + down = null + setError(null) + } + const onClick = (e: MouseEvent) => { + if (e.button !== 0 || e.metaKey || e.ctrlKey) return + claim(e) + if (!down || Math.hypot(e.clientX - down[0], e.clientY - down[1]) > 5) { + down = null + return + } + down = null + // The panel's snapped cursor, exactly where a line wall would land. + const point = draft().cursorPoint + if (!point) return + const start = draft().wallRectangleDraftStart + if (!start) { + construction = resolveTerrainWallConstructionOptions( + useScene.getState().nodes, + activeLevelId, + point, + useEditor.getState().toolDefaults.wall, + ) + draft().setWallRectangleDraftStart(point) + setError(null) + return + } + try { + createWallRectangle( + activeLevelId, + start, + point, + useEditor.getState().toolDefaults.wall ?? {}, + construction, + ) + cancel() + triggerSFX('sfx:structure-build') + } catch (e) { + setError({ message: (e as Error).message, at: point }) + } + } + const stopDouble = (e: MouseEvent) => { + if (e.button === 0) claim(e) + } + svg.addEventListener('pointerdown', onDown, true) + svg.addEventListener('click', onClick, true) + svg.addEventListener('dblclick', stopDouble, true) + emitter.on('tool:cancel', cancel) + return () => { + svg.removeEventListener('pointerdown', onDown, true) + svg.removeEventListener('click', onClick, true) + svg.removeEventListener('dblclick', stopDouble, true) + emitter.off('tool:cancel', cancel) + draft().setWallRectangleDraftStart(null) + useInteractionScope.getState().endIf((s) => s.kind === 'drafting' && s.tool === 'wall') + } + }, [activeLevelId]) + const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01 + return ( + + {error && ( + + {error.message} + + )} + + ) +} diff --git a/packages/nodes/src/wall/rectangle-command.ts b/packages/nodes/src/wall/rectangle-command.ts new file mode 100644 index 0000000000..d137608fbb --- /dev/null +++ b/packages/nodes/src/wall/rectangle-command.ts @@ -0,0 +1,36 @@ +import { + type AnyNodeId, + planWallRectangle, + resolveWallConstruction, + runAsSingleSceneHistoryStep, + useScene, + type WallConstructionOptions, + type WallNode, + type WallPlanPoint, +} from '@pascal-app/core' + +export function createWallRectangle( + levelId: AnyNodeId, + start: WallPlanPoint, + end: WallPlanPoint, + defaults: Partial = {}, + options?: WallConstructionOptions, +) { + const scene = useScene.getState() + if (scene.readOnly) throw Error('This scene is read-only.') + const plan = planWallRectangle(scene.nodes, { levelId, start, end, wallDefaults: defaults }) + if (!plan.changes.create.length && !plan.changes.update.length && !plan.changes.delete.length) + return [] + const construction = resolveWallConstruction(scene.nodes, levelId, plan.walls, options) + const walls = new Map(construction.walls.map((w) => [w.id, w])) + const changes = { + ...plan.changes, + create: plan.changes.create.map((op) => ({ + ...op, + node: walls.get(op.node.id as WallNode['id']) ?? op.node, + })), + } + if (construction.sourceSupportUpdate) changes.update.push(construction.sourceSupportUpdate) + runAsSingleSceneHistoryStep(useScene, () => scene.applyNodeChanges(changes)) + return construction.walls +} diff --git a/packages/nodes/src/wall/rectangle-tool.tsx b/packages/nodes/src/wall/rectangle-tool.tsx new file mode 100644 index 0000000000..106fbb317c --- /dev/null +++ b/packages/nodes/src/wall/rectangle-tool.tsx @@ -0,0 +1,222 @@ +'use client' +import { + emitter, + type GridEvent, + useScene, + type WallNode, + type WallPlanPoint, + wallRectangleCorners, +} from '@pascal-app/core' +import { + CursorSphere, + clearPlacementSurface, + DRAFT_LABEL_Y_OFFSET, + DraftMeasurementLabel, + EDITOR_LAYER, + formatLinearMeasurement, + isMagneticSnapActive, + markToolCancelConsumed, + NO_RAYCAST, + publishHorizontalConstructionPlane, + resolveEventConstructionPlane, + snapWallDraftPointDetailed, + triggerSFX, + useEditor, + useInteractionScope, + useLinearDisplay, + useRegistryToolContext, + useWallSnapIndicator, +} from '@pascal-app/editor' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' +import { useEffect, useState } from 'react' +import { DoubleSide } from 'three' +import { createWallRectangle } from './rectangle-command' + +export default function RectangleWallTool() { + const { activeLevelId, isCameraDragging } = useRegistryToolContext() + const canvas = useThree((s) => s.gl.domElement) + const { isImperial } = useLinearDisplay('m', 2) + const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const measurementColor = isDark ? '#ffffff' : '#111111' + const measurementShadowColor = isDark ? '#111111' : '#ffffff' + const defaults = useEditor((s) => s.toolDefaults.wall) + const [draft, setDraft] = useState<{ + start: WallPlanPoint + end: WallPlanPoint + y: number + } | null>(null) + const [message, setMessage] = useState('') + const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const levelHeight = useScene((s) => + activeLevelId && s.nodes[activeLevelId]?.type === 'level' ? s.nodes[activeLevelId].height : 3, + ) + const height = typeof defaults?.height === 'number' ? defaults.height : (levelHeight ?? 3) + const thickness = typeof defaults?.thickness === 'number' ? defaults.thickness : 0.1 + useEffect(() => { + if (!activeLevelId) return + setDraft(null) + setMessage('') + let start: WallPlanPoint | null = null + let plane: ReturnType | null = null + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'wall' }) + const pointFor = (e: GridEvent) => { + const result = snapWallDraftPointDetailed({ + point: [e.localPosition[0], e.localPosition[2]], + walls: Object.values(useScene.getState().nodes).filter( + (n): n is WallNode => n.type === 'wall' && n.parentId === activeLevelId, + ), + magnetic: isMagneticSnapActive(), + }) + useWallSnapIndicator.getState().set( + result.snap + ? { + x: result.point[0], + z: result.point[1], + kind: result.snap, + wallIds: result.targetWallIds, + } + : null, + ) + return result.point + } + const move = (e: GridEvent) => { + if (e.nativeEvent.target !== canvas || isCameraDragging()) return + const point = pointFor(e) + const hoverPlane = plane ?? resolveEventConstructionPlane(e, null) + if (plane) publishHorizontalConstructionPlane(e, plane) + setCursor([point[0], hoverPlane.localY, point[1]]) + setMessage('') + if (start) setDraft({ start, end: point, y: hoverPlane.localY }) + } + const leave = () => { + setCursor(null) + useWallSnapIndicator.getState().clear() + } + const cancel = () => { + if (start) markToolCancelConsumed() + start = null + plane = null + setDraft(null) + setMessage('') + leave() + clearPlacementSurface() + } + const click = (e: GridEvent) => { + if (e.nativeEvent.target !== canvas || e.nativeEvent.button !== 0 || isCameraDragging()) + return + const point = pointFor(e) + if (!start) { + start = point + plane = resolveEventConstructionPlane(e, null) + publishHorizontalConstructionPlane(e, plane) + setCursor([point[0], plane.localY, point[1]]) + setDraft({ start, end: point, y: plane.localY }) + setMessage('') + return + } + try { + createWallRectangle( + activeLevelId, + start, + point, + useEditor.getState().toolDefaults.wall ?? {}, + { + constructionElevation: plane?.elevation, + preferredSupportSlabId: plane?.supportSlabId, + constructionHeight: height, + }, + ) + cancel() + triggerSFX('sfx:structure-build') + } catch (error) { + setMessage((error as Error).message) + } + } + emitter.on('grid:move', move) + emitter.on('grid:click', click) + emitter.on('tool:cancel', cancel) + canvas.addEventListener('pointerleave', leave) + return () => { + emitter.off('grid:move', move) + emitter.off('grid:click', click) + emitter.off('tool:cancel', cancel) + canvas.removeEventListener('pointerleave', leave) + useWallSnapIndicator.getState().clear() + clearPlacementSurface() + useInteractionScope.getState().endIf((s) => s.kind === 'drafting' && s.tool === 'wall') + } + }, [activeLevelId, height, canvas, isCameraDragging]) + const corners = draft ? wallRectangleCorners(draft.start, draft.end) : [] + const unit = isImperial ? 'imperial' : 'metric' + // Label the two sides meeting at the cursor corner, as the floor plan does. + const cursorIndex = draft + ? corners.findIndex(([x, z]) => x === draft.end[0] && z === draft.end[1]) + : -1 + const sideLabels = + draft && cursorIndex >= 0 + ? [cursorIndex + 3, cursorIndex].map((side) => { + const a = corners[side % 4]! + const b = corners[(side + 1) % 4]! + return { + label: formatLinearMeasurement(Math.hypot(b[0] - a[0], b[1] - a[1]), unit), + position: [ + (a[0] + b[0]) / 2, + draft.y + height + DRAFT_LABEL_Y_OFFSET, + (a[1] + b[1]) / 2, + ] as [number, number, number], + } + }) + : [] + return ( + + + {corners.map((a, i) => { + const b = corners[(i + 1) % 4]! + return ( + + + + + ) + })} + {draft && message ? ( + + ) : ( + sideLabels.map((side, index) => ( + + )) + )} + + ) +} diff --git a/packages/nodes/src/wall/split-floorplan-layer.tsx b/packages/nodes/src/wall/split-floorplan-layer.tsx new file mode 100644 index 0000000000..60d8b41c9c --- /dev/null +++ b/packages/nodes/src/wall/split-floorplan-layer.tsx @@ -0,0 +1,129 @@ +'use client' +import { + getWallCurveFrameAt, + getWallCurveLength, + getWallThickness, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + clientToPlan, + FloorplanDraftWallMeasurement, + type FloorplanToolContext, + formatLinearMeasurement, + useFloorplanRender, +} from '@pascal-app/editor' +import { getSceneTheme, useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' +import { bindWallSplitPointer } from './split-pointer' +import { wallSplitDistance, wallSplitMarkerColor, wallSplitSegmentLabels } from './split-preview' +import { useWallSplit } from './split-store' + +/** The plan side of the wall's `split` reshape (`reshapeLayers.split`): the same draft as 3D. */ +export default function WallSplitFloorplanLayer(_props: FloorplanToolContext) { + const draft = useWallSplit((s) => s.draft) + const wallId = draft?.wallId + const wall = useScene((s) => (draft ? s.nodes[draft.wallId] : undefined)) + const levelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) + const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark') + const context = useFloorplanRender() + const upp = context?.unitsPerPixel ?? 0.01 + useEffect(() => { + if (!wallId || useScene.getState().nodes[wallId]?.parentId !== levelId) return + const scene = document.querySelector('g[data-floorplan-scene]') + const surface = scene?.ownerSVGElement + if (!surface) return + return bindWallSplitPointer(surface, (event) => { + if (!(event.target instanceof Node) || !surface.contains(event.target)) return null + const point = clientToPlan(event.clientX, event.clientY) + const current = useScene.getState().nodes[wallId] + if (!point || current?.type !== 'wall') return null + const distance = wallSplitDistance(current, point) + // Only the wall's own stroke is a target, including its endpoint exclusion zones. + const frame = frameAt(current, distance) + if ( + Math.hypot(frame.point.x - point[0], frame.point.y - point[1]) > hitHalfWidth(current, upp) + ) + return null + return distance + }) + }, [wallId, levelId, upp]) + if (!draft || wall?.type !== 'wall' || wall.parentId !== levelId) return null + const { preview, snap } = draft + const half = hitHalfWidth(wall, upp) + const color = wallSplitMarkerColor(preview.valid) + const cut = preview.frames[0]?.point + return ( + + {snap?.kind === 'alignment' && cut && ( + + )} + {preview.frames.map(({ point, normal }, index) => { + const line = { + x1: point.x - normal.x * half, + y1: point.y - normal.y * half, + x2: point.x + normal.x * half, + y2: point.y + normal.y * half, + } + return ( + + + + + ) + })} + {!preview.valid && cut ? ( + + {preview.message} + + ) : ( + wallSplitSegmentLabels(wall, preview).map((segment, index) => ( + 1 ? ` × ${segment.count}` : ''}`, + midpoint: segment.midpoint, + direction: segment.direction, + angleLabels: [], + }} + measurementStroke={context?.palette.measurementStroke ?? color} + sceneRotationDeg={context?.sceneRotationDeg ?? 0} + unitsPerPixel={upp} + /> + )) + )} + + ) +} + +/** The wall's stroke plus a finger's width, so the cut can be grabbed at any zoom. */ +function hitHalfWidth(wall: WallNode, upp: number) { + return getWallThickness(wall) / 2 + 8 * upp +} + +function frameAt(wall: WallNode, distance: number) { + const length = getWallCurveLength(wall) + return getWallCurveFrameAt(wall, length > 0 ? distance / length : 0) +} diff --git a/packages/nodes/src/wall/split-pointer.test.ts b/packages/nodes/src/wall/split-pointer.test.ts new file mode 100644 index 0000000000..f735ab501b --- /dev/null +++ b/packages/nodes/src/wall/split-pointer.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { bindWallSplitPointer } from './split-pointer' +import { closeWallSplit, openWallSplit } from './split-session' +import { useWallSplit } from './split-store' + +// Restore by descriptor: assigning `undefined` back would leave an own `window` +// property behind, and the next file's DOM shim would then believe one exists. +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') +const previousNode = Object.getOwnPropertyDescriptor(globalThis, 'Node') +const events = new EventTarget() +const surface = new EventTarget() as EventTarget & { contains: (node: unknown) => boolean } +surface.contains = (node) => node === surface +const level = LevelNode.parse({ children: [] }) +const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) +level.children = [wall.id] +let cleanup = () => {} +const emit = (type: string, distance: number, button = 0, altKey = false) => { + const event = new Event(type, { cancelable: true }) + Object.assign(event, { clientX: distance, pointerId: 1, button, buttons: 0, altKey }) + events.dispatchEvent(event) + return event +} +const scroll = (deltaY: number, target: unknown = surface) => { + const event = new Event('wheel', { cancelable: true }) + Object.assign(event, { deltaY, deltaMode: 0, ctrlKey: false }) + Object.defineProperty(event, 'target', { value: target }) + events.dispatchEvent(event) + return event +} +const cutAt = () => useWallSplit.getState().draft?.preview.distances +beforeEach(() => { + globalThis.window = events as unknown as Window & typeof globalThis + // `instanceof Node` guards the wheel target; the fake surface stands in for one. + ;(globalThis as { Node?: unknown }).Node = EventTarget + useEditor.setState((s) => ({ + snappingModeByContext: { ...s.snappingModeByContext, polygon: 'grid' }, + gridSnapStep: 0.5, + })) + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall }, + rootNodeIds: [level.id], + readOnly: false, + }) + useScene.temporal.getState().clear() + openWallSplit(wall) + cleanup = bindWallSplitPointer(surface as unknown as Element, (event) => + event.clientX >= 0 ? event.clientX : null, + ) +}) +afterEach(() => { + cleanup() + closeWallSplit() + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) + else Reflect.deleteProperty(globalThis, 'window') + if (previousNode) Object.defineProperty(globalThis, 'Node', previousNode) + else Reflect.deleteProperty(globalThis, 'Node') +}) + +test('the single cut follows the pointer on the grid; Alt places it freely', () => { + emit('pointermove', 2.2) + expect(cutAt()).toEqual([2]) + emit('pointermove', 3.3, 0, true) + expect(cutAt()).toEqual([3.3]) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) +}) +test('scrolling over the viewport changes the cut count; elsewhere it scrolls', () => { + expect(scroll(-120).defaultPrevented).toBe(true) + expect(useWallSplit.getState().draft?.cuts).toBe(3) + scroll(60) + expect(cutAt()).toEqual([8 / 3, 16 / 3]) + expect(scroll(-120, {}).defaultPrevented).toBe(false) + expect(useWallSplit.getState().draft?.cuts).toBe(2) + // Evenly spaced cuts ignore the pointer. + emit('pointermove', 1) + expect(cutAt()).toEqual([8 / 3, 16 / 3]) +}) +test('left click commits the release mark once; orbit and out-of-wall clicks do not cut', () => { + expect(emit('pointerdown', 3, 2).defaultPrevented).toBe(false) + emit('pointerup', 3, 2) + emit('pointerdown', -1) + emit('pointerup', -1) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + expect(emit('pointerdown', 2).defaultPrevented).toBe(true) + emit('pointermove', 3.5) + expect(emit('pointerup', 3.5).defaultPrevented).toBe(true) + expect((useScene.getState().nodes[wall.id] as WallNode).end).toEqual([3.5, 0]) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) +}) +test('cancelled pointer or release off-wall never commits a stale mark', () => { + emit('pointerdown', 2) + emit('pointercancel', 2) + emit('pointerup', 2) + emit('pointerdown', 3) + emit('pointerup', -1) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + cleanup() + emit('pointermove', 6) + expect(cutAt()).toEqual([3]) +}) diff --git a/packages/nodes/src/wall/split-pointer.ts b/packages/nodes/src/wall/split-pointer.ts new file mode 100644 index 0000000000..3bf965d79c --- /dev/null +++ b/packages/nodes/src/wall/split-pointer.ts @@ -0,0 +1,75 @@ +import { commitWallSplit, hoverWallSplit, setWallSplitCuts } from './split-session' +import { useWallSplit } from './split-store' + +// Pixels of wheel travel per cut: one mouse notch, or a short trackpad swipe. +const WHEEL_STEP_PX = 60 +const WHEEL_LINE_PX = 33 + +/** Both viewports feed a wall distance; neither writes scene nodes while hovering. */ +export function bindWallSplitPointer( + surface: Element, + distanceAt: (event: PointerEvent) => number | null, +) { + let pressed: number | null = null + let wheelTravel = 0 + const move = (event: PointerEvent) => { + if (event.buttons && pressed !== event.pointerId) return + const distance = distanceAt(event) + if (distance !== null) hoverWallSplit(distance, event.altKey) + } + const down = (event: PointerEvent) => { + if (event.button !== 0) return + const distance = distanceAt(event) + if (distance === null) return + pressed = event.pointerId + event.preventDefault() + event.stopImmediatePropagation() + hoverWallSplit(distance, event.altKey) + } + const up = (event: PointerEvent) => { + if (pressed !== event.pointerId) return + pressed = null + event.preventDefault() + event.stopImmediatePropagation() + const distance = distanceAt(event) + if (distance === null) return + hoverWallSplit(distance, event.altKey) + // Retain ownership through the click dispatched after pointerup, so normal + // selection cannot consume that same click after the cut closes its session. + const swallow = (click: Event) => { + click.preventDefault() + click.stopImmediatePropagation() + } + surface.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => surface.removeEventListener('click', swallow, true), 0) + commitWallSplit() + } + const cancel = () => { + pressed = null + } + // Scrolling over the viewport changes the cut count, as in a loop cut; + // pinch (Ctrl + wheel) still zooms. + const wheel = (event: WheelEvent) => { + if (event.ctrlKey || !(event.target instanceof Node) || !surface.contains(event.target)) return + event.preventDefault() + event.stopImmediatePropagation() + wheelTravel += event.deltaMode === 1 ? event.deltaY * WHEEL_LINE_PX : event.deltaY + const steps = Math.trunc(wheelTravel / WHEEL_STEP_PX) + if (!steps) return + wheelTravel -= steps * WHEEL_STEP_PX + const draft = useWallSplit.getState().draft + if (draft) setWallSplitCuts(draft.cuts - steps) + } + window.addEventListener('pointermove', move, true) + window.addEventListener('pointerdown', down, true) + window.addEventListener('pointerup', up, true) + window.addEventListener('pointercancel', cancel, true) + window.addEventListener('wheel', wheel, { capture: true, passive: false }) + return () => { + window.removeEventListener('pointermove', move, true) + window.removeEventListener('pointerdown', down, true) + window.removeEventListener('pointerup', up, true) + window.removeEventListener('pointercancel', cancel, true) + window.removeEventListener('wheel', wheel, true) + } +} diff --git a/packages/nodes/src/wall/split-preview.test.ts b/packages/nodes/src/wall/split-preview.test.ts new file mode 100644 index 0000000000..d46cb9c88b --- /dev/null +++ b/packages/nodes/src/wall/split-preview.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + getWallCurveFrameAt, + getWallCurveLength, + LevelNode, + planWallDivision, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { + snapWallSplitDistance, + wallSplitAnchors, + wallSplitDistance, + wallSplitDistances, + wallSplitPreview, + wallSplitSegmentLabels, +} from './split-preview' + +const level = LevelNode.parse({ children: [] }) +const graph = (...nodes: AnyNode[]) => + Object.fromEntries([level, ...nodes].map((n) => [n.id, n])) as Record +const free = { gridStep: null, anchors: null, tolerance: 0.2 } + +describe('wall split marker', () => { + test('a mouse projection and numeric distance identify the same angled cut', () => { + const wall = WallNode.parse({ parentId: level.id, start: [2, 3], end: [8, 11] }) + const nodes = graph(wall) + const distance = wallSplitDistance(wall, [4, 7.75]) + expect(distance).toBeCloseTo(5) + const preview = wallSplitPreview(nodes, wall, [distance]) + const plan = planWallDivision(nodes, wall.id, distance) + expect(preview.valid).toBe(true) + expect([preview.frames[0]!.point.x, preview.frames[0]!.point.y]).toEqual(plan.point) + expect(preview.frames).toEqual(wallSplitPreview(nodes, wall, [5]).frames) + }) + test.each([ + 1, -1, + ])('curved wall mouse target agrees with arc-length widget, offset %p', (offset) => { + const wall = WallNode.parse({ + parentId: level.id, + start: [2, 3], + end: [10, 5], + curveOffset: offset, + }) + for (const t of [0.1, 0.37, 0.9]) { + const frame = getWallCurveFrameAt(wall, t) + const distance = wallSplitDistance(wall, [ + frame.point.x + frame.normal.x * 0.15, + frame.point.y + frame.normal.y * 0.15, + ]) + expect(distance).toBeCloseTo(t * getWallCurveLength(wall), 7) + const preview = wallSplitPreview(graph(wall), wall, [distance]) + expect(preview.valid).toBe(true) + expect(preview.frames[0]!.point.x).toBeCloseTo(frame.point.x) + expect(preview.frames[0]!.point.y).toBeCloseTo(frame.point.y) + } + }) + test('invalid cut stays where the pointer is and does not silently snap past an opening', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + const window = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + width: 2, + position: [4, 1.5, 0], + }) + wall.children = [window.id] + const nodes = graph(wall, window) + const before = JSON.stringify(nodes) + for (const distance of [0, 0.02, 4, 7.99, 8]) { + const preview = wallSplitPreview(nodes, wall, [distance]) + expect(preview.valid).toBe(false) + expect(preview.frames[0]!.point.x).toBeCloseTo(distance) + expect(preview.message.length).toBeGreaterThan(0) + } + expect(wallSplitPreview(nodes, wall, [2]).valid).toBe(true) + // Three even cuts put one through the window at 4 m. + expect(wallSplitPreview(nodes, wall, wallSplitDistances(8, 3, 0)).valid).toBe(false) + expect(JSON.stringify(nodes)).toBe(before) + }) + test('linked copies cannot bypass make-real via direct mouse input', () => { + const wall = WallNode.parse({ + parentId: level.id, + start: [0, 0], + end: [8, 0], + metadata: { linkedArray: { sourceId: 'wall_original' } }, + }) + expect(wallSplitPreview(graph(wall), wall, [3]).message).toContain('Make the linked array real') + }) +}) + +describe('loop-cut counts and snapping', () => { + const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) + test('one cut follows the pointer; more cuts divide the wall evenly', () => { + expect(wallSplitDistances(8, 1, 3.3)).toEqual([3.3]) + expect(wallSplitDistances(8, 3, 3.3)).toEqual([2, 4, 6]) + }) + test('grid steps count from the wall start and never land on an end', () => { + expect(snapWallSplitDistance(wall, 3.3, { ...free, gridStep: 0.5 })).toEqual({ + distance: 3.5, + snap: { kind: 'grid' }, + }) + expect(snapWallSplitDistance(wall, 7.9, { ...free, gridStep: 0.5 }).snap).toBeNull() + }) + test("'lines' catches the midpoint or another wall's end within tolerance", () => { + const partition = WallNode.parse({ parentId: level.id, start: [2.6, 0.2], end: [2.6, 4] }) + const anchors = wallSplitAnchors(graph(wall, partition), wall) + expect(snapWallSplitDistance(wall, 3.9, { ...free, anchors })).toEqual({ + distance: 4, + snap: { kind: 'midpoint' }, + }) + expect(snapWallSplitDistance(wall, 2.5, { ...free, anchors })).toEqual({ + distance: 2.6, + snap: { kind: 'alignment', anchor: [2.6, 0.2] }, + }) + expect(snapWallSplitDistance(wall, 3.1, { ...free, anchors })).toEqual({ + distance: 3.1, + snap: null, + }) + }) + test('labels show both sides of one cut, or one length repeated across even cuts', () => { + const nodes = graph(wall) + expect( + wallSplitSegmentLabels(wall, wallSplitPreview(nodes, wall, [3])).map((s) => [ + s.length, + s.count, + ]), + ).toEqual([ + [3, 1], + [5, 1], + ]) + expect( + wallSplitSegmentLabels(wall, wallSplitPreview(nodes, wall, [2, 4, 6])).map((s) => [ + s.length, + s.count, + ]), + ).toEqual([[2, 4]]) + }) +}) diff --git a/packages/nodes/src/wall/split-preview.ts b/packages/nodes/src/wall/split-preview.ts new file mode 100644 index 0000000000..02414b0ff4 --- /dev/null +++ b/packages/nodes/src/wall/split-preview.ts @@ -0,0 +1,163 @@ +import { + type AnyNode, + type AnyNodeId, + getWallArcData, + getWallCurveFrameAt, + getWallCurveLength, + planWallDivisions, + type WallNode, + type WallPlanPoint, +} from '@pascal-app/core' + +export const WALL_SPLIT_MAX_CUTS = 32 + +/** Cut markers in both views: warm when the cuts can commit, red when they can't. */ +export function wallSplitMarkerColor(valid: boolean) { + return valid ? '#c86f45' : '#a63d2e' +} +// The planner refuses cuts closer than this to either end. +const END_CLEARANCE = 0.05 + +/** The pointer projected onto the wall, as a distance from its start; curved walls included. */ +export function wallSplitDistance(wall: WallNode, point: readonly [number, number]): number { + const length = getWallCurveLength(wall) + const arc = getWallArcData(wall) + if (arc) { + const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + const turn = Math.PI * 2 + const delta = (((arc.direction * (angle - arc.startAngle)) % turn) + turn) % turn + if (delta <= Math.abs(arc.delta)) return (delta / Math.abs(arc.delta)) * length + return Math.hypot(point[0] - wall.start[0], point[1] - wall.start[1]) <= + Math.hypot(point[0] - wall.end[0], point[1] - wall.end[1]) + ? 0 + : length + } + if (length === 0) return 0 + return Math.max( + 0, + Math.min( + length, + ((point[0] - wall.start[0]) * (wall.end[0] - wall.start[0]) + + (point[1] - wall.start[1]) * (wall.end[1] - wall.start[1])) / + length, + ), + ) +} + +/** Like a loop cut: one cut follows the pointer, more cuts divide the wall evenly. */ +export function wallSplitDistances(length: number, cuts: number, distance: number): number[] { + if (cuts <= 1) return [distance] + return Array.from({ length: cuts }, (_, index) => (length * (index + 1)) / (cuts + 1)) +} + +export type WallSplitSnap = + | { kind: 'grid' } + | { kind: 'midpoint' } + | { kind: 'alignment'; anchor: WallPlanPoint } + | null + +export type WallSplitSnapOptions = { + /** Grid step while the 'grid' snapping mode is active. */ + gridStep: number | null + /** Other wall ends to align with while the 'lines' mode is active. */ + anchors: readonly WallPlanPoint[] | null + /** How far (m) the pointer may be from an alignment target to catch it. */ + tolerance: number +} + +/** + * Snaps a single cut along its wall with the active snapping mode: grid steps + * counted from the wall start, or — in 'lines' — the midpoint and where the + * level's other wall ends project onto the wall. + */ +export function snapWallSplitDistance( + wall: WallNode, + raw: number, + options: WallSplitSnapOptions, +): { distance: number; snap: WallSplitSnap } { + const length = getWallCurveLength(wall) + const inside = (distance: number) => + distance >= END_CLEARANCE && distance <= length - END_CLEARANCE + if (options.gridStep) { + const snapped = Math.round(raw / options.gridStep) * options.gridStep + return inside(snapped) + ? { distance: snapped, snap: { kind: 'grid' } } + : { distance: raw, snap: null } + } + if (!options.anchors) return { distance: raw, snap: null } + let best: { distance: number; snap: WallSplitSnap; gap: number } = { + distance: raw, + snap: null, + gap: options.tolerance, + } + const consider = (distance: number, snap: WallSplitSnap) => { + const gap = Math.abs(distance - raw) + if (gap < best.gap && inside(distance)) best = { distance, snap, gap } + } + consider(length / 2, { kind: 'midpoint' }) + for (const anchor of options.anchors) { + consider(wallSplitDistance(wall, anchor), { kind: 'alignment', anchor }) + } + return { distance: best.distance, snap: best.snap } +} + +/** Ends of the level's other walls, for 'lines' alignment. */ +export function wallSplitAnchors( + nodes: Record, + wall: WallNode, +): WallPlanPoint[] { + const own = (point: WallPlanPoint) => + [wall.start, wall.end].some((end) => end[0] === point[0] && end[1] === point[1]) + return Object.values(nodes).flatMap((node) => + node.type === 'wall' && node.id !== wall.id && node.parentId === wall.parentId + ? [node.start, node.end].filter((point) => !own(point)) + : [], + ) +} + +export function wallSplitPreview( + nodes: Record, + wall: WallNode, + distances: readonly number[], +) { + const length = getWallCurveLength(wall) + const frames = distances.map((distance) => + getWallCurveFrameAt(wall, length > 0 ? distance / length : 0), + ) + let message = '' + if ( + [wall, ...Object.values(nodes).filter((n) => n.parentId === wall.id)].some( + (n) => n.metadata.arrayModifier || n.metadata.linkedArray, + ) + ) { + message = 'Make the linked array real before splitting this wall.' + } else { + try { + planWallDivisions(nodes, wall.id, distances) + } catch (error) { + message = error instanceof Error ? error.message : 'This wall cannot be split here.' + } + } + return { distances: [...distances], length, frames, valid: !message, message } +} + +export type WallSplitPreview = ReturnType + +/** What to label: both sides of a single cut, or one length repeated across even cuts. */ +export function wallSplitSegmentLabels(wall: WallNode, preview: WallSplitPreview) { + const bounds = [0, ...preview.distances, preview.length] + const segments = bounds.slice(1).map((end, index) => [bounds[index]!, end] as const) + const count = preview.distances.length === 1 ? 1 : segments.length + return (count === 1 ? segments : segments.slice(0, 1)).map(([from, to]) => { + const { point, tangent } = getWallCurveFrameAt( + wall, + preview.length > 0 ? (from + to) / 2 / preview.length : 0, + ) + return { + length: to - from, + count, + midpoint: [point.x, point.y] as WallPlanPoint, + direction: [tangent.x, tangent.y] as WallPlanPoint, + } + }) +} diff --git a/packages/nodes/src/wall/split-session.ts b/packages/nodes/src/wall/split-session.ts new file mode 100644 index 0000000000..1dcbd29f2a --- /dev/null +++ b/packages/nodes/src/wall/split-session.ts @@ -0,0 +1,156 @@ +import { + getWallCurveLength, + planWallDivisions, + runAsSingleSceneHistoryStep, + useScene, + type WallNode, +} from '@pascal-app/core' +import { + isGridSnapActive, + isMagneticSnapActive, + useEditor, + useInteractionScope, +} from '@pascal-app/editor' +import { + snapWallSplitDistance, + WALL_SPLIT_MAX_CUTS, + wallSplitAnchors, + wallSplitDistances, + wallSplitPreview, +} from './split-preview' +import { useWallSplit, type WallSplitDraft } from './split-store' + +// Pointer distance (m) within which a 'lines' alignment target catches the cut. +const ALIGNMENT_TOLERANCE = 0.2 + +const ownsSplit = () => { + const { scope } = useInteractionScope.getState() + return scope.kind === 'reshaping' && scope.reshape === 'split' +} +const currentWall = (draft: WallSplitDraft) => { + const wall = useScene.getState().nodes[draft.wallId] + return wall?.type === 'wall' ? wall : null +} +const withPreview = (draft: Omit, wall: WallNode): WallSplitDraft => ({ + ...draft, + preview: wallSplitPreview( + useScene.getState().nodes, + wall, + wallSplitDistances(getWallCurveLength(wall), draft.cuts, draft.distance), + ), +}) + +let teardown: (() => void) | null = null + +/** + * A split session: the wall's `reshaping` scope (so the snapping chip and the + * kind's HUD hints resolve), one centred cut, and the watchers that end it — + * Esc, the wall or its edit rights going away, another interaction, leaving + * select mode. Both views render the same draft; either can commit. + */ +export function openWallSplit(wall: WallNode) { + if (useScene.getState().readOnly) return + closeWallSplit() + useEditor.getState().setMode('select') + useInteractionScope + .getState() + .begin({ kind: 'reshaping', nodeId: wall.id, reshape: 'split', driver: 'tool' }) + useWallSplit + .getState() + .setDraft( + withPreview( + { wallId: wall.id, cuts: 1, distance: getWallCurveLength(wall) / 2, snap: null }, + wall, + ), + ) + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopImmediatePropagation() + closeWallSplit() + } + const stopScene = useScene.subscribe((state, previous) => { + if (state.readOnly || state.nodes[wall.id]?.type !== 'wall') closeWallSplit() + else if (state.nodes !== previous.nodes) refreshWallSplit() + }) + const stopScope = useInteractionScope.subscribe(({ scope }) => { + if (!(scope.kind === 'reshaping' && scope.reshape === 'split')) closeWallSplit() + }) + const stopEditor = useEditor.subscribe((state) => { + if ( + state.mode !== 'select' || + state.isCaptureMode || + state.isPreviewMode || + state.isFirstPersonMode + ) + closeWallSplit() + }) + const hasWindow = typeof window !== 'undefined' + if (hasWindow) window.addEventListener('keydown', onKey, true) + teardown = () => { + if (hasWindow) window.removeEventListener('keydown', onKey, true) + stopScene() + stopScope() + stopEditor() + } +} + +export function closeWallSplit() { + const stop = teardown + teardown = null + stop?.() + useWallSplit.getState().setDraft(null) + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'reshaping' && scope.reshape === 'split') +} + +/** Pointer projected onto the wall; Alt (`free`) skips snapping. */ +export function hoverWallSplit(raw: number, free = false) { + const draft = useWallSplit.getState().draft + // Several cuts stay evenly spaced, so the pointer has nothing to move. + if (!draft || draft.cuts > 1 || !ownsSplit()) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + const { distance, snap } = free + ? { distance: raw, snap: null } + : snapWallSplitDistance(wall, raw, { + gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : null, + anchors: isMagneticSnapActive() ? wallSplitAnchors(useScene.getState().nodes, wall) : null, + tolerance: ALIGNMENT_TOLERANCE, + }) + if (distance === draft.distance && snap?.kind === draft.snap?.kind) return + useWallSplit.getState().setDraft(withPreview({ ...draft, distance, snap }, wall)) +} + +export function setWallSplitCuts(cuts: number) { + const draft = useWallSplit.getState().draft + if (!draft || !ownsSplit()) return + const next = Math.min(WALL_SPLIT_MAX_CUTS, Math.max(1, Math.round(cuts))) + if (next === draft.cuts) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + useWallSplit.getState().setDraft(withPreview({ ...draft, cuts: next }, wall)) +} + +/** Re-plan against the current scene (after an external edit). */ +export function refreshWallSplit() { + const draft = useWallSplit.getState().draft + if (!draft) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + useWallSplit.getState().setDraft(withPreview(draft, wall)) +} + +export function commitWallSplit() { + const draft = useWallSplit.getState().draft + const current = useScene.getState() + if (!draft || current.readOnly || !ownsSplit()) return + const wall = currentWall(draft) + if (!wall) return closeWallSplit() + const checked = withPreview(draft, wall) + if (!checked.preview.valid) return useWallSplit.getState().setDraft(checked) + const plan = planWallDivisions(current.nodes, wall.id, checked.preview.distances) + runAsSingleSceneHistoryStep(useScene, () => current.applyNodeChanges(plan.changes)) + closeWallSplit() +} diff --git a/packages/nodes/src/wall/split-store.test.ts b/packages/nodes/src/wall/split-store.test.ts new file mode 100644 index 0000000000..9ff2a3bd50 --- /dev/null +++ b/packages/nodes/src/wall/split-store.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { LevelNode, useScene, WallNode, WindowNode } from '@pascal-app/core' +import { getActiveSnapContext, useInteractionScope } from '@pascal-app/editor' +import { + closeWallSplit, + commitWallSplit, + hoverWallSplit, + openWallSplit, + setWallSplitCuts, +} from './split-session' +import { useWallSplit } from './split-store' + +const level = LevelNode.parse({ children: [] }) +const wall = WallNode.parse({ parentId: level.id, start: [0, 0], end: [8, 0] }) +const window = WindowNode.parse({ + parentId: wall.id, + wallId: wall.id, + position: [6, 1.5, 0], + width: 1, +}) +level.children = [wall.id] +wall.children = [window.id] +beforeEach(() => { + closeWallSplit() + useScene.setState({ + nodes: { [level.id]: level, [wall.id]: wall, [window.id]: window }, + rootNodeIds: [level.id], + readOnly: false, + }) + useScene.temporal.getState().clear() +}) +afterEach(() => { + closeWallSplit() + useScene.setState({ readOnly: false }) +}) + +test("a split session is the wall's reshaping scope and a snapping context, one centred cut", () => { + openWallSplit(wall) + expect(useInteractionScope.getState().scope).toMatchObject({ + kind: 'reshaping', + nodeId: wall.id, + reshape: 'split', + }) + expect(getActiveSnapContext()).toBe('polygon') + expect(useWallSplit.getState().draft?.preview.distances).toEqual([4]) +}) +test('hover and cut-count changes are ephemeral; cancel does not alter nodes or undo', () => { + const before = useScene.getState().nodes + openWallSplit(wall) + for (let distance = 1; distance < 4; distance += 0.1) hoverWallSplit(distance, true) + setWallSplitCuts(3) + setWallSplitCuts(99) + expect(useWallSplit.getState().draft?.cuts).toBe(32) + expect(useScene.getState().nodes).toBe(before) + expect(useScene.temporal.getState().pastStates).toHaveLength(0) + closeWallSplit() + expect(useWallSplit.getState().draft).toBeNull() + expect(useInteractionScope.getState().scope.kind).toBe('idle') +}) +test('commit uses the displayed mark, reparents openings and is one undo', () => { + const before = useScene.getState().nodes + openWallSplit(wall) + hoverWallSplit(3.25, true) + commitWallSplit() + const after = useScene.getState().nodes + expect((after[wall.id] as WallNode).end).toEqual([3.25, 0]) + expect((after[window.id] as WindowNode).position).toEqual([2.75, 1.5, 0]) + expect(useWallSplit.getState().draft).toBeNull() + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + useScene.temporal.getState().undo() + expect(useScene.getState().nodes).toEqual(before) +}) +test('even cuts commit as a single undo step', () => { + openWallSplit(wall) + setWallSplitCuts(2) + commitWallSplit() + const walls = Object.values(useScene.getState().nodes).filter( + (node): node is WallNode => node.type === 'wall', + ) + expect(walls.map((w) => w.end[0] - w.start[0]).map((l) => Number(l.toFixed(6)))).toEqual( + [8 / 3, 8 / 3, 8 / 3].map((l) => Number(l.toFixed(6))), + ) + expect(useScene.temporal.getState().pastStates).toHaveLength(1) +}) +test('invalid opening cuts, replaced scopes and read-only transitions cannot commit', () => { + const before = useScene.getState().nodes + openWallSplit(wall) + hoverWallSplit(6, true) + commitWallSplit() + expect(useWallSplit.getState().draft?.preview.valid).toBe(false) + expect(useScene.getState().nodes).toBe(before) + hoverWallSplit(3, true) + useScene.setState({ readOnly: true }) + commitWallSplit() + expect(useScene.getState().nodes).toBe(before) + useScene.setState({ readOnly: false }) + useInteractionScope.getState().begin({ kind: 'painting' }) + commitWallSplit() + closeWallSplit() + expect(useScene.getState().nodes).toBe(before) + expect(useInteractionScope.getState().scope.kind).toBe('painting') + useInteractionScope.getState().end() +}) diff --git a/packages/nodes/src/wall/split-store.ts b/packages/nodes/src/wall/split-store.ts new file mode 100644 index 0000000000..5f8476f788 --- /dev/null +++ b/packages/nodes/src/wall/split-store.ts @@ -0,0 +1,21 @@ +import type { WallNode } from '@pascal-app/core' +import { create } from 'zustand' +import type { WallSplitPreview, WallSplitSnap } from './split-preview' + +export type WallSplitDraft = { + wallId: WallNode['id'] + cuts: number + /** The single cut's snapped distance from the wall start; more cuts are evenly spaced. */ + distance: number + snap: WallSplitSnap + preview: WallSplitPreview +} + +/** The open split's cut preview. Every transition lives in `split-session.ts`. */ +export const useWallSplit = create<{ + draft: WallSplitDraft | null + setDraft: (draft: WallSplitDraft | null) => void +}>((set) => ({ + draft: null, + setDraft: (draft) => set({ draft }), +})) diff --git a/packages/nodes/src/wall/split-tool.tsx b/packages/nodes/src/wall/split-tool.tsx new file mode 100644 index 0000000000..c67718ec5d --- /dev/null +++ b/packages/nodes/src/wall/split-tool.tsx @@ -0,0 +1,181 @@ +'use client' +import { type AnyNode, getWallThickness, sceneRegistry, useScene } from '@pascal-app/core' +import { + DRAFT_LABEL_Y_OFFSET, + DraftMeasurementLabel, + EDITOR_LAYER, + formatLinearMeasurement, + NO_RAYCAST, +} from '@pascal-app/editor' +import { BATCHED_LAYER, getSceneTheme, SCENE_LAYER, useViewer } from '@pascal-app/viewer' +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Group, type Mesh, Raycaster, Vector2 } from 'three' +import { bindWallSplitPointer } from './split-pointer' +import { wallSplitDistance, wallSplitMarkerColor, wallSplitSegmentLabels } from './split-preview' +import { useWallSplit } from './split-store' + +/** + * The wall's `split` affordance (3D): cut markers, segment lengths and the + * pointer binding. The session (`split-session.ts`) owns the scope and draft; + * the plan layer renders the same draft. + */ +export default function WallSplitTool(_props: { node: AnyNode }) { + const draft = useWallSplit((s) => s.draft) + const wallId = draft?.wallId + const wall = useScene((s) => (draft ? s.nodes[draft.wallId] : undefined)) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) + const isDark = useViewer((s) => getSceneTheme(s.sceneTheme).appearance === 'dark') + const { gl, camera } = useThree() + const root = useRef(null) + const column = useRef(null) + const top = useRef(null) + const base = useRef(null) + useEffect(() => { + if (!wallId) return + const surface = gl.domElement + const raycaster = new Raycaster() + raycaster.layers.set(SCENE_LAYER) + raycaster.layers.enable(BATCHED_LAYER) + const pointer = new Vector2() + return bindWallSplitPointer(surface, (event) => { + if (event.target !== surface) return null + const current = useScene.getState().nodes[wallId] + const object = sceneRegistry.nodes.get(wallId) + if (current?.type !== 'wall' || !object?.parent) return null + const rect = surface.getBoundingClientRect() + if (!rect.width || !rect.height) return null + pointer.set( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + 1 - ((event.clientY - rect.top) / rect.height) * 2, + ) + raycaster.setFromCamera(pointer, camera) + object.updateWorldMatrix(true, true) + const hit = raycaster.intersectObject(object, true)[0] + if (!hit) return null + const local = object.parent.worldToLocal(hit.point.clone()) + return wallSplitDistance(current, [local.x, local.z]) + }) + }, [wallId, gl, camera]) + useFrame(() => { + if (!(root.current && column.current && top.current && base.current)) return + if (wall?.type !== 'wall' || !draft) return + const object = sceneRegistry.nodes.get(wall.id) as Mesh | undefined + if (!object?.parent || !object.geometry) { + root.current.visible = false + return + } + root.current.visible = true + object.updateWorldMatrix(true, false) + root.current.matrix.copy(object.parent.matrixWorld) + if (!object.geometry.boundingBox) object.geometry.computeBoundingBox() + const bounds = object.geometry.boundingBox + const bottom = object.position.y + (bounds?.min.y ?? 0) + const height = bounds ? bounds.max.y - bounds.min.y : (wall.height ?? 3) + column.current.position.y = bottom + height / 2 + column.current.scale.y = Math.max(0.05, height + 0.05) + top.current.position.y = bottom + height + DRAFT_LABEL_Y_OFFSET + base.current.position.y = bottom + 0.01 + }) + if (!draft || wall?.type !== 'wall') return null + const { preview, snap } = draft + const thickness = getWallThickness(wall) + const color = wallSplitMarkerColor(preview.valid) + const cut = preview.frames[0]?.point + const guide = + snap?.kind === 'alignment' && cut + ? { + x: (snap.anchor[0] + cut.x) / 2, + z: (snap.anchor[1] + cut.y) / 2, + length: Math.hypot(cut.x - snap.anchor[0], cut.y - snap.anchor[1]), + angle: -Math.atan2(cut.y - snap.anchor[1], cut.x - snap.anchor[0]), + } + : null + return ( + + + {preview.frames.map(({ point, tangent }, index) => ( + + + + + + + + + + + ))} + + + {/* Why a cut can't commit shows at the mark, where the HUD used to say it. */} + {!preview.valid && cut ? ( + + ) : ( + wallSplitSegmentLabels(wall, preview).map((segment, index) => ( + 1 ? ` × ${segment.count}` : ''}`} + position={[segment.midpoint[0], 0, segment.midpoint[1]]} + shadowColor={isDark ? '#111111' : '#ffffff'} + /> + )) + )} + + + {guide && ( + + + + + )} + + + ) +} diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 745c2a172e..905611ead4 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -23,6 +23,7 @@ import { chainEndJoinsExistingWall, clearPlacementSurface, createWallOnCurrentLevel, + DraftMeasurementLabel, EDITOR_LAYER, formatAngleRadians, formatLinearMeasurement, @@ -60,10 +61,12 @@ import { type DraftAngleLabel, type DraftAxisGuideState, DraftAxisGuides, - DraftMeasurementLabel, getNearestAxisAngleLabel, } from '../shared/draft-axis-guides' +import { useWallDrawingMode, useWallDrawingModeKeys } from './drawing-mode' +import RectangleWallTool from './rectangle-tool' + /** * Phase 5 Stage D — wall placement tool (kind-owned). * @@ -443,7 +446,7 @@ function getBelowLevelWalls(): WallNode[] { return getLevelWalls(belowLevel?.id ?? null, nodes) } -export const WallTool: React.FC = () => { +const LineWallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const metricNotation = useViewer((state) => state.metricNotation) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') @@ -488,10 +491,6 @@ export const WallTool: React.FC = () => { const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' - // Clear preset-seeded defaults on deactivation so a later manual wall draw - // isn't built with a stale preset's parameters. Unmount-only. - useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) - useEffect(() => { let gridPosition: WallPlanPoint = [0, 0] let previousWallEnd: [number, number] | null = null @@ -948,4 +947,15 @@ export const WallTool: React.FC = () => { ) } +export const WallTool: React.FC = () => { + // Clear preset-seeded defaults on deactivation so a later manual wall draw + // isn't built with a stale preset's parameters. Unmount-only. + useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), []) + + useWallDrawingModeKeys() + + const mode = useWallDrawingMode((s) => s.mode) + return mode === 'rectangle' ? : +} + export default WallTool diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index ee9c383397..2096a3956a 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -38,6 +38,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { commitOpeningMove } from '../shared/commit-opening-move' import { type DormerWindowTarget, dormerEventFromHostedWindow, @@ -642,14 +643,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) history.commitStep(() => { - useScene.getState().updateNode(movingWindowNode.id, { + commitOpeningMove(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], rotation: [0, target.itemRotation, 0], side: target.side, parentId: target.wallId, wallId: target.wallId, roofSegmentId: undefined, - metadata: {}, visible: true, }) }) @@ -892,7 +892,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode visible: original.visible, }) history.commitStep(() => { - useScene.getState().updateNode(movingWindowNode.id, { + commitOpeningMove(movingWindowNode.id, { position: target.position, rotation, side, @@ -902,7 +902,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: undefined, roofFace: undefined, - metadata: {}, visible: true, }) }) @@ -1102,7 +1101,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) history.commitStep(() => { - useScene.getState().updateNode(movingWindowNode.id, { + commitOpeningMove(movingWindowNode.id, { position: target.position, rotation: [0, 0, 0], side: 'front', @@ -1110,7 +1109,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: segmentId, roofFace: target.face.id, - metadata: {}, visible: true, }) }) diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index feec01c2dd..37c4f1583e 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -58,6 +58,7 @@ import { resolveRoofWallOpeningTarget, worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' +import { wallEventFromGrid } from '../shared/wall-event-from-grid' import { collectWallOpeningAlignmentCandidates, resolveWallSlideAlignment, @@ -741,6 +742,16 @@ const WindowTool: React.FC = () => { // floor follow this tick. const ts = event.nativeEvent?.timeStamp ?? -1 if (ts === lastMeshEventTime) return + const wallEvent = wallEventFromGrid( + event, + activeLevelId, + useScene.getState().nodes, + sceneRegistry.nodes, + ) + if (wallEvent) { + onWallHover(wallEvent) + return + } // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft // and free-follow the cursor with the invalid (unplaceable) ghost. hostKind = null @@ -750,6 +761,17 @@ const WindowTool: React.FC = () => { showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y) } + const onGridPointerUp = (event: GridEvent) => { + if (isCameraDragging() || !draftRef.current) return + const wallEvent = wallEventFromGrid( + event, + activeLevelId, + useScene.getState().nodes, + sceneRegistry.nodes, + ) + if (wallEvent) onWallClick(wallEvent) + } + // ── Dormer wall faces ────────────────────────────────────────── // Dormer windows use the same WindowNode mesh and inspector as regular // windows, but their host frame is supplied by DormerRenderer. @@ -1011,6 +1033,7 @@ const WindowTool: React.FC = () => { emitter.on('window:click', onDormerWindowClick) emitter.on('window:leave', onDormerWindowLeave) emitter.on('grid:move', onGridFreeFollow) + emitter.on('grid:pointerup', onGridPointerUp) emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) // Placement tracks the cursor through wall events; keep walls hidden by @@ -1044,6 +1067,7 @@ const WindowTool: React.FC = () => { emitter.off('window:click', onDormerWindowClick) emitter.off('window:leave', onDormerWindowLeave) emitter.off('grid:move', onGridFreeFollow) + emitter.off('grid:pointerup', onGridPointerUp) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) } diff --git a/wiki/architecture/interaction-scope.md b/wiki/architecture/interaction-scope.md index 380e43f1fb..32eeb0080e 100644 --- a/wiki/architecture/interaction-scope.md +++ b/wiki/architecture/interaction-scope.md @@ -135,6 +135,11 @@ There is no per-kind snapping switch. declaring `NodeDefinition.snapProfile` (`'item' | 'structural'`); `snapContextOf(scope × profile)` maps it — `structural` while **setting direction** (drafting / endpoint drag) → `wall` (angle-bearing), `structural` otherwise (translate / curve) → `polygon` (no angle), `item` → `item`. No profile → no chip. + Tools that are not registered kinds map through `TOOL_SNAP_CONTEXTS` in the same file (the host + room-preset stamp `room`); without an entry a tool has no snap context, so Shift cycling and the HUD + chip stay dead while it runs. A kind's own reshape (the wall split, `reshape: 'split'`) runs as a + `reshaping` scope and inherits that scope's mapping (`polygon` unless it sets direction), so it + never needs a tool entry. - **Single read path.** Tools read `isGridSnapActive()` / `isMagneticSnapActive()` / `isAngleSnapActive()` (`store/use-editor`); the grid step is `useEditor.getState().gridSnapStep` gated on `isGridSnapActive()`. These resolve the mode from the scope via `getActiveSnapContext()` → `snappingModeByContext[context]`. diff --git a/wiki/architecture/node-definitions.md b/wiki/architecture/node-definitions.md index d3ddd41af5..bc6e77b222 100644 --- a/wiki/architecture/node-definitions.md +++ b/wiki/architecture/node-definitions.md @@ -295,7 +295,11 @@ during the gesture. A `Shift` hint should describe the bypass in user terms, suc `Free angle`, `Free place`, or `Bypass guided constraints`. `HelperManager` renders `def.toolHints` through `RegisteredToolHelper`, and active Shift -state can update the row to show that guided constraints are currently bypassed. Select +state can update the row to show that guided constraints are currently bypassed. +`affordanceHints?: Record` is the same contract for a kind's own +reshapes, keyed like `affordanceTools`: while a node of the kind is in that `reshaping` +scope the HUD shows those hints instead of the generic reshape rows (the wall split's +cut-count chip lives there). Select mode is not owned by a node definition, so its helper is derived separately from selection state, selected-node move/rotate capabilities, and held modifiers. diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 109e99209f..abdf638854 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -47,6 +47,7 @@ The core `Plugin` manifest owns semantic node definitions (and registry-backed i - `floorplan` — pure `(node, ctx) => FloorplanGeometry` for the 2D layer. - `floorplanAffordances` / `floorplanMoveTarget` — 2D drag handlers. - `tool` / `affordanceTools` — 3D placement + move tools (lazy components). +- `affordanceHints` — HUD hints for a kind-owned reshape, keyed like `affordanceTools`; the floorplan extension's `reshapeLayers` (its 2D sibling) and `actionMenu.actions` (action-menu buttons) complete the set. - `presentation` — palette / sidebar metadata (`label`, `icon`, `paletteSection`, etc.). - `mcp` — MCP tool descriptions for AI consumers. - `relations` / `computeLevelData` — sibling lookups + level-batch precompute. diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 19ab6d5b55..20ab309a39 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -138,6 +138,12 @@ Concretely, door/window placement/move keeps these in lockstep across `{door,win Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. +**Navigation is part of parity.** Movement learned in one view works in the other (WASD, Space + drag, middle drag, wheel, orbit), and split view keeps both in sync through `navigationSyncPose`. In 2D-only view the 3D canvas is paused (`renderPaused`), so nothing driven from its frame loop reaches the plan: the plan owns WASD and the orbit buttons there itself (`components/editor/floorplan-panel.tsx`, sharing `lib/keyboard-pan.ts` with `custom-camera-controls.tsx` — physical keys, same guards and speed) and publishes the pose to 3D when the move ends, while the camera stands down. A navigation input that only exists on the camera side is a 2D regression waiting to happen. + +**Group selection acts on the selection.** Group move / rotate / duplicate transform the selected participants only; connected walls outside the selection stretch at their shared ends (`LinkedNeighbor`). Their footprint comes from plan data, never from meshes: `groupPlanBounds` (`components/editor/group-transform-shared.ts`) reads wall outlines, polygon rings and fence runs in the level frame, and measures meshes only for placed objects (fresh bounds, `userData.placeholder` skipped, anchor as fallback). A world-space mesh box mapped into the level frame lands beside the meshes under a rotated building, and in 2D-only view meshes may be unbuilt. The 2D dashed box, the 3D rotate gizmo and keyboard R/T all pivot on that box's centre. + +**A room's slab and ceiling deselect together in the plan.** The floor plan draws the ceiling as an unfilled outline under the walls (`fill="none"` is click-through, see below), so a marquee can select it but a click can't reach it. Removing either surface from the selection removes its same-outline counterpart: slab and ceiling declare `extensions['pascal:editor/floorplan'].selectionCounterparts` (`packages/nodes/src/shared/surface-counterparts.ts`) and `applyEntrySelection` asks the registry, so the plan never names a kind. Adding stays single, and 3D keeps single toggles because each surface is clickable there. + Plan-view surface movement retains only the original host while the footprint centre is supported. Exiting commits a level-frame floor pose with support re-elected and attachment links removed atomically. Plan view never acquires a new host or cycles @@ -305,6 +311,14 @@ useLiveTransforms.getState().set(node.id, { If the tool *also* rotates the node during the drag, it should drive `rotation` from the current tool state — not from 0, not from the stale node value. +## Wall lifecycle: draw, split, merge + +Walls are drawn as a line chain or a rectangle (`R` toggles inside the wall tool; the HUD's Shape chip cycles the same `useWallDrawingMode` store, `packages/nodes/src/wall/drawing-mode.ts`). In 2D the rectangle tool only claims clicks and publishes its first corner to `useFloorplanDraftPreview`; the panel's linear draft layer draws the four mitered walls with the line draft's plates and guides, so cursor, snapping and alignment are the line wall's own (`packages/nodes/src/wall/floorplan-tool.tsx`). + +Split is a HUD-driven loop cut that lives entirely in `packages/nodes/src/wall/`: `split-session.ts` opens the wall's own `reshaping` scope (`reshape: 'split'`, `driver: 'tool'`, which resolves to the `polygon` snap context — see `interaction-scope.md`) and owns every transition of the `split-store.ts` draft; scrolling sets 1–32 cuts, a single cut follows the pointer with the snapping modes, several divide the wall evenly, and a click commits them as one undo step through `planWallDivisions` (core). The editor mounts it through kind-agnostic seams only: `def.affordanceTools.split` (3D markers, `split-tool.tsx`), `extensions['pascal:editor/floorplan'].reshapeLayers.split` (the plan layer, `split-floorplan-layer.tsx`, mounted by `FloorplanRegisteredToolLayer` while the scope runs), `def.affordanceHints.split` (the HUD's cut-count chip and hints, rendered by `HelperManager` like `toolHints`), and `actionMenu.actions` (`actions.tsx`, the Split and Merge buttons `NodeActionMenu` renders for whichever kinds are selected). Any reshape name without a dedicated arm in `ToolManager` resolves the same way, so the next kind-owned reshape needs no editor change. Merge is the inverse (core `planWallMerge`): selected walls that continue each other join into the wall with the most attachments, openings keep their world position, rooms keep one boundary reference; refusals name the difference in the button's tooltip. The join rule (`systems/wall/wall-merge.ts`) is shared with the delete heal. + +Modes and parameters of these tools live on keys, the wheel and the HUD (`ToolHint.chip`), not in sidebar option rows or floating panels. + ## SVG `fill="none"` is click-through When emitting a `FloorplanGeometry` polygon that should remain interactive but visually invisible (e.g. an item with a thumbnail image carrying the visual weight), use `fill="transparent"`, not `fill="none"`. The default `pointer-events: visiblePainted` only hit-tests the interior when there's a paint server — `none` is not paint, `transparent` is. Without this the floor-plan layer's wrapping `` never sees the `onPointerDown` and clicks don't select the node.