diff --git a/src/view/Viewport3D.tsx b/src/view/Viewport3D.tsx index 72b3a1f..12f388f 100644 --- a/src/view/Viewport3D.tsx +++ b/src/view/Viewport3D.tsx @@ -49,12 +49,21 @@ export const Viewport3D = memo(function Viewport3D(props: Viewport3DProps) { return ( - - + + + + {/* Hemisphere light: blue sky + warm earth */} + + + {/* Primary sun — late morning, front-right */} + + + {/* Weak fill from opposite side */} + @@ -72,6 +81,34 @@ export const Viewport3D = memo(function Viewport3D(props: Viewport3DProps) { ) }) +/** Dynamic shadow-camera sized to lot */ +function SunLight(props: { lotW: number; lotD: number }) { + const ref = useRef(null) + const extent = Math.max(props.lotW, props.lotD) * 0.8 + + useEffect(() => { + const light = ref.current + if (!light) return + light.shadow.mapSize.set(1024, 1024) + light.shadow.camera.left = -extent + light.shadow.camera.right = extent + light.shadow.camera.top = extent + light.shadow.camera.bottom = -extent + light.shadow.camera.near = 1 + light.shadow.camera.far = 500 + light.shadow.camera.updateProjectionMatrix() + }, [extent]) + + return ( + + ) +} + function CameraRig(props: { preset: CameraPreset evaluation: Evaluation @@ -96,7 +133,8 @@ function CameraRig(props: { const d = Math.max(80, lot.depthFt * 0.8) if (preset === 'street') { - camera.position.set(lotCenter.x, 8, -d * 0.65) + // Human eye height: ~5.5 ft + camera.position.set(lotCenter.x, 5.5, -d * 0.65) camera.lookAt(buildingTarget) } else if (preset === 'front') { camera.position.set( @@ -134,9 +172,6 @@ function Scene(props: { ui: UiConfig; evaluation: Evaluation }) { const groundW = lotW + 160 const groundD = lotD + 160 - const envW = evaluation.envelope.x1 - evaluation.envelope.x0 - const envD = evaluation.envelope.z1 - evaluation.envelope.z0 - const building = evaluation.placement.footprint const buildingW = rectWidth(building) const buildingD = rectDepth(building) @@ -159,119 +194,341 @@ function Scene(props: { ui: UiConfig; evaluation: Evaluation }) { return ( {/* Ground */} - + + {/* Lot lawn surface */} + + + + + {/* Street */} - + {/* Sidewalk */} - + + {/* Curb between sidewalk and street */} + + + + + {/* Lot outline */} - {/* Envelope wireframe */} - - - - + {/* Property pins (survey markers at corners) */} + + + + + + {/* Envelope — dashed edges */} + {/* Building mass */} - - - - + {/* Parking geometry */} {evaluation.parkingLayout.stalls.length > 0 ? ( - - {evaluation.parkingLayout.stalls.map((s, idx) => { - const w = rectWidth(s) - const d = rectDepth(s) - const c = rectCenter(s) - return ( - - - - - ) - })} - {evaluation.parkingLayout.aisle ? ( - - - - - ) : null} - + ) : null} {/* Neighbors */} {ui.showNeighbors ? ( - - - + + + ) : null} {/* Trees */} {ui.showTrees ? ( - - - + + + ) : null} ) } -function NeighborHouse(props: { x: number; z: number }) { +/** Small orange cylinder at lot corners (survey marker convention) */ +function PropertyPin(props: { x: number; z: number }) { return ( - - - + + + ) } -function Tree(props: { x: number; z: number }) { +/** Dashed-line envelope using EdgesGeometry */ +function EnvelopeEdges(props: { envelope: Evaluation['envelope'] }) { + const { envelope } = props + const envW = envelope.x1 - envelope.x0 + const envD = envelope.z1 - envelope.z0 + const envH = envelope.maxHeightFt + + const lineRef = useRef(null) + + const edgesGeo = useMemo(() => { + const box = new THREE.BoxGeometry(envW, envH, envD) + const edges = new THREE.EdgesGeometry(box) + box.dispose() + return edges + }, [envW, envH, envD]) + + const dashedMat = useMemo( + () => + new THREE.LineDashedMaterial({ + color: '#6b7280', + transparent: true, + opacity: 0.4, + dashSize: 2, + gapSize: 1.5, + }), + [], + ) + + // computeLineDistances is required for dashed lines + useEffect(() => { + const line = lineRef.current + if (line) line.computeLineDistances() + }, [edgesGeo]) + + return ( + + ) +} + +/** Building mass with floor lines, roof parapet, violation overlay, and townhouse party walls */ +function BuildingMass(props: { + buildingCenter: { x: number; z: number } + buildingW: number + buildingD: number + buildingH: number + stories: number + floorToFloorFt: number + kind: string + units: number + hasViolation: boolean +}) { + const { buildingCenter, buildingW, buildingD, buildingH, stories, floorToFloorFt, kind, units, hasViolation } = props + const parapetH = 0.6 + + return ( + + {/* Main building body */} + + + + + + {/* Floor lines — thin horizontal bands at each intermediate story */} + {stories > 1 + ? Array.from({ length: stories - 1 }, (_, i) => { + const y = (i + 1) * floorToFloorFt + return ( + + + + + ) + }) + : null} + + {/* Roof parapet */} + + + + + + {/* Townhouse party walls */} + {kind === 'townhouse' && units > 1 + ? Array.from({ length: units - 1 }, (_, i) => { + const unitW = buildingW / units + const wallX = buildingCenter.x - buildingW / 2 + (i + 1) * unitW + return ( + + + + + ) + }) + : null} + + {/* Violation overlay — translucent red on top of normal-colored building */} + {hasViolation ? ( + + + + + ) : null} + + ) +} + +/** Parking: asphalt stalls with white borders, aisle, and driveway */ +function ParkingGroup(props: { + parkingLayout: Evaluation['parkingLayout'] + buildingFootprint: { x0: number; x1: number; z0: number; z1: number } + lotW: number +}) { + const { parkingLayout, buildingFootprint, lotW } = props + + // Driveway: grey strip from lot front edge to parking aisle area + const driveX = lotW / 2 + const driveZ0 = 0 + const driveZ1 = buildingFootprint.z1 + 2 // extends past building to where parking starts + const driveW = 12 + + return ( + + {/* Driveway */} + + + + + + {/* Stalls — white border underneath, dark asphalt on top */} + {parkingLayout.stalls.map((s, idx) => { + const w = rectWidth(s) + const d = rectDepth(s) + const c = rectCenter(s) + const inset = 0.15 + return ( + + {/* White border */} + + + + + {/* Asphalt top */} + + + + + + ) + })} + + {/* Aisle */} + {parkingLayout.aisle ? ( + + + + + ) : null} + + ) +} + +/** Neighbor house with pitched roof */ +function NeighborHouse(props: { x: number; z: number; w: number; d: number; h: number; roofH: number }) { + const { x, z, w, d, h, roofH } = props + + const roofShape = useMemo(() => { + const shape = new THREE.Shape() + shape.moveTo(-w / 2, 0) + shape.lineTo(0, roofH) + shape.lineTo(w / 2, 0) + shape.closePath() + return shape + }, [w, roofH]) + + const roofGeo = useMemo(() => { + const geo = new THREE.ExtrudeGeometry(roofShape, { + steps: 1, + depth: d, + bevelEnabled: false, + }) + return geo + }, [roofShape, d]) + + return ( + + {/* House body */} + + + + + {/* Pitched roof */} + + + + + ) +} + +/** Tree with cone canopy and varied scale */ +function Tree(props: { x: number; z: number; scale: number }) { return ( - - - + + {/* Trunk */} + + - - - + {/* Canopy — cone instead of sphere */} + + + )