Merge pull request #255 from sudhir9297/feat/wall-room-creation

Feat: wall room creation and snap, stairs system
This commit is contained in:
Wassim SAMAD
2026-04-17 12:44:06 -04:00
committed by GitHub
36 changed files with 4179 additions and 835 deletions
+134
View File
@@ -0,0 +1,134 @@
export function insetPolygonFromCentroid(
polygon: Array<[number, number]>,
inset: number,
): Array<[number, number]> {
if (inset <= 0) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
const centroid = polygon.reduce(
(acc, [x, z]) => ({ x: acc.x + x, z: acc.z + z }),
{ x: 0, z: 0 },
)
centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1)
return polygon.map(([x, z]) => {
const dx = x - centroid.x
const dz = z - centroid.z
const length = Math.hypot(dx, dz)
if (length <= inset + 1e-6) {
return [x, z] as [number, number]
}
const scale = (length - inset) / length
return [centroid.x + dx * scale, centroid.z + dz * scale] as [number, number]
})
}
function pointLineDistance(
point: [number, number],
start: [number, number],
end: [number, number],
) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-9) {
return Math.hypot(point[0] - start[0], point[1] - start[1])
}
const cross = (point[0] - start[0]) * dz - (point[1] - start[1]) * dx
return Math.abs(cross) / Math.sqrt(lengthSquared)
}
function dedupePolygonPoints(
polygon: Array<[number, number]>,
tolerance = 1e-6,
): Array<[number, number]> {
const deduped: Array<[number, number]> = []
for (const point of polygon) {
const previous = deduped[deduped.length - 1]
if (previous && Math.hypot(point[0] - previous[0], point[1] - previous[1]) <= tolerance) {
continue
}
deduped.push(point)
}
if (
deduped.length > 2 &&
Math.hypot(
deduped[0]![0] - deduped[deduped.length - 1]![0],
deduped[0]![1] - deduped[deduped.length - 1]![1],
) <= tolerance
) {
deduped.pop()
}
return deduped
}
function simplifyPolyline(points: Array<[number, number]>, tolerance: number): Array<[number, number]> {
if (points.length <= 2) {
return points.map(([x, z]) => [x, z] as [number, number])
}
let maxDistance = -1
let splitIndex = -1
for (let index = 1; index < points.length - 1; index += 1) {
const distance = pointLineDistance(points[index]!, points[0]!, points[points.length - 1]!)
if (distance > maxDistance) {
maxDistance = distance
splitIndex = index
}
}
if (maxDistance <= tolerance || splitIndex === -1) {
return [points[0]!, points[points.length - 1]!]
}
const left = simplifyPolyline(points.slice(0, splitIndex + 1), tolerance)
const right = simplifyPolyline(points.slice(splitIndex), tolerance)
return [...left.slice(0, -1), ...right]
}
export function simplifyClosedPolygon(
polygon: Array<[number, number]>,
tolerance: number,
): Array<[number, number]> {
const cleanPolygon = dedupePolygonPoints(polygon)
if (cleanPolygon.length <= 3 || tolerance <= 0) {
return cleanPolygon
}
let anchorA = 0
let anchorB = Math.floor(cleanPolygon.length / 2)
let maxDistanceSquared = -1
for (let i = 0; i < cleanPolygon.length; i += 1) {
for (let j = i + 1; j < cleanPolygon.length; j += 1) {
const dx = cleanPolygon[j]![0] - cleanPolygon[i]![0]
const dz = cleanPolygon[j]![1] - cleanPolygon[i]![1]
const distanceSquared = dx * dx + dz * dz
if (distanceSquared > maxDistanceSquared) {
maxDistanceSquared = distanceSquared
anchorA = i
anchorB = j
}
}
}
const forward = cleanPolygon.slice(anchorA, anchorB + 1)
const wrapped = [...cleanPolygon.slice(anchorB), ...cleanPolygon.slice(0, anchorA + 1)]
const simplifiedForward = simplifyPolyline(forward, tolerance)
const simplifiedWrapped = simplifyPolyline(wrapped, tolerance)
const simplified = dedupePolygonPoints(
[...simplifiedForward.slice(0, -1), ...simplifiedWrapped.slice(0, -1)],
tolerance * 0.25,
)
return simplified.length >= 3 ? simplified : cleanPolygon
}
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -49,8 +49,15 @@ export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' export { SlabNode } from './nodes/slab'
export { StairNode, StairRailingMode, StairTopLandingMode, StairType } from './nodes/stair' export {
StairNode,
StairRailingMode,
StairSlabOpeningMode,
StairTopLandingMode,
StairType,
} from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { WallNode } from './nodes/wall' export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { ItemNode } from './item' import { ItemNode } from './item'
import { SurfaceHoleMetadata } from './surface-hole-metadata'
export const CeilingNode = BaseNode.extend({ export const CeilingNode = BaseNode.extend({
id: objectId('ceiling'), id: objectId('ceiling'),
@@ -12,12 +13,16 @@ export const CeilingNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
height: z.number().default(2.5), // Height in meters height: z.number().default(2.5), // Height in meters
autoFromWalls: z.boolean().default(false),
}).describe( }).describe(
dedent` dedent`
Ceiling node - used to represent a ceiling in the building Ceiling node - used to represent a ceiling in the building
- polygon: array of [x, z] points defining the ceiling boundary - polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling - holes: array of polygons representing holes in the ceiling
- holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
`, `,
) )
+6
View File
@@ -2,6 +2,7 @@ import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { SurfaceHoleMetadata } from './surface-hole-metadata'
export const SlabNode = BaseNode.extend({ export const SlabNode = BaseNode.extend({
id: objectId('slab'), id: objectId('slab'),
@@ -10,12 +11,17 @@ export const SlabNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
elevation: z.number().default(0.05), // Elevation in meters elevation: z.number().default(0.05), // Elevation in meters
autoFromWalls: z.boolean().default(false),
}).describe( }).describe(
dedent` dedent`
Slab node - used to represent a slab/floor in the building Slab node - used to represent a slab/floor in the building
- polygon: array of [x, z] points defining the slab boundary - polygon: array of [x, z] points defining the slab boundary
- holes: array of [x, z] polygons representing cutouts in the slab
- holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts
- elevation: elevation in meters - elevation: elevation in meters
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
`, `,
) )
+9
View File
@@ -7,10 +7,12 @@ import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
export const StairType = z.enum(['straight', 'curved', 'spiral']) export const StairType = z.enum(['straight', 'curved', 'spiral'])
export const StairTopLandingMode = z.enum(['none', 'integrated']) export const StairTopLandingMode = z.enum(['none', 'integrated'])
export const StairSlabOpeningMode = z.enum(['none', 'destination'])
export type StairRailingMode = z.infer<typeof StairRailingMode> export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType> export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode> export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
@@ -21,6 +23,10 @@ export const StairNode = BaseNode.extend({
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
stairType: StairType.default('straight'), stairType: StairType.default('straight'),
fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null),
slabOpeningMode: StairSlabOpeningMode.default('none'),
openingOffset: z.number().default(0),
width: z.number().default(1.0), width: z.number().default(1.0),
totalRise: z.number().default(2.5), totalRise: z.number().default(2.5),
stepCount: z.number().default(10), stepCount: z.number().default(10),
@@ -44,6 +50,9 @@ export const StairNode = BaseNode.extend({
- position: center position of the stair group - position: center position of the stair group
- rotation: rotation around Y axis - rotation: rotation around Y axis
- stairType: straight (segment-based), curved (arc-based), or spiral - stairType: straight (segment-based), curved (arc-based), or spiral
- fromLevelId / toLevelId: source and destination levels used for auto slab cutouts
- slabOpeningMode: whether a destination-level slab opening is generated for this stair
- openingOffset: extra opening expansion applied after the cutout polygon is computed
- width: stair width - width: stair width
- totalRise: total stair height - totalRise: total stair height
- stepCount: number of visible steps - stepCount: number of visible steps
@@ -0,0 +1,8 @@
import { z } from 'zod'
export const SurfaceHoleMetadata = z.object({
source: z.enum(['manual', 'stair']).default('manual'),
stairId: z.string().optional(),
})
export type SurfaceHoleMetadata = z.infer<typeof SurfaceHoleMetadata>
+5 -4
View File
@@ -2,15 +2,16 @@ import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { DoorNode } from './door'
import { ItemNode } from './item' import { ItemNode } from './item'
// import { DoorNode } from "./door"; import { WindowNode } from './window'
// import { ItemNode } from "./item";
// import { WindowNode } from "./window";
export const WallNode = BaseNode.extend({ export const WallNode = BaseNode.extend({
id: objectId('wall'), id: objectId('wall'),
type: nodeType('wall'), type: nodeType('wall'),
children: z.array(ItemNode.shape.id).default([]), children: z
.array(z.union([ItemNode.shape.id, DoorNode.shape.id, WindowNode.shape.id]))
.default([]),
material: MaterialSchema.optional(), material: MaterialSchema.optional(),
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
thickness: z.number().optional(), thickness: z.number().optional(),
+243 -1
View File
@@ -1,13 +1,224 @@
import type { AnyNode, AnyNodeId } from '../../schema' import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
import type { CollectionId } from '../../schema/collections' import type { CollectionId } from '../../schema/collections'
import type { SceneState } from '../use-scene' import type { SceneState } from '../use-scene'
type AnyContainerNode = AnyNode & { children: string[] } type AnyContainerNode = AnyNode & { children: string[] }
type WallAttachmentUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
type WallMergePlan = {
primaryWallId: AnyNodeId
secondaryWallId: AnyNodeId
mergedStart: [number, number]
mergedEnd: [number, number]
mergedChildren: WallNode['children']
attachmentUpdates: WallAttachmentUpdate[]
}
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks // Track pending RAF for updateNodesAction to prevent multiple queued callbacks
let pendingRafId: number | null = null let pendingRafId: number | null = null
let pendingUpdates: Set<AnyNodeId> = new Set() let pendingUpdates: Set<AnyNodeId> = new Set()
function pointsEqual(
a: [number, number],
b: [number, number],
tolerance = 1e-6,
) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz <= tolerance * tolerance
}
function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
}
function getWallEndpointAtPoint(
wall: Pick<WallNode, 'start' | 'end'>,
point: [number, number],
): 'start' | 'end' | null {
if (pointsEqual(wall.start, point)) return 'start'
if (pointsEqual(wall.end, point)) return 'end'
return null
}
function getWallFreeEndpoint(
wall: Pick<WallNode, 'start' | 'end'>,
sharedPoint: [number, number],
) {
return pointsEqual(wall.start, sharedPoint) ? wall.end : wall.start
}
function areWallStylesCompatible(a: WallNode, b: WallNode) {
return (
(a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
a.materialPreset === b.materialPreset &&
JSON.stringify(a.material ?? null) === JSON.stringify(b.material ?? null) &&
a.frontSide === b.frontSide &&
a.backSide === b.backSide &&
a.visible === b.visible
)
}
function areWallsCollinearAcrossPoint(
a: WallNode,
b: WallNode,
sharedPoint: [number, number],
) {
const freeA = getWallFreeEndpoint(a, sharedPoint)
const freeB = getWallFreeEndpoint(b, sharedPoint)
const ax = freeA[0] - sharedPoint[0]
const az = freeA[1] - sharedPoint[1]
const bx = freeB[0] - sharedPoint[0]
const bz = freeB[1] - sharedPoint[1]
const lenA = Math.hypot(ax, az)
const lenB = Math.hypot(bx, bz)
if (lenA < 1e-6 || lenB < 1e-6) return false
const cross = (ax * bz - az * bx) / (lenA * lenB)
const dot = (ax * bx + az * bz) / (lenA * lenB)
return Math.abs(cross) <= 1e-4 && dot < -0.999
}
function resolveMergedWallEndpoints(
primary: WallNode,
secondary: WallNode,
sharedPoint: [number, number],
): { start: [number, number]; end: [number, number] } {
const primaryEndpoint = getWallEndpointAtPoint(primary, sharedPoint)
const secondaryEndpoint = getWallEndpointAtPoint(secondary, sharedPoint)
if (primaryEndpoint === 'end' && secondaryEndpoint === 'start') {
return { start: primary.start, end: secondary.end }
}
if (primaryEndpoint === 'start' && secondaryEndpoint === 'end') {
return { start: secondary.start, end: primary.end }
}
if (primaryEndpoint === 'start' && secondaryEndpoint === 'start') {
return { start: primary.end, end: secondary.end }
}
return { start: primary.start, end: secondary.start }
}
function buildMergedWallAttachmentUpdates(
primary: WallNode,
secondary: WallNode,
mergedWallId: AnyNodeId,
mergedStart: [number, number],
mergedEnd: [number, number],
nodes: Record<AnyNodeId, AnyNode>,
): WallAttachmentUpdate[] {
const mergedLength = Math.max(Math.hypot(mergedEnd[0] - mergedStart[0], mergedEnd[1] - mergedStart[1]), 1e-6)
const tangentX = (mergedEnd[0] - mergedStart[0]) / mergedLength
const tangentZ = (mergedEnd[1] - mergedStart[1]) / mergedLength
const updates: WallAttachmentUpdate[] = []
const wallChildren = [...(primary.children ?? []), ...(secondary.children ?? [])] as AnyNodeId[]
for (const childId of wallChildren) {
const child = nodes[childId]
if (!child || !('position' in child) || !Array.isArray(child.position)) {
continue
}
const sourceWall = child.parentId === secondary.id ? secondary : primary
const sourceLength = Math.max(wallLength(sourceWall), 1e-6)
const localX = typeof child.position[0] === 'number' ? child.position[0] : 0
const worldX = sourceWall.start[0] + ((sourceWall.end[0] - sourceWall.start[0]) * localX) / sourceLength
const worldZ = sourceWall.start[1] + ((sourceWall.end[1] - sourceWall.start[1]) * localX) / sourceLength
const nextLocalX = Math.max(
0,
Math.min(mergedLength, (worldX - mergedStart[0]) * tangentX + (worldZ - mergedStart[1]) * tangentZ),
)
updates.push({
id: childId,
data: {
parentId: mergedWallId,
wallId: mergedWallId,
position: [nextLocalX, child.position[1], child.position[2]] as typeof child.position,
...('wallT' in child ? { wallT: nextLocalX / mergedLength } : {}),
} as Partial<AnyNode>,
})
}
return updates
}
function buildWallMergePlans(
nodes: Record<AnyNodeId, AnyNode>,
idsToDelete: AnyNodeId[],
): WallMergePlan[] {
const deletedWalls = idsToDelete
.map((id) => nodes[id])
.filter((node): node is WallNode => node?.type === 'wall')
const skippedWallIds = new Set(idsToDelete)
const usedWallIds = new Set<AnyNodeId>()
const mergePlans: WallMergePlan[] = []
for (const deletedWall of deletedWalls) {
const junctions: Array<[number, number]> = [deletedWall.start, deletedWall.end]
for (const junction of junctions) {
const candidates = Object.values(nodes).filter((node): node is WallNode => {
if (node?.type !== 'wall') return false
if (skippedWallIds.has(node.id) || usedWallIds.has(node.id)) return false
if ((node.parentId ?? null) !== (deletedWall.parentId ?? null)) return false
return pointsEqual(node.start, junction) || pointsEqual(node.end, junction)
})
if (candidates.length !== 2) {
continue
}
const sortedCandidates = [...candidates].sort((a, b) => {
const attachmentDiff = (b.children?.length ?? 0) - (a.children?.length ?? 0)
if (attachmentDiff !== 0) {
return attachmentDiff
}
return a.id.localeCompare(b.id)
})
const [primary, secondary] = sortedCandidates
if (
!primary ||
!secondary ||
!areWallStylesCompatible(primary, secondary) ||
!areWallsCollinearAcrossPoint(primary, secondary, junction)
) {
continue
}
const { start, end } = resolveMergedWallEndpoints(primary, secondary, junction)
const mergedChildren = Array.from(
new Set([...(primary.children ?? []), ...(secondary.children ?? [])]),
) as WallNode['children']
const attachmentUpdates = buildMergedWallAttachmentUpdates(
primary,
secondary,
primary.id,
start,
end,
nodes,
)
mergePlans.push({
primaryWallId: primary.id,
secondaryWallId: secondary.id,
mergedStart: start,
mergedEnd: end,
mergedChildren,
attachmentUpdates,
})
usedWallIds.add(primary.id)
usedWallIds.add(secondary.id)
}
}
return mergePlans
}
export const createNodesAction = ( export const createNodesAction = (
set: (fn: (state: SceneState) => Partial<SceneState>) => void, set: (fn: (state: SceneState) => Partial<SceneState>) => void,
get: () => SceneState, get: () => SceneState,
@@ -132,6 +343,8 @@ export const deleteNodesAction = (
) => { ) => {
if (get().readOnly) return if (get().readOnly) return
const parentsToMarkDirty = new Set<AnyNodeId>() const parentsToMarkDirty = new Set<AnyNodeId>()
const nodesToMarkDirty = new Set<AnyNodeId>()
const mergePlans = buildWallMergePlans(get().nodes, ids)
set((state) => { set((state) => {
const nextNodes = { ...state.nodes } const nextNodes = { ...state.nodes }
@@ -150,6 +363,32 @@ export const deleteNodesAction = (
} }
} }
for (const id of ids) collect(id) for (const id of ids) collect(id)
for (const plan of mergePlans) {
allIds.add(plan.secondaryWallId)
}
for (const plan of mergePlans) {
const primaryWall = nextNodes[plan.primaryWallId]
if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) {
continue
}
nextNodes[plan.primaryWallId] = {
...primaryWall,
start: plan.mergedStart,
end: plan.mergedEnd,
children: plan.mergedChildren,
}
nodesToMarkDirty.add(plan.primaryWallId)
for (const update of plan.attachmentUpdates) {
if (allIds.has(update.id)) continue
const child = nextNodes[update.id]
if (!child) continue
nextNodes[update.id] = { ...child, ...update.data } as AnyNode
nodesToMarkDirty.add(update.id)
}
}
for (const id of allIds) { for (const id of allIds) {
const node = nextNodes[id] const node = nextNodes[id]
@@ -199,4 +438,7 @@ export const deleteNodesAction = (
} }
} }
}) })
nodesToMarkDirty.forEach((id) => {
get().markDirty(id)
})
} }
+103
View File
@@ -8,9 +8,98 @@ import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections'
import { LevelNode } from '../schema/nodes/level' import { LevelNode } from '../schema/nodes/level'
import { SiteNode } from '../schema/nodes/site' import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import type { AnyNode, AnyNodeId } from '../schema/types' import type { AnyNode, AnyNodeId } from '../schema/types'
import * as nodeActions from './actions/node-actions' import * as nodeActions from './actions/node-actions'
function getFiniteNumber(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function getBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback
}
function getEnumValue<T extends readonly string[]>(
value: unknown,
allowed: T,
fallback: T[number],
): T[number] {
return typeof value === 'string' && allowed.includes(value) ? value : fallback
}
function getNullableString(value: unknown) {
return typeof value === 'string' ? value : null
}
function getStringArray(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string')
: []
}
function getVector3(value: unknown, fallback: [number, number, number]): [number, number, number] {
if (!Array.isArray(value) || value.length < 3) {
return fallback
}
return [
getFiniteNumber(value[0], fallback[0]),
getFiniteNumber(value[1], fallback[1]),
getFiniteNumber(value[2], fallback[2]),
]
}
function normalizeStairNode(node: Record<string, unknown>) {
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
rotation: getFiniteNumber(node.rotation, 0),
stairType: getEnumValue(node.stairType, ['straight', 'curved', 'spiral'] as const, 'straight'),
fromLevelId: getNullableString(node.fromLevelId),
toLevelId: getNullableString(node.toLevelId),
slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'),
openingOffset: getFiniteNumber(node.openingOffset, 0),
width: getFiniteNumber(node.width, 1),
totalRise: getFiniteNumber(node.totalRise, 2.5),
stepCount: getFiniteNumber(node.stepCount, 10),
thickness: getFiniteNumber(node.thickness, 0.25),
fillToFloor: getBoolean(node.fillToFloor, true),
innerRadius: getFiniteNumber(node.innerRadius, 0.9),
sweepAngle: getFiniteNumber(node.sweepAngle, Math.PI / 2),
topLandingMode: getEnumValue(node.topLandingMode, ['none', 'integrated'] as const, 'none'),
topLandingDepth: getFiniteNumber(node.topLandingDepth, 0.9),
showCenterColumn: getBoolean(node.showCenterColumn, true),
showStepSupports: getBoolean(node.showStepSupports, true),
railingMode: getEnumValue(node.railingMode, ['none', 'left', 'right', 'both'] as const, 'none'),
railingHeight: getFiniteNumber(node.railingHeight, 0.92),
children: getStringArray(node.children),
}
const parsed = StairNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
}
function normalizeStairSegmentNode(node: Record<string, unknown>) {
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
rotation: getFiniteNumber(node.rotation, 0),
segmentType: getEnumValue(node.segmentType, ['stair', 'landing'] as const, 'stair'),
width: getFiniteNumber(node.width, 1),
length: getFiniteNumber(node.length, 3),
height: getFiniteNumber(node.height, 2.5),
stepCount: getFiniteNumber(node.stepCount, 10),
attachmentSide: getEnumValue(node.attachmentSide, ['front', 'left', 'right'] as const, 'front'),
fillToFloor: getBoolean(node.fillToFloor, true),
thickness: getFiniteNumber(node.thickness, 0.25),
}
const parsed = StairSegmentNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
}
function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> { function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
const patchedNodes = { ...nodes } const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) { for (const [id, node] of Object.entries(patchedNodes)) {
@@ -50,6 +139,20 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
children: [segmentId], children: [segmentId],
} }
} }
if (node.type === 'stair') {
const normalized = normalizeStairNode(node)
if (normalized) {
patchedNodes[id] = normalized
}
}
if (node.type === 'stair-segment') {
const normalized = normalizeStairSegmentNode(node)
if (normalized) {
patchedNodes[id] = normalized
}
}
} }
return patchedNodes as Record<string, AnyNode> return patchedNodes as Record<string, AnyNode>
} }
+14 -2
View File
@@ -1,6 +1,7 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import * as THREE from 'three' import * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { insetPolygonFromCentroid, simplifyClosedPolygon } from '../../lib/polygon-geometry'
import type { AnyNodeId, SlabNode } from '../../schema' import type { AnyNodeId, SlabNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
@@ -59,6 +60,17 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
/** Half of default wall thickness — used to extend slab geometry under walls */ /** Half of default wall thickness — used to extend slab geometry under walls */
const SLAB_OUTSET = 0.05 const SLAB_OUTSET = 0.05
const AUTO_SLAB_INSET = 0.02
const AUTO_SLAB_SIMPLIFY_TOLERANCE = 0.08
function getRenderableSlabPolygon(slabNode: SlabNode): Array<[number, number]> {
return slabNode.autoFromWalls
? simplifyClosedPolygon(
insetPolygonFromCentroid(slabNode.polygon, AUTO_SLAB_INSET),
AUTO_SLAB_SIMPLIFY_TOLERANCE,
)
: outsetPolygon(slabNode.polygon, SLAB_OUTSET)
}
/** /**
* Expand a polygon outward by a uniform distance. * Expand a polygon outward by a uniform distance.
@@ -123,7 +135,7 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
* Standard slab: flat extrusion upward from Y=0 by elevation thickness. * Standard slab: flat extrusion upward from Y=0 by elevation thickness.
*/ */
function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET) const polygon = getRenderableSlabPolygon(slabNode)
const elevation = slabNode.elevation ?? 0.05 const elevation = slabNode.elevation ?? 0.05
if (polygon.length < 3) return new THREE.BufferGeometry() if (polygon.length < 3) return new THREE.BufferGeometry()
@@ -159,7 +171,7 @@ function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry
* - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool) * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool)
*/ */
function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry {
const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET) const polygon = getRenderableSlabPolygon(slabNode)
const depth = Math.abs(slabNode.elevation ?? 0.05) const depth = Math.abs(slabNode.elevation ?? 0.05)
if (polygon.length < 3) return new THREE.BufferGeometry() if (polygon.length < 3) return new THREE.BufferGeometry()
@@ -0,0 +1,697 @@
import type { AnyNode, AnyNodeId, CeilingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
type Point2D = [number, number]
type SurfaceHoleMetadata = {
source: 'manual' | 'stair'
stairId?: string
}
type SegmentTransform = {
position: [number, number, number]
rotation: number
}
type StraightStairLayout = {
segment: StairSegmentNode
transform: SegmentTransform
topElevation: number
}
type AxisAlignedRect = {
minX: number
maxX: number
minZ: number
maxZ: number
}
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
const STAIR_SLAB_OPENING_TIGHTENING = 0
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz <= tolerance * tolerance
}
function polygonsEqual(left: Point2D[][], right: Point2D[][]) {
if (left.length !== right.length) return false
return left.every((polygon, polygonIndex) => {
const other = right[polygonIndex]
if (!(other && polygon.length === other.length)) return false
return polygon.every((point, pointIndex) => {
const otherPoint = other[pointIndex]
if (!otherPoint) return false
return pointsEqual(point, otherPoint)
})
})
}
function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]) {
if (left.length !== right.length) return false
return left.every(
(entry, index) =>
entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null),
)
}
function normalizeExistingMetadata(
holes: Point2D[][],
metadata: SurfaceHoleMetadata[] | undefined,
): SurfaceHoleMetadata[] {
return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' })
}
function expandPolygonFromCentroid(polygon: Point2D[], offset: number) {
if (Math.abs(offset) < 1e-6) {
return polygon.map(([x, z]) => [x, z] as Point2D)
}
const centroid = polygon.reduce(
(acc, [x, z]) => {
acc.x += x
acc.z += z
return acc
},
{ x: 0, z: 0 },
)
centroid.x /= Math.max(polygon.length, 1)
centroid.z /= Math.max(polygon.length, 1)
return polygon.map(([x, z]) => {
const dx = x - centroid.x
const dz = z - centroid.z
const length = Math.hypot(dx, dz)
if (length < 1e-6) {
return [x, z] as Point2D
}
const scale = Math.max(0.1, (length + offset) / length)
return [centroid.x + dx * scale, centroid.z + dz * scale] as Point2D
})
}
function rotateXZ(x: number, z: number, angle: number): [number, number] {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return [x * cos + z * sin, -x * sin + z * cos]
}
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
const transforms: SegmentTransform[] = []
let currentX = 0
let currentY = 0
let currentZ = 0
let currentRot = 0
for (let index = 0; index < segments.length; index++) {
const segment = segments[index]
if (!segment) continue
if (index === 0) {
transforms.push({
position: [currentX, currentY, currentZ],
rotation: currentRot,
})
continue
}
const previous = segments[index - 1]
if (!previous) continue
let attachX = 0
let attachZ = 0
let rotationDelta = 0
switch (segment.attachmentSide) {
case 'front':
attachX = 0
attachZ = previous.length
break
case 'left':
attachX = previous.width / 2
attachZ = previous.length / 2
rotationDelta = Math.PI / 2
break
case 'right':
attachX = -previous.width / 2
attachZ = previous.length / 2
rotationDelta = -Math.PI / 2
break
}
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
currentX += deltaX
currentY += previous.height
currentZ += deltaZ
currentRot += rotationDelta
transforms.push({
position: [currentX, currentY, currentZ],
rotation: currentRot,
})
}
return transforms
}
function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>) {
if (!levelId) return undefined
const node = nodes[levelId as AnyNodeId]
return node?.type === 'level' ? node.level : undefined
}
function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNode>) {
const parentLevelId = resolveLevelId(stair, nodes)
const fromLevelId = stair.fromLevelId ?? parentLevelId
const toLevelId = stair.toLevelId ?? fromLevelId
return { fromLevelId, toLevelId }
}
function resolveStraightSegments(stair: StairNode, nodes: Record<string, AnyNode>) {
return (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false)
}
function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D {
const [worldX, worldZ] = rotateXZ(localX, localZ, stair.rotation ?? 0)
return [stair.position[0] + worldX, stair.position[2] + worldZ]
}
function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode>): StraightStairLayout[] {
const segments = resolveStraightSegments(stair, nodes)
const transforms = computeSegmentTransforms(segments)
return segments.map((segment, index) => {
const transform = transforms[index] ?? {
position: [0, 0, 0] as [number, number, number],
rotation: 0,
}
return {
segment,
transform,
topElevation: transform.position[1] + (segment.segmentType === 'stair' ? segment.height : 0),
}
})
}
function getStraightSegmentFootprintPolygon(stair: StairNode, layout: StraightStairLayout): Point2D[] {
return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length)
}
function getStraightSegmentLocalSlicePolygon(
layout: StraightStairLayout,
startAlong: number,
endAlong: number,
): Point2D[] {
const { segment, transform } = layout
const clampedStart = clamp(startAlong, 0, segment.length)
const clampedEnd = clamp(endAlong, clampedStart, segment.length)
const sliceLength = Math.max(clampedEnd - clampedStart, 1e-4)
const sliceCenterAlong = clampedStart + sliceLength / 2
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, sliceCenterAlong, transform.rotation)
const centerX = transform.position[0] + centerOffsetX
const centerZ = transform.position[2] + centerOffsetZ
const halfWidth = segment.width / 2
const halfLength = sliceLength / 2
const corners: Point2D[] = [
[-halfWidth, -halfLength],
[halfWidth, -halfLength],
[halfWidth, halfLength],
[-halfWidth, halfLength],
]
return corners.map(([localWidth, localLength]) => {
const [offsetX, offsetZ] = rotateXZ(localWidth, localLength, transform.rotation)
return [centerX + offsetX, centerZ + offsetZ]
})
}
function getStraightSegmentSlicePolygon(
stair: StairNode,
layout: StraightStairLayout,
startAlong: number,
endAlong: number,
): Point2D[] {
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => toWorldPlanPoint(stair, x, z))
}
function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) {
const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1))
return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8))
}
function polygonArea(points: Point2D[]) {
let area = 0
for (let index = 0; index < points.length; index += 1) {
const current = points[index]
const next = points[(index + 1) % points.length]
if (!current || !next) continue
area += current[0] * next[1] - next[0] * current[1]
}
return area / 2
}
function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null {
if (polygon.length < 4) return null
const xs = polygon.map(([x]) => x)
const zs = polygon.map(([, z]) => z)
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minZ = Math.min(...zs)
const maxZ = Math.max(...zs)
if (!(maxX > minX && maxZ > minZ)) return null
return { minX, maxX, minZ, maxZ }
}
function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect {
if (offset <= 1e-6) {
return rect
}
return {
minX: rect.minX - offset,
maxX: rect.maxX + offset,
minZ: rect.minZ - offset,
maxZ: rect.maxZ + offset,
}
}
function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
if (rects.length === 0) return []
const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort(
(a, b) => a - b,
)
const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort(
(a, b) => a - b,
)
if (xs.length < 2 || zs.length < 2) return []
const occupied = new Set<string>()
for (let xi = 0; xi < xs.length - 1; xi += 1) {
for (let zi = 0; zi < zs.length - 1; zi += 1) {
const cx = (xs[xi]! + xs[xi + 1]!) / 2
const cz = (zs[zi]! + zs[zi + 1]!) / 2
if (
rects.some((rect) => cx > rect.minX && cx < rect.maxX && cz > rect.minZ && cz < rect.maxZ)
) {
occupied.add(`${xi}:${zi}`)
}
}
}
const edgeMap = new Map<string, Point2D>()
const addEdge = (start: Point2D, end: Point2D) => {
edgeMap.set(`${start[0]},${start[1]}`, end)
}
for (let xi = 0; xi < xs.length - 1; xi += 1) {
for (let zi = 0; zi < zs.length - 1; zi += 1) {
if (!occupied.has(`${xi}:${zi}`)) continue
const x0 = xs[xi]!
const x1 = xs[xi + 1]!
const z0 = zs[zi]!
const z1 = zs[zi + 1]!
if (!occupied.has(`${xi}:${zi - 1}`)) addEdge([x0, z0], [x1, z0])
if (!occupied.has(`${xi + 1}:${zi}`)) addEdge([x1, z0], [x1, z1])
if (!occupied.has(`${xi}:${zi + 1}`)) addEdge([x1, z1], [x0, z1])
if (!occupied.has(`${xi - 1}:${zi}`)) addEdge([x0, z1], [x0, z0])
}
}
const polygons: Point2D[][] = []
while (edgeMap.size > 0) {
const firstEntry = edgeMap.entries().next().value as [string, Point2D] | undefined
if (!firstEntry) break
const [startKey] = firstEntry
const startParts = startKey.split(',').map(Number)
const sx = startParts[0]
const sz = startParts[1]
if (sx === undefined || sz === undefined) {
edgeMap.delete(startKey)
continue
}
const start: Point2D = [sx, sz]
const polygon: Point2D[] = [start]
let current = start
while (true) {
const currentKey = `${current[0]},${current[1]}`
const next = edgeMap.get(currentKey)
if (!next) break
edgeMap.delete(currentKey)
if (pointsEqual(next, start)) {
break
}
polygon.push(next)
current = next
}
if (polygon.length >= 3) {
polygons.push(polygonArea(polygon) < 0 ? [...polygon].reverse() : polygon)
}
}
return polygons
}
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
const width = Math.max(stair.width ?? 1, 0.4)
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
const outerRadius = innerRadius + width
const totalSweep = stair.sweepAngle ?? Math.PI / 2
const openingSweep =
Math.sign(totalSweep || 1) *
Math.max(
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
)
const startAngle = totalSweep / 2 - openingSweep
const endAngle = totalSweep / 2
const segmentCount = Math.max(
10,
Math.min(
32,
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
),
)
const outerPoints: Point2D[] = []
const innerPoints: Point2D[] = []
for (let index = 0; index <= segmentCount; index++) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius))
}
for (let index = segmentCount; index >= 0; index--) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius))
}
return [...outerPoints, ...innerPoints]
}
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
const segmentCount = 48
return Array.from({ length: segmentCount }).map((_, index) => {
const angle = (index / segmentCount) * Math.PI * 2
return toWorldPlanPoint(stair, Math.cos(angle) * radius, Math.sin(angle) * radius)
})
}
function getStraightOpeningPolygonsForSurface(
stair: StairNode,
nodes: Record<string, AnyNode>,
targetElevation: number,
) {
const layouts = getStraightStairLayouts(stair, nodes)
if (layouts.length === 0) return []
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const openingOffset = Math.max(stair.openingOffset ?? 0, 0)
const openingRects: AxisAlignedRect[] = []
for (let index = 0; index < layouts.length; index += 1) {
const layout = layouts[index]
if (!layout) continue
const { segment, transform } = layout
const segmentStartElevation = transform.position[1]
const segmentTopElevation = layout.topElevation
if (segment.segmentType === 'stair') {
if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) {
const openingDepth = getStraightFlightOpeningDepth(stair, segment)
const flightRect = getAxisAlignedRectFromPolygon(
getStraightSegmentLocalSlicePolygon(layout, Math.max(0, segment.length - openingDepth), segment.length),
)
if (flightRect) openingRects.push(expandRect(flightRect, openingOffset))
}
continue
}
if (Math.abs(targetElevation - segmentStartElevation) > targetThreshold) {
continue
}
const landingRects: AxisAlignedRect[] = []
const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length))
if (landingRect) landingRects.push(expandRect(landingRect, openingOffset))
const previous = layouts[index - 1]
if (previous?.segment.segmentType === 'stair') {
const previousTopElevation = previous.topElevation
if (Math.abs(targetElevation - previousTopElevation) <= targetThreshold) {
const previousDepth = getStraightFlightOpeningDepth(stair, previous.segment)
const previousRect = getAxisAlignedRectFromPolygon(
getStraightSegmentLocalSlicePolygon(
previous,
Math.max(0, previous.segment.length - previousDepth),
previous.segment.length,
),
)
if (previousRect) landingRects.push(expandRect(previousRect, openingOffset))
}
}
openingRects.push(...landingRects)
}
if (openingRects.length > 0) {
const unionPolygons = buildUnionPolygonsFromRects(openingRects).map((polygon) =>
polygon.map(([x, z]) => toWorldPlanPoint(stair, x, z)),
)
if (unionPolygons.length > 0) {
return unionPolygons
}
}
let fallbackLayout = layouts[layouts.length - 1]
for (let index = layouts.length - 1; index >= 0; index -= 1) {
const layout = layouts[index]
if (layout?.segment.segmentType === 'stair') {
fallbackLayout = layout
break
}
}
return fallbackLayout ? [getStraightSegmentFootprintPolygon(stair, fallbackLayout)] : []
}
function getStairOpeningPolygons(
stair: StairNode,
nodes: Record<string, AnyNode>,
targetElevation?: number,
) {
if ((stair.slabOpeningMode ?? 'none') !== 'destination') {
return []
}
if (stair.stairType === 'curved') {
return [getCurvedOpeningPolygon(stair)]
}
if (stair.stairType === 'spiral') {
return [getSpiralOpeningPolygon(stair)]
}
if (typeof targetElevation === 'number') {
return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation)
}
return getStraightOpeningPolygonsForSurface(
stair,
nodes,
Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0),
)
}
function getTargetSlabElevationForStair(
stair: StairNode,
slab: SlabNode,
slabLevelId: string,
nodes: Record<string, AnyNode>,
) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const slabLevel = getLevelNumber(slabLevelId, nodes)
if (fromLevel === undefined || slabLevel === undefined) {
return slab.elevation ?? 0.05
}
return (
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(slab.elevation ?? 0.05) -
(stair.position[1] ?? 0)
)
}
function getTargetCeilingElevationForStair(
stair: StairNode,
ceiling: CeilingNode,
ceilingLevelId: string,
nodes: Record<string, AnyNode>,
) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
if (fromLevel === undefined || ceilingLevel === undefined) {
return ceiling.height ?? DEFAULT_WALL_HEIGHT
}
return (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0)
}
function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record<string, AnyNode>) {
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const toLevel = getLevelNumber(toLevelId, nodes)
const slabLevel = getLevelNumber(slabLevelId, nodes)
if (slabLevel === undefined) {
return toLevelId === slabLevelId
}
if (fromLevel === undefined || toLevel === undefined) {
return toLevelId === slabLevelId
}
const minLevel = Math.min(fromLevel, toLevel)
const maxLevel = Math.max(fromLevel, toLevel)
return slabLevel > minLevel && slabLevel <= maxLevel
}
function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nodes: Record<string, AnyNode>) {
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const toLevel = getLevelNumber(toLevelId, nodes)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
if (ceilingLevel === undefined) {
return fromLevelId === ceilingLevelId
}
if (fromLevel === undefined || toLevel === undefined) {
return fromLevelId === ceilingLevelId
}
const minLevel = Math.min(fromLevel, toLevel)
const maxLevel = Math.max(fromLevel, toLevel)
return ceilingLevel >= minLevel && ceilingLevel < maxLevel
}
export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false)
const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab')
const ceilings = Object.values(nodes).filter((node): node is CeilingNode => node.type === 'ceiling')
const updates: Array<{ id: AnyNodeId; data: Partial<SlabNode | CeilingNode> }> = []
for (const slab of slabs) {
const slabLevelId = resolveLevelId(slab, nodes)
const existingHoles = slab.holes ?? []
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
const manualMetadata = existingMetadata
.filter((entry) => entry.source !== 'stair')
.map((entry) => ({ ...entry }))
const stairHoles = stairs
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
.flatMap((stair) =>
getStairOpeningPolygons(
stair,
nodes,
getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes),
).map((polygon) => ({
polygon:
stair.stairType === 'straight'
? polygon
: expandPolygonFromCentroid(
polygon,
Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0),
),
metadata: {
source: 'stair' as const,
stairId: stair.id,
},
})),
)
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
updates.push({
id: slab.id,
data: {
holes: nextHoles,
holeMetadata: nextMetadata,
},
})
}
}
for (const ceiling of ceilings) {
const ceilingLevelId = resolveLevelId(ceiling, nodes)
const existingHoles = ceiling.holes ?? []
const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata)
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
const manualMetadata = existingMetadata
.filter((entry) => entry.source !== 'stair')
.map((entry) => ({ ...entry }))
const stairHoles = stairs
.filter((stair) => shouldApplyStairToCeiling(stair, ceilingLevelId, nodes))
.flatMap((stair) =>
getStairOpeningPolygons(
stair,
nodes,
getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes),
).map((polygon) => ({
polygon:
stair.stairType === 'straight'
? polygon
: expandPolygonFromCentroid(
polygon,
Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0),
),
metadata: {
source: 'stair' as const,
stairId: stair.id,
},
})),
)
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
updates.push({
id: ceiling.id,
data: {
holes: nextHoles,
holeMetadata: nextMetadata,
},
})
}
}
return updates
}
@@ -1,4 +1,5 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
@@ -6,6 +7,7 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>() const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2 const MAX_STAIRS_PER_FRAME = 2
@@ -19,6 +21,26 @@ export const StairSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
const rootNodeIds = useScene((state) => state.rootNodeIds) const rootNodeIds = useScene((state) => state.rootNodeIds)
const syncingAutoOpeningsRef = useRef(false)
useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
if (updates.length === 0) return
syncingAutoOpeningsRef.current = true
useScene.getState().updateNodes(updates)
queueMicrotask(() => {
syncingAutoOpeningsRef.current = false
})
}
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
return useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return
if (state.nodes === prevState.nodes) return
applyUpdates(syncAutoStairOpenings(state.nodes))
})
}, [])
useFrame(() => { useFrame(() => {
if (rootNodeIds.length === 0) { if (rootNodeIds.length === 0) {
@@ -14,12 +14,14 @@ import {
StairSegmentNode, StairSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
WallNode,
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
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 { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useCallback, useRef } from 'react' import { Move } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
@@ -47,12 +49,17 @@ export function FloatingActionMenu() {
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
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 movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
const setCurvingWall = useEditor((s) => s.setCurvingWall) 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)
const groupRef = useRef<THREE.Group>(null) const groupRef = useRef<THREE.Group>(null)
const startEndpointGroupRef = useRef<THREE.Group>(null)
const endEndpointGroupRef = useRef<THREE.Group>(null)
const [altPressed, setAltPressed] = useState(false)
// Only show for single selection of specific types // Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
@@ -71,6 +78,34 @@ export function FloatingActionMenu() {
return false return false
}) })
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Alt') {
setAltPressed(true)
}
}
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Alt') {
setAltPressed(false)
}
}
const handleBlur = () => {
setAltPressed(false)
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleBlur)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleBlur)
}
}, [])
useFrame(() => { useFrame(() => {
if (!(selectedId && isValidType && groupRef.current)) return if (!(selectedId && isValidType && groupRef.current)) return
@@ -85,6 +120,29 @@ export function FloatingActionMenu() {
const yOffset = isStructural ? 0.8 : 0.3 const yOffset = isStructural ? 0.8 : 0.3
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z) groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
} }
if (node?.type === 'wall') {
const wall = node as WallNode
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
const endpointYOffset = 0.35
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0))
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0))
if (startEndpointGroupRef.current) {
startEndpointGroupRef.current.position.set(
startWorld.x,
startWorld.y + endpointYOffset,
startWorld.z,
)
}
if (endEndpointGroupRef.current) {
endEndpointGroupRef.current.position.set(
endWorld.x,
endWorld.y + endpointYOffset,
endWorld.z,
)
}
}
} }
}) })
@@ -122,6 +180,16 @@ export function FloatingActionMenu() {
}, },
[canCurveSelectedWall, node, setCurvingWall, setSelection], [canCurveSelectedWall, node, setCurvingWall, setSelection],
) )
const handleEndpointMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
e.stopPropagation()
if (!(node && node.type === 'wall')) return
sfxEmitter.emit('sfx:item-pick')
setMovingWallEndpoint({ wall: node, endpoint })
setSelection({ selectedIds: [] })
},
[node, setMovingWallEndpoint, setSelection],
)
const handleDuplicate = useCallback( const handleDuplicate = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -142,6 +210,8 @@ export function FloatingActionMenu() {
duplicate = WindowNode.parse(duplicateInfo) duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') { } else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo) duplicate = ItemNode.parse(duplicateInfo)
} else if (node.type === 'wall') {
duplicate = WallNode.parse(duplicateInfo)
} else if (node.type === 'fence') { } else if (node.type === 'fence') {
duplicate = FenceNode.parse(duplicateInfo) duplicate = FenceNode.parse(duplicateInfo)
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
@@ -173,6 +243,8 @@ export function FloatingActionMenu() {
if (duplicate) { if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') { if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'wall') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'fence') { } else if (duplicate.type === 'fence') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if ( } else if (
@@ -242,6 +314,7 @@ export function FloatingActionMenu() {
} }
if ( if (
duplicate.type === 'item' || duplicate.type === 'item' ||
duplicate.type === 'wall' ||
duplicate.type === 'fence' || duplicate.type === 'fence' ||
duplicate.type === 'window' || duplicate.type === 'window' ||
duplicate.type === 'door' || duplicate.type === 'door' ||
@@ -283,8 +356,15 @@ export function FloatingActionMenu() {
[cx + holeSize, cz + holeSize], [cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize], [cx - holeSize, cz + holeSize],
] ]
const currentHoles = (node as SlabNode | CeilingNode).holes || [] const surfaceNode = node as SlabNode | CeilingNode
updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] }) const currentHoles = surfaceNode.holes || []
const currentMetadata = currentHoles.map(
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
)
updateNode(selectedId as AnyNodeId, {
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
// Re-assert selection so the node stays selected // Re-assert selection so the node stays selected
setSelection({ selectedIds: [selectedId] }) setSelection({ selectedIds: [selectedId] })
@@ -307,9 +387,14 @@ export function FloatingActionMenu() {
[node?.type, selectedId, setSelection], [node?.type, selectedId, setSelection],
) )
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint
)
return null
return ( return (
<group>
<group ref={groupRef}> <group ref={groupRef}>
<Html <Html
center center
@@ -334,5 +419,46 @@ export function FloatingActionMenu() {
/> />
</Html> </Html>
</group> </group>
{node?.type === 'wall' && (
<>
<group ref={startEndpointGroupRef}>
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
<button
aria-label="Move wall start"
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
onClick={(e) => handleEndpointMove('start', e)}
onPointerDown={(e) => e.stopPropagation()}
title="Move wall start (Alt to detach)"
type="button"
>
<Move className="h-4 w-4" />
</button>
</Html>
</group>
<group ref={endEndpointGroupRef}>
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
<button
aria-label="Move wall end"
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
onClick={(e) => handleEndpointMove('end', e)}
onPointerDown={(e) => e.stopPropagation()}
title="Move wall end (Alt to detach)"
type="button"
>
<Move className="h-4 w-4" />
</button>
</Html>
</group>
</>
)}
</group>
) )
} }
@@ -37,7 +37,7 @@ import {
type ZoneNode as ZoneNodeType, type ZoneNode as ZoneNodeType,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Command } from 'lucide-react' import { Command, Move } from 'lucide-react'
import { import {
memo, memo,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
@@ -239,6 +239,9 @@ type WallEndpointDragState = {
endpoint: WallEndpoint endpoint: WallEndpoint
fixedPoint: WallPlanPoint fixedPoint: WallPlanPoint
currentPoint: WallPlanPoint currentPoint: WallPlanPoint
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
linkedWalls: LinkedWallSnapshot[]
} }
type WallCurveDragState = { type WallCurveDragState = {
@@ -286,6 +289,11 @@ type WallEndpointDraft = {
endpoint: WallEndpoint endpoint: WallEndpoint
start: WallPlanPoint start: WallPlanPoint
end: WallPlanPoint end: WallPlanPoint
linkedUpdates: Array<{
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
}>
} }
type WallCurveDraft = { type WallCurveDraft = {
@@ -2025,6 +2033,94 @@ function buildWallEndpointDraft(
endpoint, endpoint,
start: endpoint === 'start' ? movingPoint : fixedPoint, start: endpoint === 'start' ? movingPoint : fixedPoint,
end: endpoint === 'end' ? movingPoint : fixedPoint, end: endpoint === 'end' ? movingPoint : fixedPoint,
linkedUpdates: [],
}
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
}
function getLinkedWallSnapshots(
walls: WallNode[],
wallId: WallNode['id'],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
): LinkedWallSnapshot[] {
return walls.flatMap((wall) => {
if (wall.id === wallId) {
return []
}
if (
!pointsEqual(wall.start, originalStart) &&
!pointsEqual(wall.start, originalEnd) &&
!pointsEqual(wall.end, originalStart) &&
!pointsEqual(wall.end, originalEnd)
) {
return []
}
return [
{
id: wall.id,
start: [...wall.start] as WallPlanPoint,
end: [...wall.end] as WallPlanPoint,
},
]
})
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
return linkedWalls.map((wall) => ({
id: wall.id,
start: pointsEqual(wall.start, originalStart)
? nextStart
: pointsEqual(wall.start, originalEnd)
? nextEnd
: wall.start,
end: pointsEqual(wall.end, originalStart)
? nextStart
: pointsEqual(wall.end, originalEnd)
? nextEnd
: wall.end,
}))
}
function buildWallEndpointDragDraft(
dragState: Pick<
WallEndpointDragState,
'wallId' | 'endpoint' | 'fixedPoint' | 'originalStart' | 'originalEnd' | 'linkedWalls'
>,
movingPoint: WallPlanPoint,
detachLinkedWalls = false,
): WallEndpointDraft {
const nextDraft = buildWallEndpointDraft(
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
movingPoint,
)
return {
...nextDraft,
linkedUpdates: detachLinkedWalls
? []
: getLinkedWallUpdates(
dragState.linkedWalls,
dragState.originalStart,
dragState.originalEnd,
nextDraft.start,
nextDraft.end,
),
} }
} }
@@ -4878,6 +4974,7 @@ export function FloorplanPanel() {
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 [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
const [altPressed, setAltPressed] = useState(false)
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)
@@ -5074,6 +5171,18 @@ export function FloorplanPanel() {
buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end), buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
) )
} }
for (const linkedUpdate of wallEndpointDraft.linkedUpdates) {
const linkedWall = nextWallById.get(linkedUpdate.id)
if (!linkedWall) {
continue
}
nextWallById.set(
linkedWall.id,
buildWallWithUpdatedEndpoints(linkedWall, linkedUpdate.start, linkedUpdate.end),
)
}
} }
if (wallCurveDraft) { if (wallCurveDraft) {
@@ -5090,19 +5199,9 @@ export function FloorplanPanel() {
return floorplanWallById return floorplanWallById
} }
const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId return new Map(
if (!previewWallId) { Array.from(displayWallById.values()).map((wall) => [wall.id, getFloorplanWall(wall)] as const),
return floorplanWallById )
}
const previewWall = displayWallById.get(previewWallId)
if (!previewWall) {
return floorplanWallById
}
const nextFloorplanWallById = new Map(floorplanWallById)
nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
return nextFloorplanWallById
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft]) }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
const wallPolygons = useMemo( const wallPolygons = useMemo(
() => () =>
@@ -5122,31 +5221,18 @@ export function FloorplanPanel() {
return wallPolygons return wallPolygons
} }
const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId const previewWalls = Array.from(displayFloorplanWallById.values())
if (!previewWallId) { const previewMiterData = calculateLevelMiters(previewWalls)
return wallPolygons
}
const previewWall = displayWallById.get(previewWallId) return previewWalls.map((wall) => {
if (!previewWall) { const polygon = getWallPlanFootprint(wall, previewMiterData)
return wallPolygons return {
wall,
polygon,
points: formatPolygonPoints(polygon),
} }
})
const previewPolygon = getWallPlanFootprint( }, [displayFloorplanWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
getFloorplanWall(previewWall),
EMPTY_WALL_MITER_DATA,
)
return wallPolygons.map((entry) =>
entry.wall.id === previewWall.id
? {
wall: previewWall,
polygon: previewPolygon,
points: formatPolygonPoints(previewPolygon),
}
: entry,
)
}, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
const openingsPolygons = useMemo( const openingsPolygons = useMemo(
() => () =>
@@ -5992,6 +6078,21 @@ export function FloorplanPanel() {
: null, : null,
[selectedWallEntry, surfaceSize, viewBox], [selectedWallEntry, surfaceSize, viewBox],
) )
const selectedWallCornerMoveActions = useMemo(() => {
if (!selectedWallEntry) {
return []
}
return (['start', 'end'] as const).map((endpoint) => {
const point = endpoint === 'start' ? selectedWallEntry.wall.start : selectedWallEntry.wall.end
const svgPoint = toSvgPlanPoint(point)
return {
endpoint,
x: svgPoint.x,
y: svgPoint.y,
}
})
}, [selectedWallEntry])
const selectedStairActionMenuPosition = useMemo( const selectedStairActionMenuPosition = useMemo(
() => () =>
selectedStairEntry selectedStairEntry
@@ -6643,6 +6744,9 @@ export function FloorplanPanel() {
if (event.key === 'Shift') { if (event.key === 'Shift') {
setShiftPressed(true) setShiftPressed(true)
} }
if (event.key === 'Alt') {
setAltPressed(true)
}
if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) { if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) {
setStairBuildPreviewRotation((current) => current + Math.PI / 4) setStairBuildPreviewRotation((current) => current + Math.PI / 4)
@@ -6665,11 +6769,15 @@ export function FloorplanPanel() {
if (event.key === 'Shift') { if (event.key === 'Shift') {
setShiftPressed(false) setShiftPressed(false)
} }
if (event.key === 'Alt') {
setAltPressed(false)
}
setRotationModifierPressed(event.metaKey || event.ctrlKey) setRotationModifierPressed(event.metaKey || event.ctrlKey)
} }
const handleBlur = () => { const handleBlur = () => {
setShiftPressed(false) setShiftPressed(false)
setAltPressed(false)
setRotationModifierPressed(false) setRotationModifierPressed(false)
} }
@@ -6735,18 +6843,23 @@ export function FloorplanPanel() {
dragState.currentPoint = snappedPoint dragState.currentPoint = snappedPoint
setCursorPoint(snappedPoint) setCursorPoint(snappedPoint)
setWallEndpointDraft((previousDraft) => { setWallEndpointDraft((previousDraft) => {
const nextDraft = buildWallEndpointDraft( const nextDraft = buildWallEndpointDragDraft(dragState, snappedPoint, event.altKey)
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
snappedPoint,
)
if ( if (
!( !(
previousDraft && previousDraft &&
pointsEqual(previousDraft.start, nextDraft.start) && pointsEqual(previousDraft.start, nextDraft.start) &&
pointsEqual(previousDraft.end, nextDraft.end) pointsEqual(previousDraft.end, nextDraft.end) &&
previousDraft.linkedUpdates.length === nextDraft.linkedUpdates.length &&
previousDraft.linkedUpdates.every((update, index) => {
const nextUpdate = nextDraft.linkedUpdates[index]
return (
nextUpdate &&
update.id === nextUpdate.id &&
pointsEqual(update.start, nextUpdate.start) &&
pointsEqual(update.end, nextUpdate.end)
)
})
) )
) { ) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
@@ -6853,12 +6966,7 @@ export function FloorplanPanel() {
const wall = wallById.get(dragState.wallId) const wall = wallById.get(dragState.wallId)
if (wall) { if (wall) {
const nextDraft = buildWallEndpointDraft( const nextDraft = buildWallEndpointDragDraft(dragState, dragState.currentPoint, altPressed)
dragState.wallId,
dragState.endpoint,
dragState.fixedPoint,
dragState.currentPoint,
)
const hasChanged = !( const hasChanged = !(
pointsEqual(nextDraft.start, wall.start) && pointsEqual(nextDraft.end, wall.end) pointsEqual(nextDraft.start, wall.start) && pointsEqual(nextDraft.end, wall.end)
) )
@@ -6868,6 +6976,12 @@ export function FloorplanPanel() {
start: nextDraft.start, start: nextDraft.start,
end: nextDraft.end, end: nextDraft.end,
}) })
for (const linkedUpdate of nextDraft.linkedUpdates) {
updateNode(linkedUpdate.id, {
start: linkedUpdate.start,
end: linkedUpdate.end,
})
}
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
} }
} }
@@ -6940,6 +7054,7 @@ export function FloorplanPanel() {
getSvgPointFromClientPoint, getSvgPointFromClientPoint,
guideById, guideById,
getPlanPointFromClientPoint, getPlanPointFromClientPoint,
altPressed,
shiftPressed, shiftPressed,
updateNode, updateNode,
wallById, wallById,
@@ -8469,6 +8584,79 @@ export function FloorplanPanel() {
}, },
[selectedWallEntry, setMovingNode, setSelection], [selectedWallEntry, setMovingNode, setSelection],
) )
const beginWallEndpointDrag = useCallback(
(
wall: WallNode,
endpoint: WallEndpoint,
pointerId: number,
movingPoint: WallPlanPoint,
) => {
if (isWallBuildActive) {
handleWallPlacementPoint(movingPoint)
return
}
if (mode !== 'select') {
return
}
clearWallPlacementDraft()
handleWallSelect(wall)
const fixedPoint = endpoint === 'start' ? wall.end : wall.start
const linkedWalls = getLinkedWallSnapshots(walls, wall.id, wall.start, wall.end)
const originalStart = [...wall.start] as WallPlanPoint
const originalEnd = [...wall.end] as WallPlanPoint
wallEndpointDragRef.current = {
pointerId,
wallId: wall.id,
endpoint,
fixedPoint,
currentPoint: movingPoint,
originalStart,
originalEnd,
linkedWalls,
}
setWallEndpointDraft(
buildWallEndpointDragDraft(
{
wallId: wall.id,
endpoint,
fixedPoint,
originalStart,
originalEnd,
linkedWalls,
},
movingPoint,
),
)
setCursorPoint(movingPoint)
},
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode, walls],
)
const handleSelectedWallCornerMovePointerDown = useCallback(
(endpoint: WallEndpoint, event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) {
return
}
const wall = selectedWallEntry?.wall
if (!wall) {
return
}
event.preventDefault()
event.stopPropagation()
setHoveredEndpointId(null)
sfxEmitter.emit('sfx:item-pick')
const movingPoint = endpoint === 'start' ? wall.start : wall.end
beginWallEndpointDrag(wall, endpoint, event.pointerId, movingPoint)
},
[beginWallEndpointDrag, selectedWallEntry],
)
const handleSelectedWallDelete = useCallback( const handleSelectedWallDelete = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
@@ -8725,33 +8913,9 @@ export function FloorplanPanel() {
setHoveredEndpointId(null) setHoveredEndpointId(null)
const movingPoint = endpoint === 'start' ? wall.start : wall.end const movingPoint = endpoint === 'start' ? wall.start : wall.end
beginWallEndpointDrag(wall, endpoint, event.pointerId, movingPoint)
if (isWallBuildActive) {
handleWallPlacementPoint(movingPoint)
return
}
if (mode !== 'select') {
return
}
clearWallPlacementDraft()
handleWallSelect(wall)
const fixedPoint = endpoint === 'start' ? wall.end : wall.start
wallEndpointDragRef.current = {
pointerId: event.pointerId,
wallId: wall.id,
endpoint,
fixedPoint,
currentPoint: movingPoint,
}
setWallEndpointDraft(buildWallEndpointDraft(wall.id, endpoint, fixedPoint, movingPoint))
setCursorPoint(movingPoint)
}, },
[clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode], [beginWallEndpointDrag],
) )
const handleWallCurvePointerDown = useCallback( const handleWallCurvePointerDown = useCallback(
(wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => { (wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => {
@@ -9762,6 +9926,53 @@ export function FloorplanPanel() {
/> />
</div> </div>
)} )}
{selectedWallCornerMoveActions.length > 0 &&
isFloorplanHovered &&
!movingNode &&
!curvingWall &&
selectedWallCornerMoveActions.map(({ endpoint, x, y }) => (
<div
className="absolute z-30"
key={`selected-wall-corner-move-${endpoint}`}
style={{
left: x,
top: y,
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y - 4}px))`,
}}
>
<button
aria-label={endpoint === 'start' ? 'Move wall start' : 'Move wall end'}
className={cn(
'pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors',
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground',
)}
onPointerDown={(event) => handleSelectedWallCornerMovePointerDown(endpoint, event)}
title={
endpoint === 'start'
? 'Move wall start (Alt to detach)'
: 'Move wall end (Alt to detach)'
}
type="button"
>
<Move className="h-4 w-4" />
</button>
{wallEndpointDraft?.wallId === selectedWallEntry?.wall.id &&
wallEndpointDraft.endpoint === endpoint && (
<div
className={cn(
'pointer-events-none mt-2 whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors',
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground',
)}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
</div>
)}
</div>
))}
{selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && ( {selectedSlabActionMenuPosition && isFloorplanHovered && !movingNode && !curvingWall && (
<div <div
className="absolute z-30" className="absolute z-30"
@@ -23,6 +23,7 @@ import {
import { initSFXBus } from '../../lib/sfx-bus' import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system'
import { StairEditSystem } from '../systems/stair/stair-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
@@ -523,6 +524,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<ExportManager /> <ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />} {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem /> <CeilingSystem />
<CeilingSelectionAffordanceSystem />
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem /> <StairEditSystem />
{!isLoading && !isFirstPersonMode && ( {!isLoading && !isFirstPersonMode && (
@@ -0,0 +1,272 @@
'use client'
import {
type CeilingNode,
emitter,
resolveLevelId,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import type { Object3D } from 'three'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../store/use-editor'
const BRACKET_THICKNESS = 0.04
const BRACKET_HEIGHT = 0.04
const BRACKET_Y_OFFSET = 0.035
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
type CornerBracketData = {
corner: [number, number]
incomingDirection: [number, number]
outgoingDirection: [number, number]
incomingLength: number
outgoingLength: number
cornerStrength: number
}
export const CeilingSelectionAffordanceSystem = () => {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const structureLayer = useEditor((state) => state.structureLayer)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const currentLevelId = useViewer((state) => state.selection.levelId)
const ceilings = useScene(
useShallow((state) =>
Object.values(state.nodes).filter((node): node is CeilingNode => {
return (
node.type === 'ceiling' &&
node.visible !== false &&
currentLevelId !== null &&
resolveLevelId(node, state.nodes) === currentLevelId
)
}),
),
)
const shouldRender =
phase === 'structure' &&
mode === 'select' &&
structureLayer === 'elements' &&
!movingNode &&
!curvingWall &&
currentLevelId !== null
if (!shouldRender) return null
return (
<>
{ceilings.map((ceiling) => (
<CeilingSelectionAffordance ceiling={ceiling} key={ceiling.id} levelId={currentLevelId} />
))}
</>
)
}
const CeilingSelectionAffordance = ({
ceiling,
levelId,
}: {
ceiling: CeilingNode
levelId: string
}) => {
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
useEffect(() => {
let frameId = 0
const resolveLevelObject = () => {
const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
setLevelObject((currentLevelObject) => {
if (currentLevelObject === nextLevelObject) {
return currentLevelObject
}
return nextLevelObject
})
if (!nextLevelObject) {
frameId = window.requestAnimationFrame(resolveLevelObject)
}
}
resolveLevelObject()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [levelId])
if (!levelObject || corners.length === 0) return null
return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => (
<CornerBracket
ceiling={ceiling}
corner={corner}
key={`${ceiling.id}-corner-${index}`}
/>
))}
</group>,
levelObject,
)
}
const CornerBracket = ({
ceiling,
corner,
}: {
ceiling: CeilingNode
corner: CornerBracketData
}) => {
const [isHovered, setIsHovered] = useState(false)
const color = '#d4d4d4'
const opacity = 0.72
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
const cubeOpacity = isHovered ? 0.92 : 0.72
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation()
const nodes = useScene.getState().nodes
useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
emitter.emit('ceiling:click' as any, {
node: ceiling,
nativeEvent: e.nativeEvent,
localPosition: [0, 0, 0],
position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
stopPropagation: () => e.stopPropagation(),
})
}
return (
<group position={[corner.corner[0], 0, corner.corner[1]]}>
<BracketLeg
color={color}
direction={corner.incomingDirection}
length={corner.incomingLength}
onClick={handleClick}
opacity={opacity}
/>
<BracketLeg
color={color}
direction={corner.outgoingDirection}
length={corner.outgoingLength}
onClick={handleClick}
opacity={opacity}
/>
<mesh
onClick={handleClick}
onPointerEnter={(e) => {
e.stopPropagation()
setIsHovered(true)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setIsHovered(false)
}}
>
<boxGeometry args={HIT_BOX_SIZE} />
<meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
</mesh>
</group>
)
}
const BracketLeg = ({
direction,
length,
color,
onClick,
opacity,
}: {
direction: [number, number]
length: number
color: string
onClick: (e: ThreeEvent<MouseEvent>) => void
opacity: number
}) => {
const angle = Math.atan2(direction[1], direction[0])
const position: [number, number, number] = [
direction[0] * (length / 2),
0,
direction[1] * (length / 2),
]
return (
<mesh
onClick={onClick}
position={position}
rotation={[0, angle, 0]}
>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh>
)
}
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
if (polygon.length < 3) return []
const allCorners = polygon.map((corner, index) => {
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
const next = polygon[(index + 1) % polygon.length]!
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
const incomingDirection = normalize2D(incomingVector)
const outgoingDirection = normalize2D(outgoingVector)
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
return {
corner,
incomingDirection,
outgoingDirection,
incomingLength: getBracketLength(incomingLength),
outgoingLength: getBracketLength(outgoingLength),
cornerStrength,
}
})
if (allCorners.length <= 4) {
return allCorners
}
const selectedIndices = new Set(
allCorners
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
.sort((a, b) => b.strength - a.strength)
.slice(0, 4)
.map(({ index }) => index),
)
return allCorners.filter((_, index) => selectedIndices.has(index))
}
function normalize2D(vector: [number, number]): [number, number] {
const length = Math.hypot(vector[0], vector[1])
if (length < 1e-6) return [1, 0]
return [vector[0] / length, vector[1] / length]
}
function getBracketLength(edgeLength: number): number {
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
}
@@ -2,11 +2,12 @@
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core' import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
function snap(value: number) { function snap(value: number) {
return Math.round(value * 2) / 2 return Math.round(value * 2) / 2
@@ -39,6 +40,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
) )
const dragAnchorRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null)
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
const previousDeltaRef = useRef<[number, number] | null>(null)
const previewRef = useRef<{ const previewRef = useRef<{
polygon: Array<[number, number]> polygon: Array<[number, number]>
holes: Array<Array<[number, number]>> holes: Array<Array<[number, number]>>
@@ -48,6 +51,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const center = getPolygonCenter(node.polygon) const center = getPolygonCenter(node.polygon)
return [center[0], node.height ?? 2.5, center[1]] return [center[0], node.height ?? 2.5, center[1]]
}) })
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -65,13 +70,26 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
holes: Array<Array<[number, number]>>, holes: Array<Array<[number, number]>>,
) => { ) => {
previewRef.current = { polygon, holes } previewRef.current = { polygon, holes }
setPreviewPolygon(polygon)
setPreviewHoles(holes)
const center = getPolygonCenter(polygon) const center = getPolygonCenter(polygon)
setCursorLocalPos([center[0], node.height ?? 2.5, center[1]]) const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
if (
!previousCursorPosRef.current ||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
previousCursorPosRef.current[2] !== nextCursorPos[2]
) {
previousCursorPosRef.current = nextCursorPos
setCursorLocalPos(nextCursorPos)
}
useScene.getState().updateNode(node.id, { polygon, holes }) useScene.getState().updateNode(node.id, { polygon, holes })
useScene.getState().markDirty(node.id as AnyNodeId) useScene.getState().markDirty(node.id as AnyNodeId)
} }
const restoreOriginal = () => { const restoreOriginal = () => {
setPreviewPolygon(originalPolygon)
setPreviewHoles(originalHoles)
useScene.getState().updateNode(node.id, { useScene.getState().updateNode(node.id, {
holes: originalHoles, holes: originalHoles,
polygon: originalPolygon, polygon: originalPolygon,
@@ -97,6 +115,15 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
const deltaX = localX - anchor[0] const deltaX = localX - anchor[0]
const deltaZ = localZ - anchor[1] const deltaZ = localZ - anchor[1]
if (
previousDeltaRef.current &&
previousDeltaRef.current[0] === deltaX &&
previousDeltaRef.current[1] === deltaZ
) {
return
}
previousDeltaRef.current = [deltaX, deltaZ]
applyPreview( applyPreview(
translatePolygon(originalPolygon, deltaX, deltaZ), translatePolygon(originalPolygon, deltaX, deltaZ),
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)), originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
@@ -146,9 +173,77 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
} }
}, [exitMoveMode, node.height, node.id]) }, [exitMoveMode, node.height, node.id])
const previewFillGeometry = useMemo(
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
[previewHoles, previewPolygon],
)
const previewOutlineGeometry = useMemo(
() => createCeilingOutlineGeometry(previewPolygon),
[previewPolygon],
)
return ( return (
<group> <group>
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
<meshBasicMaterial
color="#f5f5f4"
depthWrite={false}
opacity={0.3}
side={DoubleSide}
transparent
/>
</mesh>
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
<CursorSphere position={cursorLocalPos} showTooltip={false} /> <CursorSphere position={cursorLocalPos} showTooltip={false} />
</group> </group>
) )
} }
function createCeilingPreviewGeometry(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry {
if (polygon.length < 3) return new BufferGeometry()
const shape = new Shape()
const [firstX, firstZ] = polygon[0]!
shape.moveTo(firstX, -firstZ)
for (let i = 1; i < polygon.length; i++) {
const [x, z] = polygon[i]!
shape.lineTo(x, -z)
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const [hx, hz] = holePolygon[0]!
hole.moveTo(hx, -hz)
for (let i = 1; i < holePolygon.length; i++) {
const [x, z] = holePolygon[i]!
hole.lineTo(x, -z)
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
return geometry
}
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
const geometry = new BufferGeometry()
if (polygon.length < 2) return geometry
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
const [firstX, firstZ] = polygon[0]!
points.push(new Vector3(firstX, 0, firstZ))
geometry.setFromPoints(points)
return geometry
}
@@ -1,22 +1,30 @@
import { isObject } from '@pascal-app/core' import { isObject } from '@pascal-app/core'
import useEditor from '../../../store/use-editor'
function getGridSnapStep(): number {
return useEditor.getState().gridSnapStep
}
function positiveModulo(value: number, divisor: number): number {
return ((value % divisor) + divisor) % divisor
}
/** /**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge, * For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead. * which doesn't align with 0.5 grid. This adds an offset so edges align instead.
*/ */
export function snapToGrid(position: number, dimension: number): number { export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
const halfDim = dimension / 2 const halfDim = dimension / 2
const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01 const offset = positiveModulo(halfDim, step)
const offset = needsOffset ? 0.25 : 0 return Math.round((position - offset) / step) * step + offset
return Math.round((position - offset) * 2) / 2 + offset
} }
/** /**
* Snap a value to 0.5 increments (used for wall-local positions). * Snap a value to 0.5 increments (used for wall-local positions).
*/ */
export function snapToHalf(value: number): number { export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value * 2) / 2 return Math.round(value / step) * step
} }
/** /**
@@ -1,7 +1,7 @@
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber' import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three' import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -44,8 +44,40 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
surfaceHeight = 0, surfaceHeight = 0,
allowPolygonMove = false, allowPolygonMove = false,
}) => { }) => {
// Get level node from registry if levelId is provided const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
)
useEffect(() => {
if (!levelId) {
setLevelNode(null)
return
}
let frameId = 0
const resolveLevelNode = () => {
const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
setLevelNode((currentLevelNode) => {
if (currentLevelNode === nextLevelNode) {
return currentLevelNode
}
return nextLevelNode
})
if (!nextLevelNode) {
frameId = window.requestAnimationFrame(resolveLevelNode)
}
}
resolveLevelNode()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [levelId])
// When using portal, edit at Y_OFFSET (local to level) // When using portal, edit at Y_OFFSET (local to level)
// When not using portal, edit at world origin // When not using portal, edit at world origin
@@ -93,11 +93,21 @@ function commitStairPlacement(
position: [0, 0, 0], position: [0, 0, 0],
}) })
const sortedLevels = Object.values(nodes)
.filter((node): node is LevelNode => node.type === 'level')
.sort((left, right) => left.level - right.level)
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
const stair = StairNode.parse({ const stair = StairNode.parse({
name, name,
position, position,
rotation, rotation,
stairType: DEFAULT_STAIR_TYPE, stairType: DEFAULT_STAIR_TYPE,
fromLevelId: levelId,
toLevelId: nextLevelId,
slabOpeningMode: 'destination',
openingOffset: 0.08,
width: DEFAULT_STAIR_WIDTH, width: DEFAULT_STAIR_WIDTH,
totalRise: DEFAULT_STAIR_HEIGHT, totalRise: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT, stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -166,9 +176,7 @@ export const StairTool: React.FC = () => {
const gridX = Math.round(event.localPosition[0] * 2) / 2 const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2 const gridZ = Math.round(event.localPosition[2] * 2) / 2
const y = event.localPosition[1] commitStairPlacement(currentLevelId, [gridX, 0, gridZ], rotationRef.current)
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
} }
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -21,6 +21,7 @@ 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 { CurveWallTool } from './wall/curve-wall-tool'
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-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'
@@ -52,6 +53,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 movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const curvingWall = useEditor((state) => state.curvingWall) 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)
@@ -142,6 +144,7 @@ export const ToolManager: React.FC = () => {
{showCeilingHoleEditor && selectedCeilingId && editingHole && ( {showCeilingHoleEditor && selectedCeilingId && editingHole && (
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} /> <CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)} )}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />} {curvingWall && <CurveWallTool node={curvingWall} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />} {movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />} {!movingNode && BuildToolComponent && <BuildToolComponent />}
@@ -18,10 +18,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
function snap(value: number) {
return Math.round(value * 2) / 2
}
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now()) const activatedAtRef = useRef<number>(Date.now())
@@ -51,6 +48,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
let wasCommitted = false let wasCommitted = false
const applyPreview = (curveOffset: number) => { const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset previewOffsetRef.current = curveOffset
const nextNode = { const nextNode = {
@@ -64,20 +64,31 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
} }
const restoreOriginal = () => { const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset }) useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId) useScene.getState().markDirty(nodeId as AnyNodeId)
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const localX = shiftPressedRef.current ? event.localPosition[0] : snap(event.localPosition[0]) const snapStep = getWallGridStep()
const localZ = shiftPressedRef.current ? event.localPosition[2] : snap(event.localPosition[2]) const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint = const offsetFromMidpoint =
-( -(
(localX - chord.midpoint.x) * chord.normal.x + (localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y (localZ - chord.midpoint.y) * chord.normal.y
) )
const snappedOffset = shiftPressedRef.current ? offsetFromMidpoint : snap(offsetFromMidpoint) const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset))) const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
if ( if (
@@ -100,8 +111,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const curveOffset = previewOffsetRef.current const curveOffset = previewOffsetRef.current
wasCommitted = true wasCommitted = true
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
if (curveOffset !== getClampedWallCurveOffset(node)) {
useScene.getState().updateNode(nodeId, { curveOffset }) useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId) useScene.getState().markDirty(nodeId as AnyNodeId)
}
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -0,0 +1,306 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
import { Html } from '@react-three/drei'
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, { type MovingWallEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import {
isWallLongEnough,
snapWallDraftPoint,
type WallPlanPoint,
} from './wall-drafting'
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedWallSnapshot = {
id: WallNode['id']
start: WallPlanPoint
end: WallPlanPoint
}
function getLinkedWallSnapshots(args: {
wallId: WallNode['id']
wallParentId: string | null
originalStart: WallPlanPoint
originalEnd: WallPlanPoint
}) {
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 WallPlanPoint,
end: [...node.end] as WallPlanPoint,
})
}
return snapshots
}
function getLinkedWallUpdates(
linkedWalls: LinkedWallSnapshot[],
originalStart: WallPlanPoint,
originalEnd: WallPlanPoint,
nextStart: WallPlanPoint,
nextEnd: WallPlanPoint,
) {
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 MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.wall.id)
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
const fixedPointRef = useRef<WallPlanPoint>(
target.endpoint === 'start'
? ([...target.wall.end] as WallPlanPoint)
: ([...target.wall.start] as WallPlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({
wallId: target.wall.id,
wallParentId: target.wall.parentId ?? null,
originalStart: target.wall.start,
originalEnd: target.wall.end,
}),
)
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingWallEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const levelWalls = Object.values(useScene.getState().nodes).filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
useScene.temporal.getState().pause()
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
) => {
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 applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...(detachLinkedWalls
? []
: getLinkedWallUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)),
])
}
const restoreOriginal = () => {
applyNodePreview([{ id: nodeId, start: originalStart, end: originalEnd }, ...linkedOriginalsRef.current])
}
const onGridMove = (event: GridEvent) => {
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls: levelWalls,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreWallIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: 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 onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
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)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
}`}
>
{altPressed ? 'Detaching corner' : 'Alt to detach'}
</div>
</div>
</Html>
</group>
)
}
@@ -7,10 +7,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function rotateVector([x, z]: [number, number], angle: number): [number, number] { function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle) const cos = Math.cos(angle)
@@ -22,6 +19,16 @@ function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
} }
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
return meta
}
const nextMeta = { ...(meta as Record<string, unknown>) }
delete nextMeta.isNew
return nextMeta
}
type LinkedWallSnapshot = { type LinkedWallSnapshot = {
id: WallNode['id'] id: WallNode['id']
start: [number, number] start: [number, number]
@@ -89,6 +96,11 @@ function getLinkedWallUpdates(
} }
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const activatedAtRef = useRef<number>(Date.now()) const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
@@ -102,7 +114,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
(node.end[1] - node.start[1]) / 2, (node.end[1] - node.start[1]) / 2,
]) ])
const linkedOriginalsRef = useRef( const linkedOriginalsRef = useRef(
getLinkedWallSnapshots({ isNew
? []
: getLinkedWallSnapshots({
wallId: node.id, wallId: node.id,
wallParentId: node.parentId ?? null, wallParentId: node.parentId ?? null,
originalStart: node.start, originalStart: node.start,
@@ -183,8 +197,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const rawX = event.localPosition[0] const rawX = event.localPosition[0]
const rawZ = event.localPosition[2] const rawZ = event.localPosition[2]
const localX = shiftPressedRef.current ? rawX : snap(rawX) const snapStep = getWallGridStep()
const localZ = shiftPressedRef.current ? rawZ : snap(rawZ) const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -225,6 +240,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
preview.end, preview.end,
), ),
]) ])
if (isNew) {
useScene.getState().updateNode(nodeId, {
metadata: stripWallIsNewMetadata(node.metadata),
})
}
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -297,7 +317,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
} }
}, [exitMoveMode]) }, [exitMoveMode, isNew, node.metadata])
return ( return (
<group> <group>
@@ -1,12 +1,36 @@
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
type DoorNode,
getScaledDimensions,
type ItemNode,
useScene,
type WallNode,
WallNode as WallSchema,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
export type WallPlanPoint = [number, number] export type WallPlanPoint = [number, number]
export const WALL_GRID_STEP = 0.5 export const WALL_GRID_STEP = 0.5
export const WALL_JOIN_SNAP_RADIUS = 0.35 export const WALL_JOIN_SNAP_RADIUS = 0.35
export const WALL_MIN_LENGTH = 0.01 export const WALL_MIN_LENGTH = 0.01
const DEFAULT_WALL_ANGLE_SNAP_STEP = Math.PI / 4
const WALL_ANGLE_SNAP_BY_GRID_STEP: Record<number, number> = {
0.5: Math.PI / 4,
0.25: Math.PI / 8,
0.1: Math.PI / 12,
0.05: Math.PI / 36,
}
type WallSplitIntersection = {
wallId: WallNode['id']
point: WallPlanPoint
}
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
const dx = a[0] - b[0] const dx = a[0] - b[0]
@@ -14,7 +38,11 @@ function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
return dx * dx + dz * dz return dx * dx + dz * dz
} }
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number { export function getWallGridStep(): number {
return useEditor.getState().gridSnapStep
}
export function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
return Math.round(value / step) * step return Math.round(value / step) * step
} }
@@ -22,17 +50,26 @@ export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): Wa
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
} }
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint { export function snapPointTo45Degrees(
start: WallPlanPoint,
cursor: WallPlanPoint,
step = WALL_GRID_STEP,
angleStep = DEFAULT_WALL_ANGLE_SNAP_STEP,
): WallPlanPoint {
const dx = cursor[0] - start[0] const dx = cursor[0] - start[0]
const dz = cursor[1] - start[1] const dz = cursor[1] - start[1]
const angle = Math.atan2(dz, dx) const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) const snappedAngle = Math.round(angle / angleStep) * angleStep
const distance = Math.sqrt(dx * dx + dz * dz) const distance = Math.sqrt(dx * dx + dz * dz)
return snapPointToGrid([ return snapPointToGrid([
start[0] + Math.cos(snappedAngle) * distance, start[0] + Math.cos(snappedAngle) * distance,
start[1] + Math.sin(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance,
]) ], step)
}
export function getWallAngleSnapStep(step = getWallGridStep()): number {
return WALL_ANGLE_SNAP_BY_GRID_STEP[step] ?? DEFAULT_WALL_ANGLE_SNAP_STEP
} }
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
@@ -53,6 +90,237 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
return [x1 + dx * t, z1 + dz * t] return [x1 + dx * t, z1 + dz * t]
} }
function splitWallAtPoint(wall: WallNode, splitPoint: WallPlanPoint): [WallNode, WallNode] {
const { id: _id, parentId: _parentId, children, ...rest } = wall
const first = WallSchema.parse({
...rest,
start: wall.start,
end: splitPoint,
children: [],
})
const second = WallSchema.parse({
...rest,
start: splitPoint,
end: wall.end,
children: [],
})
return [first, second]
}
function pointsEqual(a: WallPlanPoint, b: WallPlanPoint, tolerance = 1e-6): boolean {
return distanceSquared(a, b) <= tolerance * tolerance
}
function findWallIntersection(
point: WallPlanPoint,
walls: WallNode[],
ignoreWallIds?: string[],
): WallSplitIntersection | null {
const ignore = new Set(ignoreWallIds ?? [])
let best: WallSplitIntersection | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY
for (const wall of walls) {
if (ignore.has(wall.id)) continue
const projected = projectPointOntoWall(point, wall)
if (!projected) continue
const candidateDistanceSquared = distanceSquared(point, projected)
if (
candidateDistanceSquared > WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS ||
candidateDistanceSquared >= bestDistanceSquared
) {
continue
}
best = { wallId: wall.id, point: projected }
bestDistanceSquared = candidateDistanceSquared
}
return best
}
function wallHasAttachments(wall: WallNode, nodes: ReturnType<typeof useScene.getState>['nodes']) {
if ((wall.children?.length ?? 0) > 0) {
return true
}
return Object.values(nodes).some((node) => {
if (!node) return false
if ('parentId' in node && node.parentId === wall.id) return true
if ('wallId' in node && typeof node.wallId === 'string' && node.wallId === wall.id) return true
return false
})
}
function wallLength(wall: Pick<WallNode, 'start' | 'end'>) {
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
}
function getWallAttachmentSpan(node: AnyNode): { min: number; max: number; center: number } | null {
if (node.type === 'door') {
const door = node as DoorNode
return {
min: door.position[0] - door.width / 2,
max: door.position[0] + door.width / 2,
center: door.position[0],
}
}
if (node.type === 'window') {
const win = node as WindowNode
return {
min: win.position[0] - win.width / 2,
max: win.position[0] + win.width / 2,
center: win.position[0],
}
}
if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') {
return null
}
const [width] = getScaledDimensions(item)
return {
min: item.position[0] - width / 2,
max: item.position[0] + width / 2,
center: item.position[0],
}
}
return null
}
function remapAttachmentToWall(
node: AnyNode,
nextWallId: WallNode['id'],
nextLocalX: number,
nextWallLength: number,
): Partial<AnyNode> | null {
const clampedX = Math.max(0, Math.min(nextWallLength, nextLocalX))
if (node.type === 'door' || node.type === 'window' || node.type === 'item') {
const currentPosition = 'position' in node ? node.position : null
if (!currentPosition) return null
const nextPosition: typeof currentPosition = [
clampedX,
currentPosition[1],
currentPosition[2],
] as typeof currentPosition
return {
parentId: nextWallId,
position: nextPosition,
...(node.type === 'item'
? {
wallId: nextWallId,
wallT: nextWallLength > 1e-6 ? clampedX / nextWallLength : 0,
}
: {
wallId: nextWallId,
}),
} as Partial<AnyNode>
}
return null
}
function buildAttachmentMigrationPlan(
wall: WallNode,
splitPoint: WallPlanPoint,
firstWall: WallNode,
secondWall: WallNode,
nodes: ReturnType<typeof useScene.getState>['nodes'],
): { id: AnyNodeId; data: Partial<AnyNode> }[] | null {
const splitDistance = Math.hypot(splitPoint[0] - wall.start[0], splitPoint[1] - wall.start[1])
const firstLength = wallLength(firstWall)
const secondLength = wallLength(secondWall)
const tolerance = 1e-4
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const childId of wall.children ?? []) {
const childNode = nodes[childId as AnyNodeId]
if (!childNode) continue
const span = getWallAttachmentSpan(childNode)
if (!span) {
return null
}
if (span.max <= splitDistance + tolerance) {
const nextUpdate = remapAttachmentToWall(childNode, firstWall.id, span.center, firstLength)
if (!nextUpdate) return null
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
continue
}
if (span.min >= splitDistance - tolerance) {
const nextUpdate = remapAttachmentToWall(
childNode,
secondWall.id,
span.center - splitDistance,
secondLength,
)
if (!nextUpdate) return null
updates.push({ id: childNode.id as AnyNodeId, data: nextUpdate })
continue
}
return null
}
return updates
}
function splitWallIfNeeded(
intersection: WallSplitIntersection | null,
walls: WallNode[],
nodes: ReturnType<typeof useScene.getState>['nodes'],
createNodes: ReturnType<typeof useScene.getState>['createNodes'],
updateNodes: ReturnType<typeof useScene.getState>['updateNodes'],
deleteNode: ReturnType<typeof useScene.getState>['deleteNode'],
): { walls: WallNode[]; point: WallPlanPoint } | null {
if (!intersection) return null
const wallToSplit = walls.find((wall) => wall.id === intersection.wallId)
if (!wallToSplit) {
return { walls, point: intersection.point }
}
const [first, second] = splitWallAtPoint(wallToSplit, intersection.point)
const attachmentUpdates = buildAttachmentMigrationPlan(
wallToSplit,
intersection.point,
first,
second,
nodes,
)
if (wallHasAttachments(wallToSplit, nodes) && !attachmentUpdates) {
return { walls, point: intersection.point }
}
createNodes([
{ node: first, parentId: wallToSplit.parentId as AnyNodeId | undefined },
{ node: second, parentId: wallToSplit.parentId as AnyNodeId | undefined },
])
if (attachmentUpdates && attachmentUpdates.length > 0) {
updateNodes(attachmentUpdates)
}
deleteNode(wallToSplit.id as AnyNodeId)
return {
walls: [...walls.filter((wall) => wall.id !== wallToSplit.id), first, second],
point: intersection.point,
}
}
export function findWallSnapTarget( export function findWallSnapTarget(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
@@ -102,7 +370,12 @@ export function snapWallDraftPoint(args: {
ignoreWallIds?: string[] ignoreWallIds?: string[]
}): WallPlanPoint { }): WallPlanPoint {
const { point, walls, start, angleSnap = false, ignoreWallIds } = args const { point, walls, start, angleSnap = false, ignoreWallIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point) const step = getWallGridStep()
const angleStep = getWallAngleSnapStep(step)
const basePoint =
start && angleSnap
? snapPointTo45Degrees(start, point, step, angleStep)
: snapPointToGrid(point, step)
return ( return (
findWallSnapTarget(basePoint, walls, { findWallSnapTarget(basePoint, walls, {
@@ -120,17 +393,66 @@ export function createWallOnCurrentLevel(
end: WallPlanPoint, end: WallPlanPoint,
): WallNode | null { ): WallNode | null {
const currentLevelId = useViewer.getState().selection.levelId const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState() const { createNode, createNodes, deleteNode, nodes } = useScene.getState()
const { updateNodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) { if (!(currentLevelId && isWallLongEnough(start, end))) {
return null return null
} }
let workingWalls = Object.values(nodes).filter(
(node): node is WallNode => node?.type === 'wall' && node.parentId === currentLevelId,
)
let resolvedStart = start
let resolvedEnd = end
const endIntersection = findWallIntersection(resolvedEnd, workingWalls)
const splitEnd = splitWallIfNeeded(
endIntersection,
workingWalls,
nodes,
createNodes,
updateNodes,
deleteNode,
)
if (splitEnd) {
workingWalls = splitEnd.walls
resolvedEnd = splitEnd.point
}
const startIntersection = findWallIntersection(resolvedStart, workingWalls)
const splitStart = splitWallIfNeeded(
startIntersection,
workingWalls,
nodes,
createNodes,
updateNodes,
deleteNode,
)
if (splitStart) {
workingWalls = splitStart.walls
resolvedStart = splitStart.point
}
if (!isWallLongEnough(resolvedStart, resolvedEnd) || pointsEqual(resolvedStart, resolvedEnd)) {
return null
}
const duplicateWall = workingWalls.some(
(wall) =>
(pointsEqual(wall.start, resolvedStart) && pointsEqual(wall.end, resolvedEnd)) ||
(pointsEqual(wall.start, resolvedEnd) && pointsEqual(wall.end, resolvedStart)),
)
if (duplicateWall) {
return null
}
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
const wall = WallSchema.parse({ const wall = WallSchema.parse({
name: `Wall ${wallCount + 1}`, name: `Wall ${wallCount + 1}`,
start, start: resolvedStart,
end, end: resolvedEnd,
}) })
createNode(wall, currentLevelId) createNode(wall, currentLevelId)
@@ -86,7 +86,13 @@ export function CeilingPanel() {
[cx - holeSize, cz + holeSize], [cx - holeSize, cz + holeSize],
] ]
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] }) const currentMetadata = currentHoles.map(
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
)
handleUpdate({
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole]) }, [node, selectedId, handleUpdate, setEditingHole])
@@ -102,13 +108,18 @@ export function CeilingPanel() {
(index: number) => { (index: number) => {
if (!selectedId) return if (!selectedId) return
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
const newHoles = currentHoles.filter((_, i) => i !== index) const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles }) const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
)
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null) setEditingHole(null)
} }
}, },
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
) )
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
@@ -126,8 +137,11 @@ export function CeilingPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const current = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
} }
@@ -174,6 +188,8 @@ export function CeilingPanel() {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
return ( return (
<div <div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${ className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -190,7 +206,8 @@ export function CeilingPanel() {
Hole {index + 1} {isEditing && '(Editing)'} Hole {index + 1} {isEditing && '(Editing)'}
</p> </p>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -200,6 +217,10 @@ export function CeilingPanel() {
label="Done" label="Done"
onClick={() => setEditingHole(null)} onClick={() => setEditingHole(null)}
/> />
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : ( ) : (
<> <>
<button <button
@@ -84,7 +84,13 @@ export function SlabPanel() {
[cx - holeSize, cz + holeSize], [cx - holeSize, cz + holeSize],
] ]
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
handleUpdate({ holes: [...currentHoles, newHole] }) const currentMetadata = currentHoles.map(
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
)
handleUpdate({
holes: [...currentHoles, newHole],
holeMetadata: [...currentMetadata, { source: 'manual' }],
})
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole]) }, [node, selectedId, handleUpdate, setEditingHole])
@@ -100,13 +106,18 @@ export function SlabPanel() {
(index: number) => { (index: number) => {
if (!selectedId) return if (!selectedId) return
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
const newHoles = currentHoles.filter((_, i) => i !== index) const newHoles = currentHoles.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles }) const currentMetadata = currentHoles.map(
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
)
const newMetadata = currentMetadata.filter((_, i) => i !== index)
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) { if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null) setEditingHole(null)
} }
}, },
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
) )
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
@@ -124,8 +135,11 @@ export function SlabPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const current = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
} }
@@ -173,6 +187,8 @@ export function SlabPanel() {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
return ( return (
<div <div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${ className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
@@ -189,7 +205,8 @@ export function SlabPanel() {
Hole {index + 1} {isEditing && '(Editing)'} Hole {index + 1} {isEditing && '(Editing)'}
</p> </p>
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{holeArea.toFixed(2)} m² · {hole.length} pts {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -199,6 +216,10 @@ export function SlabPanel() {
label="Done" label="Done"
onClick={() => setEditingHole(null)} onClick={() => setEditingHole(null)}
/> />
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : ( ) : (
<> <>
<button <button
@@ -3,9 +3,11 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type LevelNode,
type MaterialSchema, type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode, type StairRailingMode,
type StairSlabOpeningMode,
type StairTopLandingMode, type StairTopLandingMode,
type StairType, type StairType,
StairNode as StairNodeSchema, StairNode as StairNodeSchema,
@@ -21,6 +23,7 @@ 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'
@@ -45,6 +48,11 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
{ label: 'Integrated', value: 'integrated' }, { label: 'Integrated', value: 'integrated' },
] ]
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Destination', value: 'destination' },
]
export function StairPanel() { export function StairPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -202,6 +210,11 @@ export function StairPanel() {
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
const levels = Object.values(nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level)
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
const segments = (node.children ?? []) const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment') .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
@@ -231,6 +244,63 @@ export function StairPanel() {
/> />
</PanelSection> </PanelSection>
<PanelSection title="Opening">
<div className="space-y-3">
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
From Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
value={resolvedFromLevelId ?? ''}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level + 1}`}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
To Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
value={resolvedToLevelId ?? ''}
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.name || `Level ${level.level + 1}`}
</option>
))}
</select>
</div>
<SegmentedControl
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
options={STAIR_SLAB_OPENING_OPTIONS}
value={node.slabOpeningMode ?? 'none'}
/>
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
<MetricControl
label="Opening Offset"
max={0.5}
min={0}
onChange={(value) => handleUpdate({ openingOffset: value })}
precision={2}
step={0.01}
unit="m"
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
/>
) : null}
</div>
</PanelSection>
{node.stairType === 'straight' && ( {node.stairType === 'straight' && (
<PanelSection title="Segments"> <PanelSection title="Segments">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -2,11 +2,17 @@
import { Icon as IconifyIcon } from '@iconify/react' import { Icon as IconifyIcon } from '@iconify/react'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react' import { Check, ChevronsLeft, ChevronsRight, Columns2, Eye, Footprints, Moon, Sun } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import type { ViewMode } from '../../store/use-editor' import type { GridSnapStep, ViewMode } from '../../store/use-editor'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './primitives/dropdown-menu'
import { useSidebarStore } from './primitives/sidebar' import { useSidebarStore } from './primitives/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
@@ -174,6 +180,18 @@ const levelModeLabels: Record<string, string> = {
solo: 'Solo', solo: 'Solo',
} }
const gridSnapOrder: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
const gridSnapLabels: Record<GridSnapStep, string> = {
0.5: '0.50',
0.25: '0.25',
0.1: '0.10',
0.05: '0.05',
}
function formatGridSnapStep(step: GridSnapStep): string {
return gridSnapLabels[step]
}
function LevelModeToggle() { function LevelModeToggle() {
const levelMode = useViewer((s) => s.levelMode) const levelMode = useViewer((s) => s.levelMode)
const setLevelMode = useViewer((s) => s.setLevelMode) const setLevelMode = useViewer((s) => s.setLevelMode)
@@ -219,6 +237,40 @@ function LevelModeToggle() {
) )
} }
function GridSnapToggle() {
const gridSnapStep = useEditor((s) => s.gridSnapStep)
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5')} type="button">
<IconifyIcon height={14} icon="lucide:grid-2x2" width={14} />
<span className="font-medium text-xs">{formatGridSnapStep(gridSnapStep)}</span>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="center" side="bottom">
{gridSnapOrder.map((step) => {
const isActive = step === gridSnapStep
return (
<DropdownMenuItem key={step} onSelect={() => setGridSnapStep(step)}>
<span className="flex min-w-12 items-center justify-between gap-3">
<span>{formatGridSnapStep(step)}</span>
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
// ── Wall mode toggle ──────────────────────────────────────────────────────── // ── Wall mode toggle ────────────────────────────────────────────────────────
const wallModeOrder = ['cutaway', 'up', 'down'] as const const wallModeOrder = ['cutaway', 'up', 'down'] as const
@@ -330,6 +382,7 @@ export function ViewerToolbarRight() {
<div className={TOOLBAR_CONTAINER}> <div className={TOOLBAR_CONTAINER}>
<LevelModeToggle /> <LevelModeToggle />
<WallModeToggle /> <WallModeToggle />
<GridSnapToggle />
<div className="my-1.5 w-px bg-border/50" /> <div className="my-1.5 w-px bg-border/50" />
<UnitToggle /> <UnitToggle />
<ThemeToggle /> <ThemeToggle />
+30 -1
View File
@@ -70,10 +70,16 @@ export type CatalogCategory =
export type StructureLayer = 'zones' | 'elements' export type StructureLayer = 'zones' | 'elements'
export type FloorplanSelectionTool = 'click' | 'marquee' export type FloorplanSelectionTool = 'click' | 'marquee'
export type GridSnapStep = 0.5 | 0.25 | 0.1 | 0.05
// Combined tool type // Combined tool type
export type Tool = SiteTool | StructureTool | FurnishTool export type Tool = SiteTool | StructureTool | FurnishTool
export type MovingWallEndpoint = {
wall: WallNode
endpoint: 'start' | 'end'
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -117,6 +123,8 @@ type EditorState = {
| BuildingNode | BuildingNode
| null, | null,
) => void ) => void
movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
curvingWall: WallNode | null curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
@@ -143,6 +151,8 @@ type EditorState = {
setFloorplanHovered: (hovered: boolean) => void setFloorplanHovered: (hovered: boolean) => void
floorplanSelectionTool: FloorplanSelectionTool floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
gridSnapStep: GridSnapStep
setGridSnapStep: (step: GridSnapStep) => void
// First-person walkthrough mode (street view) // First-person walkthrough mode (street view)
isFirstPersonMode: boolean isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null _viewModeBeforeFirstPerson: ViewMode | null
@@ -163,7 +173,11 @@ export type PersistedEditorUiState = Pick<
type PersistedEditorLayoutState = Pick< type PersistedEditorLayoutState = Pick<
EditorState, EditorState,
'activeSidebarPanel' | 'floorplanPaneRatio' | 'splitOrientation' | 'floorplanSelectionTool' | 'activeSidebarPanel'
| 'floorplanPaneRatio'
| 'splitOrientation'
| 'floorplanSelectionTool'
| 'gridSnapStep'
> >
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
@@ -182,8 +196,11 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO, floorplanPaneRatio: DEFAULT_FLOORPLAN_PANE_RATIO,
splitOrientation: 'horizontal', splitOrientation: 'horizontal',
floorplanSelectionTool: 'click', floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
} }
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
if (phase === 'site') { if (phase === 'site') {
return 'select' return 'select'
@@ -286,6 +303,9 @@ function normalizePersistedEditorLayoutState(
floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio), floorplanPaneRatio: normalizeFloorplanPaneRatio(state?.floorplanPaneRatio),
splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal', splitOrientation: state?.splitOrientation === 'vertical' ? 'vertical' : 'horizontal',
floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click', floorplanSelectionTool: state?.floorplanSelectionTool === 'marquee' ? 'marquee' : 'click',
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
? (state?.gridSnapStep as GridSnapStep)
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
} }
} }
@@ -467,6 +487,10 @@ const useEditor = create<EditorState>()(
| ItemNode | ItemNode
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode
| CeilingNode
| SlabNode
| WallNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -474,6 +498,8 @@ const useEditor = create<EditorState>()(
| BuildingNode | BuildingNode
| null, | null,
setMovingNode: (node) => set({ movingNode: node }), setMovingNode: (node) => set({ movingNode: node }),
movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
curvingWall: null, curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedReferenceId: null, selectedReferenceId: null,
@@ -507,6 +533,8 @@ const useEditor = create<EditorState>()(
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
floorplanSelectionTool: 'click' as FloorplanSelectionTool, floorplanSelectionTool: 'click' as FloorplanSelectionTool,
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }), setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
setGridSnapStep: (step) => set({ gridSnapStep: step }),
allowUndergroundCamera: false, allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
isFirstPersonMode: false, isFirstPersonMode: false,
@@ -572,6 +600,7 @@ const useEditor = create<EditorState>()(
floorplanPaneRatio: state.floorplanPaneRatio, floorplanPaneRatio: state.floorplanPaneRatio,
splitOrientation: state.splitOrientation, splitOrientation: state.splitOrientation,
floorplanSelectionTool: state.floorplanSelectionTool, floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
}), }),
}, },
), ),
@@ -1,5 +1,5 @@
import { type DoorNode, useRegistry } from '@pascal-app/core' import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
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 { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials' import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials'
@@ -8,6 +8,9 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'door', ref) useRegistry(node.id, 'door', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'door') const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
@@ -1,8 +1,18 @@
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' import {
type AnyNodeId,
type StairNode,
type StairSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
type SegmentTransform = { type SegmentTransform = {
@@ -37,6 +47,7 @@ type LandingChainNextStair = {
export const StairRenderer = ({ node }: { node: StairNode }) => { export const StairRenderer = ({ node }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
const isSegmentBasedStair = node.stairType === 'straight'
useRegistry(node.id, 'stair', ref) useRegistry(node.id, 'stair', ref)
@@ -52,7 +63,13 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
const mat = node.material const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat) return createMaterial(mat)
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
return ( return (
<group <group
@@ -63,18 +80,20 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> >
{isSegmentBasedStair ? (
<mesh castShadow material={material} name="merged-stair" receiveShadow> <mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
</mesh> </mesh>
{node.stairType === 'curved' || node.stairType === 'spiral' ? (
<CurvedStairBody material={material} stair={node} />
) : null} ) : null}
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
<StairRailings material={material} stair={node} /> <StairRailings material={material} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
))} ))}
</group> </group>
) : null}
</group> </group>
) )
} }
@@ -86,11 +105,17 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
() => () =>
(stair.children ?? []) (stair.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((node): node is StairSegmentNode => node?.type === 'stair-segment' && node.visible !== false), .filter(
(node): node is StairSegmentNode =>
node?.type === 'stair-segment' && node.visible !== false,
),
[nodes, stair.children], [nodes, stair.children],
) )
const railPaths = useMemo(() => buildStairRailPaths(segments, stair.railingMode ?? 'none'), [segments, stair.railingMode]) const railPaths = useMemo(
() => buildStairRailPaths(segments, stair.railingMode ?? 'none'),
[segments, stair.railingMode],
)
const railHeight = stair.railingHeight ?? 0.92 const railHeight = stair.railingHeight ?? 0.92
const midRailHeight = Math.max(railHeight * 0.45, 0.35) const midRailHeight = Math.max(railHeight * 0.45, 0.35)
@@ -103,10 +128,14 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
if (stair.stairType === 'curved' || stair.stairType === 'spiral') { if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const sweepAngle = stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2) const sweepAngle =
stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
const stepSweep = sweepAngle / stepCount const stepSweep = sweepAngle / stepCount
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
const innerRadius = Math.max(stair.stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius ?? 0.9) const innerRadius = Math.max(
stair.stairType === 'spiral' ? 0.05 : 0.2,
stair.innerRadius ?? 0.9,
)
const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4) const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4)
const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04 const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04
const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04 const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04
@@ -183,7 +212,11 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
{railPaths.map((segmentPath, index) => ( {railPaths.map((segmentPath, index) => (
<group <group
key={`${segmentPath.layout.segment.id}-railing`} key={`${segmentPath.layout.segment.id}-railing`}
position={[segmentPath.layout.center[0], segmentPath.layout.elevation, segmentPath.layout.center[1]]} position={[
segmentPath.layout.center[0],
segmentPath.layout.elevation,
segmentPath.layout.center[1],
]}
rotation-y={segmentPath.layout.rotation} rotation-y={segmentPath.layout.rotation}
> >
{segmentPath.sidePaths.map((sidePath, sideIndex) => ( {segmentPath.sidePaths.map((sidePath, sideIndex) => (
@@ -204,7 +237,9 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
if (!nextPoint) return null if (!nextPoint) return null
return ( return (
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}> <group
key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}
>
<RailSegment <RailSegment
end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]} end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]}
material={material} material={material}
@@ -240,12 +275,13 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const lastPoint = entry.points[entry.points.length - 1] const lastPoint = entry.points[entry.points.length - 1]
return { return {
entry, entry,
distance: lastPoint ? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint) : Number.POSITIVE_INFINITY, distance: lastPoint
? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint)
: Number.POSITIVE_INFINITY,
} }
}) })
.sort((left, right) => left.distance - right.distance)[0]?.entry .sort((left, right) => left.distance - right.distance)[0]?.entry
const previousPoint = const previousPoint = previousSidePath?.points.length
previousSidePath && previousSidePath.points.length
? previousSidePath.points[previousSidePath.points.length - 1] ? previousSidePath.points[previousSidePath.points.length - 1]
: null : null
@@ -256,18 +292,36 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint) const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint)
return ( return (
<group key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}> <group
key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}
>
<RailSegment <RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + railHeight, currentWorldPoint[2]]} end={[
currentWorldPoint[0],
currentWorldPoint[1] + railHeight,
currentWorldPoint[2],
]}
material={material} material={material}
radius={railRadius} radius={railRadius}
start={[previousWorldPoint[0], previousWorldPoint[1] + railHeight, previousWorldPoint[2]]} start={[
previousWorldPoint[0],
previousWorldPoint[1] + railHeight,
previousWorldPoint[2],
]}
/> />
<RailSegment <RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + midRailHeight, currentWorldPoint[2]]} end={[
currentWorldPoint[0],
currentWorldPoint[1] + midRailHeight,
currentWorldPoint[2],
]}
material={material} material={material}
radius={railRadius * 0.8} radius={railRadius * 0.8}
start={[previousWorldPoint[0], previousWorldPoint[1] + midRailHeight, previousWorldPoint[2]]} start={[
previousWorldPoint[0],
previousWorldPoint[1] + midRailHeight,
previousWorldPoint[2],
]}
/> />
</group> </group>
) )
@@ -296,10 +350,17 @@ function RailSegment({
const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector]) const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector])
const length = Math.max(direction.length(), 0.01) const length = Math.max(direction.length(), 0.01)
const quaternion = useMemo( const quaternion = useMemo(
() => new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize()), () =>
new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.clone().normalize(),
),
[direction], [direction],
) )
const midpoint = useMemo(() => startVector.clone().add(endVector).multiplyScalar(0.5), [endVector, startVector]) const midpoint = useMemo(
() => startVector.clone().add(endVector).multiplyScalar(0.5),
[endVector, startVector],
)
return ( return (
<mesh <mesh
@@ -327,11 +388,16 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
const fillToFloor = stair.fillToFloor ?? true const fillToFloor = stair.fillToFloor ?? true
const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03)) const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03))
const spiralColumnHeight = totalRise + thickness const spiralColumnHeight = totalRise + thickness
const spiralLandingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8)) const spiralLandingDepth = Math.max(
0.3,
stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8),
)
const spiralLandingSweep = const spiralLandingSweep =
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
? Math.min(Math.PI * 0.75, spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1)) * ? Math.min(
Math.sign(sweepAngle || 1) Math.PI * 0.75,
spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1),
) * Math.sign(sweepAngle || 1)
: 0 : 0
const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness
const spiralLandingThickness = const spiralLandingThickness =
@@ -342,28 +408,52 @@ function CurvedStairBody({ stair, material }: { stair: StairNode; material: THRE
return ( return (
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}> <group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
{isSpiral && (stair.showCenterColumn ?? true) ? ( {isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh castShadow receiveShadow material={material} position={[0, spiralColumnHeight / 2, 0]}> <mesh
<cylinderGeometry args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]} /> castShadow
receiveShadow
material={material}
position={[0, spiralColumnHeight / 2, 0]}
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
/>
</mesh> </mesh>
) : null} ) : null}
{Array.from({ length: stepCount }).map((_, index) => { {Array.from({ length: stepCount }).map((_, index) => {
const currentHeight = stepHeight * (index + 1) const currentHeight = stepHeight * (index + 1)
const actualStepHeight = isSpiral ? thickness : fillToFloor ? Math.max(currentHeight, thickness) : thickness const actualStepHeight = isSpiral
? thickness
: fillToFloor
? Math.max(currentHeight, thickness)
: thickness
const startAngle = -sweepAngle / 2 + stepSweep * index const startAngle = -sweepAngle / 2 + stepSweep * index
const endAngle = startAngle + stepSweep const endAngle = startAngle + stepSweep
const stepY = isSpiral ? stepHeight * index : fillToFloor ? 0 : Math.max(currentHeight - thickness, 0) const stepY = isSpiral
? stepHeight * index
: fillToFloor
? 0
: Math.max(currentHeight - thickness, 0)
const midAngle = startAngle + stepSweep / 2 const midAngle = startAngle + stepSweep / 2
return ( return (
<group key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`} position-y={stepY}> <group
key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`}
position-y={stepY}
>
{isSpiral && (stair.showStepSupports ?? true) ? ( {isSpiral && (stair.showStepSupports ?? true) ? (
<mesh <mesh
castShadow castShadow
material={material} material={material}
position={[ position={[
Math.cos(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02), Math.cos(midAngle) *
(spiralColumnRadius +
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
0.02),
Math.max(thickness * 0.55, 0.025) / 2, Math.max(thickness * 0.55, 0.025) / 2,
Math.sin(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02), Math.sin(midAngle) *
(spiralColumnRadius +
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
0.02),
]} ]}
receiveShadow receiveShadow
rotation-y={-midAngle} rotation-y={-midAngle}
@@ -426,11 +516,20 @@ function CurvedStepMesh({
material: THREE.Material material: THREE.Material
}) { }) {
const geometry = useMemo( const geometry = useMemo(
() => buildCurvedStepGeometry(innerRadius, outerRadius, startAngle, endAngle, Math.max(stepHeight, thickness)), () =>
buildCurvedStepGeometry(
innerRadius,
outerRadius,
startAngle,
endAngle,
Math.max(stepHeight, thickness),
),
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness], [endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness],
) )
return <mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow /> return (
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
)
} }
function buildCurvedStepGeometry( function buildCurvedStepGeometry(
@@ -445,7 +544,15 @@ function buildCurvedStepGeometry(
const y1 = clampedHeight const y1 = clampedHeight
const sweepAngle = endAngle - startAngle const sweepAngle = endAngle - startAngle
const sweepDirection = Math.sign(sweepAngle) || 1 const sweepDirection = Math.sign(sweepAngle) || 1
const segmentCount = Math.max(4, Math.min(24, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3)))) const segmentCount = Math.max(
4,
Math.min(
24,
Math.ceil(
Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3),
),
),
)
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
@@ -453,7 +560,12 @@ function buildCurvedStepGeometry(
const pointOnArc = (radius: number, angle: number, y: number) => const pointOnArc = (radius: number, angle: number, y: number) =>
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius) new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
const pushTriangle = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, normal: THREE.Vector3) => { const pushTriangle = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
normal: THREE.Vector3,
) => {
const edgeAB = b.clone().sub(a) const edgeAB = b.clone().sub(a)
const edgeAC = c.clone().sub(a) const edgeAC = c.clone().sub(a)
const faceNormal = edgeAB.cross(edgeAC) const faceNormal = edgeAB.cross(edgeAC)
@@ -464,7 +576,13 @@ function buildCurvedStepGeometry(
} }
} }
const pushQuad = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, d: THREE.Vector3, normal: THREE.Vector3) => { const pushQuad = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
d: THREE.Vector3,
normal: THREE.Vector3,
) => {
pushTriangle(a, b, c, normal) pushTriangle(a, b, c, normal)
pushTriangle(a, c, d, normal) pushTriangle(a, c, d, normal)
} }
@@ -548,7 +666,10 @@ function buildStairRailPaths(
return layouts.map((layout, index) => { return layouts.map((layout, index) => {
const previousLayout = index > 0 ? layouts[index - 1] : undefined const previousLayout = index > 0 ? layouts[index - 1] : undefined
const nextLayout = layouts[index + 1] const nextLayout = layouts[index + 1]
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index) const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(
layouts,
index,
)
const hideLandingRailing = const hideLandingRailing =
layout.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing' &&
previousLayout?.segment.segmentType === 'stair' && previousLayout?.segment.segmentType === 'stair' &&
@@ -586,11 +707,18 @@ function buildStairRailPaths(
layout, layout,
sidePaths: sidePaths:
isStraightLineDoubleLandingLayout && index === 1 isStraightLineDoubleLandingLayout && index === 1
? (['left', 'right'] as const).map((side) => buildSegmentRailPath(layouts, index, side, landingInset)) ? (['left', 'right'] as const).map((side) =>
: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)), buildSegmentRailPath(layouts, index, side, landingInset),
)
: sideCandidates.map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
),
connectFromPrevious: connectFromPrevious:
index > 0 && index > 0 &&
!(previousLayout?.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing'), !(
previousLayout?.segment.segmentType === 'landing' &&
layout.segment.segmentType === 'landing'
),
} }
}) })
} }
@@ -607,7 +735,10 @@ function buildStairRailPaths(
return layouts.map((layout, index) => { return layouts.map((layout, index) => {
const previousLayout = index > 0 ? layouts[index - 1] : undefined const previousLayout = index > 0 ? layouts[index - 1] : undefined
const nextLayout = layouts[index + 1] const nextLayout = layouts[index + 1]
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index) const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(
layouts,
index,
)
const isMiddleLandingBetweenFlights = const isMiddleLandingBetweenFlights =
layout.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing' &&
previousLayout?.segment.segmentType === 'stair' && previousLayout?.segment.segmentType === 'stair' &&
@@ -626,11 +757,12 @@ function buildStairRailPaths(
suppressMiddleLandingOnPreferredTurnSide suppressMiddleLandingOnPreferredTurnSide
const landingContinuesOnPreferredSide = const landingContinuesOnPreferredSide =
layout.segment.segmentType === 'landing' layout.segment.segmentType === 'landing'
? nextAttachmentSide == null || nextAttachmentSide === 'front' || nextAttachmentSide === railingMode ? nextAttachmentSide == null ||
nextAttachmentSide === 'front' ||
nextAttachmentSide === railingMode
: true : true
const sideCandidates = const sideCandidates = suppressLandingRailing
suppressLandingRailing
? ([] as StairRailPathSide[]) ? ([] as StairRailPathSide[])
: layout.segment.segmentType !== 'landing' : layout.segment.segmentType !== 'landing'
? [railingMode] ? [railingMode]
@@ -667,7 +799,9 @@ function buildStairRailPaths(
return { return {
layout, layout,
sidePaths: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)), sidePaths: sideCandidates.map((side) =>
buildSegmentRailPath(layouts, index, side, landingInset),
),
connectFromPrevious: connectFromPrevious:
index > 0 && index > 0 &&
!suppressLandingRailing && !suppressLandingRailing &&
@@ -677,7 +811,10 @@ function buildStairRailPaths(
}) })
} }
function resolveLandingChainNextStair(layouts: StairRailLayout[], index: number): LandingChainNextStair { function resolveLandingChainNextStair(
layouts: StairRailLayout[],
index: number,
): LandingChainNextStair {
const layout = layouts[index] const layout = layouts[index]
if (!layout || layout.segment.segmentType !== 'landing') { if (!layout || layout.segment.segmentType !== 'landing') {
return { isTerminalLandingBeforeStair: false } return { isTerminalLandingBeforeStair: false }
@@ -728,9 +865,13 @@ function buildSegmentRailPath(
const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps
const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045 const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
const flightStartX = const flightStartX =
previousLayout?.segment.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2 previousLayout?.segment.segmentType === 'landing'
? -segment.length / 2 + landingInset
: -segment.length / 2
const flightEndX = const flightEndX =
nextLayout?.segment.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2 nextLayout?.segment.segmentType === 'landing'
? segment.length / 2 - landingInset
: segment.length / 2
const landingFrontX = const landingFrontX =
previousLayout?.segment.segmentType === 'stair' && previousLayout?.segment.segmentType === 'stair' &&
segment.attachmentSide && segment.attachmentSide &&
@@ -767,7 +908,13 @@ function buildSegmentRailPath(
return { return {
side, side,
points: [ points: [
...(previousLayout?.segment.segmentType === 'landing' ? [] : ([[flightStartX, stepHeight > 0 ? stepHeight : 0, flightSideOffset]] as [number, number, number][])), ...(previousLayout?.segment.segmentType === 'landing'
? []
: ([[flightStartX, stepHeight > 0 ? stepHeight : 0, flightSideOffset]] as [
number,
number,
number,
][])),
...Array.from({ length: steps }).map( ...Array.from({ length: steps }).map(
(_, index) => (_, index) =>
[ [
@@ -783,7 +930,10 @@ function buildSegmentRailPath(
} }
} }
function toWorldRailPoint(layout: StairRailLayout, point: [number, number, number]): [number, number, number] { function toWorldRailPoint(
layout: StairRailLayout,
point: [number, number, number],
): [number, number, number] {
const [localX, localY, localZ] = point const [localX, localY, localZ] = point
const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation) const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation)
return [layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ] return [layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ]
@@ -798,7 +948,10 @@ function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransfor
const segment = segments[i]! const segment = segments[i]!
if (i === 0) { if (i === 0) {
transforms.push({ position: [currentPos.x, currentPos.y, currentPos.z], rotation: currentRot }) transforms.push({
position: [currentPos.x, currentPos.y, currentPos.z],
rotation: currentRot,
})
continue continue
} }
@@ -42,7 +42,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
</mesh> </mesh>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
))} ))}
</mesh> </mesh>
) )
@@ -1,5 +1,5 @@
import { useRegistry, type WindowNode } from '@pascal-app/core' import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useLayoutEffect, useMemo, useRef } from 'react'
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 { createMaterial, DEFAULT_WINDOW_MATERIAL } from '../../../lib/materials' import { createMaterial, DEFAULT_WINDOW_MATERIAL } from '../../../lib/materials'
@@ -8,6 +8,9 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!) const ref = useRef<Mesh>(null!)
useRegistry(node.id, 'window', ref) useRegistry(node.id, 'window', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'window') const handlers = useNodeEvents(node, 'window')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
@@ -1,6 +1,6 @@
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, Layers, UnsignedByteType } from 'three' import { Color, Layers, type Object3D, UnsignedByteType } from 'three'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { import {
@@ -47,6 +47,21 @@ const RETRY_DELAY_MS = 500
const DARK_BG = '#1f2433' const DARK_BG = '#1f2433'
const LIGHT_BG = '#ffffff' const LIGHT_BG = '#ffffff'
function sanitizeOutlineObjects(objects: Object3D[]) {
let nextIndex = 0
for (const object of objects) {
if (!(object && typeof object.id === 'number' && object.parent)) {
continue
}
objects[nextIndex] = object
nextIndex++
}
objects.length = nextIndex
}
const PostProcessingPasses = () => { const PostProcessingPasses = () => {
const { gl: renderer, scene, camera } = useThree() const { gl: renderer, scene, camera } = useThree()
const renderPipelineRef = useRef<RenderPipeline | null>(null) const renderPipelineRef = useRef<RenderPipeline | null>(null)
@@ -138,6 +153,8 @@ const PostProcessingPasses = () => {
// Clear outliner arrays synchronously to prevent stale Object3D refs // Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes. // from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
@@ -289,6 +306,10 @@ 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)
const outliner = useViewer.getState().outliner
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
if (hasPipelineErrorRef.current || !renderPipelineRef.current) { if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
try { try {
if ((renderer as any).setClearAlpha) { if ((renderer as any).setClearAlpha) {