Parameters
Computing...
0
100
Use the SunExposure tool to compute solar exposition for a time interval.
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import XYZ from "ol/source/XYZ";
import * as THREE from "three";
import {
AmbientLight,
BoxGeometry,
DirectionalLight,
Mesh,
MeshStandardMaterial,
PlaneGeometry,
SphereGeometry,
Vector3,
} from "three";
import { GLTFLoader } from "three/examples/jsm/Addons.js";
import { MapControls } from "three/examples/jsm/controls/MapControls.js";
import ColorMap from "@giro3d/giro3d/core/ColorMap";
import Coordinates from "@giro3d/giro3d/core/geographic/Coordinates";
import CoordinateSystem from "@giro3d/giro3d/core/geographic/CoordinateSystem";
import Extent from "@giro3d/giro3d/core/geographic/Extent";
import Instance from "@giro3d/giro3d/core/Instance";
import ColorLayer from "@giro3d/giro3d/core/layer/ColorLayer";
import ElevationLayer from "@giro3d/giro3d/core/layer/ElevationLayer";
import Entity3D from "@giro3d/giro3d/entities/Entity3D";
import Map from "@giro3d/giro3d/entities/Map";
import { MapLightingMode } from "@giro3d/giro3d/entities/MapLightingOptions";
import MapboxTerrainFormat from "@giro3d/giro3d/formats/MapboxTerrainFormat";
import Inspector from "@giro3d/giro3d/gui/Inspector";
import DrawTool from "@giro3d/giro3d/interactions/DrawTool";
import SunExposure from "@giro3d/giro3d/interactions/SunExposure";
import TiledImageSource from "@giro3d/giro3d/sources/TiledImageSource";
import Fetcher from "@giro3d/giro3d/utils/Fetcher";
import { bindButton } from "./widgets/bindButton";
import { bindDropDown } from "./widgets/bindDropDown";
import { bindNumberInput } from "./widgets/bindNumberInput";
import { bindTextInput } from "./widgets/bindTextInput";
import { bindToggle } from "./widgets/bindToggle";
import { makeColorRamp } from "./widgets/makeColorRamp";
import updateColorMapPreview from "./widgets/updateColorMapPreview";
const lambert93 = CoordinateSystem.register(
"EPSG:2154",
"+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
);
const utm32 = CoordinateSystem.register(
"EPSG:25832",
"+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs",
);
const params = {
abortController: new AbortController(),
aoiShape: null,
helpers: false,
showInputs: true,
scenario: "simple",
activeAttribute: "irradiation",
spatialResolution: 10,
date: new Date(Date.UTC(2025, 6, 21)),
startTime: 8,
endTime: 16,
originalVolume: new THREE.Box3().makeEmpty(),
volume: null,
boxHelper: null,
temporalResolutionMinutes: 60,
currentObjects: [],
inputs: [],
};
function createLights(instance, center) {
// Note: those lights are for illustrative purposes,
// they are not part of the sun exposure computation.
const main = new DirectionalLight();
const secondary = new DirectionalLight("white", 0.3);
const ambientLight = new AmbientLight("white", 0.2);
instance.add(ambientLight);
instance.add(main);
instance.add(main.target);
instance.add(secondary);
instance.add(secondary.target);
main.target.position.copy(center);
main.position.set(center.x + 1000, center.y + 300, 1000);
main.updateMatrixWorld(true);
main.target.updateMatrixWorld(true);
params.currentObjects.push(main);
params.currentObjects.push(main.target);
secondary.target.position.copy(center);
secondary.position.set(center.x - 1000, center.y + 300, 1000);
secondary.updateMatrixWorld(true);
secondary.target.updateMatrixWorld(true);
params.currentObjects.push(secondary);
params.currentObjects.push(secondary.target);
params.currentObjects.push(ambientLight);
}
async function planeOnlyScenario(instance) {
const center = Coordinates.WGS84(23.43, 0).as(lambert93);
const width = 200;
const areaOfInterest = Extent.fromCenterAndSize(
lambert93,
center,
width,
width,
);
const plane = new Mesh(
new PlaneGeometry(width, width),
new MeshStandardMaterial({ color: "white" }),
);
const centerVec3 = center.toVector3();
plane.position.copy(centerVec3);
plane.updateMatrixWorld();
await instance.add(plane);
const inputs = [plane];
return {
areaOfInterest,
inputs,
allowedSpatialResolutionRange: [0.5, 20, 1],
center: centerVec3,
volume: areaOfInterest.toBox3(-1, 1),
};
}
async function planeBoxScenario(instance) {
const center = Coordinates.WGS84(23.4384024785, 0).as(lambert93);
const width = 300;
const areaOfInterest = Extent.fromCenterAndSize(
lambert93,
center,
width,
width,
);
const plane = new Mesh(
new PlaneGeometry(width, width),
new MeshStandardMaterial({ color: "white" }),
);
const box = new Mesh(
new BoxGeometry(30, 30, 60),
new MeshStandardMaterial({ color: "#89dce5" }),
);
const centerVec3 = center.toVector3();
plane.position.copy(centerVec3);
box.position.set(centerVec3.x, centerVec3.y, 30);
plane.updateMatrixWorld();
box.updateMatrixWorld();
await instance.add(plane);
await instance.add(box);
const volume = new THREE.Box3();
volume.expandByObject(plane);
volume.expandByObject(box);
const inputs = [plane, box];
return {
areaOfInterest,
allowedSpatialResolutionRange: [1, 50, 1],
inputs,
center: centerVec3,
volume,
};
}
async function sphereScenario(instance) {
const center = Coordinates.WGS84(48.85304790669139, 2.3497154907829603).as(
lambert93,
);
const width = 300;
const areaOfInterest = Extent.fromCenterAndSize(
lambert93,
center,
width,
width,
);
const plane = new Mesh(
new PlaneGeometry(width, width),
new MeshStandardMaterial({ color: "white" }),
);
const sphere = new Mesh(
new SphereGeometry(30),
new MeshStandardMaterial({ color: "#89dce5" }),
);
const centerVec3 = center.toVector3();
plane.position.copy(centerVec3);
sphere.position.set(centerVec3.x, centerVec3.y, 30);
plane.updateMatrixWorld();
sphere.updateMatrixWorld();
await instance.add(plane);
await instance.add(sphere);
const inputs = [plane, sphere];
const volume = new THREE.Box3();
volume.expandByObject(plane);
volume.expandByObject(sphere);
return {
areaOfInterest,
inputs,
allowedSpatialResolutionRange: [1, 50, 1],
center: centerVec3,
volume,
};
}
async function cityBlockScenario(instance) {
const center = Coordinates.WGS84(45.93506, 6.63125).as(lambert93);
const loader = new GLTFLoader();
const model = await loader.loadAsync(
"https://3d.oslandia.com/giro3d/gltf/jena/scene.gltf",
);
model.scene.position.copy(center);
model.scene.rotateX(Math.PI / 2);
model.scene.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(model.scene);
const actualCenter = box.getCenter(new Vector3());
actualCenter.setZ(box.min.z);
const size = box.getSize(new Vector3());
const areaOfInterest = Extent.fromCenterAndSize(
lambert93,
actualCenter,
size.x,
size.y,
);
await instance.add(model.scene);
return {
areaOfInterest,
inputs: [model.scene],
allowedSpatialResolutionRange: [0.1, 50, 0.5],
center: actualCenter,
volume: box,
};
}
async function createMap(instance, extent) {
const map = new Map({
backgroundColor: "white",
lighting: {
enabled: true,
mode: MapLightingMode.LightBased,
elevationLayersOnly: true,
},
extent,
terrain: {
enabled: true,
skirts: {
enabled: true,
depth: 0,
},
},
});
await instance.add(map);
const key =
"pk.eyJ1IjoiZ2lybzNkIiwiYSI6ImNtZ3Q0NDNlNTAwY2oybHI3Ym1kcW03YmoifQ.Zl7_KZiAhqWSPjlkKDKYnQ";
// Adds a XYZ elevation layer with MapBox terrain RGB tileset
const elevationLayer = new ElevationLayer({
name: "xyz_elevation",
extent,
// We dont want the full resolution because the terrain
// mesh has a much lower resolution than the raster image
resolutionFactor: 0.5,
minmax: { min: 0, max: 5000 },
source: new TiledImageSource({
format: new MapboxTerrainFormat(),
source: new XYZ({
url: `https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=${key}`,
projection: "EPSG:3857",
crossOrigin: "anonymous",
}),
}),
});
await map.addLayer(elevationLayer);
// Adds a XYZ color layer with MapBox satellite tileset
const satelliteLayer = new ColorLayer({
name: "xyz_color",
extent,
source: new TiledImageSource({
source: new XYZ({
url: `https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp?access_token=${key}`,
projection: "EPSG:3857",
crossOrigin: "anonymous",
}),
}),
});
await map.addLayer(satelliteLayer);
return map;
}
async function terrainScenario(instance) {
const center = Coordinates.WGS84(45.9231, 6.8697).as(lambert93);
const size = 30_000;
const extent = Extent.fromCenterAndSize(lambert93, center, size, size);
const map = await createMap(instance, extent);
return {
areaOfInterest: extent,
inputs: [map],
allowedSpatialResolutionRange: [50, 1000, 100],
center: center.toVector3(),
volume: extent.toBox3(0, 6000),
};
}
const scenarios = {
box: planeBoxScenario,
plane: planeOnlyScenario,
terrain: terrainScenario,
sphere: sphereScenario,
"city-block": cityBlockScenario,
};
const colorMaps = {
meanIrradiance: new ColorMap({
colors: makeColorRamp("magma"),
min: 0,
max: 0,
}),
irradiation: new ColorMap({ colors: makeColorRamp("jet"), min: 0, max: 0 }),
hoursOfSunlight: new ColorMap({
colors: makeColorRamp("RdBu"),
min: 0,
max: 0,
}),
};
const instance = new Instance({
target: "view",
crs: lambert93,
backgroundColor: "white",
});
const controls = new MapControls(instance.view.camera, instance.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.2;
instance.view.setControls(controls);
const compassMaterial = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0.5,
visible: false,
color: "white",
});
Fetcher.texture("https://3d.oslandia.com/giro3d/images/compass.webp", {
flipY: true,
}).then((t) => {
compassMaterial.map = t;
compassMaterial.visible = true;
instance.notifyChange();
});
const compass = new Mesh(new PlaneGeometry(1, 1), compassMaterial);
compass.name = "compass";
instance.add(compass);
const progressBar = document.getElementById("progress");
let currentPointCloud = null;
let currentSunExposure = null;
async function run(scenarioName) {
if (params.currentObjects.length > 0) {
params.currentObjects.forEach((o) => instance.remove(o));
params.currentObjects.length = 0;
}
if (currentPointCloud) {
instance.remove(currentPointCloud);
currentPointCloud = null;
}
params.volume = null;
if (params.boxHelper) {
instance.remove(params.boxHelper);
params.boxHelper = null;
}
if (currentSunExposure) {
currentSunExposure.dispose();
}
const scenario = scenarios[scenarioName];
const {
areaOfInterest,
inputs,
center,
volume,
allowedSpatialResolutionRange,
} = await scenario(instance);
const spatialResInput = document.getElementById("spatial-resolution");
const [minRes, maxRes, defaultRes] = allowedSpatialResolutionRange;
spatialResInput.min = minRes;
spatialResInput.max = maxRes;
spatialResInput.value = defaultRes;
params.spatialResolution = defaultRes;
params.inputs = inputs;
params.originalVolume = volume;
params.currentObjects.push(...inputs);
const dims = areaOfInterest.dimensions();
const compassSize = dims.width * 2;
compass.scale.set(compassSize, compassSize, 1);
compass.position.set(center.x, center.y, center.z - 1);
compass.updateMatrixWorld(true);
instance.add(compass);
const pov = instance.view.goTo(inputs[0]);
createLights(instance, center);
controls.target.set(pov.target.x, pov.target.y, 0);
controls.update();
controls.saveState();
instance.notifyChange();
const setActiveAttribute = (att) => {
updateColorMapPreview("gradient", colorMaps[att].colors);
currentPointCloud?.setActiveAttribute(att);
// Note that irradiation is technically in Watt-hour/m², but for readability,
// we convert to Kilowatt-hour/m² to be displayed in the UI. Remember that
// the actual values are in Watt-hour/m² though.
const factor = att === "irradiation" ? 0.001 : 1;
const min = Math.abs(colorMaps[att].min * factor);
const max = Math.abs(colorMaps[att].max * factor);
document.getElementById("colorMapMin").innerText = min.toFixed(2);
document.getElementById("colorMapMax").innerText = max.toFixed(2);
};
const onStart = () => {
if (currentPointCloud) {
instance.remove(currentPointCloud);
currentPointCloud = null;
}
if (currentSunExposure) {
currentSunExposure.dispose();
currentSunExposure = null;
}
const yyyy = params.date.getUTCFullYear();
const mm = params.date.getUTCMonth();
const dd = params.date.getUTCDay();
const start = new Date(Date.UTC(yyyy, mm, dd, params.startTime));
const end = new Date(Date.UTC(yyyy, mm, dd, params.endTime));
inputs.forEach((obj) => (obj.visible = true));
const sunExposure = new SunExposure({
instance,
showHelpers: params.helpers,
objects: inputs,
limits: Extent.fromBox3(
lambert93,
params.volume ?? params.originalVolume,
),
spatialResolution: params.spatialResolution,
colorMap: colorMaps["irradiation"],
start,
end,
temporalResolution: params.temporalResolutionMinutes * 60,
});
currentSunExposure = sunExposure;
sunExposure.addEventListener("progress", (e) => {
const percent = (e.progress * 100).toFixed(0);
console.log(`sun computation progress: ${percent}%`);
progressBar.style.width = `${percent}%`;
if (e.progress >= 1) {
progressBar.parentElement.style.display = "none";
}
});
progressBar.parentElement.style.display = "block";
document.getElementById("attribute-group").style.display = "none";
params.abortController = new AbortController();
const startButton = document.getElementById("start");
const cancelButton = document.getElementById("cancel");
startButton.classList.add("d-none");
cancelButton.classList.remove("d-none");
sunExposure
.compute({ signal: params.abortController.signal })
.then((results) => {
currentPointCloud = results.entity;
if (!params.showInputs) {
inputs.forEach((obj) => (obj.visible = false));
}
instance.notifyChange();
colorMaps.meanIrradiance.min = results.variables.meanIrradiance.min;
colorMaps.meanIrradiance.max = results.variables.meanIrradiance.max;
colorMaps.irradiation.min = results.variables.irradiation.min;
colorMaps.irradiation.max = results.variables.irradiation.max;
colorMaps.hoursOfSunlight.min = results.variables.hoursOfSunlight.min;
colorMaps.hoursOfSunlight.max = results.variables.hoursOfSunlight.max;
results.entity.setAttributeColorMap(
"meanIrradiance",
colorMaps.meanIrradiance,
);
results.entity.setAttributeColorMap(
"irradiation",
colorMaps.irradiation,
);
results.entity.setAttributeColorMap(
"hoursOfSunlight",
colorMaps.hoursOfSunlight,
);
setActiveAttribute(params.activeAttribute);
document.getElementById("attribute-group").style.display = "flex";
})
.catch(console.warn)
.finally(() => {
startButton.classList.remove("d-none");
cancelButton.classList.add("d-none");
progressBar.parentElement.style.display = "none";
});
};
bindButton("start", onStart);
bindButton("cancel", () => params.abortController.abort());
bindDropDown("attribute", (att) => {
params.activeAttribute = att;
setActiveAttribute(att);
});
}
const drawTool = new DrawTool({ instance });
function drawAoiShape() {
drawTool
.createPolygon({
showVertices: true,
})
.then((shape) => {
const box = new THREE.Box3().setFromPoints([...shape.points]);
box.min.setZ(-100000);
box.max.setZ(100000);
box.intersect(params.originalVolume);
params.volume = box;
const boxHelper = new THREE.Box3Helper(box, "cyan");
boxHelper.updateMatrixWorld(true);
if (params.boxHelper) {
instance.remove(params.boxHelper);
}
params.boxHelper = boxHelper;
instance.add(boxHelper);
instance.remove(shape);
instance.notifyChange(boxHelper);
});
}
bindNumberInput("spatial-resolution", (v) => (params.spatialResolution = v));
bindNumberInput(
"temporal-resolution",
(v) => (params.temporalResolutionMinutes = v),
);
bindDropDown("scenario", (s) => {
run(s).catch(console.error);
});
bindNumberInput("start-time", (h) => (params.startTime = h));
bindNumberInput("end-time", (h) => (params.endTime = h));
bindTextInput("date", (date) => {
params.date = new Date(date);
});
bindButton("draw-aoi", drawAoiShape);
bindToggle("show-helpers", (v) => (params.helpers = v));
bindToggle("show-inputs", (v) => {
params.showInputs = v;
params.inputs.forEach((obj) => (obj.visible = v));
instance.notifyChange();
});
run("box").catch(console.error);
Inspector.attach("inspector", instance);
<!doctype html>
<html lang="en">
<head>
<title>Sun exposure</title>
<meta charset="UTF-8" />
<meta name="name" content="sun-exposure" />
<meta
name="description"
content="Use the <a href="../apidoc/classes/interactions_SunExposure.SunExposure.html" target="_blank"><code>SunExposure</code></a> tool to compute solar exposition for a time interval."
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="https://giro3d.org/images/favicon.svg" />
<link
href="https://giro3d.org/assets/bootstrap-custom.css"
rel="stylesheet"
/>
<script src="https://giro3d.org/assets/bootstrap.bundle.min.js"></script>
<link
rel="stylesheet"
type="text/css"
href="https://giro3d.org/latest/examples/css/example.css"
/>
</head>
<body>
<div id="view" class="m-0 p-0 w-100 h-100"></div>
<div
id="inspector"
class="position-absolute top-0 start-0 mh-100 overflow-auto"
></div>
<div class="side-pane-with-status-bar" style="width: 24rem">
<div class="card">
<div class="card-header">Parameters</div>
<div class="card-body">
<!-- Scenario selector -->
<div class="input-group">
<label class="input-group-text col-4" for="scenario"
>Scenario</label
>
<select
class="form-select"
id="scenario"
autocomplete="off"
title="Sets the solar parameter"
>
<option value="box">Plane + Box</option>
<option value="plane">Plane only</option>
<option value="sphere">Plane + Sphere</option>
<option value="terrain">Terrain only</option>
<option value="city-block">Photogrammetry mesh</option>
</select>
</div>
<!-- Date -->
<label for="date" class="form-label mt-3">Date (UTC)</label>
<input
type="date"
class="form-control"
id="date"
value="2025-06-21"
autocomplete="off"
/>
<label for="start-time" class="form-label mt-3"
>Start/end times (h)</label
>
<div class="d-flex">
<input
type="number"
min="0"
max="23"
value="8"
step="1"
class="form-control"
id="start-time"
autocomplete="off"
/>
<input
type="number"
min="0"
max="23"
value="16"
step="1"
class="form-control ms-2"
id="end-time"
autocomplete="off"
/>
</div>
<!-- Spatial resolution -->
<label for="spatial-resolution" class="form-label mt-3"
>Spatial resolution (m)</label
>
<input
type="number"
min="0.25"
max="100"
value="10"
step="0.1"
class="form-control"
id="spatial-resolution"
autocomplete="off"
/>
<!-- Temporal resolution -->
<label for="temporal-resolution" class="form-label mt-3"
>Temporal resolution (minutes)</label
>
<input
type="number"
min="1"
max="120"
value="60"
step="1"
class="form-control"
id="temporal-resolution"
autocomplete="off"
/>
<!-- Show/Hide helpers -->
<div class="form-check form-switch mt-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="show-helpers"
autocomplete="off"
/>
<label class="form-check-label" for="show-helpers"
>Show helpers</label
>
</div>
<button
type="button"
class="btn btn-secondary w-100 mt-3"
id="draw-aoi"
>
Draw simulation area
</button>
<button type="button" class="btn btn-primary w-100 mt-3" id="start">
Compute sun exposure
</button>
<button
type="button"
class="btn btn-danger w-100 mt-3 d-none"
id="cancel"
>
Cancel
</button>
<div class="progress mt-3" role="progressbar" style="display: none">
<div
class="progress-bar bg-info progress-bar-striped progress-bar-animated text-dark"
id="progress"
style="width: 0%"
>
Computing...
</div>
</div>
<!-- Show/Hide inputs -->
<div class="form-check form-switch mt-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
checked
id="show-inputs"
autocomplete="off"
/>
<label class="form-check-label" for="show-inputs"
>Show objects</label
>
</div>
<!-- Active attribute selector -->
<div
class="input-group mt-3"
id="attribute-group"
style="display: none"
>
<label class="input-group-text col-4" for="attribute"
>Variable</label
>
<select
class="form-select"
id="attribute"
autocomplete="off"
title="Sets the solar parameter"
>
<option value="irradiation">Irradiation (KWh/m²)</option>
<option value="meanIrradiance">Mean irradiance (W/m²)</option>
<option value="hoursOfSunlight">Hours of sunlight</option>
</select>
<!-- Gradient preview -->
<div class="mt-3 w-100">
<canvas
id="gradient"
height="32"
class="w-100 border rounded"
style="height: 32px; image-rendering: pixelated"
></canvas>
<div class="w-100">
<p class="float-start mb-0" id="colorMapMin">0</p>
<p class="float-end mb-0" id="colorMapMax">100</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="index.js"></script>
<script>
/* activate popovers */
const popoverTriggerList = [].slice.call(
document.querySelectorAll('[data-bs-toggle="popover"]'),
);
popoverTriggerList.map(
// bootstrap is used as script in the template, disable warning about undef
// eslint-disable-next-line no-undef
(popoverTriggerEl) =>
new bootstrap.Popover(popoverTriggerEl, {
trigger: "hover",
placement: "left",
content: document.getElementById(
popoverTriggerEl.getAttribute("data-bs-content"),
).innerHTML,
html: true,
}),
);
</script>
</body>
</html>
{
"name": "sun-exposure",
"dependencies": {
"@giro3d/giro3d": "2.0.0"
},
"devDependencies": {
"vite": "^3.2.3"
},
"scripts": {
"start": "vite",
"build": "vite build"
}
}
import { defineConfig } from "vite";
export default defineConfig({
build: {
target: 'esnext',
},
optimizeDeps: {
esbuildOptions: {
target: 'esnext',
},
},
})