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
+}