control/DragAndDrop.tstypescript
import { Control, type IControlParams } from "./Control";
import { Vector } from "../layer/Vector";
import { GeoTIFFLayer } from "../layer/GeoTIFFLayer";
import { Entity } from "../entity/Entity";
import { Object3d } from "../Object3d";
import { Gltf } from "../utils/gltf/gltfParser";
import { Vec2 } from "../math/Vec2";
import { EntityEditor } from "./entityEditor/EntityEditor";
import type { LonLat } from "../LonLat";
export interface IDragAndDropParams extends IControlParams {
entityEditor?: EntityEditor;
}
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg"];
const TIFF_EXT = ["tif", "tiff"];
const MAX_BILLBOARD_SIZE = 256;
function getExt(file: File): string {
const i = file.name.lastIndexOf(".");
return i !== -1 ? file.name.slice(i + 1).toLowerCase() : "";
}
function getBaseName(file: File): string {
const i = file.name.lastIndexOf(".");
return i !== -1 ? file.name.slice(0, i) : file.name;
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Unable to load image '${src}'`));
img.src = src;
});
}
/**
* Adds dropped files on the globe: glb/obj models and images are placed in the drop point,
* GeoTIFF rasters are placed by their georeference.
*/
export class DragAndDrop extends Control {
public geoObjectsLayer: Vector;
public markersLayer: Vector;
public tiffLayers: GeoTIFFLayer[];
protected _entityEditor: EntityEditor | null;
constructor(options: IDragAndDropParams = {}) {
super({
name: "DragAndDrop",
autoActivate: true,
...options
});
this.geoObjectsLayer = new Vector("__geoObjects__");
this.markersLayer = new Vector("__markers__");
this.tiffLayers = [];
this._entityEditor = options.entityEditor || null;
}
public override oninit() {
this.planet!.addLayers([this.geoObjectsLayer, this.markersLayer]);
}
public override onremove() {
this.geoObjectsLayer.remove();
this.markersLayer.remove();
for (let i = 0; i < this.tiffLayers.length; i++) {
this.tiffLayers[i].remove();
}
this.tiffLayers = [];
}
public override onactivate() {
const el = this.renderer!.getInnerContainer();
el.addEventListener("dragover", this._onDragOver);
el.addEventListener("drop", this._onDrop);
}
public override ondeactivate() {
const el = this.renderer!.getInnerContainer();
el.removeEventListener("dragover", this._onDragOver);
el.removeEventListener("drop", this._onDrop);
}
protected _onDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "copy";
}
};
protected _onDrop = (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
const lonLat = this._getLonLatFromEvent(e);
this.addFiles(Array.from(e.dataTransfer.files), lonLat);
};
protected _getLonLatFromEvent(e: DragEvent): LonLat | undefined {
const h = this.renderer!.handler;
const rect = h.canvas!.getBoundingClientRect();
const px = new Vec2((e.clientX - rect.left) * h.pixelRatio, (e.clientY - rect.top) * h.pixelRatio);
return this.planet!.getLonLatFromPixelTerrain(px);
}
/**
* Adds files on the globe. Mtl file goes along with the obj file in the same list.
*/
public addFiles(files: File[], lonLat?: LonLat) {
const mtlFile = files.find((f) => getExt(f) === "mtl") || null;
for (let i = 0; i < files.length; i++) {
this.addFile(files[i], lonLat, mtlFile).catch((err) => {
console.warn(`DragAndDrop: unable to add '${files[i].name}'.`, err);
});
}
}
public async addFile(file: File, lonLat?: LonLat, mtlFile?: File | null): Promise<void> {
const ext = getExt(file);
if (ext === "glb" || ext === "gltf") {
lonLat && (await this._addGltf(file, lonLat));
} else if (ext === "obj") {
lonLat && (await this._addObj(file, lonLat, mtlFile));
} else if (IMAGE_EXT.includes(ext)) {
lonLat && (await this._addBillboard(file, lonLat));
} else if (TIFF_EXT.includes(ext)) {
await this._addTiff(file);
}
}
protected async _addGltf(file: File, lonLat: LonLat) {
const url = URL.createObjectURL(file);
try {
const gltf = getExt(file) === "glb" ? await Gltf.loadGlb(url) : await Gltf.loadGltf(url);
const entity = new Entity({
name: getBaseName(file),
lonlat: lonLat
});
entity.appendChildren(gltf.toEntities(), true);
this.geoObjectsLayer.add(entity);
this._selectEntity(entity);
} finally {
URL.revokeObjectURL(url);
}
}
protected async _addObj(file: File, lonLat: LonLat, mtlFile?: File | null) {
const name = getBaseName(file);
const objects = await Object3d.readFileObj(file, mtlFile);
const entity = new Entity({
name,
lonlat: lonLat
});
for (let i = 0; i < objects.length; i++) {
entity.appendChild(
new Entity({
relativePosition: true,
geoObject: {
tag: `${name}:${i.toString()}`,
object3d: objects[i]
}
})
);
}
this.geoObjectsLayer.add(entity);
this._selectEntity(entity);
}
protected _selectEntity(entity: Entity) {
if (!this._entityEditor) {
const control = this.renderer?.controls.EntityEditor;
if (control instanceof EntityEditor) {
this._entityEditor = control;
}
}
this._entityEditor?.selectEntity(entity);
}
protected async _addBillboard(file: File, lonLat: LonLat) {
const image = await loadImage(URL.createObjectURL(file));
const scale = Math.min(1.0, MAX_BILLBOARD_SIZE / Math.max(image.width, image.height));
this.markersLayer.add(
new Entity({
name: getBaseName(file),
lonlat: lonLat,
billboard: {
image,
width: Math.round(image.width * scale),
height: Math.round(image.height * scale)
}
})
);
}
protected _createTiffLayer(file: File): GeoTIFFLayer {
return new GeoTIFFLayer(`__tif__${file.name}`, { src: file });
}
protected async _addTiff(file: File) {
const layer = this._createTiffLayer(file);
try {
await layer.whenReady();
} catch (err) {
layer.remove();
throw err;
}
this.tiffLayers.push(layer);
this.planet!.addLayer(layer);
}
}