feat(paint-slots): full world-space UVs for roof gable walls (match walls)
The earlier gable fix world-referenced only V, leaving U in segment-local space — so gable and wall lined up vertically but slid horizontally. Replace the V-only scalar offset with the segment's full world matrix (roof group composed with segment transform) and project vertical faces exactly like the wall kind: U = ±worldX/Z, V = 1 - worldY. Gable now tiles continuously into the walls in both axes. Sloped shingle UVs are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
65a7fcb74b
commit
faf73d66f2
@@ -54,22 +54,59 @@ const _uvFaceNormal = new THREE.Vector3()
|
||||
const _uvWorldDown = new THREE.Vector3(0, -1, 0)
|
||||
const _uvDownSlope = new THREE.Vector3()
|
||||
const _uvAcrossSlope = new THREE.Vector3()
|
||||
// World Y of the segment whose geometry is currently being built. Vertical
|
||||
// (gable wall) faces tile their V in WORLD space (`V = 1 - worldY`) so the band
|
||||
// lines up with the wall below — which THREE's ExtrudeGeometry UVs map as
|
||||
// `1 - height` from the wall base, i.e. `1 - worldY` for a ground-floor wall.
|
||||
// Set around each segment build via `withSegmentUvWorldY`, otherwise 0.
|
||||
let _segmentUvWorldY = 0
|
||||
// World transform of the segment whose geometry is currently being built, so
|
||||
// vertical (gable wall) faces project their UVs in WORLD space — identical to
|
||||
// the wall kind's `applyWorldPlanarWallUVs` (U = ±worldX/Z, V = 1 - worldY) so
|
||||
// the gable band tiles continuously into the walls below. Identity = local
|
||||
// (the default outside a segment build). Set via `withSegmentUvMatrix`.
|
||||
const _segmentUvMatrix = new THREE.Matrix4()
|
||||
const _segmentUvNormalMatrix = new THREE.Matrix3()
|
||||
const _uvWorldPoint = new THREE.Vector3()
|
||||
const _uvWorldNormal = new THREE.Vector3()
|
||||
const _prevSegmentUvMatrix = new THREE.Matrix4()
|
||||
const _prevSegmentUvNormalMatrix = new THREE.Matrix3()
|
||||
|
||||
function withSegmentUvWorldY<T>(worldY: number, build: () => T): T {
|
||||
const previous = _segmentUvWorldY
|
||||
_segmentUvWorldY = worldY
|
||||
function withSegmentUvMatrix<T>(matrix: THREE.Matrix4, build: () => T): T {
|
||||
_prevSegmentUvMatrix.copy(_segmentUvMatrix)
|
||||
_prevSegmentUvNormalMatrix.copy(_segmentUvNormalMatrix)
|
||||
_segmentUvMatrix.copy(matrix)
|
||||
_segmentUvNormalMatrix.getNormalMatrix(matrix)
|
||||
try {
|
||||
return build()
|
||||
} finally {
|
||||
_segmentUvWorldY = previous
|
||||
_segmentUvMatrix.copy(_prevSegmentUvMatrix)
|
||||
_segmentUvNormalMatrix.copy(_prevSegmentUvNormalMatrix)
|
||||
}
|
||||
}
|
||||
|
||||
// Scratch matrices for composing a segment's world transform (roof group ∘
|
||||
// segment) at each build, without per-call allocation.
|
||||
const _segWorldMatrix = new THREE.Matrix4()
|
||||
const _roofGroupMatrix = new THREE.Matrix4()
|
||||
const _segLocalMatrix = new THREE.Matrix4()
|
||||
const _uvTmpPos = new THREE.Vector3()
|
||||
const _uvTmpQuat = new THREE.Quaternion()
|
||||
const _uvUnitScale = new THREE.Vector3(1, 1, 1)
|
||||
|
||||
/** Compose the world transform `T(roofPos)·Ry(roofRot) · T(segPos)·Ry(segRot)`. */
|
||||
function composeSegmentWorldMatrix(
|
||||
roofPosition: readonly number[] | undefined,
|
||||
roofRotation: number,
|
||||
segPosition: readonly number[],
|
||||
segRotation: number,
|
||||
): THREE.Matrix4 {
|
||||
_roofGroupMatrix.compose(
|
||||
_uvTmpPos.set(roofPosition?.[0] ?? 0, roofPosition?.[1] ?? 0, roofPosition?.[2] ?? 0),
|
||||
_uvTmpQuat.setFromAxisAngle(_yAxis, roofRotation),
|
||||
_uvUnitScale,
|
||||
)
|
||||
_segLocalMatrix.compose(
|
||||
_uvTmpPos.set(segPosition[0] ?? 0, segPosition[1] ?? 0, segPosition[2] ?? 0),
|
||||
_uvTmpQuat.setFromAxisAngle(_yAxis, segRotation),
|
||||
_uvUnitScale,
|
||||
)
|
||||
return _segWorldMatrix.multiplyMatrices(_roofGroupMatrix, _segLocalMatrix)
|
||||
}
|
||||
const _tmpVec3A = new THREE.Vector3()
|
||||
const _tmpVec3B = new THREE.Vector3()
|
||||
const _surfaceRay = new THREE.Ray()
|
||||
@@ -392,8 +429,14 @@ function updateMergedRoofGeometry(
|
||||
let totalInner: Brush | null = null
|
||||
|
||||
for (const child of children) {
|
||||
const brushes = withSegmentUvWorldY(roofNode.position[1] + child.position[1], () =>
|
||||
getRoofSegmentBrushes(child),
|
||||
const brushes = withSegmentUvMatrix(
|
||||
composeSegmentWorldMatrix(
|
||||
roofNode.position,
|
||||
roofNode.rotation ?? 0,
|
||||
child.position,
|
||||
child.rotation ?? 0,
|
||||
),
|
||||
() => getRoofSegmentBrushes(child),
|
||||
)
|
||||
if (!brushes) continue
|
||||
|
||||
@@ -871,10 +914,20 @@ export function generateRoofSegmentGeometry(
|
||||
nodes?: Record<string, AnyNode>,
|
||||
): THREE.BufferGeometry {
|
||||
const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined
|
||||
const parentRoofWorldY =
|
||||
parentRoof && 'position' in parentRoof ? ((parentRoof.position as number[])[1] ?? 0) : 0
|
||||
const brushes = withSegmentUvWorldY(parentRoofWorldY + node.position[1], () =>
|
||||
getRoofSegmentBrushes(node),
|
||||
const parentRoofPosition =
|
||||
parentRoof && 'position' in parentRoof ? (parentRoof.position as number[]) : undefined
|
||||
const parentRoofRotation =
|
||||
parentRoof && 'rotation' in parentRoof
|
||||
? ((parentRoof as { rotation?: number }).rotation ?? 0)
|
||||
: 0
|
||||
const brushes = withSegmentUvMatrix(
|
||||
composeSegmentWorldMatrix(
|
||||
parentRoofPosition,
|
||||
parentRoofRotation,
|
||||
node.position,
|
||||
node.rotation ?? 0,
|
||||
),
|
||||
() => getRoofSegmentBrushes(node),
|
||||
)
|
||||
if (!brushes) {
|
||||
// Fallback: simple box
|
||||
@@ -1342,14 +1395,19 @@ function createGeometryFromFaces(
|
||||
}
|
||||
|
||||
function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) {
|
||||
_uvFaceNormal.copy(normal).normalize()
|
||||
// Project in WORLD space (via the current segment's world transform) so
|
||||
// vertical gable faces tile identically to the walls below; for a local
|
||||
// build the matrix is identity and this is the original behaviour.
|
||||
const p = _uvWorldPoint.copy(point).applyMatrix4(_segmentUvMatrix)
|
||||
_uvFaceNormal.copy(normal).applyMatrix3(_segmentUvNormalMatrix).normalize()
|
||||
_uvWorldNormal.copy(_uvFaceNormal)
|
||||
|
||||
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)
|
||||
uvs.push(p.x, p.z)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1358,20 +1416,20 @@ function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3)
|
||||
if (_uvDownSlope.lengthSq() > 1e-8) {
|
||||
_uvDownSlope.normalize()
|
||||
_uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize()
|
||||
uvs.push(point.dot(_uvAcrossSlope), point.dot(_uvDownSlope))
|
||||
uvs.push(p.dot(_uvAcrossSlope), p.dot(_uvDownSlope))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Vertical (gable wall) faces: V tiles in world space so the band aligns with
|
||||
// the wall below (see `_segmentUvWorldY`). U stays in the face's local run.
|
||||
const wallV = 1 - (point.y + _segmentUvWorldY)
|
||||
// Vertical (gable wall) faces: U = ±worldX/Z (axis across the face normal),
|
||||
// V = 1 - worldY — the same world-space projection the wall kind uses.
|
||||
const wallV = 1 - p.y
|
||||
if (absX >= absZ) {
|
||||
uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, wallV)
|
||||
uvs.push(_uvWorldNormal.x >= 0 ? p.z : -p.z, wallV)
|
||||
return
|
||||
}
|
||||
|
||||
uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, wallV)
|
||||
uvs.push(_uvWorldNormal.z >= 0 ? p.x : -p.x, wallV)
|
||||
}
|
||||
|
||||
// ─── Skylight cutout ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user