From b1709de44a4e262295656b0f7b0e379665899f03 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Wed, 15 Apr 2026 22:11:16 +0530 Subject: [PATCH] feat: curved wall and fixes (#236) * feat: add polygon movement support to editor and implement dedicated move tools for walls, slabs, and ceilings * feat: add uv2 attribute to ceiling and slab geometries for lightmap support * Fix curved wall miters and align roof position sliders * Fix wall measurement label and extension anchors * Fix WebGPU renderer initialization order * fix(editor): scope wall movement to the selected level --------- Co-authored-by: Pascal --- packages/core/src/index.ts | 15 + packages/core/src/schema/nodes/wall.ts | 2 + .../src/systems/ceiling/ceiling-system.tsx | 8 + .../core/src/systems/slab/slab-system.tsx | 52 +- packages/core/src/systems/wall/wall-curve.ts | 230 ++++++ .../core/src/systems/wall/wall-footprint.ts | 32 +- .../core/src/systems/wall/wall-mitering.ts | 108 ++- .../core/src/systems/wall/wall-system.tsx | 62 +- .../editor/floating-action-menu.tsx | 42 +- .../src/components/editor/floorplan-panel.tsx | 679 ++++++++++++++++-- .../components/editor/node-action-menu.tsx | 15 +- .../components/editor/selection-manager.tsx | 9 +- .../editor/wall-measurement-label.tsx | 148 +++- .../tools/ceiling/ceiling-hole-editor.tsx | 1 + .../tools/ceiling/move-ceiling-tool.tsx | 154 ++++ .../src/components/tools/door/door-tool.tsx | 12 + .../components/tools/door/move-door-tool.tsx | 10 + .../src/components/tools/item/move-tool.tsx | 9 + .../tools/shared/polygon-editor.tsx | 63 +- .../components/tools/slab/move-slab-tool.tsx | 154 ++++ .../tools/slab/slab-hole-editor.tsx | 1 + .../src/components/tools/tool-manager.tsx | 3 + .../components/tools/wall/curve-wall-tool.tsx | 157 ++++ .../components/tools/wall/move-wall-tool.tsx | 307 ++++++++ .../tools/window/move-window-tool.tsx | 10 + .../components/tools/window/window-tool.tsx | 12 + .../components/ui/panels/ceiling-panel.tsx | 16 +- .../src/components/ui/panels/roof-panel.tsx | 7 +- .../ui/panels/roof-segment-panel.tsx | 7 +- .../src/components/ui/panels/slab-panel.tsx | 14 +- .../src/components/ui/panels/stair-panel.tsx | 7 +- .../ui/panels/stair-segment-panel.tsx | 7 +- .../src/components/ui/panels/wall-panel.tsx | 61 +- packages/editor/src/store/use-editor.tsx | 13 + .../renderers/slab/slab-renderer.tsx | 27 +- .../viewer/src/components/viewer/index.tsx | 4 +- .../src/components/viewer/post-processing.tsx | 37 +- 37 files changed, 2307 insertions(+), 188 deletions(-) create mode 100644 packages/core/src/systems/wall/wall-curve.ts create mode 100644 packages/editor/src/components/tools/ceiling/move-ceiling-tool.tsx create mode 100644 packages/editor/src/components/tools/slab/move-slab-tool.tsx create mode 100644 packages/editor/src/components/tools/wall/curve-wall-tool.tsx create mode 100644 packages/editor/src/components/tools/wall/move-wall-tool.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 663bb44f..134770e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,9 +69,24 @@ export { getWallPlanFootprint, getWallThickness, } from './systems/wall/wall-footprint' +export { + getClampedWallCurveOffset, + getMaxWallCurveOffset, + getWallChordFrame, + getWallCurveFrameAt, + getWallCurveLength, + getWallMidpointHandlePoint, + getWallStraightSnapOffset, + getWallSurfacePolygon, + isCurvedWall, + normalizeWallCurveOffset, + sampleWallCenterline, +} from './systems/wall/wall-curve' export { calculateLevelMiters, + getWallMiterBoundaryPoints, type Point2D, + type WallMiterBoundaryPoints, pointToKey, type WallMiterData, } from './systems/wall/wall-mitering' diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 23c1b3ab..cc7312b2 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -15,6 +15,7 @@ export const WallNode = BaseNode.extend({ materialPreset: z.string().optional(), thickness: z.number().optional(), height: z.number().optional(), + curveOffset: z.number().optional(), // e.g., start/end points for path start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), @@ -26,6 +27,7 @@ export const WallNode = BaseNode.extend({ Wall node - used to represent a wall in the building - thickness: thickness in meters - height: height in meters + - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system - end: end point of the wall in level coordinate system - size: size of the wall in grid units diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/core/src/systems/ceiling/ceiling-system.tsx index 6787864a..4f47fab1 100644 --- a/packages/core/src/systems/ceiling/ceiling-system.tsx +++ b/packages/core/src/systems/ceiling/ceiling-system.tsx @@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNodeId, CeilingNode } from '../../schema' import useScene from '../../store/use-scene' +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} + // ============================================================================ // CEILING SYSTEM // ============================================================================ @@ -100,6 +107,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG // Rotate so the shape lies flat in X-Z plane geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() + ensureUv2Attribute(geometry) return geometry } diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index 8e43df7b..5c75b713 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import type { AnyNodeId, SlabNode } from '../../schema' import useScene from '../../store/use-scene' +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} + // ============================================================================ // SLAB SYSTEM // ============================================================================ @@ -39,6 +46,7 @@ export const SlabSystem = () => { */ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { const newGeo = generateSlabGeometry(node) + ensureUv2Attribute(newGeo) mesh.geometry.dispose() mesh.geometry = newGeo @@ -157,16 +165,46 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { if (polygon.length < 3) return new THREE.BufferGeometry() const positions: number[] = [] + const uvs: number[] = [] const indices: number[] = [] const n = polygon.length + const bounds = new THREE.Box2() + + for (const [x, z] of polygon) { + bounds.expandByPoint(new THREE.Vector2(x, z)) + } + for (const hole of slabNode.holes ?? []) { + for (const [x, z] of hole) { + bounds.expandByPoint(new THREE.Vector2(x, z)) + } + } + + const floorWidth = Math.max(bounds.max.x - bounds.min.x, 0.001) + const floorHeight = Math.max(bounds.max.y - bounds.min.y, 0.001) + + const pushFloorVertex = (x: number, y: number, z: number) => { + positions.push(x, y, z) + uvs.push((x - bounds.min.x) / floorWidth, (z - bounds.min.y) / floorHeight) + } + + const pushWallVertex = ( + x: number, + y: number, + z: number, + u: number, + v: number, + ) => { + positions.push(x, y, z) + uvs.push(u, v) + } // --- Floor at Y=0 --- - for (const [x, z] of polygon) positions.push(x!, 0, z!) + for (const [x, z] of polygon) pushFloorVertex(x!, 0, z!) const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) for (const hole of slabNode.holes ?? []) { - for (const [x, z] of hole) positions.push(x!, 0, z!) + for (const [x, z] of hole) pushFloorVertex(x!, 0, z!) } const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d) @@ -182,11 +220,12 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const [x0, z0] = polygon[i]! const [x1, z1] = polygon[j]! const vBase = positions.length / 3 + const segmentLength = Math.max(Math.hypot(x1 - x0, z1 - z0), 0.001) - positions.push(x0!, 0, z0!) // v0 — floor level - positions.push(x1!, 0, z1!) // v1 — floor level - positions.push(x1!, depth, z1!) // v2 — ground level - positions.push(x0!, depth, z0!) // v3 — ground level + pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level + pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level + pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level + pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 2, vBase + 3) @@ -194,6 +233,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geo.setIndex(indices) geo.computeVertexNormals() return geo diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts new file mode 100644 index 00000000..5b6c281c --- /dev/null +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -0,0 +1,230 @@ +import type { Point2D } from './wall-mitering' +import type { WallNode } from '../../schema' + +const CURVE_EPSILON = 1e-6 +const DEFAULT_SAMPLE_SEGMENTS = 24 + +type WallCurveLike = Pick + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +type WallSurfaceMiterOverrides = { + startLeft?: Point2D + startRight?: Point2D + endLeft?: Point2D + endRight?: Point2D +} + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +function distance(a: Point2D, b: Point2D) { + return Math.hypot(b.x - a.x, b.y - a.y) +} + +export function getWallStartPoint(wall: WallCurveLike): Point2D { + return { x: wall.start[0], y: wall.start[1] } +} + +export function getWallEndPoint(wall: WallCurveLike): Point2D { + return { x: wall.end[0], y: wall.end[1] } +} + +export function getWallChordLength(wall: WallCurveLike) { + return distance(getWallStartPoint(wall), getWallEndPoint(wall)) +} + +export function getMaxWallCurveOffset(wall: WallCurveLike) { + return getWallChordLength(wall) / 2 +} + +export function getWallStraightSnapOffset(wall: WallCurveLike) { + return Math.min(0.03, Math.max(0.005, getWallChordLength(wall) * 0.005)) +} + +function clampCurveOffset(wall: WallCurveLike, offset: number) { + const maxOffset = getMaxWallCurveOffset(wall) + if (!Number.isFinite(maxOffset) || maxOffset < CURVE_EPSILON) { + return 0 + } + + return Math.max(-maxOffset, Math.min(maxOffset, offset)) +} + +export function normalizeWallCurveOffset(wall: WallCurveLike, offset: number) { + const clamped = clampCurveOffset(wall, offset) + return Math.abs(clamped) <= getWallStraightSnapOffset(wall) ? 0 : clamped +} + +export function getClampedWallCurveOffset(wall: WallCurveLike) { + const value = wall.curveOffset ?? 0 + const normalized = normalizeWallCurveOffset(wall, value) + return Math.abs(normalized) > CURVE_EPSILON ? normalized : 0 +} + +export function isCurvedWall(wall: WallCurveLike) { + return Math.abs(getClampedWallCurveOffset(wall)) > CURVE_EPSILON +} + +export function getWallChordFrame(wall: WallCurveLike) { + const start = getWallStartPoint(wall) + const end = getWallEndPoint(wall) + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.hypot(dx, dy) + + if (length < CURVE_EPSILON) { + return { + start, + end, + midpoint: start, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + length: 0, + } + } + + return { + start, + end, + midpoint: { + x: (start.x + end.x) / 2, + y: (start.y + end.y) / 2, + }, + tangent: { x: dx / length, y: dy / length }, + normal: { x: -dy / length, y: dx / length }, + length, + } +} + +function getWallArcData(wall: WallCurveLike) { + const chord = getWallChordFrame(wall) + const sagitta = getClampedWallCurveOffset(wall) + + if (Math.abs(sagitta) <= CURVE_EPSILON || chord.length < CURVE_EPSILON) { + return null + } + + const absSagitta = Math.abs(sagitta) + const radius = chord.length * chord.length / (8 * absSagitta) + absSagitta / 2 + const centerOffset = radius - absSagitta + const direction = Math.sign(sagitta) || 1 + const center = { + x: chord.midpoint.x + chord.normal.x * centerOffset * direction, + y: chord.midpoint.y + chord.normal.y * centerOffset * direction, + } + const startAngle = Math.atan2(chord.start.y - center.y, chord.start.x - center.x) + const endAngle = Math.atan2(chord.end.y - center.y, chord.end.x - center.x) + + let delta = endAngle - startAngle + if (direction > 0) { + while (delta <= 0) delta += Math.PI * 2 + } else { + while (delta >= 0) delta -= Math.PI * 2 + } + + return { center, radius, startAngle, delta, direction } +} + +export function getWallCurveFrameAt(wall: WallCurveLike, t: number): CurveFrame { + const chord = getWallChordFrame(wall) + if (!isCurvedWall(wall) || chord.length < CURVE_EPSILON) { + return { + point: { + x: lerp(chord.start.x, chord.end.x, clamp01(t)), + y: lerp(chord.start.y, chord.end.y, clamp01(t)), + }, + tangent: chord.tangent, + normal: chord.normal, + } + } + + const arc = getWallArcData(wall) + if (!arc) { + return { + point: chord.midpoint, + tangent: chord.tangent, + normal: chord.normal, + } + } + + const angle = arc.startAngle + arc.delta * clamp01(t) + const point = { + x: arc.center.x + Math.cos(angle) * arc.radius, + y: arc.center.y + Math.sin(angle) * arc.radius, + } + const tangent = + arc.direction > 0 + ? { x: -Math.sin(angle), y: Math.cos(angle) } + : { x: Math.sin(angle), y: -Math.cos(angle) } + + return { + point, + tangent, + normal: { + x: -tangent.y, + y: tangent.x, + }, + } +} + +export function getWallMidpointHandlePoint(wall: WallCurveLike) { + return getWallCurveFrameAt(wall, 0.5).point +} + +export function sampleWallCenterline(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { + const count = Math.max(1, segments) + return Array.from({ length: count + 1 }, (_, index) => getWallCurveFrameAt(wall, index / count).point) +} + +export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPLE_SEGMENTS) { + const points = sampleWallCenterline(wall, segments) + let totalLength = 0 + + for (let index = 1; index < points.length; index += 1) { + totalLength += distance(points[index - 1]!, points[index]!) + } + + return totalLength +} + +export function getWallSurfacePolygon( + wall: Pick, + segments = DEFAULT_SAMPLE_SEGMENTS, + miterOverrides?: WallSurfaceMiterOverrides, +) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + const count = Math.max(1, segments) + const left: Point2D[] = [] + const right: Point2D[] = [] + + for (let index = 0; index <= count; index += 1) { + const frame = getWallCurveFrameAt(wall, index / count) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + + if (left.length > 0 && right.length > 0) { + left[0] = miterOverrides?.startLeft ?? left[0]! + right[0] = miterOverrides?.startRight ?? right[0]! + left[left.length - 1] = miterOverrides?.endLeft ?? left[left.length - 1]! + right[right.length - 1] = miterOverrides?.endRight ?? right[right.length - 1]! + } + + return [...right, ...left.reverse()] +} diff --git a/packages/core/src/systems/wall/wall-footprint.ts b/packages/core/src/systems/wall/wall-footprint.ts index e66c737a..0313699b 100644 --- a/packages/core/src/systems/wall/wall-footprint.ts +++ b/packages/core/src/systems/wall/wall-footprint.ts @@ -1,8 +1,15 @@ import type { WallNode } from '../../schema' -import { type Point2D, pointToKey, type WallMiterData } from './wall-mitering' +import { getWallSurfacePolygon, isCurvedWall } from './wall-curve' +import { + getWallMiterBoundaryPoints, + type Point2D, + pointToKey, + type WallMiterData, +} from './wall-mitering' export const DEFAULT_WALL_THICKNESS = 0.1 export const DEFAULT_WALL_HEIGHT = 2.5 +const CURVED_WALL_SURFACE_SEGMENTS = 24 export function getWallThickness(wallNode: WallNode): number { return wallNode.thickness ?? DEFAULT_WALL_THICKNESS @@ -10,25 +17,38 @@ export function getWallThickness(wallNode: WallNode): number { export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] { const { junctionData } = miterData - const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } const thickness = getWallThickness(wallNode) const halfT = thickness / 2 - const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } const L = Math.sqrt(v.x * v.x + v.y * v.y) + if (L < 1e-9) { return [] } const nUnit = { x: -v.y / L, y: v.x / L } - const keyStart = pointToKey(wallStart) const keyEnd = pointToKey(wallEnd) - const startJunction = junctionData.get(keyStart)?.get(wallNode.id) const endJunction = junctionData.get(keyEnd)?.get(wallNode.id) + if (isCurvedWall(wallNode)) { + const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData) + if (!boundaryPoints) { + return [] + } + + const { startLeft, startRight, endLeft, endRight } = boundaryPoints + + return getWallSurfacePolygon(wallNode, CURVED_WALL_SURFACE_SEGMENTS, { + endLeft, + endRight, + startLeft, + startRight, + }) + } + const pStartLeft: Point2D = startJunction?.left || { x: wallStart.x + nUnit.x * halfT, y: wallStart.y + nUnit.y * halfT, @@ -37,8 +57,6 @@ export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterDat x: wallStart.x - nUnit.x * halfT, y: wallStart.y - nUnit.y * halfT, } - - // Junction offsets are stored relative to the outgoing direction. const pEndLeft: Point2D = endJunction?.right || { x: wallEnd.x + nUnit.x * halfT, y: wallEnd.y + nUnit.y * halfT, diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 069b7684..f1ab2729 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -1,4 +1,5 @@ import type { WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from './wall-curve' // ============================================================================ // TYPES @@ -9,6 +10,13 @@ export interface Point2D { y: number } +export interface WallMiterBoundaryPoints { + startLeft: Point2D + startRight: Point2D + endLeft: Point2D + endRight: Point2D +} + interface LineEquation { a: number b: number @@ -127,6 +135,70 @@ function findJunctions(walls: WallNode[]): Map { return actualJunctions } +function getWallDirectionFromJunction( + wall: WallNode, + endType: 'start' | 'end' | 'passthrough', +) { + if (endType === 'passthrough') { + return { + x: wall.end[0] - wall.start[0], + y: wall.end[1] - wall.start[1], + } + } + + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) + return endType === 'start' + ? frame.tangent + : { x: -frame.tangent.x, y: -frame.tangent.y } + } + + return endType === 'start' + ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } +} + +function getWallBoundaryFrame( + wall: WallNode, + endType: 'start' | 'end', +) { + if (isCurvedWall(wall)) { + const frame = getWallCurveFrameAt(wall, endType === 'start' ? 0 : 1) + return { + point: frame.point, + tangent: + endType === 'start' + ? frame.tangent + : { x: -frame.tangent.x, y: -frame.tangent.y }, + normal: frame.normal, + } + } + + const point = + endType === 'start' + ? { x: wall.start[0], y: wall.start[1] } + : { x: wall.end[0], y: wall.end[1] } + const vector = + endType === 'start' + ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } + const length = Math.hypot(vector.x, vector.y) + + if (length < 1e-9) { + return { + point, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + return { + point, + tangent: { x: vector.x / length, y: vector.y / length }, + normal: { x: -vector.y / length, y: vector.x / length }, + } +} + // ============================================================================ // MITER CALCULATION (exactly like demo) // ============================================================================ @@ -171,10 +243,7 @@ function calculateJunctionIntersections( } } else { // Normal wall endpoint (start or end) - const v = - endType === 'start' - ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } - : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } + const v = getWallDirectionFromJunction(wall, endType) const L = Math.sqrt(v.x * v.x + v.y * v.y) if (L < 1e-9) continue @@ -264,6 +333,37 @@ export function calculateLevelMiters(walls: WallNode[]): WallMiterData { return { junctionData, junctions } } +export function getWallMiterBoundaryPoints( + wall: WallNode, + miterData: WallMiterData, +): WallMiterBoundaryPoints | null { + const thickness = wall.thickness ?? 0.1 + const halfThickness = thickness / 2 + const startFrame = getWallBoundaryFrame(wall, 'start') + const endFrame = getWallBoundaryFrame(wall, 'end') + const startJunction = miterData.junctionData.get(pointToKey(startFrame.point))?.get(wall.id) + const endJunction = miterData.junctionData.get(pointToKey(endFrame.point))?.get(wall.id) + + return { + startLeft: startJunction?.left ?? { + x: startFrame.point.x + startFrame.normal.x * halfThickness, + y: startFrame.point.y + startFrame.normal.y * halfThickness, + }, + startRight: startJunction?.right ?? { + x: startFrame.point.x - startFrame.normal.x * halfThickness, + y: startFrame.point.y - startFrame.normal.y * halfThickness, + }, + endLeft: endJunction?.right ?? { + x: endFrame.point.x + endFrame.normal.x * halfThickness, + y: endFrame.point.y + endFrame.normal.y * halfThickness, + }, + endRight: endJunction?.left ?? { + x: endFrame.point.x - endFrame.normal.x * halfThickness, + y: endFrame.point.y - endFrame.normal.y * halfThickness, + }, + } +} + /** * Gets wall IDs that share junctions with the given walls */ diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 41606054..062eb1f6 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -8,15 +8,19 @@ import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import useScene from '../../store/use-scene' import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' +import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' import { calculateLevelMiters, getAdjacentWallIds, + getWallMiterBoundaryPoints, type Point2D, type WallMiterData, + pointToKey, } from './wall-mitering' // Reusable CSG evaluator for better performance const csgEvaluator = new Evaluator() +const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 function ensureUv2Attribute(geometry: THREE.BufferGeometry) { const uv = geometry.getAttribute('uv') @@ -25,6 +29,55 @@ function ensureUv2Attribute(geometry: THREE.BufferGeometry) { geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) } +function insetCurvedWallBoundaryPointsFor3D( + wall: WallNode, + boundaryPoints: ReturnType, + miterData: WallMiterData, +) { + if (!boundaryPoints || !isCurvedWall(wall)) { + return boundaryPoints + } + + const insetDistance = Math.min( + CURVED_WALL_3D_ENDPOINT_INSET, + Math.max((wall.thickness ?? 0.1) * 0.01, 0.0005), + ) + + if (insetDistance <= 0) { + return boundaryPoints + } + + const next = { ...boundaryPoints } + const startJunction = miterData.junctions.get(pointToKey({ x: wall.start[0], y: wall.start[1] })) + const endJunction = miterData.junctions.get(pointToKey({ x: wall.end[0], y: wall.end[1] })) + + if (startJunction && startJunction.connectedWalls.length > 1) { + const frame = getWallCurveFrameAt(wall, 0) + next.startLeft = { + x: next.startLeft.x + frame.tangent.x * insetDistance, + y: next.startLeft.y + frame.tangent.y * insetDistance, + } + next.startRight = { + x: next.startRight.x + frame.tangent.x * insetDistance, + y: next.startRight.y + frame.tangent.y * insetDistance, + } + } + + if (endJunction && endJunction.connectedWalls.length > 1) { + const frame = getWallCurveFrameAt(wall, 1) + next.endLeft = { + x: next.endLeft.x - frame.tangent.x * insetDistance, + y: next.endLeft.y - frame.tangent.y * insetDistance, + } + next.endRight = { + x: next.endRight.x - frame.tangent.x * insetDistance, + y: next.endRight.y - frame.tangent.y * insetDistance, + } + } + + return next +} + // ============================================================================ // WALL SYSTEM // ============================================================================ @@ -170,7 +223,14 @@ export function generateExtrudedWall( if (L < 1e-9) { return new THREE.BufferGeometry() } - const polyPoints = getWallPlanFootprint(wallNode, miterData) + const boundaryPoints = getWallMiterBoundaryPoints(wallNode, miterData) + const polyPoints = isCurvedWall(wallNode) + ? getWallSurfacePolygon( + wallNode, + 24, + insetCurvedWallBoundaryPointsFor3D(wallNode, boundaryPoints, miterData) ?? undefined, + ) + : getWallPlanFootprint(wallNode, miterData) if (polyPoints.length < 3) { return new THREE.BufferGeometry() } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index ea6e46b0..1f6a6280 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -38,7 +38,7 @@ const ALLOWED_TYPES = [ 'slab', 'ceiling', ] -const DELETE_ONLY_TYPES = ['wall'] +const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] export function FloatingActionMenu() { @@ -48,6 +48,7 @@ export function FloatingActionMenu() { const mode = useEditor((s) => s.mode) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const setMovingNode = useEditor((s) => s.setMovingNode) + const setCurvingWall = useEditor((s) => s.setCurvingWall) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) @@ -57,6 +58,18 @@ export function FloatingActionMenu() { const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const node = selectedId ? nodes[selectedId as AnyNodeId] : null const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false + const canCurveSelectedWall = + node?.type === 'wall' && + !(node.children ?? []).some((childId) => { + const child = nodes[childId as AnyNodeId] + if (!child) return false + if (child.type === 'door' || child.type === 'window') return true + if (child.type === 'item') { + const attachTo = child.asset?.attachTo + return attachTo === 'wall' || attachTo === 'wall-side' + } + return false + }) useFrame(() => { if (!(selectedId && isValidType && groupRef.current)) return @@ -84,7 +97,10 @@ export function FloatingActionMenu() { node.type === 'item' || node.type === 'window' || node.type === 'door' || + node.type === 'wall' || node.type === 'fence' || + node.type === 'slab' || + node.type === 'ceiling' || node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || @@ -96,6 +112,16 @@ export function FloatingActionMenu() { }, [node, setMovingNode, setSelection], ) + const handleCurve = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!canCurveSelectedWall || !node || node.type !== 'wall') return + sfxEmitter.emit('sfx:item-pick') + setCurvingWall(node) + setSelection({ selectedIds: [] }) + }, + [canCurveSelectedWall, node, setCurvingWall, setSelection], + ) const handleDuplicate = useCallback( (e: React.MouseEvent) => { @@ -263,10 +289,15 @@ export function FloatingActionMenu() { (e: React.MouseEvent) => { e.stopPropagation() if (!selectedId) return + if (node?.type === 'item') { + sfxEmitter.emit('sfx:item-delete') + } else { + sfxEmitter.emit('sfx:structure-delete') + } setSelection({ selectedIds: [] }) useScene.getState().deleteNode(selectedId as AnyNodeId) }, - [selectedId, setSelection], + [node?.type, selectedId, setSelection], ) if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null @@ -283,17 +314,14 @@ export function FloatingActionMenu() { > e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index b9701612..4d303a9a 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -6,11 +6,17 @@ import { type AnyNodeId, type BuildingNode, calculateLevelMiters, + type CeilingNode, DoorNode, emitter, type GridEvent, type GuideNode, + getWallChordFrame, + isCurvedWall, + getWallMidpointHandlePoint, + normalizeWallCurveOffset, getScaledDimensions, + getWallCurveLength, getWallPlanFootprint, type ItemNode, ItemNode as ItemNodeSchema, @@ -235,6 +241,12 @@ type WallEndpointDragState = { currentPoint: WallPlanPoint } +type WallCurveDragState = { + pointerId: number + wallId: WallNode['id'] + currentCurveOffset: number +} + const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const type GuideCorner = (typeof GUIDE_CORNERS)[number] @@ -276,6 +288,11 @@ type WallEndpointDraft = { end: WallPlanPoint } +type WallCurveDraft = { + wallId: WallNode['id'] + curveOffset: number +} + type SlabBoundaryDraft = { slabId: SlabNode['id'] polygon: WallPlanPoint[] @@ -2176,7 +2193,7 @@ function getWallMeasurementOverlay( ): WallMeasurementOverlay | null { const dx = wall.end[0] - wall.start[0] const dz = wall.end[1] - wall.start[1] - const length = Math.hypot(dx, dz) + const length = getWallCurveLength(wall) if (length < 0.1) { return null @@ -2425,13 +2442,19 @@ function buildGridPath( function findClosestWallPoint( point: WallPlanPoint, walls: WallNode[], - maxDistance = 0.5, + options?: { + maxDistance?: number + canUseWall?: (wall: WallNode) => boolean + }, ): { wall: WallNode point: WallPlanPoint t: number normal: [number, number, number] } | null { + const maxDistance = options?.maxDistance ?? 0.5 + const canUseWall = options?.canUseWall + let best: { wall: WallNode point: WallPlanPoint @@ -2441,6 +2464,10 @@ function findClosestWallPoint( let bestDistSq = maxDistance * maxDistance for (const wall of walls) { + if (canUseWall && !canUseWall(wall)) { + continue + } + const [x1, z1] = wall.start const [x2, z2] = wall.end const dx = x2 - x1 @@ -4351,6 +4378,97 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({ ) }) +const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLayer({ + curveHandles, + hoveredHandleId, + onHandleHoverChange, + onWallCurvePointerDown, + palette, +}: { + curveHandles: Array<{ + wall: WallNode + point: WallPlanPoint + isActive: boolean + }> + hoveredHandleId: string | null + onHandleHoverChange: (handleId: string | null) => void + onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent) => void + palette: FloorplanPalette +}) { + return ( + <> + {curveHandles.map(({ wall, point, isActive }) => { + const handleId = `curve:${wall.id}` + const isHovered = hoveredHandleId === handleId + const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke + const hoverStroke = isActive + ? palette.endpointHandleActiveStroke + : palette.endpointHandleHoverStroke + const svgPoint = toSvgPlanPoint(point) + const radius = isActive ? 0.16 : 0.14 + + return ( + { + event.stopPropagation() + }} + onPointerEnter={() => onHandleHoverChange(handleId)} + onPointerLeave={() => onHandleHoverChange(null)} + > + + + + onWallCurvePointerDown(wall, event)} + pointerEvents="all" + r={radius} + stroke="transparent" + strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH} + style={{ cursor: EDITOR_CURSOR }} + vectorEffect="non-scaling-stroke" + /> + + ) + })} + + ) +}) + const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ hoveredHandleId, midpointHandles, @@ -4544,6 +4662,7 @@ export function FloorplanPanel() { const guideInteractionRef = useRef(null) const guideTransformDraftRef = useRef(null) const wallEndpointDragRef = useRef(null) + const wallCurveDragRef = useRef(null) const siteBoundaryDraftRef = useRef(null) const slabBoundaryDraftRef = useRef(null) const zoneBoundaryDraftRef = useRef(null) @@ -4576,6 +4695,7 @@ export function FloorplanPanel() { const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setMode = useEditor((state) => state.setMode) const movingNode = useEditor((state) => state.movingNode) + const curvingWall = useEditor((state) => state.curvingWall) const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const setPhase = useEditor((state) => state.setPhase) @@ -4680,6 +4800,22 @@ export function FloorplanPanel() { .filter((node): node is SlabNode => node?.type === 'slab') }), ) + const ceilings = useScene( + useShallow((state) => { + if (!levelId) { + return [] as CeilingNode[] + } + + const nextLevelNode = state.nodes[levelId] + if (!nextLevelNode || nextLevelNode.type !== 'level') { + return [] as CeilingNode[] + } + + return nextLevelNode.children + .map((childId) => state.nodes[childId]) + .filter((node): node is CeilingNode => node?.type === 'ceiling') + }), + ) const levelGuides = useScene( useShallow((state) => { if (!levelId) { @@ -4741,6 +4877,7 @@ export function FloorplanPanel() { const [cursorPoint, setCursorPoint] = useState(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState(null) const [wallEndpointDraft, setWallEndpointDraft] = useState(null) + const [wallCurveDraft, setWallCurveDraft] = useState(null) const [hoveredOpeningId, setHoveredOpeningId] = useState(null) const [hoveredWallId, setHoveredWallId] = useState(null) const [hoveredSlabId, setHoveredSlabId] = useState(null) @@ -4748,6 +4885,7 @@ export function FloorplanPanel() { const [hoveredStairId, setHoveredStairId] = useState(null) const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) + const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState(null) const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState(null) @@ -4922,29 +5060,42 @@ export function FloorplanPanel() { [floorplanWalls], ) const displayWallById = useMemo(() => { - if (!wallEndpointDraft) { - return wallById - } - - const wall = wallById.get(wallEndpointDraft.wallId) - if (!wall) { + if (!wallEndpointDraft && !wallCurveDraft) { return wallById } const nextWallById = new Map(wallById) - nextWallById.set( - wall.id, - buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), - ) + + if (wallEndpointDraft) { + const wall = nextWallById.get(wallEndpointDraft.wallId) + if (wall) { + nextWallById.set( + wall.id, + buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), + ) + } + } + + if (wallCurveDraft) { + const wall = nextWallById.get(wallCurveDraft.wallId) + if (wall) { + nextWallById.set(wall.id, { ...wall, curveOffset: wallCurveDraft.curveOffset }) + } + } return nextWallById - }, [wallById, wallEndpointDraft]) + }, [wallById, wallCurveDraft, wallEndpointDraft]) const displayFloorplanWallById = useMemo(() => { - if (!wallEndpointDraft) { + if (!wallEndpointDraft && !wallCurveDraft) { return floorplanWallById } - const previewWall = displayWallById.get(wallEndpointDraft.wallId) + const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId + if (!previewWallId) { + return floorplanWallById + } + + const previewWall = displayWallById.get(previewWallId) if (!previewWall) { return floorplanWallById } @@ -4952,7 +5103,7 @@ export function FloorplanPanel() { const nextFloorplanWallById = new Map(floorplanWallById) nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall)) return nextFloorplanWallById - }, [displayWallById, floorplanWallById, wallEndpointDraft]) + }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) const wallPolygons = useMemo( () => walls.map((wall) => { @@ -4967,11 +5118,16 @@ export function FloorplanPanel() { [floorplanWallById, wallMiterData, walls], ) const displayWallPolygons = useMemo(() => { - if (!wallEndpointDraft) { + if (!wallEndpointDraft && !wallCurveDraft) { return wallPolygons } - const previewWall = displayWallById.get(wallEndpointDraft.wallId) + const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId + if (!previewWallId) { + return wallPolygons + } + + const previewWall = displayWallById.get(previewWallId) if (!previewWall) { return wallPolygons } @@ -4990,7 +5146,7 @@ export function FloorplanPanel() { } : entry, ) - }, [displayWallById, wallEndpointDraft, wallPolygons]) + }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons]) const openingsPolygons = useMemo( () => @@ -5046,6 +5202,29 @@ export function FloorplanPanel() { : entry, ) }, [slabBoundaryDraft, slabPolygons]) + const ceilingPolygons = useMemo( + () => + ceilings.flatMap((ceiling) => { + const polygon = toFloorplanPolygon(ceiling.polygon) + if (polygon.length < 3) { + return [] + } + + const holes = (ceiling.holes ?? []) + .map((hole) => toFloorplanPolygon(hole)) + .filter((hole) => hole.length >= 3) + + return [ + { + ceiling, + polygon, + holes, + path: formatPolygonPath(polygon, holes), + }, + ] + }), + [ceilings], + ) const zonePolygons = useMemo( () => zones.flatMap((zone) => { @@ -5176,6 +5355,13 @@ export function FloorplanPanel() { return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null }, [floorplanItemEntries, selectedIds]) + const selectedWallEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return displayWallPolygons.find(({ wall }) => wall.id === selectedIds[0]) ?? null + }, [displayWallPolygons, selectedIds]) const selectedStairEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -5192,6 +5378,13 @@ export function FloorplanPanel() { return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null }, [displaySlabPolygons, selectedIds]) + const selectedCeilingEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return ceilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null + }, [ceilingPolygons, selectedIds]) const selectedZoneEntry = useMemo(() => { if (!selectedZoneId) { return null @@ -5212,12 +5405,23 @@ export function FloorplanPanel() { const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' const isStairMoveActive = movingNode?.type === 'stair' + const isSlabMoveActive = movingNode?.type === 'slab' + const isCeilingMoveActive = movingNode?.type === 'ceiling' + const isWallMoveActive = movingNode?.type === 'wall' + const isWallCurveActive = curvingWall?.type === 'wall' const isItemPlacementPreviewActive = (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo const isFloorplanGridInteractionActive = - isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive + isStairBuildActive || + isStairMoveActive || + isSlabMoveActive || + isCeilingMoveActive || + isWallMoveActive || + isWallCurveActive || + isFloorItemBuildActive || + isFloorItemMoveActive const floorplanPreviewStairSegment = useMemo( () => StairSegmentNodeSchema.parse({ @@ -5399,6 +5603,56 @@ export function FloorplanPanel() { shouldShowPersistentWallEndpointHandles, wallEndpointDraft, ]) + const wallCurveHandles = useMemo(() => { + if ( + isOpeningPlacementActive || + movingNode || + mode !== 'select' || + floorplanSelectionTool !== 'click' || + !selectedWallEntry + ) { + return [] + } + + const hasWallChildrenBlockingCurve = (selectedWallEntry.wall.children ?? []).some((childId) => { + const childNode = levelDescendantNodeById.get(childId as AnyNodeId) + if (!childNode) { + return false + } + + if (childNode.type === 'door' || childNode.type === 'window') { + return true + } + + if (childNode.type === 'item') { + const attachTo = childNode.asset?.attachTo + return attachTo === 'wall' || attachTo === 'wall-side' + } + + return false + }) + if (hasWallChildrenBlockingCurve) { + return [] + } + + const centerPoint = getWallMidpointHandlePoint(selectedWallEntry.wall) + + return [ + { + wall: selectedWallEntry.wall, + point: [centerPoint.x, centerPoint.y] as WallPlanPoint, + isActive: wallCurveDraft?.wallId === selectedWallEntry.wall.id, + }, + ] + }, [ + floorplanSelectionTool, + isOpeningPlacementActive, + mode, + movingNode, + levelDescendantNodeById, + selectedWallEntry, + wallCurveDraft, + ]) const slabVertexHandles = useMemo(() => { if (!shouldShowSlabBoundaryHandles) { return [] @@ -5717,6 +5971,27 @@ export function FloorplanPanel() { : null, [selectedItemEntry, surfaceSize, viewBox], ) + const selectedSlabActionMenuPosition = useMemo( + () => + selectedSlabEntry + ? getFloorplanActionMenuPosition(selectedSlabEntry.polygon, viewBox, surfaceSize) + : null, + [selectedSlabEntry, surfaceSize, viewBox], + ) + const selectedCeilingActionMenuPosition = useMemo( + () => + selectedCeilingEntry + ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize) + : null, + [selectedCeilingEntry, surfaceSize, viewBox], + ) + const selectedWallActionMenuPosition = useMemo( + () => + selectedWallEntry + ? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize) + : null, + [selectedWallEntry, surfaceSize, viewBox], + ) const selectedStairActionMenuPosition = useMemo( () => selectedStairEntry @@ -6198,6 +6473,11 @@ export function FloorplanPanel() { setWallEndpointDraft(null) setHoveredEndpointId(null) }, []) + const clearWallCurveDrag = useCallback(() => { + wallCurveDragRef.current = null + setWallCurveDraft(null) + setHoveredWallCurveHandleId(null) + }, []) const clearSiteBoundaryInteraction = useCallback(() => { setSiteVertexDragState(null) setSiteBoundaryDraft(null) @@ -6219,11 +6499,13 @@ export function FloorplanPanel() { clearSlabPlacementDraft() clearZonePlacementDraft() clearWallEndpointDrag() + clearWallCurveDrag() clearSiteBoundaryInteraction() clearSlabBoundaryInteraction() clearZoneBoundaryInteraction() setCursorPoint(null) }, [ + clearWallCurveDrag, clearSiteBoundaryInteraction, clearSlabBoundaryInteraction, clearSlabPlacementDraft, @@ -6430,51 +6712,86 @@ export function FloorplanPanel() { } const dragState = wallEndpointDragRef.current - if (!dragState || event.pointerId !== dragState.pointerId) { + if (dragState && event.pointerId === dragState.pointerId) { + event.preventDefault() + + const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) + if (!planPoint) { + return + } + + const snappedPoint = snapWallDraftPoint({ + point: planPoint, + walls, + start: dragState.fixedPoint, + angleSnap: !shiftPressed, + ignoreWallIds: [dragState.wallId], + }) + + if (pointsEqual(dragState.currentPoint, snappedPoint)) { + return + } + + dragState.currentPoint = snappedPoint + setCursorPoint(snappedPoint) + setWallEndpointDraft((previousDraft) => { + const nextDraft = buildWallEndpointDraft( + dragState.wallId, + dragState.endpoint, + dragState.fixedPoint, + snappedPoint, + ) + + if ( + !( + previousDraft && + pointsEqual(previousDraft.start, nextDraft.start) && + pointsEqual(previousDraft.end, nextDraft.end) + ) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + + return nextDraft + }) + return + } + + const curveDragState = wallCurveDragRef.current + if (!curveDragState || event.pointerId !== curveDragState.pointerId) { return } event.preventDefault() const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) - if (!planPoint) { + const wall = wallById.get(curveDragState.wallId) + if (!(planPoint && wall)) { return } - const snappedPoint = snapWallDraftPoint({ - point: planPoint, - walls, - start: dragState.fixedPoint, - angleSnap: !shiftPressed, - ignoreWallIds: [dragState.wallId], - }) - - if (pointsEqual(dragState.currentPoint, snappedPoint)) { - return - } - - dragState.currentPoint = snappedPoint - setCursorPoint(snappedPoint) - setWallEndpointDraft((previousDraft) => { - const nextDraft = buildWallEndpointDraft( - dragState.wallId, - dragState.endpoint, - dragState.fixedPoint, - snappedPoint, + const chord = getWallChordFrame(wall) + const snappedPoint: WallPlanPoint = shiftPressed + ? planPoint + : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const rawCurveOffset = + -( + (snappedPoint[0] - chord.midpoint.x) * chord.normal.x + + (snappedPoint[1] - chord.midpoint.y) * chord.normal.y ) + const nextCurveOffset = normalizeWallCurveOffset( + wall, + shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset), + ) - if ( - !( - previousDraft && - pointsEqual(previousDraft.start, nextDraft.start) && - pointsEqual(previousDraft.end, nextDraft.end) - ) - ) { - sfxEmitter.emit('sfx:grid-snap') - } + if (curveDragState.currentCurveOffset === nextCurveOffset) { + return + } - return nextDraft - }) + curveDragState.currentCurveOffset = nextCurveOffset + setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset }) + setCursorPoint(snappedPoint) + sfxEmitter.emit('sfx:grid-snap') } const commitGuideInteraction = (event: PointerEvent) => { @@ -6559,6 +6876,26 @@ export function FloorplanPanel() { setCursorPoint(null) } + const commitWallCurveDrag = (event: PointerEvent) => { + const dragState = wallCurveDragRef.current + if (!dragState || event.pointerId !== dragState.pointerId) { + return + } + + const wall = wallById.get(dragState.wallId) + if (wall) { + const nextCurveOffset = normalizeWallCurveOffset(wall, dragState.currentCurveOffset) + const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) + if (nextCurveOffset !== currentCurveOffset) { + updateNode(wall.id, { curveOffset: nextCurveOffset }) + sfxEmitter.emit('sfx:structure-build') + } + } + + clearWallCurveDrag() + setCursorPoint(null) + } + const cancelWallEndpointDrag = (event: PointerEvent) => { const dragState = wallEndpointDragRef.current if (!dragState || event.pointerId !== dragState.pointerId) { @@ -6569,11 +6906,23 @@ export function FloorplanPanel() { setCursorPoint(null) } + const cancelWallCurveDrag = (event: PointerEvent) => { + const dragState = wallCurveDragRef.current + if (!dragState || event.pointerId !== dragState.pointerId) { + return + } + + clearWallCurveDrag() + setCursorPoint(null) + } + window.addEventListener('pointermove', handleWindowPointerMove) window.addEventListener('pointerup', commitGuideInteraction) window.addEventListener('pointercancel', cancelGuideInteraction) window.addEventListener('pointerup', commitWallEndpointDrag) window.addEventListener('pointercancel', cancelWallEndpointDrag) + window.addEventListener('pointerup', commitWallCurveDrag) + window.addEventListener('pointercancel', cancelWallCurveDrag) return () => { window.removeEventListener('pointermove', handleWindowPointerMove) @@ -6581,8 +6930,11 @@ export function FloorplanPanel() { window.removeEventListener('pointercancel', cancelGuideInteraction) window.removeEventListener('pointerup', commitWallEndpointDrag) window.removeEventListener('pointercancel', cancelWallEndpointDrag) + window.removeEventListener('pointerup', commitWallCurveDrag) + window.removeEventListener('pointercancel', cancelWallCurveDrag) } }, [ + clearWallCurveDrag, clearGuideInteraction, clearWallEndpointDrag, getSvgPointFromClientPoint, @@ -6596,7 +6948,8 @@ export function FloorplanPanel() { useEffect(() => { clearWallEndpointDrag() - }, [clearWallEndpointDrag, levelId]) + clearWallCurveDrag() + }, [clearWallCurveDrag, clearWallEndpointDrag, levelId]) useEffect(() => { if (shouldShowSiteBoundaryHandles) { @@ -7066,7 +7419,9 @@ export function FloorplanPanel() { } if (isOpeningPlacementActive) { - const closest = findClosestWallPoint(planPoint, walls) + const closest = findClosestWallPoint(planPoint, walls, { + canUseWall: (wall) => !isCurvedWall(wall), + }) if (closest) { const dx = closest.wall.end[0] - closest.wall.start[0] const dz = closest.wall.end[1] - closest.wall.start[1] @@ -7286,7 +7641,9 @@ export function FloorplanPanel() { } if (isOpeningPlacementActive) { - const closest = findClosestWallPoint(planPoint, walls) + const closest = findClosestWallPoint(planPoint, walls, { + canUseWall: (wall) => !isCurvedWall(wall), + }) if (closest) { const dx = closest.wall.end[0] - closest.wall.start[0] const dz = closest.wall.end[1] - closest.wall.start[1] @@ -7373,7 +7730,9 @@ export function FloorplanPanel() { isOpeningPlacementActive, isPolygonBuildActive, isWallBuildActive, + isWindowBuildActive, isZoneBuildActive, + movingOpeningType, setSelectedReferenceId, setSelection, shiftPressed, @@ -8095,6 +8454,96 @@ export function FloorplanPanel() { }, [deleteNode, selectedItemEntry, setSelection], ) + const handleSelectedWallMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!wall) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(wall) + setSelection({ selectedIds: [] }) + }, + [selectedWallEntry, setMovingNode, setSelection], + ) + const handleSelectedWallDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const wall = selectedWallEntry?.wall + if (!wall) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(wall.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedWallEntry, setSelection], + ) + const handleSelectedSlabMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + if (!slab) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(slab) + setSelection({ selectedIds: [] }) + }, + [selectedSlabEntry, setMovingNode, setSelection], + ) + const handleSelectedSlabDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const slab = selectedSlabEntry?.slab + if (!slab) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(slab.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedSlabEntry, setSelection], + ) + const handleSelectedCeilingMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!ceiling) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(ceiling) + setSelection({ selectedIds: [] }) + }, + [selectedCeilingEntry, setMovingNode, setSelection], + ) + const handleSelectedCeilingDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const ceiling = selectedCeilingEntry?.ceiling + if (!ceiling) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(ceiling.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedCeilingEntry, setSelection], + ) const handleStairDoubleClick = useCallback( (stair: StairNode, event: ReactMouseEvent) => { emitFloorplanNodeClick(stair.id, 'double-click', event) @@ -8304,6 +8753,45 @@ export function FloorplanPanel() { }, [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode], ) + const handleWallCurvePointerDown = useCallback( + (wall: WallNode, event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + event.preventDefault() + event.stopPropagation() + setHoveredWallCurveHandleId(null) + + if (isWallBuildActive || mode !== 'select') { + return + } + + clearWallPlacementDraft() + handleWallSelect(wall) + clearWallEndpointDrag() + + const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0) + wallCurveDragRef.current = { + pointerId: event.pointerId, + wallId: wall.id, + currentCurveOffset, + } + setWallCurveDraft({ + wallId: wall.id, + curveOffset: currentCurveOffset, + }) + const center = getWallMidpointHandlePoint(wall) + setCursorPoint([center.x, center.y]) + }, + [ + clearWallEndpointDrag, + clearWallPlacementDraft, + handleWallSelect, + isWallBuildActive, + mode, + ], + ) const handleSlabVertexPointerDown = useCallback( (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent) => { if (event.button !== 0) { @@ -8662,12 +9150,21 @@ export function FloorplanPanel() { !zoneVertexDragState ) { const rect = event.currentTarget.getBoundingClientRect() - setFloorplanCursorPosition({ + const nextPosition = { x: event.clientX - rect.left, y: event.clientY - rect.top, - }) + } + setFloorplanCursorPosition((currentPosition) => + currentPosition && + currentPosition.x === nextPosition.x && + currentPosition.y === nextPosition.y + ? currentPosition + : nextPosition, + ) } else { - setFloorplanCursorPosition(null) + setFloorplanCursorPosition((currentPosition) => + currentPosition === null ? currentPosition : null, + ) } handlePointerMove(event) @@ -9230,7 +9727,7 @@ export function FloorplanPanel() { rotationModifierPressed={rotationModifierPressed} /> )} - {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && ( + {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
)} - {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && ( + {selectedWallActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ )} + {selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ )} + {selectedCeilingActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( +
+ event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + /> +
+ )} + {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
)} - {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && ( + {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
+ onDuplicate?: MouseEventHandler onMove?: MouseEventHandler + onCurve?: MouseEventHandler onPointerDown?: PointerEventHandler onPointerUp?: PointerEventHandler onPointerEnter?: PointerEventHandler @@ -20,6 +21,7 @@ export function NodeActionMenu({ onDelete, onDuplicate, onMove, + onCurve, onPointerDown, onPointerUp, onPointerEnter, @@ -44,6 +46,17 @@ export function NodeActionMenu({ )} + {onCurve && ( + + )} {onDuplicate && (