gable-roof back
This commit is contained in:
@@ -4,6 +4,18 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import type { AnyNodeId, RoofNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
// ============================================================================
|
||||
// ROOF GEOMETRY CONSTANTS
|
||||
// ============================================================================
|
||||
|
||||
const THICKNESS_A = 0.05 // Roof cover thickness (5cm)
|
||||
const THICKNESS_B = 0.1 // Structure thickness (10cm)
|
||||
const ROOF_COVER_OVERHANG = 0.05 // Extension of cover past structure (5cm)
|
||||
const EAVE_OVERHANG = 0.4 // Horizontal eave overhang (40cm)
|
||||
const RAKE_OVERHANG = 0.3 // Overhang at gable ends (30cm)
|
||||
const WALL_THICKNESS = 0.2 // Gable wall thickness (20cm)
|
||||
const BASE_HEIGHT = 0.5 // Base height / knee wall / truss heel (50cm)
|
||||
|
||||
// ============================================================================
|
||||
// ROOF SYSTEM
|
||||
// ============================================================================
|
||||
@@ -49,81 +61,257 @@ function updateRoofGeometry(node: RoofNode, mesh: THREE.Mesh) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates gable roof geometry from length, height, leftWidth, rightWidth
|
||||
*
|
||||
* The roof is centered at origin (position applied via mesh transform)
|
||||
* - Ridge runs along the X axis (length direction)
|
||||
* - Left slope goes down toward -Z with horizontal distance leftWidth
|
||||
* - Right slope goes down toward +Z with horizontal distance rightWidth
|
||||
* - Total width = leftWidth + rightWidth
|
||||
* - Gable ends at -X/2 and +X/2
|
||||
* Helper to solve pitch angle analytically given rise, run and thicknesses
|
||||
* Solves: run * tan(a) + (ThickA + ThickB)/cos(a) = rise
|
||||
*/
|
||||
function solvePitch(rise: number, run: number, thickA: number, thickB: number): number {
|
||||
const T = thickA + thickB
|
||||
if (run < 0.01) return 0
|
||||
|
||||
const R = Math.sqrt(run * run + rise * rise)
|
||||
if (R <= T) {
|
||||
return Math.atan2(rise, run) * 0.5 // Fallback
|
||||
}
|
||||
|
||||
const phi = Math.atan2(rise, run)
|
||||
const shift = Math.asin(T / R)
|
||||
|
||||
return phi - shift
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a Three.js Shape from polygon points
|
||||
*/
|
||||
function createShape(points: { x: number; y: number }[]): THREE.Shape {
|
||||
const shape = new THREE.Shape()
|
||||
if (points.length === 0) return shape
|
||||
const firstPoint = points[0]
|
||||
if (!firstPoint) return shape
|
||||
shape.moveTo(firstPoint.x, firstPoint.y)
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const point = points[i]
|
||||
if (point) {
|
||||
shape.lineTo(point.x, point.y)
|
||||
}
|
||||
}
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate profile for one side of the roof (left or right)
|
||||
*/
|
||||
function getSideProfile(
|
||||
dir: 1 | -1,
|
||||
width: number,
|
||||
roofHeight: number,
|
||||
): {
|
||||
pointsA: { x: number; y: number }[]
|
||||
pointsB: { x: number; y: number }[]
|
||||
pointsSide: { x: number; y: number }[]
|
||||
pointsC1: { x: number; y: number }[]
|
||||
pointsC2: { x: number; y: number }[]
|
||||
} {
|
||||
const halfWall = WALL_THICKNESS / 2
|
||||
|
||||
const rise = Math.max(0, roofHeight - BASE_HEIGHT)
|
||||
const run = width - halfWall
|
||||
|
||||
const angle = solvePitch(rise, run, THICKNESS_A, THICKNESS_B)
|
||||
const tanA = Math.tan(angle)
|
||||
const cosA = Math.cos(angle)
|
||||
const sinA = Math.sin(angle)
|
||||
|
||||
const ridgeUnderY = BASE_HEIGHT + run * tanA
|
||||
const ridgeInterfaceY = ridgeUnderY + THICKNESS_B / cosA
|
||||
const ridgeTopY = ridgeInterfaceY + THICKNESS_A / cosA
|
||||
|
||||
const wallOuterTopY = BASE_HEIGHT - WALL_THICKNESS * tanA
|
||||
|
||||
const overhangDx = EAVE_OVERHANG * cosA
|
||||
|
||||
const eaveTopZ = width + halfWall + overhangDx
|
||||
const eaveTopY = ridgeTopY - eaveTopZ * tanA
|
||||
|
||||
const coverExtDx = ROOF_COVER_OVERHANG * cosA
|
||||
const coverExtDy = ROOF_COVER_OVERHANG * sinA
|
||||
|
||||
const eaveTopExtZ = eaveTopZ + coverExtDx
|
||||
const eaveTopExtY = eaveTopY - coverExtDy
|
||||
|
||||
const eaveInterfaceExtZ = eaveTopExtZ - THICKNESS_A * sinA
|
||||
const eaveInterfaceExtY = eaveTopExtY - THICKNESS_A * cosA
|
||||
|
||||
const eaveInterfaceZ = eaveTopZ
|
||||
|
||||
const eaveBottomZ = eaveTopZ
|
||||
const eaveBottomY = ridgeUnderY - eaveTopZ * tanA
|
||||
|
||||
// Layer A (Cover)
|
||||
const pointsA = [
|
||||
{ x: 0, y: ridgeTopY },
|
||||
{ x: dir * eaveTopExtZ, y: eaveTopExtY },
|
||||
{ x: dir * eaveInterfaceExtZ, y: eaveInterfaceExtY },
|
||||
{ x: 0, y: ridgeInterfaceY },
|
||||
]
|
||||
|
||||
// Layer B (Structure)
|
||||
const pointsB = [
|
||||
{ x: 0, y: ridgeInterfaceY },
|
||||
{ x: dir * eaveInterfaceZ, y: ridgeInterfaceY - eaveTopZ * tanA },
|
||||
{ x: dir * eaveBottomZ, y: eaveBottomY },
|
||||
{ x: 0, y: ridgeUnderY },
|
||||
]
|
||||
|
||||
// Side Wall
|
||||
const zInner = width - halfWall
|
||||
const zOuter = width + halfWall
|
||||
|
||||
const pointsSide = [
|
||||
{ x: dir * zInner, y: 0 },
|
||||
{ x: dir * zOuter, y: 0 },
|
||||
{ x: dir * zOuter, y: Math.max(0, wallOuterTopY) },
|
||||
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||
]
|
||||
|
||||
// Gable Top (C1)
|
||||
const pointsC1 = [
|
||||
{ x: 0, y: BASE_HEIGHT },
|
||||
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||
{ x: 0, y: ridgeUnderY },
|
||||
]
|
||||
|
||||
// Gable Base (C2)
|
||||
const pointsC2 = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: dir * zInner, y: 0 },
|
||||
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||
{ x: 0, y: BASE_HEIGHT },
|
||||
]
|
||||
|
||||
return { pointsA, pointsB, pointsSide, pointsC1, pointsC2 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates detailed gable roof geometry with layers, walls, and overhangs
|
||||
*/
|
||||
export function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry {
|
||||
const { length, height, leftWidth, rightWidth } = roofNode
|
||||
|
||||
// Half length for centering
|
||||
const halfLength = length / 2
|
||||
const ridgeLength = length
|
||||
|
||||
// Ridge is at Y = height, centered at Z = 0
|
||||
// Left eave is at Z = -leftWidth, Y = 0
|
||||
// Right eave is at Z = +rightWidth, Y = 0
|
||||
// Get profiles for both sides
|
||||
const leftP = getSideProfile(1, leftWidth, height)
|
||||
const rightP = getSideProfile(-1, rightWidth, height)
|
||||
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const indices: number[] = []
|
||||
|
||||
const addVertex = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => {
|
||||
const idx = positions.length / 3
|
||||
positions.push(x, y, z)
|
||||
normals.push(nx, ny, nz)
|
||||
return idx
|
||||
// Create shapes from profiles
|
||||
const shapes = {
|
||||
ALeft: createShape(leftP.pointsA),
|
||||
ARight: createShape(rightP.pointsA),
|
||||
BLeft: createShape(leftP.pointsB),
|
||||
BRight: createShape(rightP.pointsB),
|
||||
SideLeft: createShape(leftP.pointsSide),
|
||||
SideRight: createShape(rightP.pointsSide),
|
||||
C1Left: createShape(leftP.pointsC1),
|
||||
C1Right: createShape(rightP.pointsC1),
|
||||
C2Left: createShape(leftP.pointsC2),
|
||||
C2Right: createShape(rightP.pointsC2),
|
||||
}
|
||||
|
||||
// Calculate slope normals
|
||||
// Left slope: from (0, height, 0) to (0, 0, -leftWidth)
|
||||
const leftSlopeLen = Math.sqrt(height * height + leftWidth * leftWidth)
|
||||
const leftNormalY = leftWidth / leftSlopeLen
|
||||
const leftNormalZ = height / leftSlopeLen
|
||||
// Calculate extrusion lengths and offsets
|
||||
const lengths = {
|
||||
A: ridgeLength + 2 * RAKE_OVERHANG + 2 * ROOF_COVER_OVERHANG + WALL_THICKNESS,
|
||||
B: ridgeLength + 2 * RAKE_OVERHANG + WALL_THICKNESS,
|
||||
Side: ridgeLength + WALL_THICKNESS,
|
||||
Gable: WALL_THICKNESS,
|
||||
}
|
||||
|
||||
// Right slope: from (0, height, 0) to (0, 0, +rightWidth)
|
||||
const rightSlopeLen = Math.sqrt(height * height + rightWidth * rightWidth)
|
||||
const rightNormalY = rightWidth / rightSlopeLen
|
||||
const rightNormalZ = height / rightSlopeLen
|
||||
const offsets = {
|
||||
A: -RAKE_OVERHANG - ROOF_COVER_OVERHANG - WALL_THICKNESS / 2,
|
||||
B: -RAKE_OVERHANG - WALL_THICKNESS / 2,
|
||||
Side: -WALL_THICKNESS / 2,
|
||||
GableFront: -WALL_THICKNESS / 2,
|
||||
GableBack: ridgeLength - WALL_THICKNESS / 2,
|
||||
}
|
||||
|
||||
// Left slope (negative Z side) - CCW winding for outward-facing
|
||||
const leftNormal = [0, leftNormalY, -leftNormalZ] as const
|
||||
const v0 = addVertex(-halfLength, 0, -leftWidth, ...leftNormal) // back-left eave
|
||||
const v1 = addVertex(halfLength, 0, -leftWidth, ...leftNormal) // front-left eave
|
||||
const v2 = addVertex(halfLength, height, 0, ...leftNormal) // front ridge
|
||||
const v3 = addVertex(-halfLength, height, 0, ...leftNormal) // back ridge
|
||||
indices.push(v0, v2, v1, v0, v3, v2)
|
||||
// Helper to create and position extruded geometry
|
||||
const createPart = (shape: THREE.Shape, depth: number, xOffset: number) => {
|
||||
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false })
|
||||
// Rotate to align: extrusion goes along X axis
|
||||
geo.rotateY(Math.PI / 2)
|
||||
geo.translate(xOffset, 0, 0)
|
||||
return geo
|
||||
}
|
||||
|
||||
// Right slope (positive Z side) - CCW winding for outward-facing
|
||||
const rightNormal = [0, rightNormalY, rightNormalZ] as const
|
||||
const v4 = addVertex(halfLength, 0, rightWidth, ...rightNormal) // front-right eave
|
||||
const v5 = addVertex(-halfLength, 0, rightWidth, ...rightNormal) // back-right eave
|
||||
const v6 = addVertex(-halfLength, height, 0, ...rightNormal) // back ridge
|
||||
const v7 = addVertex(halfLength, height, 0, ...rightNormal) // front ridge
|
||||
indices.push(v4, v6, v5, v4, v7, v6)
|
||||
// Create all parts
|
||||
const geometries: THREE.BufferGeometry[] = []
|
||||
|
||||
// Front gable end (positive X) - CCW winding for outward-facing
|
||||
const frontNormal = [1, 0, 0] as const
|
||||
const v8 = addVertex(halfLength, 0, -leftWidth, ...frontNormal)
|
||||
const v9 = addVertex(halfLength, 0, rightWidth, ...frontNormal)
|
||||
const v10 = addVertex(halfLength, height, 0, ...frontNormal)
|
||||
indices.push(v8, v10, v9)
|
||||
// Layer A (Cover) - both sides
|
||||
geometries.push(createPart(shapes.ALeft, lengths.A, offsets.A))
|
||||
geometries.push(createPart(shapes.ARight, lengths.A, offsets.A))
|
||||
|
||||
// Back gable end (negative X) - CCW winding for outward-facing
|
||||
const backNormal = [-1, 0, 0] as const
|
||||
const v11 = addVertex(-halfLength, 0, rightWidth, ...backNormal)
|
||||
const v12 = addVertex(-halfLength, 0, -leftWidth, ...backNormal)
|
||||
const v13 = addVertex(-halfLength, height, 0, ...backNormal)
|
||||
indices.push(v11, v13, v12)
|
||||
// Layer B (Structure) - both sides
|
||||
geometries.push(createPart(shapes.BLeft, lengths.B, offsets.B))
|
||||
geometries.push(createPart(shapes.BRight, lengths.B, offsets.B))
|
||||
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
geometry.setIndex(indices)
|
||||
// Side Walls - both sides
|
||||
geometries.push(createPart(shapes.SideLeft, lengths.Side, offsets.Side))
|
||||
geometries.push(createPart(shapes.SideRight, lengths.Side, offsets.Side))
|
||||
|
||||
return geometry
|
||||
// Gable Walls (Front)
|
||||
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableFront))
|
||||
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableFront))
|
||||
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableFront))
|
||||
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableFront))
|
||||
|
||||
// Gable Walls (Back)
|
||||
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableBack))
|
||||
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableBack))
|
||||
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableBack))
|
||||
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableBack))
|
||||
|
||||
// Merge all geometries
|
||||
const mergedGeometry = new THREE.BufferGeometry()
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
for (const geo of geometries) {
|
||||
const posAttr = geo.getAttribute('position')
|
||||
const normAttr = geo.getAttribute('normal')
|
||||
const uvAttr = geo.getAttribute('uv')
|
||||
|
||||
if (posAttr) {
|
||||
for (let i = 0; i < posAttr.count; i++) {
|
||||
positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i))
|
||||
}
|
||||
}
|
||||
if (normAttr) {
|
||||
for (let i = 0; i < normAttr.count; i++) {
|
||||
normals.push(normAttr.getX(i), normAttr.getY(i), normAttr.getZ(i))
|
||||
}
|
||||
}
|
||||
if (uvAttr) {
|
||||
for (let i = 0; i < uvAttr.count; i++) {
|
||||
uvs.push(uvAttr.getX(i), uvAttr.getY(i))
|
||||
}
|
||||
}
|
||||
|
||||
geo.dispose()
|
||||
}
|
||||
|
||||
mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
if (uvs.length > 0) {
|
||||
mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
}
|
||||
|
||||
mergedGeometry.computeVertexNormals()
|
||||
|
||||
// Center the geometry at X=0 (translate by -ridgeLength/2)
|
||||
// This matches the old geometry centering behavior
|
||||
mergedGeometry.translate(-ridgeLength / 2, 0, 0)
|
||||
|
||||
return mergedGeometry
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
<mesh ref={ref} castShadow receiveShadow visible={node.visible}>
|
||||
{/* WallSystem will replace this geometry in the next frame */}
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<meshStandardMaterial color="lightgray" />
|
||||
<meshStandardMaterial color="white" />
|
||||
<mesh name="collision-mesh" {...handlers} visible={false}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
|
||||
Reference in New Issue
Block a user