Merge pull request #255 from sudhir9297/feat/wall-room-creation
Feat: wall room creation and snap, stairs system
This commit is contained in:
@@ -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
@@ -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
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +387,78 @@ 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 ref={groupRef}>
|
<group>
|
||||||
<Html
|
<group ref={groupRef}>
|
||||||
center
|
<Html
|
||||||
style={{
|
center
|
||||||
pointerEvents: 'auto',
|
style={{
|
||||||
touchAction: 'none',
|
pointerEvents: 'auto',
|
||||||
}}
|
touchAction: 'none',
|
||||||
zIndexRange={[100, 0]}
|
}}
|
||||||
>
|
zIndexRange={[100, 0]}
|
||||||
<NodeActionMenu
|
>
|
||||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
<NodeActionMenu
|
||||||
onCurve={canCurveSelectedWall ? handleCurve : undefined}
|
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||||
onDelete={handleDelete}
|
onCurve={canCurveSelectedWall ? handleCurve : undefined}
|
||||||
onDuplicate={
|
onDelete={handleDelete}
|
||||||
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
|
onDuplicate={
|
||||||
? handleDuplicate
|
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
|
||||||
: undefined
|
? handleDuplicate
|
||||||
}
|
: undefined
|
||||||
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
|
}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
|
||||||
onPointerUp={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
/>
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
</Html>
|
/>
|
||||||
|
</Html>
|
||||||
|
</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>
|
</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,
|
||||||
const previewPolygon = getWallPlanFootprint(
|
points: formatPolygonPoints(polygon),
|
||||||
getFloorplanWall(previewWall),
|
}
|
||||||
EMPTY_WALL_MITER_DATA,
|
})
|
||||||
)
|
}, [displayFloorplanWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
|
||||||
|
|
||||||
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 && (
|
||||||
|
|||||||
+272
@@ -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()
|
||||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
if (curveOffset !== getClampedWallCurveOffset(node)) {
|
||||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||||
|
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,12 +114,14 @@ 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
|
||||||
wallId: node.id,
|
? []
|
||||||
wallParentId: node.parentId ?? null,
|
: getLinkedWallSnapshots({
|
||||||
originalStart: node.start,
|
wallId: node.id,
|
||||||
originalEnd: node.end,
|
wallParentId: node.parentId ?? null,
|
||||||
}),
|
originalStart: node.start,
|
||||||
|
originalEnd: node.end,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||||
const nodeIdRef = useRef(node.id)
|
const nodeIdRef = useRef(node.id)
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
<mesh castShadow material={material} name="merged-stair" receiveShadow>
|
{isSegmentBasedStair ? (
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<mesh castShadow material={material} name="merged-stair" receiveShadow>
|
||||||
</mesh>
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
{node.stairType === 'curved' || node.stairType === 'spiral' ? (
|
</mesh>
|
||||||
<CurvedStairBody material={material} stair={node} />
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isSegmentBasedStair ? <CurvedStairBody material={material} stair={node} /> : null}
|
||||||
<StairRailings material={material} stair={node} />
|
<StairRailings material={material} stair={node} />
|
||||||
<group name="segments-wrapper" visible={false}>
|
{isSegmentBasedStair ? (
|
||||||
{(node.children ?? []).map((childId) => (
|
<group name="segments-wrapper" visible={false}>
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
{(node.children ?? []).map((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,14 +275,15 @@ 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
|
|
||||||
|
|
||||||
if (!(previousPoint && currentPoint)) {
|
if (!(previousPoint && currentPoint)) {
|
||||||
return null
|
return 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' &&
|
||||||
@@ -565,32 +686,39 @@ function buildStairRailPaths(
|
|||||||
? (['front', 'left'] as const)
|
? (['front', 'left'] as const)
|
||||||
: (['left', 'right'] as const)
|
: (['left', 'right'] as const)
|
||||||
: hideLandingRailing
|
: hideLandingRailing
|
||||||
? visualTurnSide === 'left'
|
? visualTurnSide === 'left'
|
||||||
? (['front', 'right'] as const)
|
|
||||||
: visualTurnSide === 'right'
|
|
||||||
? (['front', 'left'] as const)
|
|
||||||
: (['left', 'right'] as const)
|
|
||||||
: layout.segment.segmentType === 'landing'
|
|
||||||
? nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'left'
|
|
||||||
? (['front', 'right'] as const)
|
? (['front', 'right'] as const)
|
||||||
: nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'right'
|
: visualTurnSide === 'right'
|
||||||
? (['front', 'left'] as const)
|
? (['front', 'left'] as const)
|
||||||
: visualTurnSide === 'left'
|
: (['left', 'right'] as const)
|
||||||
? (['right'] as const)
|
: layout.segment.segmentType === 'landing'
|
||||||
: visualTurnSide === 'right'
|
? nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'left'
|
||||||
? (['left'] as const)
|
? (['front', 'right'] as const)
|
||||||
: (['left', 'right'] as const)
|
: nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'right'
|
||||||
: (['left', 'right'] as const)
|
? (['front', 'left'] as const)
|
||||||
|
: visualTurnSide === 'left'
|
||||||
|
? (['right'] as const)
|
||||||
|
: visualTurnSide === 'right'
|
||||||
|
? (['left'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
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,28 +757,29 @@ 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]
|
: isTerminalLandingBeforeStair
|
||||||
: isTerminalLandingBeforeStair
|
? railingMode === 'left'
|
||||||
? railingMode === 'left'
|
? terminalNextAttachmentSide === 'right'
|
||||||
? terminalNextAttachmentSide === 'right'
|
? (['front', 'left'] as const)
|
||||||
? (['front', 'left'] as const)
|
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
|
||||||
|
? (['left'] as const)
|
||||||
|
: ([] as StairRailPathSide[])
|
||||||
|
: railingMode === 'right'
|
||||||
|
? terminalNextAttachmentSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
|
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
|
||||||
? (['left'] as const)
|
? (['right'] as const)
|
||||||
: ([] as StairRailPathSide[])
|
: ([] as StairRailPathSide[])
|
||||||
: railingMode === 'right'
|
: [railingMode]
|
||||||
? terminalNextAttachmentSide === 'left'
|
|
||||||
? (['front', 'right'] as const)
|
|
||||||
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
|
|
||||||
? (['right'] as const)
|
|
||||||
: ([] as StairRailPathSide[])
|
|
||||||
: [railingMode]
|
|
||||||
: isStraightLineDoubleLandingLayout
|
: isStraightLineDoubleLandingLayout
|
||||||
? [railingMode]
|
? [railingMode]
|
||||||
: isMiddleLandingBetweenFlights && railingMode === 'left'
|
: isMiddleLandingBetweenFlights && railingMode === 'left'
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user