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 <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-04-15 12:41:16 -04:00
committed by GitHub
co-authored by Pascal
parent 57df224948
commit b1709de44a
37 changed files with 2307 additions and 188 deletions
+15
View File
@@ -69,9 +69,24 @@ export {
getWallPlanFootprint, getWallPlanFootprint,
getWallThickness, getWallThickness,
} from './systems/wall/wall-footprint' } from './systems/wall/wall-footprint'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
getWallMidpointHandlePoint,
getWallStraightSnapOffset,
getWallSurfacePolygon,
isCurvedWall,
normalizeWallCurveOffset,
sampleWallCenterline,
} from './systems/wall/wall-curve'
export { export {
calculateLevelMiters, calculateLevelMiters,
getWallMiterBoundaryPoints,
type Point2D, type Point2D,
type WallMiterBoundaryPoints,
pointToKey, pointToKey,
type WallMiterData, type WallMiterData,
} from './systems/wall/wall-mitering' } from './systems/wall/wall-mitering'
+2
View File
@@ -15,6 +15,7 @@ export const WallNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
height: z.number().optional(), height: z.number().optional(),
curveOffset: z.number().optional(),
// e.g., start/end points for path // e.g., start/end points for path
start: z.tuple([z.number(), z.number()]), start: z.tuple([z.number(), z.number()]),
end: 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 Wall node - used to represent a wall in the building
- thickness: thickness in meters - thickness: thickness in meters
- height: height 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 - start: start point of the wall in level coordinate system
- end: end 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 - size: size of the wall in grid units
@@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, CeilingNode } from '../../schema' import type { AnyNodeId, CeilingNode } from '../../schema'
import useScene from '../../store/use-scene' 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 // CEILING SYSTEM
// ============================================================================ // ============================================================================
@@ -100,6 +107,7 @@ export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferG
// Rotate so the shape lies flat in X-Z plane // Rotate so the shape lies flat in X-Z plane
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals() geometry.computeVertexNormals()
ensureUv2Attribute(geometry)
return geometry return geometry
} }
+46 -6
View File
@@ -4,6 +4,13 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, SlabNode } from '../../schema' import type { AnyNodeId, SlabNode } from '../../schema'
import useScene from '../../store/use-scene' 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 // SLAB SYSTEM
// ============================================================================ // ============================================================================
@@ -39,6 +46,7 @@ export const SlabSystem = () => {
*/ */
function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
const newGeo = generateSlabGeometry(node) const newGeo = generateSlabGeometry(node)
ensureUv2Attribute(newGeo)
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeo mesh.geometry = newGeo
@@ -157,16 +165,46 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
if (polygon.length < 3) return new THREE.BufferGeometry() if (polygon.length < 3) return new THREE.BufferGeometry()
const positions: number[] = [] const positions: number[] = []
const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const n = polygon.length 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 --- // --- 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 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!))) const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!)))
for (const hole of slabNode.holes ?? []) { 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) const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d)
@@ -182,11 +220,12 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const [x0, z0] = polygon[i]! const [x0, z0] = polygon[i]!
const [x1, z1] = polygon[j]! const [x1, z1] = polygon[j]!
const vBase = positions.length / 3 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 pushWallVertex(x0!, 0, z0!, 0, 0) // v0 — floor level
positions.push(x1!, 0, z1!) // v1 — floor level pushWallVertex(x1!, 0, z1!, segmentLength, 0) // v1 — floor level
positions.push(x1!, depth, z1!) // v2 — ground level pushWallVertex(x1!, depth, z1!, segmentLength, depth) // v2 — ground level
positions.push(x0!, depth, z0!) // v3 — ground level pushWallVertex(x0!, depth, z0!, 0, depth) // v3 — ground level
indices.push(vBase, vBase + 1, vBase + 2) indices.push(vBase, vBase + 1, vBase + 2)
indices.push(vBase, vBase + 2, vBase + 3) indices.push(vBase, vBase + 2, vBase + 3)
@@ -194,6 +233,7 @@ function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry() const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geo.setIndex(indices) geo.setIndex(indices)
geo.computeVertexNormals() geo.computeVertexNormals()
return geo return geo
@@ -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<WallNode, 'start' | 'end' | 'curveOffset'>
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<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
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()]
}
@@ -1,8 +1,15 @@
import type { WallNode } from '../../schema' 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_THICKNESS = 0.1
export const DEFAULT_WALL_HEIGHT = 2.5 export const DEFAULT_WALL_HEIGHT = 2.5
const CURVED_WALL_SURFACE_SEGMENTS = 24
export function getWallThickness(wallNode: WallNode): number { export function getWallThickness(wallNode: WallNode): number {
return wallNode.thickness ?? DEFAULT_WALL_THICKNESS return wallNode.thickness ?? DEFAULT_WALL_THICKNESS
@@ -10,25 +17,38 @@ export function getWallThickness(wallNode: WallNode): number {
export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] { export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterData): Point2D[] {
const { junctionData } = miterData const { junctionData } = miterData
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
const thickness = getWallThickness(wallNode) const thickness = getWallThickness(wallNode)
const halfT = thickness / 2 const halfT = thickness / 2
const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y } const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y }
const L = Math.sqrt(v.x * v.x + v.y * v.y) const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) { if (L < 1e-9) {
return [] return []
} }
const nUnit = { x: -v.y / L, y: v.x / L } const nUnit = { x: -v.y / L, y: v.x / L }
const keyStart = pointToKey(wallStart) const keyStart = pointToKey(wallStart)
const keyEnd = pointToKey(wallEnd) const keyEnd = pointToKey(wallEnd)
const startJunction = junctionData.get(keyStart)?.get(wallNode.id) const startJunction = junctionData.get(keyStart)?.get(wallNode.id)
const endJunction = junctionData.get(keyEnd)?.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 || { const pStartLeft: Point2D = startJunction?.left || {
x: wallStart.x + nUnit.x * halfT, x: wallStart.x + nUnit.x * halfT,
y: wallStart.y + nUnit.y * halfT, y: wallStart.y + nUnit.y * halfT,
@@ -37,8 +57,6 @@ export function getWallPlanFootprint(wallNode: WallNode, miterData: WallMiterDat
x: wallStart.x - nUnit.x * halfT, x: wallStart.x - nUnit.x * halfT,
y: wallStart.y - nUnit.y * halfT, y: wallStart.y - nUnit.y * halfT,
} }
// Junction offsets are stored relative to the outgoing direction.
const pEndLeft: Point2D = endJunction?.right || { const pEndLeft: Point2D = endJunction?.right || {
x: wallEnd.x + nUnit.x * halfT, x: wallEnd.x + nUnit.x * halfT,
y: wallEnd.y + nUnit.y * halfT, y: wallEnd.y + nUnit.y * halfT,
+104 -4
View File
@@ -1,4 +1,5 @@
import type { WallNode } from '../../schema' import type { WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from './wall-curve'
// ============================================================================ // ============================================================================
// TYPES // TYPES
@@ -9,6 +10,13 @@ export interface Point2D {
y: number y: number
} }
export interface WallMiterBoundaryPoints {
startLeft: Point2D
startRight: Point2D
endLeft: Point2D
endRight: Point2D
}
interface LineEquation { interface LineEquation {
a: number a: number
b: number b: number
@@ -127,6 +135,70 @@ function findJunctions(walls: WallNode[]): Map<string, Junction> {
return actualJunctions 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) // MITER CALCULATION (exactly like demo)
// ============================================================================ // ============================================================================
@@ -171,10 +243,7 @@ function calculateJunctionIntersections(
} }
} else { } else {
// Normal wall endpoint (start or end) // Normal wall endpoint (start or end)
const v = const v = getWallDirectionFromJunction(wall, endType)
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 L = Math.sqrt(v.x * v.x + v.y * v.y) const L = Math.sqrt(v.x * v.x + v.y * v.y)
if (L < 1e-9) continue if (L < 1e-9) continue
@@ -264,6 +333,37 @@ export function calculateLevelMiters(walls: WallNode[]): WallMiterData {
return { junctionData, junctions } 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 * Gets wall IDs that share junctions with the given walls
*/ */
+61 -1
View File
@@ -8,15 +8,19 @@ import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { import {
calculateLevelMiters, calculateLevelMiters,
getAdjacentWallIds, getAdjacentWallIds,
getWallMiterBoundaryPoints,
type Point2D, type Point2D,
type WallMiterData, type WallMiterData,
pointToKey,
} from './wall-mitering' } from './wall-mitering'
// Reusable CSG evaluator for better performance // Reusable CSG evaluator for better performance
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
function ensureUv2Attribute(geometry: THREE.BufferGeometry) { function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
const uv = geometry.getAttribute('uv') 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)) geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
} }
function insetCurvedWallBoundaryPointsFor3D(
wall: WallNode,
boundaryPoints: ReturnType<typeof getWallMiterBoundaryPoints>,
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 // WALL SYSTEM
// ============================================================================ // ============================================================================
@@ -170,7 +223,14 @@ export function generateExtrudedWall(
if (L < 1e-9) { if (L < 1e-9) {
return new THREE.BufferGeometry() 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) { if (polyPoints.length < 3) {
return new THREE.BufferGeometry() return new THREE.BufferGeometry()
} }
@@ -38,7 +38,7 @@ const ALLOWED_TYPES = [
'slab', 'slab',
'ceiling', 'ceiling',
] ]
const DELETE_ONLY_TYPES = ['wall'] const DELETE_ONLY_TYPES: string[] = []
const HOLE_TYPES = ['slab', 'ceiling'] const HOLE_TYPES = ['slab', 'ceiling']
export function FloatingActionMenu() { export function FloatingActionMenu() {
@@ -48,6 +48,7 @@ export function FloatingActionMenu() {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -57,6 +58,18 @@ export function FloatingActionMenu() {
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null const node = selectedId ? nodes[selectedId as AnyNodeId] : null
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false 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(() => { useFrame(() => {
if (!(selectedId && isValidType && groupRef.current)) return if (!(selectedId && isValidType && groupRef.current)) return
@@ -84,7 +97,10 @@ export function FloatingActionMenu() {
node.type === 'item' || node.type === 'item' ||
node.type === 'window' || node.type === 'window' ||
node.type === 'door' || node.type === 'door' ||
node.type === 'wall' ||
node.type === 'fence' || node.type === 'fence' ||
node.type === 'slab' ||
node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' || node.type === 'stair' ||
@@ -96,6 +112,16 @@ export function FloatingActionMenu() {
}, },
[node, setMovingNode, setSelection], [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( const handleDuplicate = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -263,10 +289,15 @@ export function FloatingActionMenu() {
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
if (!selectedId) return if (!selectedId) return
if (node?.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
useScene.getState().deleteNode(selectedId as AnyNodeId) useScene.getState().deleteNode(selectedId as AnyNodeId)
}, },
[selectedId, setSelection], [node?.type, selectedId, setSelection],
) )
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
@@ -283,17 +314,14 @@ export function FloatingActionMenu() {
> >
<NodeActionMenu <NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined}
onDelete={handleDelete} onDelete={handleDelete}
onDuplicate={ onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleDuplicate ? handleDuplicate
: undefined : undefined
} }
onMove={ onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
? handleMove
: undefined
}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()}
/> />
@@ -6,11 +6,17 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
calculateLevelMiters, calculateLevelMiters,
type CeilingNode,
DoorNode, DoorNode,
emitter, emitter,
type GridEvent, type GridEvent,
type GuideNode, type GuideNode,
getWallChordFrame,
isCurvedWall,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
getScaledDimensions, getScaledDimensions,
getWallCurveLength,
getWallPlanFootprint, getWallPlanFootprint,
type ItemNode, type ItemNode,
ItemNode as ItemNodeSchema, ItemNode as ItemNodeSchema,
@@ -235,6 +241,12 @@ type WallEndpointDragState = {
currentPoint: WallPlanPoint currentPoint: WallPlanPoint
} }
type WallCurveDragState = {
pointerId: number
wallId: WallNode['id']
currentCurveOffset: number
}
const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
type GuideCorner = (typeof GUIDE_CORNERS)[number] type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -276,6 +288,11 @@ type WallEndpointDraft = {
end: WallPlanPoint end: WallPlanPoint
} }
type WallCurveDraft = {
wallId: WallNode['id']
curveOffset: number
}
type SlabBoundaryDraft = { type SlabBoundaryDraft = {
slabId: SlabNode['id'] slabId: SlabNode['id']
polygon: WallPlanPoint[] polygon: WallPlanPoint[]
@@ -2176,7 +2193,7 @@ function getWallMeasurementOverlay(
): WallMeasurementOverlay | null { ): WallMeasurementOverlay | null {
const dx = wall.end[0] - wall.start[0] const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1] const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz) const length = getWallCurveLength(wall)
if (length < 0.1) { if (length < 0.1) {
return null return null
@@ -2425,13 +2442,19 @@ function buildGridPath(
function findClosestWallPoint( function findClosestWallPoint(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
maxDistance = 0.5, options?: {
maxDistance?: number
canUseWall?: (wall: WallNode) => boolean
},
): { ): {
wall: WallNode wall: WallNode
point: WallPlanPoint point: WallPlanPoint
t: number t: number
normal: [number, number, number] normal: [number, number, number]
} | null { } | null {
const maxDistance = options?.maxDistance ?? 0.5
const canUseWall = options?.canUseWall
let best: { let best: {
wall: WallNode wall: WallNode
point: WallPlanPoint point: WallPlanPoint
@@ -2441,6 +2464,10 @@ function findClosestWallPoint(
let bestDistSq = maxDistance * maxDistance let bestDistSq = maxDistance * maxDistance
for (const wall of walls) { for (const wall of walls) {
if (canUseWall && !canUseWall(wall)) {
continue
}
const [x1, z1] = wall.start const [x1, z1] = wall.start
const [x2, z2] = wall.end const [x2, z2] = wall.end
const dx = x2 - x1 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<SVGCircleElement>) => 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 (
<g
key={handleId}
onClick={(event) => {
event.stopPropagation()
}}
onPointerEnter={() => onHandleHoverChange(handleId)}
onPointerLeave={() => onHandleHoverChange(null)}
>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill="none"
pointerEvents="none"
r={radius}
stroke={hoverStroke}
strokeOpacity={isActive ? 0.24 : 0.16}
strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}
style={{
opacity: isHovered ? 1 : 0,
transition: FLOORPLAN_HOVER_TRANSITION,
}}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
fillOpacity={0.96}
pointerEvents="none"
r={radius}
stroke={stroke}
strokeWidth="0.05"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill={stroke}
pointerEvents="none"
r={0.045}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={svgPoint.x}
cy={svgPoint.y}
fill="transparent"
onPointerDown={(event) => onWallCurvePointerDown(wall, event)}
pointerEvents="all"
r={radius}
stroke="transparent"
strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}
style={{ cursor: EDITOR_CURSOR }}
vectorEffect="non-scaling-stroke"
/>
</g>
)
})}
</>
)
})
const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
hoveredHandleId, hoveredHandleId,
midpointHandles, midpointHandles,
@@ -4544,6 +4662,7 @@ export function FloorplanPanel() {
const guideInteractionRef = useRef<GuideInteractionState | null>(null) const guideInteractionRef = useRef<GuideInteractionState | null>(null)
const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null) const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null) const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null) const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null) const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null) const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
@@ -4576,6 +4695,7 @@ export function FloorplanPanel() {
const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId) const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
const setMode = useEditor((state) => state.setMode) const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
@@ -4680,6 +4800,22 @@ export function FloorplanPanel() {
.filter((node): node is SlabNode => node?.type === 'slab') .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( const levelGuides = useScene(
useShallow((state) => { useShallow((state) => {
if (!levelId) { if (!levelId) {
@@ -4741,6 +4877,7 @@ export function FloorplanPanel() {
const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null) const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null) const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null) const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null) const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null) const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null) const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
@@ -4748,6 +4885,7 @@ export function FloorplanPanel() {
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null) const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null) const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null) const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null) const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
@@ -4922,29 +5060,42 @@ export function FloorplanPanel() {
[floorplanWalls], [floorplanWalls],
) )
const displayWallById = useMemo(() => { const displayWallById = useMemo(() => {
if (!wallEndpointDraft) { if (!wallEndpointDraft && !wallCurveDraft) {
return wallById
}
const wall = wallById.get(wallEndpointDraft.wallId)
if (!wall) {
return wallById return wallById
} }
const nextWallById = new Map(wallById) const nextWallById = new Map(wallById)
nextWallById.set(
wall.id, if (wallEndpointDraft) {
buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), 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 return nextWallById
}, [wallById, wallEndpointDraft]) }, [wallById, wallCurveDraft, wallEndpointDraft])
const displayFloorplanWallById = useMemo(() => { const displayFloorplanWallById = useMemo(() => {
if (!wallEndpointDraft) { if (!wallEndpointDraft && !wallCurveDraft) {
return floorplanWallById 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) { if (!previewWall) {
return floorplanWallById return floorplanWallById
} }
@@ -4952,7 +5103,7 @@ export function FloorplanPanel() {
const nextFloorplanWallById = new Map(floorplanWallById) const nextFloorplanWallById = new Map(floorplanWallById)
nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall)) nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
return nextFloorplanWallById return nextFloorplanWallById
}, [displayWallById, floorplanWallById, wallEndpointDraft]) }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const wallPolygons = useMemo( const wallPolygons = useMemo(
() => () =>
walls.map((wall) => { walls.map((wall) => {
@@ -4967,11 +5118,16 @@ export function FloorplanPanel() {
[floorplanWallById, wallMiterData, walls], [floorplanWallById, wallMiterData, walls],
) )
const displayWallPolygons = useMemo(() => { const displayWallPolygons = useMemo(() => {
if (!wallEndpointDraft) { if (!wallEndpointDraft && !wallCurveDraft) {
return wallPolygons 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) { if (!previewWall) {
return wallPolygons return wallPolygons
} }
@@ -4990,7 +5146,7 @@ export function FloorplanPanel() {
} }
: entry, : entry,
) )
}, [displayWallById, wallEndpointDraft, wallPolygons]) }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo( const openingsPolygons = useMemo(
() => () =>
@@ -5046,6 +5202,29 @@ export function FloorplanPanel() {
: entry, : entry,
) )
}, [slabBoundaryDraft, slabPolygons]) }, [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( const zonePolygons = useMemo(
() => () =>
zones.flatMap((zone) => { zones.flatMap((zone) => {
@@ -5176,6 +5355,13 @@ export function FloorplanPanel() {
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
}, [floorplanItemEntries, selectedIds]) }, [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(() => { const selectedStairEntry = useMemo(() => {
if (selectedIds.length !== 1) { if (selectedIds.length !== 1) {
return null return null
@@ -5192,6 +5378,13 @@ export function FloorplanPanel() {
return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null
}, [displaySlabPolygons, selectedIds]) }, [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(() => { const selectedZoneEntry = useMemo(() => {
if (!selectedZoneId) { if (!selectedZoneId) {
return null return null
@@ -5212,12 +5405,23 @@ export function FloorplanPanel() {
const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
const isStairMoveActive = movingNode?.type === '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 = const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item' (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
const isFloorplanGridInteractionActive = const isFloorplanGridInteractionActive =
isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive isStairBuildActive ||
isStairMoveActive ||
isSlabMoveActive ||
isCeilingMoveActive ||
isWallMoveActive ||
isWallCurveActive ||
isFloorItemBuildActive ||
isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo( const floorplanPreviewStairSegment = useMemo(
() => () =>
StairSegmentNodeSchema.parse({ StairSegmentNodeSchema.parse({
@@ -5399,6 +5603,56 @@ export function FloorplanPanel() {
shouldShowPersistentWallEndpointHandles, shouldShowPersistentWallEndpointHandles,
wallEndpointDraft, 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(() => { const slabVertexHandles = useMemo(() => {
if (!shouldShowSlabBoundaryHandles) { if (!shouldShowSlabBoundaryHandles) {
return [] return []
@@ -5717,6 +5971,27 @@ export function FloorplanPanel() {
: null, : null,
[selectedItemEntry, surfaceSize, viewBox], [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( const selectedStairActionMenuPosition = useMemo(
() => () =>
selectedStairEntry selectedStairEntry
@@ -6198,6 +6473,11 @@ export function FloorplanPanel() {
setWallEndpointDraft(null) setWallEndpointDraft(null)
setHoveredEndpointId(null) setHoveredEndpointId(null)
}, []) }, [])
const clearWallCurveDrag = useCallback(() => {
wallCurveDragRef.current = null
setWallCurveDraft(null)
setHoveredWallCurveHandleId(null)
}, [])
const clearSiteBoundaryInteraction = useCallback(() => { const clearSiteBoundaryInteraction = useCallback(() => {
setSiteVertexDragState(null) setSiteVertexDragState(null)
setSiteBoundaryDraft(null) setSiteBoundaryDraft(null)
@@ -6219,11 +6499,13 @@ export function FloorplanPanel() {
clearSlabPlacementDraft() clearSlabPlacementDraft()
clearZonePlacementDraft() clearZonePlacementDraft()
clearWallEndpointDrag() clearWallEndpointDrag()
clearWallCurveDrag()
clearSiteBoundaryInteraction() clearSiteBoundaryInteraction()
clearSlabBoundaryInteraction() clearSlabBoundaryInteraction()
clearZoneBoundaryInteraction() clearZoneBoundaryInteraction()
setCursorPoint(null) setCursorPoint(null)
}, [ }, [
clearWallCurveDrag,
clearSiteBoundaryInteraction, clearSiteBoundaryInteraction,
clearSlabBoundaryInteraction, clearSlabBoundaryInteraction,
clearSlabPlacementDraft, clearSlabPlacementDraft,
@@ -6430,51 +6712,86 @@ export function FloorplanPanel() {
} }
const dragState = wallEndpointDragRef.current 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 return
} }
event.preventDefault() event.preventDefault()
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
if (!planPoint) { const wall = wallById.get(curveDragState.wallId)
if (!(planPoint && wall)) {
return return
} }
const snappedPoint = snapWallDraftPoint({ const chord = getWallChordFrame(wall)
point: planPoint, const snappedPoint: WallPlanPoint = shiftPressed
walls, ? planPoint
start: dragState.fixedPoint, : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
angleSnap: !shiftPressed, const rawCurveOffset =
ignoreWallIds: [dragState.wallId], -(
}) (snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
(snappedPoint[1] - chord.midpoint.y) * chord.normal.y
if (pointsEqual(dragState.currentPoint, snappedPoint)) {
return
}
dragState.currentPoint = snappedPoint
setCursorPoint(snappedPoint)
setWallEndpointDraft((previousDraft) => {
const nextDraft = buildWallEndpointDraft(
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
snappedPoint,
) )
const nextCurveOffset = normalizeWallCurveOffset(
wall,
shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
)
if ( if (curveDragState.currentCurveOffset === nextCurveOffset) {
!( return
previousDraft && }
pointsEqual(previousDraft.start, nextDraft.start) &&
pointsEqual(previousDraft.end, nextDraft.end)
)
) {
sfxEmitter.emit('sfx:grid-snap')
}
return nextDraft curveDragState.currentCurveOffset = nextCurveOffset
}) setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
setCursorPoint(snappedPoint)
sfxEmitter.emit('sfx:grid-snap')
} }
const commitGuideInteraction = (event: PointerEvent) => { const commitGuideInteraction = (event: PointerEvent) => {
@@ -6559,6 +6876,26 @@ export function FloorplanPanel() {
setCursorPoint(null) 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 cancelWallEndpointDrag = (event: PointerEvent) => {
const dragState = wallEndpointDragRef.current const dragState = wallEndpointDragRef.current
if (!dragState || event.pointerId !== dragState.pointerId) { if (!dragState || event.pointerId !== dragState.pointerId) {
@@ -6569,11 +6906,23 @@ export function FloorplanPanel() {
setCursorPoint(null) 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('pointermove', handleWindowPointerMove)
window.addEventListener('pointerup', commitGuideInteraction) window.addEventListener('pointerup', commitGuideInteraction)
window.addEventListener('pointercancel', cancelGuideInteraction) window.addEventListener('pointercancel', cancelGuideInteraction)
window.addEventListener('pointerup', commitWallEndpointDrag) window.addEventListener('pointerup', commitWallEndpointDrag)
window.addEventListener('pointercancel', cancelWallEndpointDrag) window.addEventListener('pointercancel', cancelWallEndpointDrag)
window.addEventListener('pointerup', commitWallCurveDrag)
window.addEventListener('pointercancel', cancelWallCurveDrag)
return () => { return () => {
window.removeEventListener('pointermove', handleWindowPointerMove) window.removeEventListener('pointermove', handleWindowPointerMove)
@@ -6581,8 +6930,11 @@ export function FloorplanPanel() {
window.removeEventListener('pointercancel', cancelGuideInteraction) window.removeEventListener('pointercancel', cancelGuideInteraction)
window.removeEventListener('pointerup', commitWallEndpointDrag) window.removeEventListener('pointerup', commitWallEndpointDrag)
window.removeEventListener('pointercancel', cancelWallEndpointDrag) window.removeEventListener('pointercancel', cancelWallEndpointDrag)
window.removeEventListener('pointerup', commitWallCurveDrag)
window.removeEventListener('pointercancel', cancelWallCurveDrag)
} }
}, [ }, [
clearWallCurveDrag,
clearGuideInteraction, clearGuideInteraction,
clearWallEndpointDrag, clearWallEndpointDrag,
getSvgPointFromClientPoint, getSvgPointFromClientPoint,
@@ -6596,7 +6948,8 @@ export function FloorplanPanel() {
useEffect(() => { useEffect(() => {
clearWallEndpointDrag() clearWallEndpointDrag()
}, [clearWallEndpointDrag, levelId]) clearWallCurveDrag()
}, [clearWallCurveDrag, clearWallEndpointDrag, levelId])
useEffect(() => { useEffect(() => {
if (shouldShowSiteBoundaryHandles) { if (shouldShowSiteBoundaryHandles) {
@@ -7066,7 +7419,9 @@ export function FloorplanPanel() {
} }
if (isOpeningPlacementActive) { if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls) const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) { if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0] const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1] const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7286,7 +7641,9 @@ export function FloorplanPanel() {
} }
if (isOpeningPlacementActive) { if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls) const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) { if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0] const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1] const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7373,7 +7730,9 @@ export function FloorplanPanel() {
isOpeningPlacementActive, isOpeningPlacementActive,
isPolygonBuildActive, isPolygonBuildActive,
isWallBuildActive, isWallBuildActive,
isWindowBuildActive,
isZoneBuildActive, isZoneBuildActive,
movingOpeningType,
setSelectedReferenceId, setSelectedReferenceId,
setSelection, setSelection,
shiftPressed, shiftPressed,
@@ -8095,6 +8454,96 @@ export function FloorplanPanel() {
}, },
[deleteNode, selectedItemEntry, setSelection], [deleteNode, selectedItemEntry, setSelection],
) )
const handleSelectedWallMove = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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( const handleStairDoubleClick = useCallback(
(stair: StairNode, event: ReactMouseEvent<SVGElement>) => { (stair: StairNode, event: ReactMouseEvent<SVGElement>) => {
emitFloorplanNodeClick(stair.id, 'double-click', event) emitFloorplanNodeClick(stair.id, 'double-click', event)
@@ -8304,6 +8753,45 @@ export function FloorplanPanel() {
}, },
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode], [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode],
) )
const handleWallCurvePointerDown = useCallback(
(wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => {
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( const handleSlabVertexPointerDown = useCallback(
(slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => { (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {
if (event.button !== 0) { if (event.button !== 0) {
@@ -8662,12 +9150,21 @@ export function FloorplanPanel() {
!zoneVertexDragState !zoneVertexDragState
) { ) {
const rect = event.currentTarget.getBoundingClientRect() const rect = event.currentTarget.getBoundingClientRect()
setFloorplanCursorPosition({ const nextPosition = {
x: event.clientX - rect.left, x: event.clientX - rect.left,
y: event.clientY - rect.top, y: event.clientY - rect.top,
}) }
setFloorplanCursorPosition((currentPosition) =>
currentPosition &&
currentPosition.x === nextPosition.x &&
currentPosition.y === nextPosition.y
? currentPosition
: nextPosition,
)
} else { } else {
setFloorplanCursorPosition(null) setFloorplanCursorPosition((currentPosition) =>
currentPosition === null ? currentPosition : null,
)
} }
handlePointerMove(event) handlePointerMove(event)
@@ -9230,7 +9727,7 @@ export function FloorplanPanel() {
rotationModifierPressed={rotationModifierPressed} rotationModifierPressed={rotationModifierPressed}
/> />
)} )}
{selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && ( {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
style={{ style={{
@@ -9248,7 +9745,58 @@ export function FloorplanPanel() {
/> />
</div> </div>
)} )}
{selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && ( {selectedWallActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
left: selectedWallActionMenuPosition.x,
top: selectedWallActionMenuPosition.y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
}}
>
<NodeActionMenu
onDelete={handleSelectedWallDelete}
onMove={handleSelectedWallMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
)}
{selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
left: selectedSlabActionMenuPosition.x,
top: selectedSlabActionMenuPosition.y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
}}
>
<NodeActionMenu
onDelete={handleSelectedSlabDelete}
onMove={handleSelectedSlabMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
)}
{selectedCeilingActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div
className="absolute z-30"
style={{
left: selectedCeilingActionMenuPosition.x,
top: selectedCeilingActionMenuPosition.y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
}}
>
<NodeActionMenu
onDelete={handleSelectedCeilingDelete}
onMove={handleSelectedCeilingMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
)}
{selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
style={{ style={{
@@ -9266,7 +9814,7 @@ export function FloorplanPanel() {
/> />
</div> </div>
)} )}
{selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && ( {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
style={{ style={{
@@ -9543,6 +10091,13 @@ export function FloorplanPanel() {
onWallEndpointPointerDown={handleWallEndpointPointerDown} onWallEndpointPointerDown={handleWallEndpointPointerDown}
palette={palette} palette={palette}
/> />
<FloorplanWallCurveHandleLayer
curveHandles={wallCurveHandles}
hoveredHandleId={hoveredWallCurveHandleId}
onHandleHoverChange={setHoveredWallCurveHandleId}
onWallCurvePointerDown={handleWallCurvePointerDown}
palette={palette}
/>
<FloorplanPolygonHandleLayer <FloorplanPolygonHandleLayer
hoveredHandleId={hoveredSlabHandleId} hoveredHandleId={hoveredSlabHandleId}
@@ -1,7 +1,7 @@
'use client' 'use client'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Copy, Move, Trash2 } from 'lucide-react' import { Copy, Move, Spline, Trash2 } from 'lucide-react'
import type { MouseEventHandler, PointerEventHandler } from 'react' import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = { type NodeActionMenuProps = {
@@ -9,6 +9,7 @@ type NodeActionMenuProps = {
onDelete?: MouseEventHandler<HTMLButtonElement> onDelete?: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement> onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement> onMove?: MouseEventHandler<HTMLButtonElement>
onCurve?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement> onPointerDown?: PointerEventHandler<HTMLDivElement>
onPointerUp?: PointerEventHandler<HTMLDivElement> onPointerUp?: PointerEventHandler<HTMLDivElement>
onPointerEnter?: PointerEventHandler<HTMLDivElement> onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -20,6 +21,7 @@ export function NodeActionMenu({
onDelete, onDelete,
onDuplicate, onDuplicate,
onMove, onMove,
onCurve,
onPointerDown, onPointerDown,
onPointerUp, onPointerUp,
onPointerEnter, onPointerEnter,
@@ -44,6 +46,17 @@ export function NodeActionMenu({
<Move className="h-4 w-4" /> <Move className="h-4 w-4" />
</button> </button>
)} )}
{onCurve && (
<button
aria-label="Curve"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onCurve}
title="Curve"
type="button"
>
<Spline className="h-4 w-4" />
</button>
)}
{onDuplicate && ( {onDuplicate && (
<button <button
aria-label="Duplicate" aria-label="Duplicate"
@@ -346,6 +346,7 @@ export const SelectionManager = () => {
const clickHandledRef = useRef(false) const clickHandledRef = useRef(false)
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
useEffect(() => { useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
@@ -384,7 +385,7 @@ export const SelectionManager = () => {
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode) return if (movingNode || curvingWall) return
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node) // Skip if box-select just completed (drag ended over a node)
@@ -485,12 +486,12 @@ export const SelectionManager = () => {
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
} }
}, [mode, movingNode]) }, [curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover // Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => { useEffect(() => {
if (mode !== 'select') return if (mode !== 'select') return
if (movingNode) return if (movingNode || curvingWall) return
const onEnter = (event: NodeEvent) => { const onEnter = (event: NodeEvent) => {
const node = event.node const node = event.node
@@ -619,7 +620,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
}) })
} }
}, [mode, movingNode]) }, [curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool) // Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => { useEffect(() => {
@@ -4,9 +4,14 @@ import {
type AnyNodeId, type AnyNodeId,
calculateLevelMiters, calculateLevelMiters,
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_HEIGHT,
getWallCurveLength,
getWallMiterBoundaryPoints,
getWallPlanFootprint, getWallPlanFootprint,
getWallSurfacePolygon,
isCurvedWall,
type Point2D, type Point2D,
pointToKey, pointToKey,
sampleWallCenterline,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallMiterData, type WallMiterData,
@@ -15,7 +20,7 @@ import {
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber' import { createPortal, useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
const GUIDE_Y_OFFSET = 0.08 const GUIDE_Y_OFFSET = 0.08
@@ -28,8 +33,7 @@ const BAR_AXIS = new THREE.Vector3(0, 1, 0)
type Vec3 = [number, number, number] type Vec3 = [number, number, number]
type MeasurementGuide = { type MeasurementGuide = {
guideStart: Vec3 guidePath: Vec3[]
guideEnd: Vec3
extStartStart: Vec3 extStartStart: Vec3
extStartEnd: Vec3 extStartEnd: Vec3
extEndStart: Vec3 extEndStart: Vec3
@@ -56,18 +60,19 @@ export function WallMeasurementLabel() {
const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null const selectedNode = selectedId ? nodes[selectedId as WallNode['id']] : null
const wall = selectedNode?.type === 'wall' ? selectedNode : null const wall = selectedNode?.type === 'wall' ? selectedNode : null
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null) const [wallObjectState, setWallObjectState] = useState<{
id: WallNode['id']
useEffect(() => { object: THREE.Object3D
setWallObject(null) } | null>(null)
}, [selectedId]) const wallObject =
selectedId && wallObjectState?.id === selectedId ? wallObjectState.object : null
useFrame(() => { useFrame(() => {
if (!selectedId || wallObject) return if (!selectedId || wallObject) return
const nextWallObject = sceneRegistry.nodes.get(selectedId) const nextWallObject = sceneRegistry.nodes.get(selectedId)
if (nextWallObject) { if (nextWallObject) {
setWallObject(nextWallObject) setWallObjectState({ id: selectedId as WallNode['id'], object: nextWallObject })
} }
}) })
@@ -131,6 +136,34 @@ function worldPointToWallLocal(wall: WallNode, point: Point2D): Vec3 {
return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA] return [dx * cosA - dz * sinA, 0, dx * sinA + dz * cosA]
} }
function getWallExteriorOffsetSign(wall: Pick<WallNode, 'frontSide' | 'backSide'>) {
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') {
return 1
}
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') {
return -1
}
return 1
}
function getCurvedWallMeasurementPath(wall: WallNode, miterData: WallMiterData): Point2D[] | null {
const boundaryPoints = getWallMiterBoundaryPoints(wall, miterData)
if (!boundaryPoints) return null
const surface = getWallSurfacePolygon(wall, 24, boundaryPoints)
const sidePointCount = 25
if (surface.length < sidePointCount * 2) return null
const offsetSign = getWallExteriorOffsetSign(wall)
if (offsetSign >= 0) {
return surface.slice(sidePointCount).reverse()
}
return surface.slice(0, sidePointCount)
}
function buildMeasurementGuide( function buildMeasurementGuide(
wall: WallNode, wall: WallNode,
nodes: Record<string, WallNode | { type: string; children?: string[] }>, nodes: Record<string, WallNode | { type: string; children?: string[] }>,
@@ -143,31 +176,66 @@ function buildMeasurementGuide(
const height = wall.height ?? DEFAULT_WALL_HEIGHT const height = wall.height ?? DEFAULT_WALL_HEIGHT
const startLocal = worldPointToWallLocal(wall, middlePoints.start) const startLocal = worldPointToWallLocal(wall, middlePoints.start)
const endLocal = worldPointToWallLocal(wall, middlePoints.end) const endLocal = worldPointToWallLocal(wall, middlePoints.end)
const curvedMeasurementPath = isCurvedWall(wall)
? getCurvedWallMeasurementPath(wall, miterData)
: null
const guidePath: Vec3[] = curvedMeasurementPath
? curvedMeasurementPath.map((point) => {
const localPoint = worldPointToWallLocal(wall, point)
return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
})
: isCurvedWall(wall)
? sampleWallCenterline(wall, 24).map((point, index, points) => {
const localPoint =
index === 0
? startLocal
: index === points.length - 1
? endLocal
: worldPointToWallLocal(wall, point)
const guideStart: Vec3 = [startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]] return [localPoint[0], height + GUIDE_Y_OFFSET, localPoint[2]]
const guideEnd: Vec3 = [endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]] })
: [
[startLocal[0], height + GUIDE_Y_OFFSET, startLocal[2]],
[endLocal[0], height + GUIDE_Y_OFFSET, endLocal[2]],
]
const dirX = guideEnd[0] - guideStart[0] if (guidePath.length < 2) return null
const dirZ = guideEnd[2] - guideStart[2]
const dirLength = Math.hypot(dirX, dirZ)
if (!Number.isFinite(dirLength) || dirLength < 0.001) return null let guideLength = 0
for (let index = 1; index < guidePath.length; index += 1) {
const prev = guidePath[index - 1]!
const next = guidePath[index]!
guideLength += Math.hypot(next[0] - prev[0], next[2] - prev[2])
}
if (!Number.isFinite(guideLength) || guideLength < 0.001) return null
// Extension lines coming out of the extremity markers of the wall // Extension lines coming out of the extremity markers of the wall
const extOvershoot = 0.04 const extOvershoot = 0.04
const guideStart = guidePath[0]!
const guideEnd = guidePath[guidePath.length - 1]!
const extensionStartBase = curvedMeasurementPath ? guideStart : startLocal
const extensionEndBase = curvedMeasurementPath ? guideEnd : endLocal
const midpoint = curvedMeasurementPath
? guidePath[Math.floor(guidePath.length / 2)]!
: ([
(guideStart[0] + guideEnd[0]) / 2,
guideStart[1],
(guideStart[2] + guideEnd[2]) / 2,
] as Vec3)
return { return {
guideStart, guidePath,
guideEnd, extStartStart: [extensionStartBase[0], height, extensionStartBase[2]],
extStartStart: [startLocal[0], height, startLocal[2]], extStartEnd: [
extStartEnd: [startLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, startLocal[2]], extensionStartBase[0],
extEndStart: [endLocal[0], height, endLocal[2]], height + GUIDE_Y_OFFSET + extOvershoot,
extEndEnd: [endLocal[0], height + GUIDE_Y_OFFSET + extOvershoot, endLocal[2]], extensionStartBase[2],
labelPosition: [
(guideStart[0] + guideEnd[0]) / 2,
guideStart[1] + LABEL_LIFT,
(guideStart[2] + guideEnd[2]) / 2,
], ],
extEndStart: [extensionEndBase[0], height, extensionEndBase[2]],
extEndEnd: [extensionEndBase[0], height + GUIDE_Y_OFFSET + extOvershoot, extensionEndBase[2]],
labelPosition: [midpoint[0], midpoint[1] + LABEL_LIFT, midpoint[2]],
} }
} }
@@ -208,6 +276,16 @@ function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color:
) )
} }
function MeasurementPath({ path, color }: { path: Vec3[]; color: string }) {
return (
<>
{path.slice(1).map((point, index) => (
<MeasurementBar color={color} end={point} key={index} start={path[index]!} />
))}
</>
)
}
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const theme = useViewer((state) => state.theme) const theme = useViewer((state) => state.theme)
@@ -216,10 +294,6 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
const color = isNight ? '#ffffff' : '#111111' const color = isNight ? '#ffffff' : '#111111'
const shadowColor = isNight ? '#111111' : '#ffffff' const shadowColor = isNight ? '#111111' : '#ffffff'
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz)
const label = formatMeasurement(length, unit)
const guide = useMemo( const guide = useMemo(
() => () =>
buildMeasurementGuide( buildMeasurementGuide(
@@ -228,12 +302,26 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
), ),
[nodes, wall], [nodes, wall],
) )
const length = useMemo(() => {
if (!guide?.guidePath?.length || guide.guidePath.length < 2) {
return getWallCurveLength(wall)
}
let total = 0
for (let index = 1; index < guide.guidePath.length; index += 1) {
const prev = guide.guidePath[index - 1]!
const next = guide.guidePath[index]!
total += Math.hypot(next[0] - prev[0], next[2] - prev[2])
}
return total
}, [guide, wall])
const label = formatMeasurement(length, unit)
if (!(guide && Number.isFinite(length) && length >= 0.01)) return null if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
return ( return (
<group> <group>
<MeasurementBar color={color} end={guide.guideEnd} start={guide.guideStart} /> <MeasurementPath color={color} path={guide.guidePath} />
<MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} /> <MeasurementBar color={color} end={guide.extStartEnd} start={guide.extStartStart} />
<MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} /> <MeasurementBar color={color} end={guide.extEndEnd} start={guide.extEndStart} />
@@ -36,6 +36,7 @@ export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId,
return ( return (
<PolygonEditor <PolygonEditor
allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
minVertices={3} minVertices={3}
@@ -0,0 +1,154 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], node.height ?? 2.5, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.height, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
DoorNode, DoorNode,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -84,6 +85,11 @@ export const DoorTool: React.FC = () => {
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId() const levelId = getLevelId()
if (!levelId) return if (!levelId) return
if (event.node.parentId !== levelId) return if (event.node.parentId !== levelId) return
@@ -130,6 +136,11 @@ export const DoorTool: React.FC = () => {
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -190,6 +201,7 @@ export const DoorTool: React.FC = () => {
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -2,6 +2,7 @@ import {
type AnyNodeId, type AnyNodeId,
DoorNode, DoorNode,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -98,6 +99,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -151,6 +156,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -213,6 +222,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
const side = getSideFromNormal(event.normal) const side = getSideFromNormal(event.normal)
@@ -1,21 +1,27 @@
import type { import type {
BuildingNode, BuildingNode,
CeilingNode,
DoorNode, DoorNode,
FenceNode, FenceNode,
ItemNode, ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
SlabNode,
StairNode, StairNode,
StairSegmentNode, StairSegmentNode,
WallNode,
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Vector3 } from 'three' import { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool' import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
import { MoveDoorTool } from '../door/move-door-tool' import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveSlabTool } from '../slab/move-slab-tool'
import { MoveWallTool } from '../wall/move-wall-tool'
import { MoveWindowTool } from '../window/move-window-tool' import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types' import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node' import { useDraftNode } from './use-draft-node'
@@ -89,6 +95,9 @@ export const MoveTool: React.FC = () => {
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} /> if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} /> if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
@@ -9,8 +9,10 @@ const Y_OFFSET = 0.02
type DragState = { type DragState = {
isDragging: boolean isDragging: boolean
vertexIndex: number mode: 'vertex' | 'polygon'
vertexIndex: number | null
initialPosition: [number, number] initialPosition: [number, number]
initialPolygon: Array<[number, number]>
pointerId: number pointerId: number
} }
@@ -23,6 +25,8 @@ export interface PolygonEditorProps {
levelId?: string levelId?: string
/** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */ /** Height of the surface being edited (e.g. slab elevation). Handles adapt to this. */
surfaceHeight?: number surfaceHeight?: number
/** Whether to show the center handle that moves the entire polygon. */
allowPolygonMove?: boolean
} }
/** /**
@@ -38,6 +42,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
minVertices = 3, minVertices = 3,
levelId, levelId,
surfaceHeight = 0, surfaceHeight = 0,
allowPolygonMove = false,
}) => { }) => {
// Get level node from registry if levelId is provided // Get level node from registry if levelId is provided
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
@@ -75,6 +80,17 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// The polygon to display (preview during drag, or actual polygon) // The polygon to display (preview during drag, or actual polygon)
const displayPolygon = previewPolygon ?? polygon const displayPolygon = previewPolygon ?? polygon
const polygonCenter = useMemo(() => {
if (displayPolygon.length === 0) return [0, 0] as [number, number]
let sumX = 0
let sumZ = 0
for (const [x, z] of displayPolygon) {
sumX += x
sumZ += z
}
return [sumX / displayPolygon.length, sumZ / displayPolygon.length] as [number, number]
}, [displayPolygon])
// Calculate midpoints for adding new vertices // Calculate midpoints for adding new vertices
const midpoints = useMemo(() => { const midpoints = useMemo(() => {
if (displayPolygon.length < 2) return [] if (displayPolygon.length < 2) return []
@@ -158,7 +174,15 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Update vertex position during drag // Update vertex position during drag
if (dragState?.isDragging) { if (dragState?.isDragging) {
handleVertexDrag(dragState.vertexIndex, newPosition) if (dragState.mode === 'vertex' && dragState.vertexIndex !== null) {
handleVertexDrag(dragState.vertexIndex, newPosition)
} else if (dragState.mode === 'polygon') {
const deltaX = newPosition[0] - dragState.initialPosition[0]
const deltaZ = newPosition[1] - dragState.initialPosition[1]
setPreviewPolygon(
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
)
}
} }
} }
@@ -257,7 +281,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
{/* Vertex handles - blue cylinders that match surface height */} {/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => { {displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index const isHovered = hoveredVertex === index
const isDragging = dragState?.vertexIndex === index const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const radius = 0.1 const radius = 0.1
const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) const height = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
@@ -282,8 +306,10 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
e.stopPropagation() e.stopPropagation()
setDragState({ setDragState({
isDragging: true, isDragging: true,
mode: 'vertex',
vertexIndex: index, vertexIndex: index,
initialPosition: [x!, z!], initialPosition: [x!, z!],
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId, pointerId: e.pointerId,
}) })
}} }}
@@ -305,6 +331,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
) )
})} })}
{allowPolygonMove && (
<mesh
castShadow
layers={EDITOR_LAYER}
onClick={(e) => {
if (e.button !== 0) return
e.stopPropagation()
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
setDragState({
isDragging: true,
mode: 'polygon',
vertexIndex: null,
initialPosition: polygonCenter,
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
pointerId: e.pointerId,
})
}}
position={[
polygonCenter[0],
editY + Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) + 0.08,
polygonCenter[1],
]}
>
<sphereGeometry args={[0.09, 20, 20]} />
<meshStandardMaterial color={dragState?.mode === 'polygon' ? '#22c55e' : '#f59e0b'} />
</mesh>
)}
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */} {/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
{!dragState && {!dragState &&
midpoints.map(([x, z], index) => { midpoints.map(([x, z], index) => {
@@ -0,0 +1,154 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
deltaZ: number,
): Array<[number, number]> {
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
}
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
if (polygon.length === 0) return [0, 0]
let sumX = 0
let sumZ = 0
for (const [x, z] of polygon) {
sumX += x
sumZ += z
}
return [sumX / polygon.length, sumZ / polygon.length]
}
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
const originalHolesRef = useRef(
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{
polygon: Array<[number, number]>
holes: Array<Array<[number, number]>>
} | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const center = getPolygonCenter(node.polygon)
return [center[0], 0, center[1]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
) => {
previewRef.current = { polygon, holes }
const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], 0, center[1]])
useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(node.id, {
holes: originalHoles,
polygon: originalPolygon,
})
useScene.getState().markDirty(node.id as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(node.id, preview)
useScene.getState().markDirty(node.id as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [node.id] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode, node.id])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
return ( return (
<PolygonEditor <PolygonEditor
allowPolygonMove
color="#ef4444" color="#ef4444"
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
minVertices={3} minVertices={3}
@@ -20,6 +20,7 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool' import { SlabTool } from './slab/slab-tool'
import { StairTool } from './stair/stair-tool' import { StairTool } from './stair/stair-tool'
import { CurveWallTool } from './wall/curve-wall-tool'
import { WallTool } from './wall/wall-tool' import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool' import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
@@ -51,6 +52,7 @@ export const ToolManager: React.FC = () => {
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const editingHole = useEditor((state) => state.editingHole) const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId) const buildingId = useViewer((state) => state.selection.buildingId)
@@ -140,6 +142,7 @@ export const ToolManager: React.FC = () => {
{showCeilingHoleEditor && selectedCeilingId && editingHole && ( {showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)} )}
{curvingWall && <CurveWallTool node={curvingWall} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />} {movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
</group> </group>
@@ -0,0 +1,157 @@
'use client'
import {
type AnyNodeId,
emitter,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingWall(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
useScene.temporal.getState().pause()
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const localX = shiftPressedRef.current ? event.localPosition[0] : snap(event.localPosition[0])
const localZ = shiftPressedRef.current ? event.localPosition[2] : snap(event.localPosition[2])
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current ? offsetFromMidpoint : snap(offsetFromMidpoint)
const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -0,0 +1,307 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos - z * sin, x * sin + z * cos]
}
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: [number, number]
end: [number, number]
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { wallId, wallParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedWallSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'wall' && node.id !== wallId)) {
continue
}
if ((node.parentId ?? null) !== wallParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as [number, number],
end: [...node.end] as [number, number],
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: [number, number],
originalEnd: [number, number],
nextStart: [number, number],
nextEnd: [number, number],
) {
return linkedWalls.map((wall) => ({
id: wall.id,
start: samePoint(wall.start, originalStart)
? nextStart
: samePoint(wall.start, originalEnd)
? nextEnd
: wall.start,
end: samePoint(wall.end, originalStart)
? nextStart
: samePoint(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const originalCenterRef = useRef<[number, number]>([
(node.start[0] + node.end[0]) / 2,
(node.start[1] + node.end[1]) / 2,
])
const originalHalfVectorRef = useRef<[number, number]>([
(node.end[0] - node.start[0]) / 2,
(node.end[1] - node.start[1]) / 2,
])
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: node.id,
wallParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
)
const dragAnchorRef = useRef<[number, number] | null>(null)
const nodeIdRef = useRef(node.id)
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
const pendingRotationRef = useRef(0)
const shiftPressedRef = useRef(false)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current
useScene.temporal.getState().pause()
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const buildWallFromCenter = (center: [number, number]) => {
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
return { start: nextStart, end: nextEnd }
}
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
previewRef.current = { start: nextStart, end: nextEnd }
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
),
])
}
const restoreOriginal = () => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
}
const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const localX = shiftPressedRef.current ? rawX : snap(rawX)
const localZ = shiftPressedRef.current ? rawZ : snap(rawZ)
if (
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [localX, localZ]
const anchor = dragAnchorRef.current ?? [localX, localZ]
dragAnchorRef.current = anchor
const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1]
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
const nextWall = buildWallFromCenter(nextCenter)
applyPreview(nextWall.start, nextWall.end)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
),
])
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta === 0) {
return
}
event.preventDefault()
pendingRotationRef.current += rotationDelta
sfxEmitter.emit('sfx:item-rotate')
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const currentCenter: [number, number] = [
(preview.start[0] + preview.end[0]) / 2,
(preview.start[1] + preview.end[1]) / 2,
]
const nextWall = buildWallFromCenter(currentCenter)
applyPreview(nextWall.start, nextWall.end)
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
shiftPressedRef.current = false
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitMoveMode])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,6 +1,7 @@
import { import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -112,6 +113,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -168,6 +173,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
hideCursor()
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -233,6 +242,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -1,6 +1,7 @@
import { import {
type AnyNodeId, type AnyNodeId,
emitter, emitter,
isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useScene, useScene,
@@ -86,6 +87,11 @@ export const WindowTool: React.FC = () => {
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
const levelId = getLevelId() const levelId = getLevelId()
if (!levelId) return if (!levelId) return
// Only interact with walls on the current level // Only interact with walls on the current level
@@ -135,6 +141,11 @@ export const WindowTool: React.FC = () => {
const onWallMove = (event: WallEvent) => { const onWallMove = (event: WallEvent) => {
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) {
destroyDraft()
hideCursor()
return
}
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -198,6 +209,7 @@ export const WindowTool: React.FC = () => {
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
if (!draftRef.current) return if (!draftRef.current) return
if (!isValidWallSideFace(event.normal)) return if (!isValidWallSideFace(event.normal)) return
if (isCurvedWall(event.node)) return
// Only interact with walls on the current level // Only interact with walls on the current level
if (event.node.parentId !== getLevelId()) return if (event.node.parentId !== getLevelId()) return
@@ -2,10 +2,11 @@
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -18,6 +19,7 @@ export function CeilingPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
@@ -109,6 +111,13 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
) )
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -238,6 +247,9 @@ export function CeilingPanel() {
value={node.material} value={node.material}
/> />
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
@@ -173,7 +172,7 @@ export function RoofPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -187,7 +186,7 @@ export function RoofPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -201,7 +200,7 @@ export function RoofPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -16,7 +16,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -237,7 +236,7 @@ export function RoofSegmentPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -251,7 +250,7 @@ export function RoofSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -265,7 +264,7 @@ export function RoofSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -2,8 +2,9 @@
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Edit, Plus, Trash2 } from 'lucide-react' import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
@@ -18,6 +19,7 @@ export function SlabPanel() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole) const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole) const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
@@ -107,6 +109,13 @@ export function SlabPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
) )
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -236,6 +245,9 @@ export function SlabPanel() {
value={node.material} value={node.material}
/> />
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
@@ -21,7 +21,6 @@ import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -368,7 +367,7 @@ export function StairPanel() {
)} )}
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -382,7 +381,7 @@ export function StairPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -396,7 +395,7 @@ export function StairPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -17,7 +17,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -250,7 +249,7 @@ export function StairSegmentPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <SliderControl
label="X" label="X"
max={50} max={50}
min={-50} min={-50}
@@ -264,7 +263,7 @@ export function StairSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[0] * 100) / 100} value={Math.round(node.position[0] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Y" label="Y"
max={50} max={50}
min={-50} min={-50}
@@ -278,7 +277,7 @@ export function StairSegmentPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<MetricControl <SliderControl
label="Z" label="Z"
max={50} max={50}
min={-50} min={-50}
@@ -3,12 +3,20 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
normalizeWallCurveOffset,
type MaterialSchema, type MaterialSchema,
useScene, useScene,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -19,6 +27,8 @@ export function WallPanel() {
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
@@ -73,14 +83,40 @@ export function WallPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
const handleMove = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleCurve = useCallback(() => {
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node)
setSelection({ selectedIds: [] })
}, [node, setCurvingWall, setSelection])
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0] const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1] const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz) const length = getWallCurveLength(node)
const height = node.height ?? 2.5 const height = node.height ?? 2.5
const thickness = node.thickness ?? 0.1 const thickness = node.thickness ?? 0.1
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
const hasWallChildrenBlockingCurve = (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
})
return ( return (
<PanelWrapper <PanelWrapper
@@ -120,6 +156,18 @@ export function WallPanel() {
unit="m" unit="m"
value={Math.round(thickness * 1000) / 1000} value={Math.round(thickness * 1000) / 1000}
/> />
{!hasWallChildrenBlockingCurve && (
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2}
step={0.01}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
)}
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
@@ -131,6 +179,17 @@ export function WallPanel() {
value={node.material} value={node.material}
/> />
</PanelSection> </PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelWrapper> </PanelWrapper>
) )
} }
+13
View File
@@ -3,16 +3,19 @@
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import { import {
type BuildingNode, type BuildingNode,
type CeilingNode,
type DoorNode, type DoorNode,
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type SlabNode,
type Space, type Space,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
useScene, useScene,
type WallNode,
type WindowNode, type WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -88,6 +91,9 @@ type EditorState = {
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode | FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -100,6 +106,9 @@ type EditorState = {
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode | FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -107,6 +116,8 @@ type EditorState = {
| BuildingNode | BuildingNode
| null, | null,
) => void ) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -437,6 +448,8 @@ const useEditor = create<EditorState>()(
| BuildingNode | BuildingNode
| null, | null,
setMovingNode: (node) => set({ movingNode: node }), setMovingNode: (node) => set({ movingNode: node }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
@@ -1,5 +1,6 @@
import { type SlabNode, useRegistry } from '@pascal-app/core' import { type SlabNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { import {
@@ -17,10 +18,20 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const material = useMemo(() => { const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
const mat = node.material const slabMaterial = sourceMaterial.clone()
if (!mat) return DEFAULT_SLAB_MATERIAL
return createMaterial(mat) // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
return slabMaterial
}, [ }, [
node.material, node.material,
node.material?.preset, node.material?.preset,
@@ -29,6 +40,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
node.materialPreset, node.materialPreset,
]) ])
useEffect(() => {
return () => {
material.dispose()
}
}, [material])
return ( return (
<mesh <mesh
castShadow castShadow
@@ -106,11 +106,11 @@ const Viewer: React.FC<ViewerProps> = ({
camera={{ position: [50, 50, 50], fov: 50 }} camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`} className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]} dpr={[1, 1.5]}
gl={(props) => { gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any) const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9 renderer.toneMappingExposure = 0.9
// renderer.init() // Only use when using <DebugRenderer /> await renderer.init()
return renderer return renderer
}} }}
resize={{ resize={{
@@ -53,7 +53,6 @@ const PostProcessingPasses = () => {
const hasPipelineErrorRef = useRef(false) const hasPipelineErrorRef = useRef(false)
const retryCountRef = useRef(0) const retryCountRef = useRef(0)
const rebuildTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const rebuildTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
// Background color uniform — updated every frame via lerp, read by the TSL pipeline. // Background color uniform — updated every frame via lerp, read by the TSL pipeline.
// Initialised from the current theme so there's no flash on first render. // Initialised from the current theme so there's no flash on first render.
@@ -85,35 +84,6 @@ const PostProcessingPasses = () => {
setPipelineVersion((v) => v + 1) setPipelineVersion((v) => v + 1)
}, []) }, [])
// Renderer initialization
useEffect(() => {
let mounted = true
const initRenderer = async () => {
try {
if (renderer && (renderer as any).init) {
await (renderer as any).init()
}
if (mounted) {
setIsInitialized(true)
}
} catch (error) {
console.error('[viewer] Failed to initialize renderer for post-processing.', error)
if (mounted) {
setIsInitialized(false)
}
}
}
initRenderer()
return () => {
mounted = false
}
}, [renderer])
// Reset retry state when project changes // Reset retry state when project changes
useEffect(() => { useEffect(() => {
// Intentionally touch projectId so the effect reruns on project switches. // Intentionally touch projectId so the effect reruns on project switches.
@@ -141,7 +111,7 @@ const PostProcessingPasses = () => {
void projectId void projectId
void pipelineVersion void pipelineVersion
if (!(renderer && scene && camera && isInitialized)) { if (!(renderer && scene && camera)) {
return return
} }
@@ -298,7 +268,6 @@ const PostProcessingPasses = () => {
scene, scene,
camera, camera,
hoverHighlightMode, hoverHighlightMode,
isInitialized,
zoneLayers, zoneLayers,
projectId, projectId,
pipelineVersion, pipelineVersion,
@@ -310,10 +279,6 @@ const PostProcessingPasses = () => {
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
bgUniform.current.value.copy(bgCurrent.current) bgUniform.current.value.copy(bgCurrent.current)
if (!isInitialized) {
return
}
if (hasPipelineErrorRef.current || !renderPipelineRef.current) { if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
try { try {
if ((renderer as any).setClearAlpha) { if ((renderer as any).setClearAlpha) {