Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
public/draco
1 change: 1 addition & 0 deletions RELEASE-PROCEDURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
3 changes: 1 addition & 2 deletions src/features/three-viewer/components/Overlay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -39,7 +38,7 @@ function Overlay({ frontendState, setFrontendState }) {
<>
{!window.isTouchDevice && <MouseHoverInfo />}
<OverlayWrapper>
{sceneContext.selectedPVSystem.length > 0 && (
{sceneContext.pvSystems.some((system) => system.selected) && (
<NotificationForSelectedPV />
)}

Expand Down
25 changes: 13 additions & 12 deletions src/features/three-viewer/components/Scene.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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('')
Expand All @@ -52,8 +49,6 @@ const Scene = ({
buildings,
pvPoints,
setPVPoints,
selectedPVSystem,
setSelectedPVSystem,
pvSystems,
setPVSystems,
showTerrain,
Expand Down Expand Up @@ -91,14 +86,20 @@ const Scene = ({
{buildings.length > 0 &&
buildings.map((b) => <BuildingMesh building={b} />)}

{selectedPVSystem && <HighlightedPVSystem />}
{simulationBuildings.length > 0 && frontendState == 'Results' && (
<CustomMapControl />
)}
{frontendState == 'DrawPV' && <DrawPVControl />}
{frontendState == 'DrawPV' && <PointsAndEdges />}

{pvSystems.length > 0 && <PVSystems />}
{pvSystems.length > 0 &&
pvSystems.map((pvSystem) => (
<PVSystem
pvSystem={pvSystem}
key={pvSystem.id}
highlighted={pvSystem.selected || false}
/>
))}

{vegetationGeometries && (
<>
Expand Down
33 changes: 9 additions & 24 deletions src/features/three-viewer/controls/CustomMapControl.jsx
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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))
}

Expand Down Expand Up @@ -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
}
1 change: 0 additions & 1 deletion src/features/three-viewer/controls/DrawPVControl.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ const DrawPVControl = () => {
) {
createPVSystem({
setPVSystems: sceneContext.setPVSystems,
setSelectedPVSystem: sceneContext.setSelectedPVSystem,
pvPoints: pvPointsRef,
setPVPoints: sceneContext.setPVPoints,
simulationBuildings:
Expand Down
235 changes: 235 additions & 0 deletions src/features/three-viewer/core/geometryProcessing.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading