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 _uvWorldDown = new THREE.Vector3(0, -1, 0)
|
||||||
const _uvDownSlope = new THREE.Vector3()
|
const _uvDownSlope = new THREE.Vector3()
|
||||||
const _uvAcrossSlope = new THREE.Vector3()
|
const _uvAcrossSlope = new THREE.Vector3()
|
||||||
// World Y of the segment whose geometry is currently being built. Vertical
|
// World transform of the segment whose geometry is currently being built, so
|
||||||
// (gable wall) faces tile their V in WORLD space (`V = 1 - worldY`) so the band
|
// vertical (gable wall) faces project their UVs in WORLD space — identical to
|
||||||
// lines up with the wall below — which THREE's ExtrudeGeometry UVs map as
|
// the wall kind's `applyWorldPlanarWallUVs` (U = ±worldX/Z, V = 1 - worldY) so
|
||||||
// `1 - height` from the wall base, i.e. `1 - worldY` for a ground-floor wall.
|
// the gable band tiles continuously into the walls below. Identity = local
|
||||||
// Set around each segment build via `withSegmentUvWorldY`, otherwise 0.
|
// (the default outside a segment build). Set via `withSegmentUvMatrix`.
|
||||||
let _segmentUvWorldY = 0
|
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 {
|
function withSegmentUvMatrix<T>(matrix: THREE.Matrix4, build: () => T): T {
|
||||||
const previous = _segmentUvWorldY
|
_prevSegmentUvMatrix.copy(_segmentUvMatrix)
|
||||||
_segmentUvWorldY = worldY
|
_prevSegmentUvNormalMatrix.copy(_segmentUvNormalMatrix)
|
||||||
|
_segmentUvMatrix.copy(matrix)
|
||||||
|
_segmentUvNormalMatrix.getNormalMatrix(matrix)
|
||||||
try {
|
try {
|
||||||
return build()
|
return build()
|
||||||
} finally {
|
} 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 _tmpVec3A = new THREE.Vector3()
|
||||||
const _tmpVec3B = new THREE.Vector3()
|
const _tmpVec3B = new THREE.Vector3()
|
||||||
const _surfaceRay = new THREE.Ray()
|
const _surfaceRay = new THREE.Ray()
|
||||||
@@ -392,8 +429,14 @@ function updateMergedRoofGeometry(
|
|||||||
let totalInner: Brush | null = null
|
let totalInner: Brush | null = null
|
||||||
|
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
const brushes = withSegmentUvWorldY(roofNode.position[1] + child.position[1], () =>
|
const brushes = withSegmentUvMatrix(
|
||||||
getRoofSegmentBrushes(child),
|
composeSegmentWorldMatrix(
|
||||||
|
roofNode.position,
|
||||||
|
roofNode.rotation ?? 0,
|
||||||
|
child.position,
|
||||||
|
child.rotation ?? 0,
|
||||||
|
),
|
||||||
|
() => getRoofSegmentBrushes(child),
|
||||||
)
|
)
|
||||||
if (!brushes) continue
|
if (!brushes) continue
|
||||||
|
|
||||||
@@ -871,10 +914,20 @@ export function generateRoofSegmentGeometry(
|
|||||||
nodes?: Record<string, AnyNode>,
|
nodes?: Record<string, AnyNode>,
|
||||||
): THREE.BufferGeometry {
|
): THREE.BufferGeometry {
|
||||||
const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined
|
const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined
|
||||||
const parentRoofWorldY =
|
const parentRoofPosition =
|
||||||
parentRoof && 'position' in parentRoof ? ((parentRoof.position as number[])[1] ?? 0) : 0
|
parentRoof && 'position' in parentRoof ? (parentRoof.position as number[]) : undefined
|
||||||
const brushes = withSegmentUvWorldY(parentRoofWorldY + node.position[1], () =>
|
const parentRoofRotation =
|
||||||
getRoofSegmentBrushes(node),
|
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) {
|
if (!brushes) {
|
||||||
// Fallback: simple box
|
// Fallback: simple box
|
||||||
@@ -1342,14 +1395,19 @@ function createGeometryFromFaces(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) {
|
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 absX = Math.abs(_uvFaceNormal.x)
|
||||||
const absY = Math.abs(_uvFaceNormal.y)
|
const absY = Math.abs(_uvFaceNormal.y)
|
||||||
const absZ = Math.abs(_uvFaceNormal.z)
|
const absZ = Math.abs(_uvFaceNormal.z)
|
||||||
|
|
||||||
if (absY >= absX && absY >= absZ) {
|
if (absY >= absX && absY >= absZ) {
|
||||||
uvs.push(point.x, point.z)
|
uvs.push(p.x, p.z)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,20 +1416,20 @@ function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3)
|
|||||||
if (_uvDownSlope.lengthSq() > 1e-8) {
|
if (_uvDownSlope.lengthSq() > 1e-8) {
|
||||||
_uvDownSlope.normalize()
|
_uvDownSlope.normalize()
|
||||||
_uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize()
|
_uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize()
|
||||||
uvs.push(point.dot(_uvAcrossSlope), point.dot(_uvDownSlope))
|
uvs.push(p.dot(_uvAcrossSlope), p.dot(_uvDownSlope))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical (gable wall) faces: V tiles in world space so the band aligns with
|
// Vertical (gable wall) faces: U = ±worldX/Z (axis across the face normal),
|
||||||
// the wall below (see `_segmentUvWorldY`). U stays in the face's local run.
|
// V = 1 - worldY — the same world-space projection the wall kind uses.
|
||||||
const wallV = 1 - (point.y + _segmentUvWorldY)
|
const wallV = 1 - p.y
|
||||||
if (absX >= absZ) {
|
if (absX >= absZ) {
|
||||||
uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, wallV)
|
uvs.push(_uvWorldNormal.x >= 0 ? p.z : -p.z, wallV)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, wallV)
|
uvs.push(_uvWorldNormal.z >= 0 ? p.x : -p.x, wallV)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Skylight cutout ─────────────────────────────────────────────────
|
// ─── Skylight cutout ─────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user