Merge remote-tracking branch 'origin/main' into feat/mcp-server
This commit is contained in:
@@ -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')
|
||||
@@ -20,26 +98,13 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
const px = position.getX(index)
|
||||
const py = position.getY(index)
|
||||
const pz = position.getZ(index)
|
||||
minX = Math.min(minX, px)
|
||||
minY = Math.min(minY, py)
|
||||
minZ = Math.min(minZ, pz)
|
||||
maxX = Math.max(maxX, px)
|
||||
maxY = Math.max(maxY, py)
|
||||
maxZ = Math.max(maxZ, pz)
|
||||
minX = Math.min(minX, position.getX(index))
|
||||
minY = Math.min(minY, position.getY(index))
|
||||
minZ = Math.min(minZ, position.getZ(index))
|
||||
}
|
||||
|
||||
const width = Math.max(maxX - minX, 0.001)
|
||||
const height = Math.max(maxY - minY, 0.001)
|
||||
const depth = Math.max(maxZ - minZ, 0.001)
|
||||
|
||||
for (let index = 0; index < position.count; index += 1) {
|
||||
const px = position.getX(index)
|
||||
const py = position.getY(index)
|
||||
@@ -52,14 +117,14 @@ function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
let v = 0
|
||||
|
||||
if (ny >= nx && ny >= nz) {
|
||||
u = (px - minX) / width
|
||||
v = (pz - minZ) / depth
|
||||
u = px - minX
|
||||
v = pz - minZ
|
||||
} else if (nx >= nz) {
|
||||
u = (pz - minZ) / depth
|
||||
v = (py - minY) / height
|
||||
u = pz - minZ
|
||||
v = py - minY
|
||||
} else {
|
||||
u = (px - minX) / width
|
||||
v = (py - minY) / height
|
||||
u = px - minX
|
||||
v = py - minY
|
||||
}
|
||||
|
||||
uvs[index * 2] = u
|
||||
@@ -84,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)
|
||||
@@ -100,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
|
||||
@@ -132,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
|
||||
@@ -154,16 +237,14 @@ 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])
|
||||
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())
|
||||
applyFenceUVs(merged)
|
||||
const mergedUv = merged.getAttribute('uv')
|
||||
if (mergedUv) {
|
||||
merged.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(mergedUv.array), 2))
|
||||
}
|
||||
merged.computeVertexNormals()
|
||||
return merged
|
||||
}
|
||||
@@ -178,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 = () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
|
||||
const csgEvaluator = new Evaluator()
|
||||
csgEvaluator.useGroups = true
|
||||
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
||||
csgEvaluator.attributes = ['position', 'normal']
|
||||
csgEvaluator.attributes = ['position', 'normal', 'uv']
|
||||
|
||||
function prepareBrushForCSG(brush: Brush) {
|
||||
brush.geometry.computeBoundsTree = computeBoundsTree
|
||||
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
|
||||
const _quaternion = new THREE.Quaternion()
|
||||
const _scale = new THREE.Vector3(1, 1, 1)
|
||||
const _yAxis = new THREE.Vector3(0, 1, 0)
|
||||
const _uvFaceNormal = new THREE.Vector3()
|
||||
|
||||
// Pending merged-roof updates carried across frames (for throttling)
|
||||
const pendingRoofUpdates = new Set<AnyNodeId>()
|
||||
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
|
||||
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
|
||||
}
|
||||
|
||||
ensureUv2Attribute(resultGeo)
|
||||
resultGeo.computeVertexNormals()
|
||||
mergedMesh.geometry.dispose()
|
||||
mergedMesh.geometry = resultGeo
|
||||
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
|
||||
wallBrush.geometry.dispose()
|
||||
innerBrush.geometry.dispose()
|
||||
|
||||
ensureUv2Attribute(resultGeo)
|
||||
resultGeo.computeVertexNormals()
|
||||
return resultGeo
|
||||
}
|
||||
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
|
||||
): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const indices: number[] = []
|
||||
const groups: { start: number; count: number; materialIndex: number }[] = []
|
||||
let vertexCount = 0
|
||||
@@ -974,6 +978,10 @@ function createGeometryFromFaces(
|
||||
normals.push(normal.x, normal.y, normal.z)
|
||||
normals.push(normal.x, normal.y, normal.z)
|
||||
|
||||
pushRoofUv(uvs, p0, normal)
|
||||
pushRoofUv(uvs, fi, normal)
|
||||
pushRoofUv(uvs, fi1, normal)
|
||||
|
||||
indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
|
||||
|
||||
faceVertexCount += 3
|
||||
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
geometry.setIndex(indices)
|
||||
|
||||
for (const g of groups) {
|
||||
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
|
||||
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
||||
const mergedGeo = mergeVertices(geometry, 1e-4)
|
||||
geometry.dispose()
|
||||
ensureUv2Attribute(mergedGeo)
|
||||
|
||||
return mergedGeo
|
||||
}
|
||||
|
||||
function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) {
|
||||
_uvFaceNormal.copy(normal).normalize()
|
||||
|
||||
const absX = Math.abs(_uvFaceNormal.x)
|
||||
const absY = Math.abs(_uvFaceNormal.y)
|
||||
const absZ = Math.abs(_uvFaceNormal.z)
|
||||
|
||||
if (absY >= absX && absY >= absZ) {
|
||||
uvs.push(point.x, point.z)
|
||||
return
|
||||
}
|
||||
|
||||
if (absX >= absZ) {
|
||||
uvs.push(point.z, point.y)
|
||||
return
|
||||
}
|
||||
|
||||
uvs.push(point.x, point.y)
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
const pendingStairUpdates = new Set<AnyNodeId>()
|
||||
const MAX_STAIRS_PER_FRAME = 2
|
||||
const MAX_SEGMENTS_PER_FRAME = 4
|
||||
const STAIR_TREAD_MATERIAL_INDEX = 0
|
||||
const STAIR_SIDE_MATERIAL_INDEX = 1
|
||||
const _uvPosition = new THREE.Vector3()
|
||||
const _uvNormal = new THREE.Vector3()
|
||||
|
||||
// ============================================================================
|
||||
// STAIR SYSTEM
|
||||
@@ -198,7 +202,7 @@ function generateStairSegmentGeometry(
|
||||
|
||||
shape.lineTo(0, 0)
|
||||
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
const extrudedGeometry = new THREE.ExtrudeGeometry(shape, {
|
||||
steps: 1,
|
||||
depth: width,
|
||||
bevelEnabled: false,
|
||||
@@ -209,7 +213,16 @@ function generateStairSegmentGeometry(
|
||||
const matrix = new THREE.Matrix4()
|
||||
matrix.makeRotationY(-Math.PI / 2)
|
||||
matrix.setPosition(width / 2, 0, 0)
|
||||
geometry.applyMatrix4(matrix)
|
||||
extrudedGeometry.applyMatrix4(matrix)
|
||||
extrudedGeometry.computeVertexNormals()
|
||||
|
||||
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
|
||||
if (geometry !== extrudedGeometry) {
|
||||
extrudedGeometry.dispose()
|
||||
}
|
||||
|
||||
applyStairSegmentUvs(geometry)
|
||||
ensureUv2Attribute(geometry)
|
||||
|
||||
return geometry
|
||||
}
|
||||
@@ -219,6 +232,7 @@ function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
|
||||
const absoluteHeight = computeAbsoluteHeight(node)
|
||||
|
||||
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
|
||||
applyStraightStairMaterialGroups(newGeometry)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeometry
|
||||
@@ -363,6 +377,7 @@ function updateMergedStairGeometry(
|
||||
}
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
|
||||
applyStraightStairMaterialGroups(merged)
|
||||
replaceMeshGeometry(mergedMesh, merged)
|
||||
|
||||
// Dispose individual geometries
|
||||
@@ -371,6 +386,108 @@ function updateMergedStairGeometry(
|
||||
}
|
||||
}
|
||||
|
||||
function applyStraightStairMaterialGroups(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
if (!position || position.count < 3) {
|
||||
geometry.clearGroups()
|
||||
return
|
||||
}
|
||||
|
||||
const index = geometry.getIndex()
|
||||
const triangleCount = index ? index.count / 3 : position.count / 3
|
||||
|
||||
if (!Number.isFinite(triangleCount) || triangleCount <= 0) {
|
||||
geometry.clearGroups()
|
||||
return
|
||||
}
|
||||
|
||||
const triangleMaterials: number[] = new Array(triangleCount)
|
||||
const v0 = new THREE.Vector3()
|
||||
const v1 = new THREE.Vector3()
|
||||
const v2 = new THREE.Vector3()
|
||||
const edge1 = new THREE.Vector3()
|
||||
const edge2 = new THREE.Vector3()
|
||||
const normal = new THREE.Vector3()
|
||||
|
||||
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex++) {
|
||||
const vertexOffset = triangleIndex * 3
|
||||
const a = index ? index.getX(vertexOffset) : vertexOffset
|
||||
const b = index ? index.getX(vertexOffset + 1) : vertexOffset + 1
|
||||
const c = index ? index.getX(vertexOffset + 2) : vertexOffset + 2
|
||||
|
||||
v0.fromBufferAttribute(position, a)
|
||||
v1.fromBufferAttribute(position, b)
|
||||
v2.fromBufferAttribute(position, c)
|
||||
|
||||
edge1.subVectors(v1, v0)
|
||||
edge2.subVectors(v2, v0)
|
||||
normal.crossVectors(edge1, edge2)
|
||||
|
||||
triangleMaterials[triangleIndex] =
|
||||
normal.lengthSq() > 0 && normal.normalize().y > 0.75
|
||||
? STAIR_TREAD_MATERIAL_INDEX
|
||||
: STAIR_SIDE_MATERIAL_INDEX
|
||||
}
|
||||
|
||||
geometry.clearGroups()
|
||||
|
||||
let currentMaterial = triangleMaterials[0]
|
||||
let groupStart = 0
|
||||
|
||||
for (let triangleIndex = 1; triangleIndex < triangleMaterials.length; triangleIndex++) {
|
||||
const materialIndex = triangleMaterials[triangleIndex]
|
||||
if (materialIndex === currentMaterial) continue
|
||||
|
||||
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
|
||||
groupStart = triangleIndex
|
||||
currentMaterial = materialIndex
|
||||
}
|
||||
|
||||
geometry.addGroup(
|
||||
groupStart * 3,
|
||||
(triangleMaterials.length - groupStart) * 3,
|
||||
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
|
||||
)
|
||||
}
|
||||
|
||||
function applyStairSegmentUvs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
|
||||
if (!position || !normal || position.count === 0) {
|
||||
geometry.deleteAttribute('uv')
|
||||
return
|
||||
}
|
||||
|
||||
const uv: number[] = []
|
||||
|
||||
for (let index = 0; index < position.count; index++) {
|
||||
_uvPosition.fromBufferAttribute(position, index)
|
||||
_uvNormal.fromBufferAttribute(normal, index).normalize()
|
||||
|
||||
const absX = Math.abs(_uvNormal.x)
|
||||
const absY = Math.abs(_uvNormal.y)
|
||||
const absZ = Math.abs(_uvNormal.z)
|
||||
|
||||
if (absY >= absX && absY >= absZ) {
|
||||
uv.push(_uvPosition.x, _uvPosition.z)
|
||||
} else if (absX >= absZ) {
|
||||
uv.push(_uvPosition.z, _uvPosition.y)
|
||||
} else {
|
||||
uv.push(_uvPosition.x, _uvPosition.y)
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv) return
|
||||
|
||||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEGMENT CHAINING
|
||||
// ============================================================================
|
||||
@@ -441,6 +558,8 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||
function createEmptyGeometry(): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
|
||||
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
|
||||
return geometry
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -7,20 +7,30 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||
import {
|
||||
calculateLevelMiters,
|
||||
getAdjacentWallIds,
|
||||
getWallMiterBoundaryPoints,
|
||||
type Point2D,
|
||||
type WallMiterData,
|
||||
pointToKey,
|
||||
type WallMiterData,
|
||||
} from './wall-mitering'
|
||||
|
||||
// Reusable CSG evaluator for better performance
|
||||
const csgEvaluator = new Evaluator()
|
||||
const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015
|
||||
const WALL_FACE_NORMAL_Y_EPSILON = 0.6
|
||||
const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003
|
||||
|
||||
type WallBoundaryEdgeTag = 'front' | 'back' | 'base'
|
||||
|
||||
type TaggedWallBoundaryEdge = {
|
||||
start: THREE.Vector2
|
||||
end: THREE.Vector2
|
||||
tag: WallBoundaryEdgeTag
|
||||
}
|
||||
|
||||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||||
const uv = geometry.getAttribute('uv')
|
||||
@@ -78,6 +88,207 @@ function insetCurvedWallBoundaryPointsFor3D(
|
||||
return next
|
||||
}
|
||||
|
||||
function addTaggedWallBoundaryEdge(
|
||||
edges: TaggedWallBoundaryEdge[],
|
||||
points: { x: number; z: number }[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
tag: WallBoundaryEdgeTag,
|
||||
) {
|
||||
const start = points[startIndex]
|
||||
const end = points[endIndex]
|
||||
if (!(start && end)) return
|
||||
if (Math.hypot(end.x - start.x, end.z - start.z) < 1e-6) return
|
||||
|
||||
edges.push({
|
||||
start: new THREE.Vector2(start.x, start.z),
|
||||
end: new THREE.Vector2(end.x, end.z),
|
||||
tag,
|
||||
})
|
||||
}
|
||||
|
||||
function buildTaggedWallBoundaryEdges(
|
||||
wall: WallNode,
|
||||
localPoints: { x: number; z: number }[],
|
||||
miterData: WallMiterData,
|
||||
): TaggedWallBoundaryEdge[] {
|
||||
if (localPoints.length < 2) return []
|
||||
|
||||
const edges: TaggedWallBoundaryEdge[] = []
|
||||
|
||||
if (isCurvedWall(wall)) {
|
||||
const sidePointCount = Math.floor(localPoints.length / 2)
|
||||
if (sidePointCount < 2) return edges
|
||||
|
||||
for (let index = 0; index < sidePointCount - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'back')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, sidePointCount - 1, sidePointCount, 'base')
|
||||
|
||||
for (let index = sidePointCount; index < localPoints.length - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'front')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
|
||||
return edges
|
||||
}
|
||||
|
||||
const startKey = pointToKey({ x: wall.start[0], y: wall.start[1] })
|
||||
const startJunction = miterData.junctionData.get(startKey)?.get(wall.id)
|
||||
const startLeftIndex = startJunction ? localPoints.length - 2 : localPoints.length - 1
|
||||
const endLeftIndex = startJunction ? localPoints.length - 3 : localPoints.length - 2
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, 0, 1, 'back')
|
||||
|
||||
for (let index = 1; index < endLeftIndex; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, endLeftIndex, startLeftIndex, 'front')
|
||||
|
||||
for (let index = startLeftIndex; index < localPoints.length - 1; index += 1) {
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, index, index + 1, 'base')
|
||||
}
|
||||
|
||||
addTaggedWallBoundaryEdge(edges, localPoints, localPoints.length - 1, 0, 'base')
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBoundaryEdge): number {
|
||||
const edgeDx = edge.end.x - edge.start.x
|
||||
const edgeDz = edge.end.y - edge.start.y
|
||||
const pointDx = point.x - edge.start.x
|
||||
const pointDz = point.y - edge.start.y
|
||||
const edgeLengthSq = edgeDx * edgeDx + edgeDz * edgeDz
|
||||
|
||||
if (edgeLengthSq < 1e-12) {
|
||||
return point.distanceTo(edge.start)
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((pointDx * edgeDx + pointDz * edgeDz) / edgeLengthSq, 0, 1)
|
||||
const closestX = edge.start.x + edgeDx * t
|
||||
const closestZ = edge.start.y + edgeDz * t
|
||||
|
||||
return Math.hypot(point.x - closestX, point.y - closestZ)
|
||||
}
|
||||
|
||||
function getWallFaceMaterialIndex(
|
||||
wall: Pick<WallNode, 'frontSide' | 'backSide'>,
|
||||
face: 'front' | 'back',
|
||||
): 0 | 1 | 2 {
|
||||
const semantic = face === 'front' ? wall.frontSide : wall.backSide
|
||||
const fallback = face === 'front' ? 1 : 2
|
||||
|
||||
if (semantic === 'interior') return 1
|
||||
if (semantic === 'exterior') return 2
|
||||
return fallback
|
||||
}
|
||||
|
||||
function assignWallMaterialGroups(
|
||||
geometry: THREE.BufferGeometry,
|
||||
wall: WallNode,
|
||||
boundaryEdges: TaggedWallBoundaryEdge[],
|
||||
) {
|
||||
const position = geometry.getAttribute('position')
|
||||
if (!position) return
|
||||
|
||||
const index = geometry.getIndex()
|
||||
const triangleCount = index ? Math.floor(index.count / 3) : Math.floor(position.count / 3)
|
||||
if (triangleCount === 0) {
|
||||
geometry.clearGroups()
|
||||
return
|
||||
}
|
||||
|
||||
const triangleMaterials = new Array<number>(triangleCount).fill(0)
|
||||
const a = new THREE.Vector3()
|
||||
const b = new THREE.Vector3()
|
||||
const c = new THREE.Vector3()
|
||||
const ab = new THREE.Vector3()
|
||||
const ac = new THREE.Vector3()
|
||||
const normal = new THREE.Vector3()
|
||||
const centroid = new THREE.Vector3()
|
||||
const projectedCentroid = new THREE.Vector2()
|
||||
const maxBoundaryDistance = Math.max(
|
||||
getWallThickness(wall) * 0.02,
|
||||
WALL_FACE_EDGE_DISTANCE_EPSILON,
|
||||
)
|
||||
|
||||
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
|
||||
const baseIndex = triangleIndex * 3
|
||||
const ia = index ? index.getX(baseIndex) : baseIndex
|
||||
const ib = index ? index.getX(baseIndex + 1) : baseIndex + 1
|
||||
const ic = index ? index.getX(baseIndex + 2) : baseIndex + 2
|
||||
|
||||
a.fromBufferAttribute(position, ia)
|
||||
b.fromBufferAttribute(position, ib)
|
||||
c.fromBufferAttribute(position, ic)
|
||||
|
||||
ab.subVectors(b, a)
|
||||
ac.subVectors(c, a)
|
||||
normal.crossVectors(ab, ac)
|
||||
|
||||
if (normal.lengthSq() < 1e-12) {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
normal.normalize()
|
||||
|
||||
if (Math.abs(normal.y) >= WALL_FACE_NORMAL_Y_EPSILON) {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
centroid
|
||||
.copy(a)
|
||||
.add(b)
|
||||
.add(c)
|
||||
.multiplyScalar(1 / 3)
|
||||
projectedCentroid.set(centroid.x, centroid.z)
|
||||
|
||||
let nearestTag: WallBoundaryEdgeTag | null = null
|
||||
let nearestDistance = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const edge of boundaryEdges) {
|
||||
const distance = distanceToWallBoundaryEdge(projectedCentroid, edge)
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance
|
||||
nearestTag = edge.tag
|
||||
}
|
||||
}
|
||||
|
||||
if (!nearestTag || nearestDistance > maxBoundaryDistance) {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (nearestTag === 'base') {
|
||||
triangleMaterials[triangleIndex] = 0
|
||||
continue
|
||||
}
|
||||
|
||||
triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag)
|
||||
}
|
||||
|
||||
geometry.clearGroups()
|
||||
|
||||
let currentMaterial = triangleMaterials[0] ?? 0
|
||||
let groupStart = 0
|
||||
|
||||
for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
|
||||
const materialIndex = triangleMaterials[triangleIndex] ?? 0
|
||||
if (materialIndex === currentMaterial) continue
|
||||
|
||||
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
|
||||
groupStart = triangleIndex
|
||||
currentMaterial = materialIndex
|
||||
}
|
||||
|
||||
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WALL SYSTEM
|
||||
// ============================================================================
|
||||
@@ -252,6 +463,7 @@ export function generateExtrudedWall(
|
||||
|
||||
// Convert polygon to local coordinates
|
||||
const localPoints = polyPoints.map(worldToLocal)
|
||||
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData)
|
||||
|
||||
// Build THREE.js shape
|
||||
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
|
||||
@@ -272,6 +484,7 @@ export function generateExtrudedWall(
|
||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(geometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(geometry)
|
||||
|
||||
// Apply CSG subtraction for cutouts (doors/windows)
|
||||
@@ -307,6 +520,7 @@ export function generateExtrudedWall(
|
||||
|
||||
const resultGeometry = resultBrush.geometry
|
||||
resultGeometry.computeVertexNormals()
|
||||
assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges)
|
||||
ensureUv2Attribute(resultGeometry)
|
||||
|
||||
return resultGeometry
|
||||
|
||||
Reference in New Issue
Block a user