diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..33d49f7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +public/draco diff --git a/RELEASE-PROCEDURE.md b/RELEASE-PROCEDURE.md index 3b3d1f9..8dfcb4c 100644 --- a/RELEASE-PROCEDURE.md +++ b/RELEASE-PROCEDURE.md @@ -12,6 +12,7 @@ It always has the format `MAJOR.MINOR.PATCH`, e.g. `1.5.0`. - Navigate to test.openpv.de - Check that this is the website you want to deploy - Check that it has no bugs +- Update modified date in ./public/sitemap.xml ### 2. 🐙 Create a `GitHub Release` diff --git a/src/features/three-viewer/components/Overlay.jsx b/src/features/three-viewer/components/Overlay.jsx index 27d925e..a33cdbf 100644 --- a/src/features/three-viewer/components/Overlay.jsx +++ b/src/features/three-viewer/components/Overlay.jsx @@ -20,7 +20,6 @@ function Overlay({ frontendState, setFrontendState }) { const handleCreatePVButtonClick = () => { createPVSystem({ setPVSystems: sceneContext.setPVSystems, - setSelectedPVSystem: sceneContext.setSelectedPVSystem, pvPoints: sceneContext.pvPoints, setPVPoints: sceneContext.setPVPoints, simulatedBuildings: sceneContext.buildings.filter( @@ -39,7 +38,7 @@ function Overlay({ frontendState, setFrontendState }) { <> {!window.isTouchDevice && } - {sceneContext.selectedPVSystem.length > 0 && ( + {sceneContext.pvSystems.some((system) => system.selected) && ( )} diff --git a/src/features/three-viewer/components/Scene.jsx b/src/features/three-viewer/components/Scene.jsx index d7d800b..a562559 100644 --- a/src/features/three-viewer/components/Scene.jsx +++ b/src/features/three-viewer/components/Scene.jsx @@ -2,16 +2,15 @@ import { useRef, useState } from 'react' import { Canvas } from 'react-three-fiber' import * as THREE from 'three' +import Overlay from '@/features/three-viewer/components/Overlay' +import PointsAndEdges from '@/features/three-viewer/components/PointsAndEdges' +import Terrain from '@/features/three-viewer/components/Terrain' import { SceneContext } from '@/features/three-viewer/context/SceneContext' import CustomMapControl from '@/features/three-viewer/controls/CustomMapControl' import DrawPVControl from '@/features/three-viewer/controls/DrawPVControl' import { BuildingMesh } from '@/features/three-viewer/meshes/BuildingMesh' -import { HighlightedPVSystem } from '@/features/three-viewer/meshes/HighlitedPVSystem' -import { PVSystems } from '@/features/three-viewer/meshes/PVSystems' +import { PVSystem } from '@/features/three-viewer/meshes/PVSystems' import VegetationMesh from '@/features/three-viewer/meshes/VegetationMesh' -import Overlay from '@/features/three-viewer/components/Overlay' -import PointsAndEdges from '@/features/three-viewer/components/PointsAndEdges' -import Terrain from '@/features/three-viewer/components/Terrain' const Scene = ({ frontendState, @@ -22,12 +21,10 @@ const Scene = ({ }) => { // showTerrain decides if the underlying Map is visible or not const [showTerrain, setShowTerrain] = useState(true) - // A list of visible PV Systems - they get visible after they are drawn on a building and calculated + // Array of PV system objects (see three-viewer/README.md for structure) const [pvSystems, setPVSystems] = useState([]) // pvPoints are the red points that appear when drawing PV systems const [pvPoints, setPVPoints] = useState([]) - // highlighted PVSystems for deletion or calculation - const [selectedPVSystem, setSelectedPVSystem] = useState([]) const [slope, setSlope] = useState('') const [azimuth, setAzimuth] = useState('') const [yieldPerKWP, setYieldPerKWP] = useState('') @@ -52,8 +49,6 @@ const Scene = ({ buildings, pvPoints, setPVPoints, - selectedPVSystem, - setSelectedPVSystem, pvSystems, setPVSystems, showTerrain, @@ -91,14 +86,20 @@ const Scene = ({ {buildings.length > 0 && buildings.map((b) => )} - {selectedPVSystem && } {simulationBuildings.length > 0 && frontendState == 'Results' && ( )} {frontendState == 'DrawPV' && } {frontendState == 'DrawPV' && } - {pvSystems.length > 0 && } + {pvSystems.length > 0 && + pvSystems.map((pvSystem) => ( + + ))} {vegetationGeometries && ( <> diff --git a/src/features/three-viewer/controls/CustomMapControl.jsx b/src/features/three-viewer/controls/CustomMapControl.jsx index ad627d6..3834d93 100644 --- a/src/features/three-viewer/controls/CustomMapControl.jsx +++ b/src/features/three-viewer/controls/CustomMapControl.jsx @@ -1,8 +1,13 @@ +import { SceneContext } from '@/features/three-viewer/context/SceneContext' +import { + calculateAzimuthFromNormal, + calculateSlopeFromNormal, + calculateYieldPerKWP, +} from '@/features/three-viewer/utils/pvSystemUtils' import { MapControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useContext, useEffect, useRef } from 'react' import * as THREE from 'three' -import { SceneContext } from '@/features/three-viewer/context/SceneContext' function CustomMapControl() { const sceneContext = useContext(SceneContext) @@ -51,9 +56,8 @@ function CustomMapControl() { const intersected = ignoreSprites(intersects) if (!intersected) return const intersectedFace = intersected.face - const [slope, azimuth] = calculateSlopeAzimuthFromNormal( - intersectedFace.normal, - ) + const slope = calculateSlopeFromNormal(intersectedFace.normal) + const azimuth = calculateAzimuthFromNormal(intersectedFace.normal) sceneContext.setSlope(Math.round(slope)) sceneContext.setAzimuth(Math.round(azimuth)) if (!intersected.object.geometry.attributes?.intensities) { @@ -64,7 +68,7 @@ function CustomMapControl() { const intensityAttr = intersected.object.geometry.attributes.intensities const faceIdx = intersected.faceIndex const intensity = intensityAttr.array[faceIdx] - const yieldPerKWP = calculateYieldPerKKW(intensity) + const yieldPerKWP = calculateYieldPerKWP(intensity) sceneContext.setYieldPerKWP(Math.round(yieldPerKWP)) } @@ -137,22 +141,3 @@ function CustomMapControl() { } export default CustomMapControl - -const calculateSlopeAzimuthFromNormal = (normal) => { - const up = new THREE.Vector3(0, 0, 1) - const angleRad = normal.angleTo(up) - const slope = THREE.MathUtils.radToDeg(angleRad) - - // Swap y and x in atan to get clockwise angle from y-axis - const azimuthRad = Math.atan2(normal.x, normal.y) - let azimuth = THREE.MathUtils.radToDeg(azimuthRad) - if (azimuth < 0) { - azimuth += 360 - } - - return [slope, azimuth] -} - -const calculateYieldPerKKW = (intensity) => { - return intensity * 5.5 -} diff --git a/src/features/three-viewer/controls/DrawPVControl.jsx b/src/features/three-viewer/controls/DrawPVControl.jsx index 87ca42c..8ab8174 100644 --- a/src/features/three-viewer/controls/DrawPVControl.jsx +++ b/src/features/three-viewer/controls/DrawPVControl.jsx @@ -76,7 +76,6 @@ const DrawPVControl = () => { ) { createPVSystem({ setPVSystems: sceneContext.setPVSystems, - setSelectedPVSystem: sceneContext.setSelectedPVSystem, pvPoints: pvPointsRef, setPVPoints: sceneContext.setPVPoints, simulationBuildings: diff --git a/src/features/three-viewer/core/geometryProcessing.js b/src/features/three-viewer/core/geometryProcessing.js new file mode 100644 index 0000000..7483ce0 --- /dev/null +++ b/src/features/three-viewer/core/geometryProcessing.js @@ -0,0 +1,235 @@ +import * as THREE from 'three' + +/** + * Recursively subdivides a triangle into smaller triangles if its area exceeds a threshold. + * Uses midpoint subdivision to create 4 smaller triangles. + * + * @param {Object} triangle - Triangle with vertices {a, b, c} + * @param {number} threshold - Maximum area threshold for subdivision (in m²) + * @returns {Array} Array of subdivided triangles + */ +export function subdivideTriangle(triangle, threshold) { + const distance = (p1, p2) => + Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2 + (p2.z - p1.z) ** 2) + + const midPoint = (p1, p2) => ({ + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2, + z: (p1.z + p2.z) / 2, + }) + + const aToB = distance(triangle.a, triangle.b) + const bToC = distance(triangle.b, triangle.c) + const cToA = distance(triangle.c, triangle.a) + + const area = calculateTriangleArea(triangle) + + if (area < threshold) { + return [triangle] + } + + const abMid = midPoint(triangle.a, triangle.b) + const bcMid = midPoint(triangle.b, triangle.c) + const caMid = midPoint(triangle.c, triangle.a) + + return subdivideTriangle({ a: triangle.a, b: abMid, c: caMid }, threshold) + .concat(subdivideTriangle({ a: abMid, b: triangle.b, c: bcMid }, threshold)) + .concat(subdivideTriangle({ a: caMid, b: bcMid, c: triangle.c }, threshold)) + .concat(subdivideTriangle({ a: abMid, b: bcMid, c: caMid }, threshold)) +} + +/** + * Calculates the total area of a polygon from its triangulation. + * + * @param {Array} polygon - Array of triangles that make up the polygon + * @returns {number} Total area in m² + */ +export function calculatePolygonArea(polygon) { + let totalArea = 0 + + polygon.forEach((triangle) => { + totalArea += calculateTriangleArea(triangle) + }) + + return totalArea +} + +/** + * Calculates the area of a triangle using the cross product method. + * + * @param {Object} triangle - Triangle with vertices {a, b, c} + * @returns {number} Area in m² + */ +export function calculateTriangleArea(triangle) { + const { a, b, c } = triangle + + const ab = new THREE.Vector3().subVectors(b, a) + const ac = new THREE.Vector3().subVectors(c, a) + const crossProduct = new THREE.Vector3().crossVectors(ab, ac) + const area = 0.5 * crossProduct.length() + + return area +} + +/** + * Triangulates a polygon in 3D space using ear clipping algorithm. + * Handles polygons with 3 or more vertices. + * + * @param {Array} points - Array of points with {point, normal} structure + * @returns {Array} Array of triangles with structure {a, b, c} + */ +export function triangulate(points) { + if (points.length == 3) { + return [{ a: points[0], b: points[1], c: points[2] }] + } else if (points.length < 3) { + return [] + } + + // As the triangle is in 3d-space anyways, we can just assume that vertices are given in CCW order + const pt = (i) => points[(i + points.length) % points.length] + + const ab = sub(pt(1).point, pt(0).point) + const ac = sub(pt(2).point, pt(0).point) + const normal = new THREE.Vector3().crossVectors(ab, ac) + + let countNegative = 0 + let countPositive = 0 + + // Taking inspiration from a polygon triangulation based on the two ears theorem + // However, in R3, things can get a bit more wonky... + // https://en.wikipedia.org/wiki/Two_ears_theorem#Relation_to_triangulations + const makeTriplet = (left, vertex, right) => { + const det = determinant( + sub(vertex.point, left.point), + sub(vertex.point, right.point), + normal, + ) + + if (det > 0) { + countPositive += 1 + } else { + countNegative += 1 + } + + return { left: left, vertex: vertex, right: right, det } + } + + const triplets = points.map((cur, i) => + makeTriplet(pt(i - 1), cur, pt(i + 1)), + ) + + if (countPositive < countNegative) { + // negative det => convex vertex, so we flip all determinants + for (let t of triplets) { + t.det = -t.det + } + } + + const concaveVertices = triplets.filter((t) => t.det < 0).map((t) => t.vertex) + + let anyEar = false + for (let t of triplets) { + // Idea: Define the 3d analogue of a polygon ear by looking at triples and projecting the + // remaining points onto the plane spanned by that particular triangle + // An ear is any triangle having no concave vertices lying inside it + const containedConcaveVertices = concaveVertices + .filter((v) => v != t.left && v != t.vertex && v != t.right) + .filter((v) => + pointInsideTriangle( + v.point, + t.left.point, + t.vertex.point, + t.right.point, + ), + ) + + t.isEar = t.det > 0 && containedConcaveVertices.length == 0 + if (t.isEar) { + anyEar = true + } + } + + // Prevent infinite loop + if (!anyEar) { + console.warn('No ear found in ear clipping!') + triplets[0].isEar = true + } + + for (let ear of triplets.filter((t) => t.isEar)) { + const remainingPoints = triplets + .filter((t) => t != ear) + .map((t) => t.vertex) + return [{ a: ear.left, b: ear.vertex, c: ear.right }].concat( + triangulate(remainingPoints), + ) + } +} + +/** + * Calculates the determinant of a 3x3 matrix formed by three vectors. + * + * @param {THREE.Vector3} v1 - First column vector + * @param {THREE.Vector3} v2 - Second column vector + * @param {THREE.Vector3} v3 - Third column vector + * @returns {number} Determinant value + */ +export function determinant(v1, v2, v3) { + const matrix = new THREE.Matrix3() + matrix.set( + v1.x, + v2.x, + v3.x, // First column + v1.y, + v2.y, + v3.y, // Second column + v1.z, + v2.z, + v3.z, // Third column + ) + return matrix.determinant() +} + +/** + * Subtracts two vectors (v1 - v2). + * + * @param {THREE.Vector3} v1 - First vector + * @param {THREE.Vector3} v2 - Second vector + * @returns {THREE.Vector3} Result vector + */ +export function sub(v1, v2) { + return new THREE.Vector3().subVectors(v1, v2) +} + +/** + * Calculates the cross product of two vectors. + * + * @param {THREE.Vector3} v1 - First vector + * @param {THREE.Vector3} v2 - Second vector + * @returns {THREE.Vector3} Cross product vector + */ +export function cross(v1, v2) { + return new THREE.Vector3().crossVectors(v1, v2) +} + +/** + * Tests if a point lies inside a triangle in 3D space. + * + * @param {THREE.Vector3} point - Point to test + * @param {THREE.Vector3} v1 - First triangle vertex + * @param {THREE.Vector3} v2 - Second triangle vertex + * @param {THREE.Vector3} v3 - Third triangle vertex + * @returns {boolean} True if point is inside triangle + */ +export function pointInsideTriangle(point, v1, v2, v3) { + const normal = cross(sub(v1, v2), sub(v2, v3)) + const n1 = cross(normal, sub(v1, v2)) + const n2 = cross(normal, sub(v2, v3)) + const n3 = cross(normal, sub(v3, v1)) + + const d1 = Math.sign(n1.dot(sub(v1, point))) + const d2 = Math.sign(n2.dot(sub(v2, point))) + const d3 = Math.sign(n3.dot(sub(v3, point))) + + // Inside if all 3 have the same sign + return d1 == d2 && d2 == d3 +} diff --git a/src/features/three-viewer/core/index.js b/src/features/three-viewer/core/index.js new file mode 100644 index 0000000..ff7f49f --- /dev/null +++ b/src/features/three-viewer/core/index.js @@ -0,0 +1,25 @@ +// PV System Creation +export { createPVSystemData } from './pvSystemCreation' + +// Geometry Processing +export { + triangulate, + subdivideTriangle, + calculatePolygonArea, + calculateTriangleArea, + determinant, + sub, + cross, + pointInsideTriangle, +} from './geometryProcessing' + +// Yield Calculations +export { + findClosestPolygon, + filterPolygonsByDistance, + projectOntoTriangle, + getColorAtPointOnTriangle, + getIntensityAtPointOnTriangle, + calculatePolygonIntensity, + calculateTriangleIntensity, +} from './yieldCalculations' diff --git a/src/features/three-viewer/core/pvSystemCreation.js b/src/features/three-viewer/core/pvSystemCreation.js new file mode 100644 index 0000000..346b106 --- /dev/null +++ b/src/features/three-viewer/core/pvSystemCreation.js @@ -0,0 +1,169 @@ +import * as THREE from 'three' +import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js' +import { + calculateCenterFromGeometry, + calculateYieldPerKWP, + generatePVSystemId, +} from '@/features/three-viewer/utils/pvSystemUtils' +import { + triangulate, + subdivideTriangle, + calculatePolygonArea, +} from './geometryProcessing' +import { + filterPolygonsByDistance, + findClosestPolygon, + projectOntoTriangle, + getColorAtPointOnTriangle, + getIntensityAtPointOnTriangle, + calculatePolygonIntensity, +} from './yieldCalculations' + +/** + * Creates PV system data from user-drawn points. + * Handles triangulation, building intersection analysis, and yield calculations. + * Returns a complete PV system object ready for rendering. + * + * @param {Object} params + * @param {Array} params.pvPoints - Array of points the user clicked (with {point, normal} structure) + * @param {Array} params.simulatedBuildings - Array of building objects containing simulation meshes + * @returns {Object|null} PV system object with geometry, area, and yield data, or null if invalid + */ +export function createPVSystemData({ pvPoints, simulatedBuildings }) { + const points = pvPoints.map((obj) => obj.point) + + // Validation: need at least 3 points to create a polygon + if (pvPoints.length < 3) { + return null + } + + // Step 1: Triangulate the user-drawn polygon + const geometry = new THREE.BufferGeometry() + const trianglesWithNormals = triangulate(pvPoints) + const triangles = [] + const bufferTriangles = [] + const normalOffset = 0.1 // Offset to prevent z-fighting with building surfaces + + // Step 2: Apply normal offset for visual clarity and prepare triangles + for (const { a, b, c } of trianglesWithNormals) { + const shift = (element) => ({ + x: element.point.x + element.normal.x * normalOffset, + y: element.point.y + element.normal.y * normalOffset, + z: element.point.z + element.normal.z * normalOffset, + }) + + const sa = shift(a) + const sb = shift(b) + const sc = shift(c) + + triangles.push({ a: a.point, b: b.point, c: c.point }) + bufferTriangles.push(sa.x, sa.y, sa.z, sb.x, sb.y, sb.z, sc.x, sc.y, sc.z) + } + + // Create Three.js geometry for rendering + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(bufferTriangles, 3), + ) + geometry.name = 'pvSystem' + + // Step 3: Subdivide large triangles for higher resolution intensity sampling + let subdividedTriangles = [] + const triangleSubdivisionThreshold = 0.8 // m² + triangles.forEach((triangle) => { + subdividedTriangles = subdividedTriangles.concat( + subdivideTriangle(triangle, triangleSubdivisionThreshold), + ) + }) + + // Step 4: Merge all simulated building geometries + const geometries = [] + simulatedBuildings.forEach((building) => { + const mesh = building.mesh + if (mesh && mesh.geometry) { + const geom = mesh.geometry.clone() + geom.applyMatrix4(mesh.matrixWorld) + geometries.push(geom) + } + }) + const simulationGeometry = BufferGeometryUtils.mergeGeometries( + geometries, + true, + ) + + // Step 5: Pre-filter building polygons by distance to PV points + const polygonPrefilteringCutoff = 10 // meters + const prefilteredPolygons = filterPolygonsByDistance( + simulationGeometry, + points, + polygonPrefilteringCutoff, + ) + + // Step 6: For each vertex, find closest building polygon and extract intensity + const newVertices = [] + const newColors = [] + const newIntensities = [] + + subdividedTriangles.forEach((triangle) => { + newVertices.push(triangle.a.x, triangle.a.y, triangle.a.z) + newVertices.push(triangle.b.x, triangle.b.y, triangle.b.z) + newVertices.push(triangle.c.x, triangle.c.y, triangle.c.z) + }) + + for (let i = 0; i < newVertices.length; i += 3) { + const vertex = new THREE.Vector3( + newVertices[i], + newVertices[i + 1], + newVertices[i + 2], + ) + const closestPolygon = findClosestPolygon( + vertex, + prefilteredPolygons, + polygonPrefilteringCutoff, + ) + + if (closestPolygon) { + // Project vertex onto building surface and interpolate intensity + const projectedVertex = projectOntoTriangle(vertex, closestPolygon) + const color = getColorAtPointOnTriangle(projectedVertex, closestPolygon) + const intensity = getIntensityAtPointOnTriangle( + projectedVertex, + closestPolygon, + ) + newColors.push(color.r, color.g, color.b) + newIntensities.push(intensity) + } else { + // No building found nearby - use default values + newColors.push(1, 1, 1) + newIntensities.push(-1) + } + } + + // Step 7: Calculate area-weighted average intensity and total yield + const polygonArea = calculatePolygonArea(triangles) + const polygonIntensity = calculatePolygonIntensity( + newVertices, + newIntensities, + ) + const annualYield = polygonArea * polygonIntensity + + // Keep geometry properties for backward compatibility + geometry.annualYield = annualYield + geometry.area = polygonArea + + // Step 8: Calculate pre-computed properties + const center = calculateCenterFromGeometry(geometry) + const yieldPerKWPPerYear = calculateYieldPerKWP(polygonIntensity) + + // Step 9: Return complete PV system object + return { + id: generatePVSystemId(), + points: [...pvPoints], + geometry: geometry, + center: center, + totalArea: polygonArea, + yieldPerArea: polygonIntensity, + annualYield: annualYield, + yieldPerKWPPerYear: yieldPerKWPPerYear, + } +} diff --git a/src/features/three-viewer/core/yieldCalculations.js b/src/features/three-viewer/core/yieldCalculations.js new file mode 100644 index 0000000..d999eb8 --- /dev/null +++ b/src/features/three-viewer/core/yieldCalculations.js @@ -0,0 +1,277 @@ +import * as THREE from 'three' +import { calculateTriangleArea } from './geometryProcessing' + +/** + * Finds the closest building polygon to a given vertex. + * + * @param {THREE.Vector3} vertex - The vertex to find the closest polygon for + * @param {Array} polygons - Array of polygon objects with vertices property + * @param {number} polygonPrefilteringCutoff - Distance threshold for warnings + * @returns {Object|null} Closest polygon object or null + */ +export function findClosestPolygon( + vertex, + polygons, + polygonPrefilteringCutoff, +) { + let closestPolygon = null + let minDistance = Infinity + + polygons.forEach((polygon) => { + const [v0, v1, v2] = polygon.vertices + const distance = + vertex.distanceTo(v0) + vertex.distanceTo(v1) + vertex.distanceTo(v2) + if (distance < minDistance) { + minDistance = distance + closestPolygon = polygon + } + }) + + if (minDistance >= polygonPrefilteringCutoff) { + console.error( + `Error: Trying to create a polygon with a distance longer than the threshold (${minDistance})`, + ) + } + + return closestPolygon +} + +/** + * Filters building polygons by distance to PV system points. + * Only returns polygons within the threshold distance. + * + * @param {THREE.BufferGeometry} geometry - Building mesh geometry + * @param {Array} points - Array of PV system points + * @param {number} threshold - Maximum distance threshold + * @returns {Array} Filtered array of polygon objects with vertices, colors, normal, and intensities + */ +export function filterPolygonsByDistance(geometry, points, threshold) { + const filteredPolygons = [] + + if (!geometry.isBufferGeometry) return + + const positions = geometry.attributes.position.array + const colors = geometry.attributes.color + ? geometry.attributes.color.array + : null + const intensities = geometry.attributes.intensities + ? geometry.attributes.intensities.array + : null + + for (let i = 0; i < positions.length; i += 9) { + const v0 = new THREE.Vector3( + positions[i], + positions[i + 1], + positions[i + 2], + ) + const v1 = new THREE.Vector3( + positions[i + 3], + positions[i + 4], + positions[i + 5], + ) + const v2 = new THREE.Vector3( + positions[i + 6], + positions[i + 7], + positions[i + 8], + ) + + const color0 = colors + ? new THREE.Color(colors[i], colors[i + 1], colors[i + 2]) + : new THREE.Color(1, 1, 1) + const color1 = colors + ? new THREE.Color(colors[i + 3], colors[i + 4], colors[i + 5]) + : new THREE.Color(1, 1, 1) + const color2 = colors + ? new THREE.Color(colors[i + 6], colors[i + 7], colors[i + 8]) + : new THREE.Color(1, 1, 1) + + const intensity1 = intensities ? intensities[i / 9] : -1000 + const intensity2 = intensities ? intensities[i / 9] : -1000 + const intensity3 = intensities ? intensities[i / 9] : -1000 + + let minDistance = Infinity + points.forEach((point) => { + const distance = Math.min( + point.distanceTo(v0), + point.distanceTo(v1), + point.distanceTo(v2), + ) + if (distance < minDistance) { + minDistance = distance + } + }) + + if (minDistance < threshold) { + const normal = new THREE.Triangle(v0, v1, v2).getNormal( + new THREE.Vector3(), + ) + filteredPolygons.push({ + vertices: [v0, v1, v2], + colors: [color0, color1, color2], + normal, + intensities: [intensity1, intensity2, intensity3], + }) + } + } + + return filteredPolygons +} + +/** + * Projects a vertex orthogonally onto a triangle's plane. + * + * @param {THREE.Vector3} vertex - Vertex to project + * @param {Object} triangle - Triangle object with vertices and normal + * @returns {THREE.Vector3} Projected point on triangle's plane + */ +export function projectOntoTriangle(vertex, triangle) { + const [v0, v1, v2] = triangle.vertices + const normal = triangle.normal.clone().normalize() + + const d = v0.dot(normal) + const t = (d - vertex.dot(normal)) / normal.dot(normal) + const projection = vertex.clone().add(normal.clone().multiplyScalar(t)) + + return projection +} + +/** + * Gets the interpolated color at a point on a triangle using barycentric coordinates. + * + * @param {THREE.Vector3} point - Point on the triangle + * @param {Object} triangle - Triangle object with vertices, colors, and normal + * @returns {THREE.Color} Interpolated color at the point + */ +export function getColorAtPointOnTriangle(point, triangle) { + const [v0, v1, v2] = triangle.vertices + const normal = triangle.normal.clone().normalize() + + const areaABC = normal.dot( + new THREE.Vector3().crossVectors(v1.clone().sub(v0), v2.clone().sub(v0)), + ) + const areaPBC = normal.dot( + new THREE.Vector3().crossVectors( + v1.clone().sub(point), + v2.clone().sub(point), + ), + ) + const areaPCA = normal.dot( + new THREE.Vector3().crossVectors( + v2.clone().sub(point), + v0.clone().sub(point), + ), + ) + + const u = areaPBC / areaABC + const v = areaPCA / areaABC + const w = 1 - u - v + + const color0 = triangle.colors[0] + const color1 = triangle.colors[1] + const color2 = triangle.colors[2] + + const r = u * color0.r + v * color1.r + w * color2.r + const g = u * color0.g + v * color1.g + w * color2.g + const b = u * color0.b + v * color1.b + w * color2.b + + return new THREE.Color(r, g, b) +} + +/** + * Gets the interpolated solar intensity at a point on a triangle using barycentric coordinates. + * + * @param {THREE.Vector3} point - Point on the triangle + * @param {Object} triangle - Triangle object with vertices, intensities, and normal + * @returns {number} Interpolated intensity at the point (kWh/m²/year) + */ +export function getIntensityAtPointOnTriangle(point, triangle) { + const [v0, v1, v2] = triangle.vertices + const normal = triangle.normal.clone().normalize() + + const areaABC = normal.dot( + new THREE.Vector3().crossVectors(v1.clone().sub(v0), v2.clone().sub(v0)), + ) + const areaPBC = normal.dot( + new THREE.Vector3().crossVectors( + v1.clone().sub(point), + v2.clone().sub(point), + ), + ) + const areaPCA = normal.dot( + new THREE.Vector3().crossVectors( + v2.clone().sub(point), + v0.clone().sub(point), + ), + ) + + const u = areaPBC / areaABC + const v = areaPCA / areaABC + const w = 1 - u - v + + const intensity0 = triangle.intensities[0] + const intensity1 = triangle.intensities[1] + const intensity2 = triangle.intensities[2] + + const intensityAtPoint = u * intensity0 + v * intensity1 + w * intensity2 + + return intensityAtPoint +} + +/** + * Calculates the area-weighted average intensity for a polygon. + * + * @param {Array} vertices - Flat array of vertex coordinates [x1, y1, z1, x2, ...] + * @param {Array} intensities - Array of intensity values for each vertex + * @returns {number} Average intensity weighted by triangle area (kWh/m²/year) + */ +export function calculatePolygonIntensity(vertices, intensities) { + const numTriangles = vertices.length / 9 + let totalIntensity = 0 + let totalArea = 0 + + for (let i = 0; i < numTriangles; i++) { + const triangle = { + a: new THREE.Vector3( + vertices[i * 9], + vertices[i * 9 + 1], + vertices[i * 9 + 2], + ), + b: new THREE.Vector3( + vertices[i * 9 + 3], + vertices[i * 9 + 4], + vertices[i * 9 + 5], + ), + c: new THREE.Vector3( + vertices[i * 9 + 6], + vertices[i * 9 + 7], + vertices[i * 9 + 8], + ), + intensities: [ + intensities[i * 3], + intensities[i * 3 + 1], + intensities[i * 3 + 2], + ], + } + + const triangleArea = calculateTriangleArea(triangle) + const triangleIntensity = calculateTriangleIntensity(triangle) + totalIntensity += triangleIntensity * triangleArea + totalArea += triangleArea + } + + const averageIntensity = totalIntensity / totalArea + return averageIntensity +} + +/** + * Calculates the average intensity for a triangle from its vertex intensities. + * + * @param {Object} triangle - Triangle object with intensities array + * @returns {number} Average intensity (kWh/m²/year) + */ +export function calculateTriangleIntensity(triangle) { + const intensities = triangle.intensities + const averageIntensity = + (intensities[0] + intensities[1] + intensities[2]) / 3 + return averageIntensity +} diff --git a/src/features/three-viewer/dialogs/NotificationForSelectedPV.jsx b/src/features/three-viewer/dialogs/NotificationForSelectedPV.jsx index c510de6..0cf80e0 100644 --- a/src/features/three-viewer/dialogs/NotificationForSelectedPV.jsx +++ b/src/features/three-viewer/dialogs/NotificationForSelectedPV.jsx @@ -7,10 +7,10 @@ import { DialogRoot, DialogTitle, } from '@/components/ui/dialog' +import { SceneContext } from '@/features/three-viewer/context/SceneContext' import { SimpleGrid } from '@chakra-ui/react' import { useContext } from 'react' import { useTranslation } from 'react-i18next' -import { SceneContext } from '@/features/three-viewer/context/SceneContext' import { SavingCalculationDialog } from './SavingCalculationDialog' /** @@ -19,14 +19,26 @@ import { SavingCalculationDialog } from './SavingCalculationDialog' */ export const NotificationForSelectedPV = () => { const { t } = useTranslation() - const { setSelectedPVSystem, setPVSystems } = useContext(SceneContext) + const { setPVSystems } = useContext(SceneContext) + + const deselectAll = () => { + setPVSystems((prevSystems) => + prevSystems.map((system) => ({ ...system, selected: false })), + ) + } + + const deleteSelected = () => { + setPVSystems((prevSystems) => + prevSystems.filter((system) => !system.selected), + ) + } return ( setSelectedPVSystem([])} + onInteractOutside={deselectAll} > @@ -35,18 +47,12 @@ export const NotificationForSelectedPV = () => { - - setSelectedPVSystem([])} /> + ) diff --git a/src/features/three-viewer/dialogs/SavingCalculationDialog.jsx b/src/features/three-viewer/dialogs/SavingCalculationDialog.jsx index dcb185a..fdcffe8 100644 --- a/src/features/three-viewer/dialogs/SavingCalculationDialog.jsx +++ b/src/features/three-viewer/dialogs/SavingCalculationDialog.jsx @@ -22,7 +22,7 @@ import { calculateSavings } from '@/features/simulation/components/savingsCalcul */ export const SavingCalculationDialog = () => { const { t } = useTranslation() - const { selectedPVSystem } = useContext(SceneContext) + const { pvSystems } = useContext(SceneContext) const [annualConsumption, setAnnualConsumption] = useState('3000') const [storageCapacity, setStorageCapacity] = useState('0') @@ -31,10 +31,11 @@ export const SavingCalculationDialog = () => { const [annualSavings, setAnnualSavings] = useState(0) const [showResults, setShowResults] = useState(false) + const selectedPVSystems = pvSystems.filter((system) => system.selected) const pvProduction = - selectedPVSystem.length > 0 + selectedPVSystems.length > 0 ? Math.round( - selectedPVSystem.reduce( + selectedPVSystems.reduce( (previous, current) => previous + current.annualYield, 0, ), diff --git a/src/features/three-viewer/index.js b/src/features/three-viewer/index.js index 31493d5..db8df83 100644 --- a/src/features/three-viewer/index.js +++ b/src/features/three-viewer/index.js @@ -30,5 +30,9 @@ export { default as SavingCalculationDialog } from './dialogs/SavingCalculationD // Context export { SceneContext, SceneProvider } from './context/SceneContext' +// Core business logic +export * from './core' + // Utils export * from './utils/colorMapUtils' +export * from './utils/pvSystemUtils' diff --git a/src/features/three-viewer/meshes/HighlitedPVSystem.jsx b/src/features/three-viewer/meshes/HighlitedPVSystem.jsx deleted file mode 100644 index 582d611..0000000 --- a/src/features/three-viewer/meshes/HighlitedPVSystem.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useContext } from 'react' -import * as THREE from 'three' -import { SceneContext } from '@/features/three-viewer/context/SceneContext' - -export function HighlightedPVSystem() { - const sceneContext = useContext(SceneContext) - return ( - <> - {sceneContext.selectedPVSystem.map((geometry, index) => ( - - ))} - - ) -} diff --git a/src/features/three-viewer/meshes/PVSystems.jsx b/src/features/three-viewer/meshes/PVSystems.jsx index 0ebf7a7..cb68c73 100644 --- a/src/features/three-viewer/meshes/PVSystems.jsx +++ b/src/features/three-viewer/meshes/PVSystems.jsx @@ -1,588 +1,91 @@ -import { useContext, useRef } from 'react' +import TextSprite from '@/features/three-viewer/components/TextSprite' +import { createPVSystemData } from '@/features/three-viewer/core/pvSystemCreation' +import { useRef } from 'react' import { useFrame } from 'react-three-fiber' import * as THREE from 'three' -import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js' -import { SceneContext } from '@/features/three-viewer/context/SceneContext' -import TextSprite from '@/features/three-viewer/components/TextSprite' - -export const PVSystems = () => { - const sceneContext = useContext(SceneContext) - return ( - <> - {sceneContext.pvSystems.map((geometry) => ( - - ))} - - ) -} /** - * Creates a PV system mesh based on the user‑drawn points. + * Wrapper function for backward compatibility. + * Creates a PV system and updates state. * * @param {Object} params * @param {Function} params.setPVSystems - state setter for the list of PV systems - * @param {Function} params.setSelectedPVSystem - state setter for the currently selected PV system * @param {Array} params.pvPoints - array of points the user clicked (with normal vectors) * @param {Function} params.setPVPoints - state setter to clear points after creation * @param {Array} params.simulatedBuildings - array of building objects that contain the simulation mesh */ export function createPVSystem({ setPVSystems, - setSelectedPVSystem, pvPoints, setPVPoints, simulatedBuildings, }) { - const points = pvPoints.map((obj) => obj.point) - if (pvPoints.length < 3) { - return - } - const geometry = new THREE.BufferGeometry() - const trianglesWithNormals = triangulate(pvPoints) - const triangles = [] - const bufferTriangles = [] - const normalOffset = 0.1 // Adjust this value as needed - - for (const { a, b, c } of trianglesWithNormals) { - const shift = (element) => ({ - x: element.point.x + element.normal.x * normalOffset, - y: element.point.y + element.normal.y * normalOffset, - z: element.point.z + element.normal.z * normalOffset, - }) - - const sa = shift(a) - const sb = shift(b) - const sc = shift(c) - - triangles.push({ a: a.point, b: b.point, c: c.point }) - bufferTriangles.push(sa.x, sa.y, sa.z, sb.x, sb.y, sb.z, sc.x, sc.y, sc.z) - } - - geometry.setAttribute( - 'position', - new THREE.Float32BufferAttribute(bufferTriangles, 3), - ) - geometry.name = 'pvSystem' - - // Subdivide triangles for higher resolution PV placement - let subdividedTriangles = [] - const triangleSubdivisionThreshold = 0.8 - triangles.forEach((triangle) => { - subdividedTriangles = subdividedTriangles.concat( - subdivideTriangle(triangle, triangleSubdivisionThreshold), - ) + const pvSystemData = createPVSystemData({ + pvPoints, + simulatedBuildings, }) - const geometries = [] - simulatedBuildings.forEach((building) => { - const mesh = building.mesh - if (mesh && mesh.geometry) { - const geom = mesh.geometry.clone() - geom.applyMatrix4(mesh.matrixWorld) - geometries.push(geom) - } - }) - const simulationGeometry = BufferGeometryUtils.mergeGeometries( - geometries, - true, - ) - const polygonPrefilteringCutoff = 10 - const prefilteredPolygons = filterPolygonsByDistance( - simulationGeometry, - points, - polygonPrefilteringCutoff, - ) - const newVertices = [] - const newColors = [] - const newIntensities = [] - subdividedTriangles.forEach((triangle) => { - newVertices.push(triangle.a.x, triangle.a.y, triangle.a.z) - newVertices.push(triangle.b.x, triangle.b.y, triangle.b.z) - newVertices.push(triangle.c.x, triangle.c.y, triangle.c.z) - }) - for (let i = 0; i < newVertices.length; i += 3) { - const vertex = new THREE.Vector3( - newVertices[i], - newVertices[i + 1], - newVertices[i + 2], - ) - const closestPolygon = findClosestPolygon( - vertex, - prefilteredPolygons, - polygonPrefilteringCutoff, - ) - if (closestPolygon) { - const projectedVertex = projectOntoTriangle(vertex, closestPolygon) - const color = getColorAtPointOnTriangle(projectedVertex, closestPolygon) - const intensity = getIntensityAtPointOnTriangle( - projectedVertex, - closestPolygon, - ) - newColors.push(color.r, color.g, color.b) - newIntensities.push(intensity) - } else { - newColors.push(1, 1, 1) - newIntensities.push(-1) - } + if (!pvSystemData) { + return } - const polygonArea = calculatePolygonArea(triangles) - const polygonIntensity = calculatePolygonIntensity( - newVertices, - newIntensities, - ) - const annualYield = polygonArea * polygonIntensity - - geometry.annualYield = annualYield - geometry.area = polygonArea - setPVSystems((prevSystems) => [...prevSystems, geometry]) + // Mark the new system as selected and deselect all other systems + setPVSystems((prevSystems) => [ + ...prevSystems.map((system) => ({ ...system, selected: false })), + { ...pvSystemData, selected: true }, + ]) setPVPoints([]) - setSelectedPVSystem([geometry]) } -const PVSystem = ({ geometry }) => { +/** + * Pure rendering component for a single PV system. + * Displays the PV panel mesh and label with yield information. + * + * @param {Object} props + * @param {Object} props.pvSystem - PV system object with geometry and yield data + * @param {boolean} props.highlighted - Whether this PV system is highlighted/selected + */ +export const PVSystem = ({ pvSystem, highlighted = false }) => { const textRef = useRef() - const center = calculateCenter(geometry.attributes.position.array) + // Use pre-computed center instead of calculating on every render + const center = new THREE.Vector3( + pvSystem.center.x, + pvSystem.center.y, + pvSystem.center.z, + ) + // Update text sprite rotation to face camera useFrame(({ camera }) => { if (textRef.current) { textRef.current.quaternion.copy(camera.quaternion) } }) + const material = highlighted + ? new THREE.MeshLambertMaterial({ + color: 'red', + transparent: false, + }) + : new THREE.MeshStandardMaterial({ + color: '#2b2c40', + transparent: true, + opacity: 0.5, + metalness: 1, + side: THREE.DoubleSide, + }) + return ( <> - + ) } - -const calculateCenter = (points) => { - const length = points.length / 3 - const sum = points.reduce( - (acc, value, index) => { - acc[index % 3] += value - return acc - }, - [0, 0, 0], - ) - return new THREE.Vector3(sum[0] / length, sum[1] / length, sum[2] / length) -} - -function subdivideTriangle(triangle, threshold) { - const distance = (p1, p2) => - Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2 + (p2.z - p1.z) ** 2) - - const midPoint = (p1, p2) => ({ - x: (p1.x + p2.x) / 2, - y: (p1.y + p2.y) / 2, - z: (p1.z + p2.z) / 2, - }) - - const aToB = distance(triangle.a, triangle.b) - const bToC = distance(triangle.b, triangle.c) - const cToA = distance(triangle.c, triangle.a) - - const area = calculateTriangleArea(triangle) - - if (area < threshold) { - return [triangle] - } - - const abMid = midPoint(triangle.a, triangle.b) - const bcMid = midPoint(triangle.b, triangle.c) - const caMid = midPoint(triangle.c, triangle.a) - - return subdivideTriangle({ a: triangle.a, b: abMid, c: caMid }, threshold) - .concat(subdivideTriangle({ a: abMid, b: triangle.b, c: bcMid }, threshold)) - .concat(subdivideTriangle({ a: caMid, b: bcMid, c: triangle.c }, threshold)) - .concat(subdivideTriangle({ a: abMid, b: bcMid, c: caMid }, threshold)) -} - -function calculatePolygonArea(polygon) { - let totalArea = 0 - - polygon.forEach((triangle) => { - totalArea += calculateTriangleArea(triangle) - }) - - return totalArea -} - -function calculateTriangleArea(triangle) { - const { a, b, c } = triangle - - const ab = new THREE.Vector3().subVectors(b, a) - const ac = new THREE.Vector3().subVectors(c, a) - const crossProduct = new THREE.Vector3().crossVectors(ab, ac) - const area = 0.5 * crossProduct.length() - - return area -} - -function findClosestPolygon(vertex, polygons, polygonPrefilteringCutoff) { - let closestPolygon = null - let minDistance = Infinity - - polygons.forEach((polygon) => { - const [v0, v1, v2] = polygon.vertices - const distance = - vertex.distanceTo(v0) + vertex.distanceTo(v1) + vertex.distanceTo(v2) - if (distance < minDistance) { - minDistance = distance - closestPolygon = polygon - } - }) - - if (minDistance >= polygonPrefilteringCutoff) { - console.error( - `Error: Trying to create a polygon with a distance longer than the threshold (${minDistance})`, - ) - } - - return closestPolygon -} - -function filterPolygonsByDistance(geometry, points, threshold) { - const filteredPolygons = [] - - if (!geometry.isBufferGeometry) return - - const positions = geometry.attributes.position.array - const colors = geometry.attributes.color - ? geometry.attributes.color.array - : null - const intensities = geometry.attributes.intensities - ? geometry.attributes.intensities.array - : null - - for (let i = 0; i < positions.length; i += 9) { - const v0 = new THREE.Vector3( - positions[i], - positions[i + 1], - positions[i + 2], - ) - const v1 = new THREE.Vector3( - positions[i + 3], - positions[i + 4], - positions[i + 5], - ) - const v2 = new THREE.Vector3( - positions[i + 6], - positions[i + 7], - positions[i + 8], - ) - - const color0 = colors - ? new THREE.Color(colors[i], colors[i + 1], colors[i + 2]) - : new THREE.Color(1, 1, 1) - const color1 = colors - ? new THREE.Color(colors[i + 3], colors[i + 4], colors[i + 5]) - : new THREE.Color(1, 1, 1) - const color2 = colors - ? new THREE.Color(colors[i + 6], colors[i + 7], colors[i + 8]) - : new THREE.Color(1, 1, 1) - - const intensity1 = intensities ? intensities[i / 9] : -1000 - const intensity2 = intensities ? intensities[i / 9] : -1000 - const intensity3 = intensities ? intensities[i / 9] : -1000 - - let minDistance = Infinity - points.forEach((point) => { - const distance = Math.min( - point.distanceTo(v0), - point.distanceTo(v1), - point.distanceTo(v2), - ) - if (distance < minDistance) { - minDistance = distance - } - }) - - if (minDistance < threshold) { - const normal = new THREE.Triangle(v0, v1, v2).getNormal( - new THREE.Vector3(), - ) - filteredPolygons.push({ - vertices: [v0, v1, v2], - colors: [color0, color1, color2], - normal, - intensities: [intensity1, intensity2, intensity3], - }) - } - } - - return filteredPolygons -} - -function projectOntoTriangle(vertex, triangle) { - const [v0, v1, v2] = triangle.vertices - const normal = triangle.normal.clone().normalize() - - const d = v0.dot(normal) - const t = (d - vertex.dot(normal)) / normal.dot(normal) - const projection = vertex.clone().add(normal.clone().multiplyScalar(t)) - - return projection -} - -function getColorAtPointOnTriangle(point, triangle) { - const [v0, v1, v2] = triangle.vertices - const normal = triangle.normal.clone().normalize() - - const areaABC = normal.dot( - new THREE.Vector3().crossVectors(v1.clone().sub(v0), v2.clone().sub(v0)), - ) - const areaPBC = normal.dot( - new THREE.Vector3().crossVectors( - v1.clone().sub(point), - v2.clone().sub(point), - ), - ) - const areaPCA = normal.dot( - new THREE.Vector3().crossVectors( - v2.clone().sub(point), - v0.clone().sub(point), - ), - ) - - const u = areaPBC / areaABC - const v = areaPCA / areaABC - const w = 1 - u - v - - const color0 = triangle.colors[0] - const color1 = triangle.colors[1] - const color2 = triangle.colors[2] - - const r = u * color0.r + v * color1.r + w * color2.r - const g = u * color0.g + v * color1.g + w * color2.g - const b = u * color0.b + v * color1.b + w * color2.b - - return new THREE.Color(r, g, b) -} - -function getIntensityAtPointOnTriangle(point, triangle) { - const [v0, v1, v2] = triangle.vertices - const normal = triangle.normal.clone().normalize() - - const areaABC = normal.dot( - new THREE.Vector3().crossVectors(v1.clone().sub(v0), v2.clone().sub(v0)), - ) - const areaPBC = normal.dot( - new THREE.Vector3().crossVectors( - v1.clone().sub(point), - v2.clone().sub(point), - ), - ) - const areaPCA = normal.dot( - new THREE.Vector3().crossVectors( - v2.clone().sub(point), - v0.clone().sub(point), - ), - ) - - const u = areaPBC / areaABC - const v = areaPCA / areaABC - const w = 1 - u - v - - const intensity0 = triangle.intensities[0] - const intensity1 = triangle.intensities[1] - const intensity2 = triangle.intensities[2] - - const intensityAtPoint = u * intensity0 + v * intensity1 + w * intensity2 - - return intensityAtPoint -} -function calculatePolygonIntensity(vertices, intensities) { - const numTriangles = vertices.length / 9 - let totalIntensity = 0 - let totalArea = 0 - - for (let i = 0; i < numTriangles; i++) { - const triangle = { - a: new THREE.Vector3( - vertices[i * 9], - vertices[i * 9 + 1], - vertices[i * 9 + 2], - ), - b: new THREE.Vector3( - vertices[i * 9 + 3], - vertices[i * 9 + 4], - vertices[i * 9 + 5], - ), - c: new THREE.Vector3( - vertices[i * 9 + 6], - vertices[i * 9 + 7], - vertices[i * 9 + 8], - ), - intensities: [ - intensities[i * 3], - intensities[i * 3 + 1], - intensities[i * 3 + 2], - ], - } - - const triangleArea = calculateTriangleArea(triangle) - const triangleIntensity = calculateTriangleIntensity(triangle) - totalIntensity += triangleIntensity * triangleArea - totalArea += triangleArea - } - - const averageIntensity = totalIntensity / totalArea - return averageIntensity -} - -function calculateTriangleIntensity(triangle) { - const intensities = triangle.intensities - const averageIntensity = - (intensities[0] + intensities[1] + intensities[2]) / 3 - return averageIntensity -} - -// Takes a sequence of points [[x, y, z], ...] and -// returns a sequence of triangles [[x1, y1, z1, x2, ...], ...], -// making sure to generate a valid triangulation of the polygon -// Highly inefficient implementation, but we don't triangulate many polygons so it should be fine -export function triangulate(points) { - if (points.length == 3) { - return [{ a: points[0], b: points[1], c: points[2] }] - } else if (points.length < 3) { - return [] - } - - // As the triangle is in 3d-space anyways, we can just assume that vertices are given in CCW order - const pt = (i) => points[(i + points.length) % points.length] - - const ab = sub(pt(1).point, pt(0).point) - const ac = sub(pt(2).point, pt(0).point) - const normal = new THREE.Vector3().crossVectors(ab, ac) - - let countNegative = 0 - let countPositive = 0 - - // Taking inspiration from a polygon triangulation based on the two ears theorem - // However, in R3, things can get a bit more wonky... - // https://en.wikipedia.org/wiki/Two_ears_theorem#Relation_to_triangulations - const makeTriplet = (left, vertex, right) => { - const det = determinant( - sub(vertex.point, left.point), - sub(vertex.point, right.point), - normal, - ) - - if (det > 0) { - countPositive += 1 - } else { - countNegative += 1 - } - - return { left: left, vertex: vertex, right: right, det } - } - - const triplets = points.map((cur, i) => - makeTriplet(pt(i - 1), cur, pt(i + 1)), - ) - - if (countPositive < countNegative) { - // negative det => convex vertex, so we flip all determinants - for (let t of triplets) { - t.det = -t.det - } - } - - const concaveVertices = triplets.filter((t) => t.det < 0).map((t) => t.vertex) - - let anyEar = false - for (let t of triplets) { - // Idea: Define the 3d analogue of a polygon ear by looking at triples and projecting the - // remaining points onto the plane spanned by that particular triangle - // An ear is any triangle having no concave vertices lying inside it - const containedConcaveVertices = concaveVertices - .filter((v) => v != t.left && v != t.vertex && v != t.right) - .filter((v) => - pointInsideTriangle( - v.point, - t.left.point, - t.vertex.point, - t.right.point, - ), - ) - - t.isEar = t.det > 0 && containedConcaveVertices.length == 0 - if (t.isEar) { - anyEar = true - } - } - - // Prevent infinite loop - if (!anyEar) { - console.warn('No ear found in ear clipping!') - triplets[0].isEar = true - } - - for (let ear of triplets.filter((t) => t.isEar)) { - const remainingPoints = triplets - .filter((t) => t != ear) - .map((t) => t.vertex) - return [{ a: ear.left, b: ear.vertex, c: ear.right }].concat( - triangulate(remainingPoints), - ) - } -} - -function determinant(v1, v2, v3) { - const matrix = new THREE.Matrix3() - matrix.set( - v1.x, - v2.x, - v3.x, // First column - v1.y, - v2.y, - v3.y, // Second column - v1.z, - v2.z, - v3.z, // Third column - ) - return matrix.determinant() -} - -function sub(v1, v2) { - return new THREE.Vector3().subVectors(v1, v2) -} - -function cross(v1, v2) { - return new THREE.Vector3().crossVectors(v1, v2) -} - -export function pointInsideTriangle(point, v1, v2, v3) { - const normal = cross(sub(v1, v2), sub(v2, v3)) - const n1 = cross(normal, sub(v1, v2)) - const n2 = cross(normal, sub(v2, v3)) - const n3 = cross(normal, sub(v3, v1)) - - const d1 = Math.sign(n1.dot(sub(v1, point))) - const d2 = Math.sign(n2.dot(sub(v2, point))) - const d3 = Math.sign(n3.dot(sub(v3, point))) - - // Inside if all 3 have the same sign - return d1 == d2 && d2 == d3 -} diff --git a/src/features/three-viewer/utils/pvSystemUtils.js b/src/features/three-viewer/utils/pvSystemUtils.js new file mode 100644 index 0000000..e4ac22e --- /dev/null +++ b/src/features/three-viewer/utils/pvSystemUtils.js @@ -0,0 +1,110 @@ +import * as THREE from 'three' + +/** + * Generates a unique ID for a PV system. + * Format: "pv-{timestamp}-{random}" + * + * @returns {string} Unique identifier for the PV system + * + * @example + * const id = generatePVSystemId() + * // Returns: "pv-1703001234567-k8n2p9x4q" + */ +export function generatePVSystemId() { + const timestamp = Date.now() + const random = Math.random().toString(36).substring(2, 11) + return `pv-${timestamp}-${random}` +} + +/** + * Calculates the geometric center from a THREE.BufferGeometry. + * Averages all vertex positions from the geometry's position attribute. + * + * @param {THREE.BufferGeometry} geometry - The geometry to calculate center from + * @returns {{x: number, y: number, z: number}} The geometric center point + * + * @example + * const center = calculateCenterFromGeometry(geometry) + * // Returns: {x: 10.5, y: 20.3, z: 5.7} + */ +export function calculateCenterFromGeometry(geometry) { + const points = geometry.attributes.position.array + const length = points.length / 3 + const sum = points.reduce( + (acc, value, index) => { + acc[index % 3] += value + return acc + }, + [0, 0, 0], + ) + return { + x: sum[0] / length, + y: sum[1] / length, + z: sum[2] / length, + } +} + +/** + * Calculates the slope (tilt angle) from a normal vector. + * Slope is the angle between the normal and the vertical (up) direction. + * + * @param {THREE.Vector3} normal - The surface normal vector + * @returns {number} Slope in degrees from horizontal (0° = horizontal, 90° = vertical) + * + * @example + * const normal = new THREE.Vector3(0, 0, 1) + * const slope = calculateSlopeFromNormal(normal) + * // Returns: 0 (horizontal surface) + * + * @example + * const normal = new THREE.Vector3(0, 1, 0) + * const slope = calculateSlopeFromNormal(normal) + * // Returns: 90 (vertical surface) + */ +export function calculateSlopeFromNormal(normal) { + const up = new THREE.Vector3(0, 0, 1) + const angleRad = normal.angleTo(up) + return THREE.MathUtils.radToDeg(angleRad) +} + +/** + * Calculates the azimuth (compass direction) from a normal vector. + * Azimuth is measured clockwise from North (positive Y-axis). + * + * @param {THREE.Vector3} normal - The surface normal vector + * @returns {number} Azimuth in degrees (0° = North, 90° = East, 180° = South, 270° = West) + * + * @example + * const normal = new THREE.Vector3(0, 1, 0) + * const azimuth = calculateAzimuthFromNormal(normal) + * // Returns: 0 (facing North) + * + * @example + * const normal = new THREE.Vector3(1, 0, 0) + * const azimuth = calculateAzimuthFromNormal(normal) + * // Returns: 90 (facing East) + */ +export function calculateAzimuthFromNormal(normal) { + const azimuthRad = Math.atan2(normal.x, normal.y) + let azimuth = THREE.MathUtils.radToDeg(azimuthRad) + if (azimuth < 0) { + azimuth += 360 + } + return azimuth +} + +/** + * Calculates the yield per kilowatt-peak (kWp) from yield per area. + * Uses a conversion factor of 5.5 to convert from kWh/m²/year to kWh/kWp/year. + * + * @param {number} yieldPerArea - Solar yield per area in kWh/m²/year + * @returns {number} Yield per kWp in kWh/kWp/year + * + * @example + * const yieldPerArea = 1000 // kWh/m²/year + * const yieldPerKWP = calculateYieldPerKWP(yieldPerArea) + * // Returns: 5500 (kWh/kWp/year) + */ +export function calculateYieldPerKWP(yieldPerArea) { + return yieldPerArea * 5.5 +}