Merge branch 'main' into feat/color-picker-tool

This commit is contained in:
Sudhir Yadav
2026-04-22 17:57:26 +05:30
committed by GitHub
31 changed files with 1104 additions and 149 deletions
+6
View File
@@ -54,6 +54,12 @@ export {
type ItemInteractiveState,
useInteractive,
} from './store/use-interactive'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
} from './store/history-control'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { FenceSystem } from './systems/fence/fence-system'
export { clearSceneHistory, default as useScene } from './store/use-scene'
+8 -2
View File
@@ -4,6 +4,11 @@ import {
isCurvedWall,
} from '../systems/wall/wall-curve'
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resumeSceneHistory,
} from '../store/history-control'
import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number }
@@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
const unsubscribe = sceneStore.subscribe((state: any) => {
if (isProcessing) return
if (getSceneHistoryPauseDepth() > 0) return
const nodes = state.nodes
const wallsByLevel = new Map<string, WallNode[]>()
@@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
}
isProcessing = true
sceneStore.temporal.getState().pause()
pauseSceneHistory(sceneStore)
try {
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
} finally {
sceneStore.temporal.getState().resume()
resumeSceneHistory(sceneStore)
previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot)
+2
View File
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
materialPreset: z.string().optional(),
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
curveOffset: z.number().optional(),
height: z.number().default(1.8),
thickness: z.number().default(0.08),
baseHeight: z.number().default(0.22),
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
- height/thickness: overall fence dimensions in meters
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
@@ -0,0 +1,36 @@
let sceneHistoryPauseDepth = 0
type TemporalStoreLike = {
temporal: {
getState(): {
pause(): void
resume(): void
}
}
}
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause()
}
sceneHistoryPauseDepth += 1
}
export function resumeSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
return
}
sceneHistoryPauseDepth -= 1
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().resume()
}
}
export function getSceneHistoryPauseDepth(): number {
return sceneHistoryPauseDepth
}
export function resetSceneHistoryPauseDepth(): void {
sceneHistoryPauseDepth = 0
}
+5 -2
View File
@@ -11,6 +11,7 @@ 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 { resetSceneHistoryPauseDepth } from './history-control'
import * as nodeActions from './actions/node-actions'
function getFiniteNumber(value: unknown, fallback: number) {
@@ -630,6 +631,7 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
export function clearSceneHistory() {
useScene.temporal.getState().clear()
resetSceneHistoryPauseDepth()
prevPastLength = 0
prevFutureLength = 0
prevNodesSnapshot = null
@@ -649,8 +651,9 @@ useScene.temporal.subscribe((state) => {
// Capture the previous snapshot before RAF fires
const snapshotBefore = prevNodesSnapshot
// Use RAF to ensure all middleware and store updates are complete
requestAnimationFrame(() => {
// Defer to a microtask so the scene store has settled before we diff,
// but still mark walls/items dirty before the next paint.
queueMicrotask(() => {
const currentNodes = useScene.getState().nodes
const { markDirty } = useScene.getState()
+128 -42
View File
@@ -4,12 +4,90 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, FenceNode } from '../../schema'
import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
type FencePart = {
position: [number, number, number]
rotationY?: number
scale: [number, number, number]
}
const MIN_CURVE_SEGMENT_LENGTH = 0.18
function createFencePartGeometry(part: FencePart) {
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
if (part.rotationY) {
geometry.rotateY(part.rotationY)
}
geometry.translate(part.position[0], part.position[1], part.position[2])
applyFenceUVs(geometry)
return geometry
}
function getFencePointAt(fence: FenceNode, t: number) {
const frame = getWallCurveFrameAt(fence, t)
return {
point: frame.point,
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
}
}
function createStraightFenceSpanPart(
start: [number, number],
end: [number, number],
centerY: number,
height: number,
depth: number,
): FencePart | null {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length <= 1e-4) {
return null
}
return {
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
rotationY: -Math.atan2(dz, dx),
scale: [length, height, depth],
}
}
function createFenceCurveSpanParts(
fence: FenceNode,
startT: number,
endT: number,
centerY: number,
height: number,
depth: number,
): FencePart[] {
const parts: FencePart[] = []
const frameCount = Math.max(
1,
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
)
let previous = getFencePointAt(fence, startT)
for (let index = 1; index <= frameCount; index += 1) {
const t = startT + (endT - startT) * (index / frameCount)
const current = getFencePointAt(fence, t)
const segment = createStraightFenceSpanPart(
[previous.point.x, previous.point.y],
[current.point.x, current.point.y],
centerY,
height,
depth,
)
if (segment) {
parts.push(segment)
}
previous = current
}
return parts
}
function applyFenceUVs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
@@ -71,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
function createFenceParts(fence: FenceNode): FencePart[] {
const parts: FencePart[] = []
const length = Math.max(
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
0.01,
)
const length = Math.max(getWallCurveLength(fence), 0.01)
const panelDepth = Math.max(fence.thickness, 0.03)
const clearance = Math.max(fence.groundClearance, 0)
const styleDefaults = getStyleDefaults(fence.style)
@@ -87,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const isFloating = fence.baseStyle === 'floating'
const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight
const startInsetT = Math.min(0.499, edgeInset / length)
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
if (!isFloating) {
parts.push({
position: [0, baseY + effectiveBaseHeight / 2, 0],
scale: [length, effectiveBaseHeight, panelDepth * 1.05],
})
parts.push({
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0],
scale: [length, topRailHeight * 0.8, panelDepth * 0.35],
})
parts.push(
...createFenceCurveSpanParts(
fence,
0,
1,
baseY + effectiveBaseHeight / 2,
effectiveBaseHeight,
panelDepth * 1.05,
),
)
parts.push(
...createFenceCurveSpanParts(
fence,
0,
1,
baseY + effectiveBaseHeight + verticalHeight * 0.15,
topRailHeight * 0.8,
panelDepth * 0.35,
),
)
}
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
const startX = -length / 2 + edgeInset
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
for (let index = 0; index < count; index += 1) {
const x = count === 1 ? 0 : startX + step * index
let posX = x
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
const frame = getFencePointAt(fence, t)
const isEdgePost = index === 0 || index === count - 1
if (count > 1) {
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
}
const postHeight =
isFloating && isEdgePost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
@@ -119,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
parts.push({
position: [posX, postY, 0],
position: [frame.point.x, postY, frame.point.y],
rotationY: -frame.tangentAngle,
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
})
}
parts.push({
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0],
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
})
parts.push(
...createFenceCurveSpanParts(
fence,
0,
1,
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
topRailHeight,
Math.max(panelDepth * 0.55, 0.018),
),
)
if (isFloating) {
parts.push({
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0],
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
})
parts.push(
...createFenceCurveSpanParts(
fence,
0,
1,
baseY + effectiveBaseHeight + topRailHeight / 2,
topRailHeight,
Math.max(panelDepth * 0.55, 0.018),
),
)
}
return parts
@@ -141,13 +237,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
function generateFenceGeometry(fence: FenceNode) {
const parts = createFenceParts(fence)
const geometries = parts.map((part) => {
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
applyFenceUVs(geometry)
geometry.translate(part.position[0], part.position[1], part.position[2])
return geometry
})
const geometries = parts.map(createFencePartGeometry)
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose())
@@ -169,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
const newGeometry = generateFenceGeometry(node)
mesh.geometry.dispose()
mesh.geometry = newGeometry
const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
mesh.position.set(centerX, 0, centerZ)
mesh.rotation.set(0, -angle, 0)
mesh.position.set(0, 0, 0)
mesh.rotation.set(0, 0, 0)
}
export const FenceSystem = () => {
+3 -3
View File
@@ -1,10 +1,10 @@
import type { Point2D } from './wall-mitering'
import type { WallNode } from '../../schema'
import type { FenceNode, WallNode } from '../../schema'
const CURVE_EPSILON = 1e-6
const DEFAULT_SAMPLE_SEGMENTS = 24
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'>
type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
type CurveFrame = {
point: Point2D
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
}
export function getWallSurfacePolygon(
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
segments = DEFAULT_SAMPLE_SEGMENTS,
miterOverrides?: WallSurfaceMiterOverrides,
) {