Initial commit
This commit is contained in:
commit
05c65aad4d
155 changed files with 93617 additions and 0 deletions
1144
viewer/editor/annotations.js
Normal file
1144
viewer/editor/annotations.js
Normal file
File diff suppressed because it is too large
Load diff
546
viewer/editor/materials-editor.js
Normal file
546
viewer/editor/materials-editor.js
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
import { core } from "../core.js";
|
||||
import { showToast, toastHelper } from "../viewer-utils.js";
|
||||
import { t } from "../i18n-utils.js";
|
||||
import THREE from "../init.js";
|
||||
|
||||
export function attachMaterialsEditor(Viewer) {
|
||||
Object.assign(Viewer, {
|
||||
openMaterialsFolder(materialUuid = null) {
|
||||
this.buildMaterialsDialog();
|
||||
if (!this.materialsDialog) return;
|
||||
if (!this.materialsEditorObject || !this.materialsList?.length) {
|
||||
showToast(t("gui.selectByMaterial", "select by material"), "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncMaterialsDialogSelectionOptions();
|
||||
|
||||
const nextUuid =
|
||||
materialUuid && materialUuid !== ""
|
||||
? materialUuid
|
||||
: this.selectedMaterialUuid || this.materialsList[0]?.uuid || "";
|
||||
|
||||
if (nextUuid) {
|
||||
this.selectMaterialInEditor(this.materialsEditorObject, nextUuid);
|
||||
}
|
||||
|
||||
this.updateMaterialsDialogBounds();
|
||||
this.materialsDialog.hidden = false;
|
||||
this.syncMaterialsDialogFields();
|
||||
this.closeActionMenu();
|
||||
this.materialsDialogSelect?.focus();
|
||||
},
|
||||
|
||||
destroyMaterialGuiControls() {
|
||||
if (this.materialGuiControls) {
|
||||
Object.values(this.materialGuiControls).forEach((controller) => {
|
||||
if (controller?.destroy) {
|
||||
controller.destroy();
|
||||
}
|
||||
});
|
||||
this.materialGuiControls = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroyMaterialSelectionController() {
|
||||
this.materialsEditorObject = null;
|
||||
this.selectedMaterialUuid = null;
|
||||
this.materialsList = [];
|
||||
if (this.materialsDialogSelect) {
|
||||
this.materialsDialogSelect.innerHTML = "";
|
||||
}
|
||||
this.syncMaterialsDialogFields();
|
||||
},
|
||||
|
||||
getMaterialByUuid(object, uuid) {
|
||||
if (!object || !uuid) return null;
|
||||
let found = null;
|
||||
object.traverse((child) => {
|
||||
if (
|
||||
child.isMesh &&
|
||||
child.material
|
||||
) {
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.forEach((material) => {
|
||||
if (material?.isMaterial && material.uuid === uuid) {
|
||||
found = material;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return found;
|
||||
},
|
||||
|
||||
normalizeMaterialColorValue(value, fallback = "#ffffff") {
|
||||
if (!value) return fallback;
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("0x")) {
|
||||
return `#${value.slice(2).padStart(6, "0")}`;
|
||||
}
|
||||
return value.startsWith("#") ? value : `#${value}`;
|
||||
}
|
||||
if (typeof value.getHexString === "function") {
|
||||
return `#${value.getHexString()}`;
|
||||
}
|
||||
return fallback;
|
||||
},
|
||||
|
||||
syncMaterialsDialogSelectionOptions() {
|
||||
if (!this.materialsDialogSelect) return;
|
||||
|
||||
const currentValue = this.selectedMaterialUuid || "";
|
||||
this.materialsDialogSelect.innerHTML = "";
|
||||
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = t("gui.selectByMaterial", "select by material");
|
||||
this.materialsDialogSelect.appendChild(placeholder);
|
||||
|
||||
this.materialsList.forEach((item) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.uuid;
|
||||
option.textContent = item.label;
|
||||
this.materialsDialogSelect.appendChild(option);
|
||||
});
|
||||
|
||||
this.materialsDialogSelect.value = currentValue;
|
||||
},
|
||||
|
||||
updateMaterialsDialogLabels() {
|
||||
if (!this.materialsDialog) return;
|
||||
|
||||
const title = this.materialsDialog.querySelector("#materialsDialogTitle");
|
||||
const closeButton = this.materialsDialog.querySelector(".materials-dialog__close");
|
||||
const fieldLabels = Array.from(
|
||||
this.materialsDialog.querySelectorAll(".materials-dialog__field > span")
|
||||
);
|
||||
const hint = this.materialsDialogInputs?.hint;
|
||||
const emptyState = this.materialsDialogInputs?.emptyState;
|
||||
|
||||
if (title) {
|
||||
title.textContent = t("gui.materials", "Materials");
|
||||
}
|
||||
if (closeButton) {
|
||||
closeButton.setAttribute("aria-label", t("gui.materials", "Materials"));
|
||||
}
|
||||
|
||||
if (fieldLabels.length > 0) {
|
||||
fieldLabels[0].textContent = t("gui.editMaterial", "Edit material");
|
||||
}
|
||||
if (fieldLabels.length > 1) {
|
||||
fieldLabels[1].textContent = t("gui.color", "Color");
|
||||
}
|
||||
if (fieldLabels.length > 2) {
|
||||
fieldLabels[2].textContent = t("gui.emissive", "Emissive");
|
||||
}
|
||||
if (fieldLabels.length > 3) {
|
||||
fieldLabels[3].textContent = t("gui.intensity", "Intensity");
|
||||
}
|
||||
if (fieldLabels.length > 4) {
|
||||
fieldLabels[4].textContent = t("gui.metalness", "Metalness");
|
||||
}
|
||||
|
||||
if (this.materialsDialogSelect) {
|
||||
const placeholderOption = this.materialsDialogSelect.querySelector('option[value=""]');
|
||||
if (placeholderOption) {
|
||||
placeholderOption.textContent = t("gui.selectByMaterial", "select by material");
|
||||
}
|
||||
}
|
||||
|
||||
if (hint) {
|
||||
hint.textContent = t("gui.selectByMaterial", "select by material");
|
||||
}
|
||||
if (emptyState) {
|
||||
emptyState.textContent = t("gui.selectByMaterial", "select by material");
|
||||
}
|
||||
},
|
||||
|
||||
syncMaterialsDialogFields() {
|
||||
if (!this.materialsDialogInputs) return;
|
||||
|
||||
const material = this.materialsEditorObject && this.selectedMaterialUuid
|
||||
? this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid)
|
||||
: null;
|
||||
const hasMaterial = Boolean(material);
|
||||
const {
|
||||
color,
|
||||
emissiveColor,
|
||||
emissive,
|
||||
metalness,
|
||||
emptyState,
|
||||
hint,
|
||||
} = this.materialsDialogInputs;
|
||||
|
||||
if (this.materialsDialogSelect) {
|
||||
this.materialsDialogSelect.disabled = this.materialsList.length === 0;
|
||||
this.materialsDialogSelect.value = this.selectedMaterialUuid || "";
|
||||
}
|
||||
|
||||
[color, emissiveColor, emissive, metalness].forEach((input) => {
|
||||
if (input) input.disabled = !hasMaterial;
|
||||
});
|
||||
|
||||
if (!hasMaterial) {
|
||||
if (color) color.value = "#ffffff";
|
||||
if (emissiveColor) emissiveColor.value = "#000000";
|
||||
if (emissive) emissive.value = "0";
|
||||
if (metalness) metalness.value = "0";
|
||||
if (emptyState) emptyState.hidden = this.materialsList.length > 0;
|
||||
if (hint) hint.textContent = t("gui.selectByMaterial", "select by material");
|
||||
return;
|
||||
}
|
||||
|
||||
if (color) color.value = this.normalizeMaterialColorValue(material.color, "#ffffff");
|
||||
if (emissiveColor) emissiveColor.value = this.normalizeMaterialColorValue(material.emissive, "#000000");
|
||||
if (emissive) emissive.value = String(material.emissiveIntensity ?? 0);
|
||||
if (metalness) metalness.value = String(material.metalness ?? 0);
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
if (hint) {
|
||||
hint.textContent =
|
||||
this.materialsList.find((item) => item.uuid === material.uuid)?.label || material.uuid;
|
||||
}
|
||||
},
|
||||
|
||||
selectMaterialInEditor(object, value) {
|
||||
if (!object) return;
|
||||
if (!value) {
|
||||
this.destroyMaterialGuiControls();
|
||||
this.selectedMaterialUuid = null;
|
||||
this.refreshMaterialsToolbarMenu();
|
||||
this.syncMaterialsDialogFields();
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyMaterialGuiControls();
|
||||
const material = this.getMaterialByUuid(object, value);
|
||||
if (!material) {
|
||||
this.selectedMaterialUuid = null;
|
||||
this.refreshMaterialsToolbarMenu();
|
||||
this.syncMaterialsDialogFields();
|
||||
return;
|
||||
}
|
||||
|
||||
core.materialProperties.color = this.normalizeMaterialColorValue(material.color, "#ffffff");
|
||||
core.materialProperties.emissiveColor = this.normalizeMaterialColorValue(material.emissive, "#000000");
|
||||
core.materialProperties.emissive = material.emissiveIntensity ?? 0;
|
||||
core.materialProperties.metalness = material.metalness ?? 0;
|
||||
this.selectedMaterialUuid = value;
|
||||
this.syncMaterialsDialogSelectionOptions();
|
||||
this.refreshMaterialsToolbarMenu();
|
||||
this.syncMaterialsDialogFields();
|
||||
},
|
||||
|
||||
initializeMaterialsEditor(object) {
|
||||
if (!object) return;
|
||||
this.destroyMaterialGuiControls();
|
||||
this.destroyMaterialSelectionController();
|
||||
|
||||
const materials = new Map();
|
||||
const registerMaterial = (material) => {
|
||||
if (!material || !material.isMaterial || !material.uuid) return;
|
||||
if (!materials.has(material.uuid)) {
|
||||
materials.set(material.uuid, material);
|
||||
}
|
||||
};
|
||||
|
||||
const gatherMaterials = (mesh) => {
|
||||
if (!mesh.material) return;
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach(registerMaterial);
|
||||
} else {
|
||||
registerMaterial(mesh.material);
|
||||
}
|
||||
};
|
||||
|
||||
if (object.isMesh) {
|
||||
gatherMaterials(object);
|
||||
}
|
||||
object.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
gatherMaterials(child);
|
||||
}
|
||||
});
|
||||
|
||||
const options = {
|
||||
[t("gui.selectByMaterial", "select by material")]: "",
|
||||
};
|
||||
this.materialsList = [];
|
||||
|
||||
materials.forEach((material, uuid) => {
|
||||
const label = material.name?.trim() ? material.name : uuid;
|
||||
options[label] = uuid;
|
||||
this.materialsList.push({ uuid, label });
|
||||
});
|
||||
|
||||
core.materialsPropertiesText["Edit material"] = "";
|
||||
this.materialsEditorObject = object;
|
||||
this.refreshMaterialsToolbarMenu();
|
||||
this.syncMaterialsDialogSelectionOptions();
|
||||
this.syncMaterialsDialogFields();
|
||||
},
|
||||
|
||||
refreshMaterialsToolbarMenu() {
|
||||
if (!this.materialsSubmenu) return;
|
||||
this.materialsSubmenu.innerHTML = "";
|
||||
|
||||
const list = this.materialsList?.length ? this.materialsList : [];
|
||||
if (!list.length) {
|
||||
const emptyButton = document.createElement("button");
|
||||
emptyButton.type = "button";
|
||||
emptyButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-tool_submenu-control viewer-editor-tool_submenu-materials";
|
||||
emptyButton.disabled = true;
|
||||
const label = t("gui.selectByMaterial", "Select by material");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = label;
|
||||
emptyButton.appendChild(text);
|
||||
this.materialsSubmenu.appendChild(emptyButton);
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach((item) => {
|
||||
const subButton = document.createElement("button");
|
||||
subButton.type = "button";
|
||||
subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-tool_submenu-control viewer-editor-tool_submenu-materials";
|
||||
if (item.uuid === this.selectedMaterialUuid) {
|
||||
subButton.classList.add("is-active");
|
||||
}
|
||||
subButton.dataset.materialUuid = item.uuid;
|
||||
subButton.setAttribute("title", item.label);
|
||||
subButton.setAttribute("aria-label", item.label);
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "viewer-editor-tool_icon";
|
||||
iconSpan.setAttribute("aria-hidden", "true");
|
||||
iconSpan.innerHTML = this.getEditorToolbarIcon("color");
|
||||
subButton.appendChild(iconSpan);
|
||||
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "viewer-editor-tool_label";
|
||||
labelSpan.textContent = item.label;
|
||||
subButton.appendChild(labelSpan);
|
||||
|
||||
this.bindEventListener(subButton, "click", (event) => {
|
||||
event.stopPropagation();
|
||||
this.openMaterialsFolder(item.uuid);
|
||||
});
|
||||
|
||||
this.materialsSubmenu.appendChild(subButton);
|
||||
});
|
||||
},
|
||||
|
||||
buildMaterialsDialog() {
|
||||
if (!core.container || this.materialsDialog) return;
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.id = "materialsDialog";
|
||||
dialog.className = "materials-dialog";
|
||||
dialog.hidden = true;
|
||||
dialog.innerHTML = `
|
||||
<div class="materials-dialog__backdrop" data-materials-dismiss="true"></div>
|
||||
<div class="materials-dialog__panel" role="dialog" aria-modal="true" aria-labelledby="materialsDialogTitle">
|
||||
<div class="materials-dialog__header">
|
||||
<h3 id="materialsDialogTitle">${t("gui.materials", "Materials")}</h3>
|
||||
<button type="button" class="materials-dialog__close" data-materials-dismiss="true" aria-label="${t("gui.materials", "Materials")}">×</button>
|
||||
</div>
|
||||
<div class="materials-dialog__body">
|
||||
<label class="materials-dialog__field">
|
||||
<span>${t("gui.editMaterial", "Edit material")}</span>
|
||||
<select id="materialsDialogSelect"></select>
|
||||
</label>
|
||||
<p id="materialsDialogHint" class="materials-dialog__hint">${t("gui.selectByMaterial", "select by material")}</p>
|
||||
<p id="materialsDialogEmpty" class="materials-dialog__empty">${t("gui.selectByMaterial", "select by material")}</p>
|
||||
<div class="materials-dialog__grid">
|
||||
<label class="materials-dialog__field">
|
||||
<span>${t("gui.color", "Color")}</span>
|
||||
<input id="materialsDialogColor" type="color" />
|
||||
</label>
|
||||
<label class="materials-dialog__field">
|
||||
<span>${t("gui.emissive", "Emissive")}</span>
|
||||
<input id="materialsDialogEmissiveColor" type="color" />
|
||||
</label>
|
||||
<label class="materials-dialog__field">
|
||||
<span>${t("gui.intensity", "Intensity")}</span>
|
||||
<input id="materialsDialogEmissive" type="range" min="0" max="1" step="0.01" />
|
||||
</label>
|
||||
<label class="materials-dialog__field">
|
||||
<span>${t("gui.metalness", "Metalness")}</span>
|
||||
<input id="materialsDialogMetalness" type="range" min="0" max="1" step="0.01" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
this.materialsDialog = dialog;
|
||||
this.materialsDialogPosition = null;
|
||||
this.materialsDialogSelect = dialog.querySelector("#materialsDialogSelect");
|
||||
const panel = dialog.querySelector(".materials-dialog__panel");
|
||||
const header = dialog.querySelector(".materials-dialog__header");
|
||||
this.materialsDialogInputs = {
|
||||
color: dialog.querySelector("#materialsDialogColor"),
|
||||
emissiveColor: dialog.querySelector("#materialsDialogEmissiveColor"),
|
||||
emissive: dialog.querySelector("#materialsDialogEmissive"),
|
||||
metalness: dialog.querySelector("#materialsDialogMetalness"),
|
||||
emptyState: dialog.querySelector("#materialsDialogEmpty"),
|
||||
hint: dialog.querySelector("#materialsDialogHint"),
|
||||
};
|
||||
|
||||
this.bindEventListener(dialog, "click", (event) => {
|
||||
const dismissTrigger = event.target?.closest?.("[data-materials-dismiss='true']");
|
||||
if (dismissTrigger) {
|
||||
this.closeMaterialsDialog();
|
||||
}
|
||||
});
|
||||
|
||||
this.bindEventListener(document, "keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (!this.materialsDialog || this.materialsDialog.hidden) return;
|
||||
event.preventDefault();
|
||||
this.closeMaterialsDialog();
|
||||
});
|
||||
|
||||
const handleMaterialSelect = (event) => {
|
||||
this.selectMaterialInEditor(this.materialsEditorObject, event.target.value);
|
||||
};
|
||||
this.bindEventListener(this.materialsDialogSelect, "change", handleMaterialSelect);
|
||||
this.bindEventListener(this.materialsDialogSelect, "input", handleMaterialSelect);
|
||||
|
||||
this.bindEventListener(this.materialsDialogInputs.color, "input", (event) => {
|
||||
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
|
||||
if (!material?.color) return;
|
||||
const value = event.target.value;
|
||||
core.materialProperties.color = value;
|
||||
material.color = new THREE.Color(value);
|
||||
});
|
||||
|
||||
this.bindEventListener(this.materialsDialogInputs.emissiveColor, "input", (event) => {
|
||||
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
|
||||
if (!material || material.emissive === undefined) return;
|
||||
const value = event.target.value;
|
||||
core.materialProperties.emissiveColor = value;
|
||||
material.emissive = new THREE.Color(value);
|
||||
});
|
||||
|
||||
this.bindEventListener(this.materialsDialogInputs.emissive, "input", (event) => {
|
||||
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
|
||||
if (!material) return;
|
||||
const value = parseFloat(event.target.value);
|
||||
core.materialProperties.emissive = value;
|
||||
material.emissiveIntensity = value;
|
||||
});
|
||||
|
||||
this.bindEventListener(this.materialsDialogInputs.metalness, "input", (event) => {
|
||||
const material = this.getMaterialByUuid(this.materialsEditorObject, this.selectedMaterialUuid);
|
||||
if (!material) return;
|
||||
const value = parseFloat(event.target.value);
|
||||
core.materialProperties.metalness = value;
|
||||
material.metalness = value;
|
||||
});
|
||||
|
||||
this.bindEventListener(header, "pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target?.closest?.(".materials-dialog__close")) return;
|
||||
const targetRect =
|
||||
Viewer.mainCanvas?.getBoundingClientRect?.() ||
|
||||
core.container?.getBoundingClientRect?.();
|
||||
const panelRect = panel?.getBoundingClientRect?.();
|
||||
if (!targetRect || !panelRect) return;
|
||||
|
||||
this.materialsDialogDragging = {
|
||||
offsetX: event.clientX - panelRect.left,
|
||||
offsetY: event.clientY - panelRect.top,
|
||||
};
|
||||
panel.setPointerCapture?.(event.pointerId);
|
||||
panel.classList.add("is-dragging");
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
this.bindEventListener(document, "pointermove", (event) => {
|
||||
if (!this.materialsDialogDragging || !this.materialsDialog || this.materialsDialog.hidden) return;
|
||||
const targetRect =
|
||||
Viewer.mainCanvas?.getBoundingClientRect?.() ||
|
||||
core.container?.getBoundingClientRect?.();
|
||||
const panelRect = panel?.getBoundingClientRect?.();
|
||||
if (!targetRect || !panelRect) return;
|
||||
|
||||
const nextLeft = event.clientX - this.materialsDialogDragging.offsetX;
|
||||
const nextTop = event.clientY - this.materialsDialogDragging.offsetY;
|
||||
const minLeft = targetRect.left + 12;
|
||||
const maxLeft = targetRect.right - panelRect.width - 12;
|
||||
const minTop = targetRect.top + 12;
|
||||
const maxTop = targetRect.bottom - panelRect.height - 12;
|
||||
|
||||
this.materialsDialogPosition = {
|
||||
left: Math.min(Math.max(nextLeft, minLeft), Math.max(minLeft, maxLeft)),
|
||||
top: Math.min(Math.max(nextTop, minTop), Math.max(minTop, maxTop)),
|
||||
};
|
||||
|
||||
this.updateMaterialsDialogBounds();
|
||||
});
|
||||
|
||||
const stopMaterialsDialogDrag = () => {
|
||||
this.materialsDialogDragging = false;
|
||||
panel?.classList.remove("is-dragging");
|
||||
};
|
||||
|
||||
this.bindEventListener(document, "pointerup", stopMaterialsDialogDrag);
|
||||
this.bindEventListener(document, "pointercancel", stopMaterialsDialogDrag);
|
||||
|
||||
this.bindEventListener(window, "resize", () => this.updateMaterialsDialogBounds());
|
||||
this.bindEventListener(window, "scroll", () => this.updateMaterialsDialogBounds(), true);
|
||||
this.bindEventListener(document, "fullscreenchange", () => this.updateMaterialsDialogBounds());
|
||||
|
||||
this.syncMaterialsDialogSelectionOptions();
|
||||
this.syncMaterialsDialogFields();
|
||||
},
|
||||
|
||||
updateMaterialsDialogBounds() {
|
||||
if (!this.materialsDialog) return;
|
||||
const targetRect =
|
||||
Viewer.mainCanvas?.getBoundingClientRect?.() ||
|
||||
core.container?.getBoundingClientRect?.();
|
||||
if (!targetRect) return;
|
||||
|
||||
const left = Math.max(0, Math.round(targetRect.left));
|
||||
const top = Math.max(0, Math.round(targetRect.top));
|
||||
const width = Math.max(0, Math.round(targetRect.width));
|
||||
const height = Math.max(0, Math.round(targetRect.height));
|
||||
const panel = this.materialsDialog.querySelector(".materials-dialog__panel");
|
||||
const panelWidth = panel?.offsetWidth || Math.min(360, width - 24);
|
||||
const panelHeight = panel?.offsetHeight || Math.min(520, height - 24);
|
||||
|
||||
if (!this.materialsDialogPosition) {
|
||||
this.materialsDialogPosition = {
|
||||
left: Math.max(left + 12, left + width - panelWidth - 16),
|
||||
top: Math.max(top + 16, top + Math.min(40, Math.max(16, height * 0.08))),
|
||||
};
|
||||
} else {
|
||||
const minLeft = left + 12;
|
||||
const maxLeft = left + width - panelWidth - 12;
|
||||
const minTop = top + 12;
|
||||
const maxTop = top + height - panelHeight - 12;
|
||||
this.materialsDialogPosition = {
|
||||
left: Math.min(Math.max(this.materialsDialogPosition.left, minLeft), Math.max(minLeft, maxLeft)),
|
||||
top: Math.min(Math.max(this.materialsDialogPosition.top, minTop), Math.max(minTop, maxTop)),
|
||||
};
|
||||
}
|
||||
|
||||
this.materialsDialog.style.left = `${left}px`;
|
||||
this.materialsDialog.style.top = `${top}px`;
|
||||
this.materialsDialog.style.width = `${width}px`;
|
||||
this.materialsDialog.style.height = `${height}px`;
|
||||
if (panel) {
|
||||
panel.style.left = `${this.materialsDialogPosition.left - left}px`;
|
||||
panel.style.top = `${this.materialsDialogPosition.top - top}px`;
|
||||
panel.style.right = "auto";
|
||||
panel.style.transform = "none";
|
||||
}
|
||||
},
|
||||
|
||||
closeMaterialsDialog() {
|
||||
if (!this.materialsDialog) return;
|
||||
this.materialsDialog.hidden = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
90
viewer/editor/measurement.js
Normal file
90
viewer/editor/measurement.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { core } from "../core.js";
|
||||
import { distanceBetweenPointsVector, vectorBetweenPoints, halfwayBetweenPoints, interpolateDistanceBetweenPoints } from "../utils.js";
|
||||
import THREE from "../init.js";
|
||||
|
||||
export function attachMeasurement(Viewer) {
|
||||
Object.assign(Viewer, {
|
||||
buildRuler(_id) {
|
||||
Viewer.rulerObject = new THREE.Object3D();
|
||||
const gridSize = Viewer.gridSize || core.gridSize || 1;
|
||||
const sphereRadius = Math.max(gridSize / 150, 0.001);
|
||||
const textScale = Math.max(gridSize / 100, 0.01);
|
||||
const measureSize = Math.max(gridSize / 200, 0.01);
|
||||
|
||||
var sphere = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(sphereRadius, 7, 7),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff0000,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
);
|
||||
var newPoint = new THREE.Vector3(_id.point.x, _id.point.y, _id.point.z);
|
||||
sphere.position.set(newPoint.x, newPoint.y, newPoint.z);
|
||||
Viewer.rulerObject.add(sphere);
|
||||
Viewer.linePoints.push(newPoint);
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(Viewer.linePoints);
|
||||
const lineMaterial = new THREE.LineBasicMaterial({ color: 0x0000ff });
|
||||
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||
Viewer.rulerObject.add(line);
|
||||
var lineMtr = new THREE.LineBasicMaterial({
|
||||
color: 0x0000ff,
|
||||
linewidth: 1,
|
||||
opacity: 1,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
if (Viewer.linePoints.length > 1) {
|
||||
var vectorPoints = vectorBetweenPoints(
|
||||
Viewer.linePoints[Viewer.linePoints.length - 2],
|
||||
newPoint
|
||||
);
|
||||
var distancePoints = distanceBetweenPointsVector(vectorPoints);
|
||||
const measuredDistance = Viewer.formatMeasuredDistance(distancePoints);
|
||||
|
||||
//var distancePoints = distanceBetweenPoints(Viewer.linePoints[Viewer.linePoints.length-2], newPoint);
|
||||
var halfwayPoints = halfwayBetweenPoints(
|
||||
Viewer.linePoints[Viewer.linePoints.length - 2],
|
||||
newPoint
|
||||
);
|
||||
Viewer.addTextPoint(measuredDistance.text, textScale, halfwayPoints);
|
||||
var rulerI = 0;
|
||||
// `measureSize` was already precomputed outside, keep same scale
|
||||
while (rulerI <= distancePoints * 100) {
|
||||
const geoSegm = [];
|
||||
var interpolatePoints = interpolateDistanceBetweenPoints(
|
||||
Viewer.linePoints[Viewer.linePoints.length - 2],
|
||||
vectorPoints,
|
||||
distancePoints,
|
||||
rulerI / 100
|
||||
);
|
||||
geoSegm.push(
|
||||
new THREE.Vector3(
|
||||
interpolatePoints.x,
|
||||
interpolatePoints.y,
|
||||
interpolatePoints.z
|
||||
)
|
||||
);
|
||||
geoSegm.push(
|
||||
new THREE.Vector3(
|
||||
interpolatePoints.x + measureSize,
|
||||
interpolatePoints.y + measureSize,
|
||||
interpolatePoints.z + measureSize
|
||||
)
|
||||
);
|
||||
const geometryLine = new THREE.BufferGeometry().setFromPoints(geoSegm);
|
||||
var lineSegm = new THREE.Line(geometryLine, lineMtr);
|
||||
Viewer.rulerObject.add(lineSegm);
|
||||
rulerI += 10;
|
||||
}
|
||||
}
|
||||
Viewer.rulerObject.renderOrder = 10;
|
||||
core.scene.add(Viewer.rulerObject);
|
||||
Viewer.ruler.push(Viewer.rulerObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
192
viewer/editor/metadata-persistence.js
Normal file
192
viewer/editor/metadata-persistence.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import THREE from "../init.js";
|
||||
import { core } from "../core.js";
|
||||
import { toastHelper } from "../viewer-utils.js";
|
||||
|
||||
function pickMetadataValue(save, current, original) {
|
||||
return save ? current : original;
|
||||
}
|
||||
|
||||
export function buildEditorMetadata(viewer, rotateMetadata) {
|
||||
const originalMetadata = viewer.originalMetadata;
|
||||
const saveProperties = viewer.saveProperties;
|
||||
const metadata = {};
|
||||
|
||||
metadata.objPosition = pickMetadataValue(
|
||||
saveProperties.Position,
|
||||
[
|
||||
core.helperObjects[0].position.x,
|
||||
core.helperObjects[0].position.y,
|
||||
core.helperObjects[0].position.z
|
||||
],
|
||||
originalMetadata.objPosition
|
||||
);
|
||||
|
||||
metadata.objRotation = pickMetadataValue(
|
||||
saveProperties.Rotation,
|
||||
[rotateMetadata.x, rotateMetadata.y, rotateMetadata.z],
|
||||
originalMetadata.objRotation
|
||||
);
|
||||
|
||||
metadata.objScale = pickMetadataValue(
|
||||
saveProperties.Scale,
|
||||
[
|
||||
core.helperObjects[0].scale.x,
|
||||
core.helperObjects[0].scale.y,
|
||||
core.helperObjects[0].scale.z
|
||||
],
|
||||
originalMetadata.objScale
|
||||
);
|
||||
|
||||
metadata.cameraPosition = pickMetadataValue(
|
||||
saveProperties.Camera,
|
||||
[
|
||||
core.camera.position.x,
|
||||
core.camera.position.y,
|
||||
core.camera.position.z
|
||||
],
|
||||
originalMetadata.cameraPosition
|
||||
);
|
||||
|
||||
metadata.controlsTarget = pickMetadataValue(
|
||||
saveProperties.Camera,
|
||||
[
|
||||
core.controls.target.x,
|
||||
core.controls.target.y,
|
||||
core.controls.target.z
|
||||
],
|
||||
originalMetadata.controlsTarget
|
||||
);
|
||||
|
||||
metadata.controlsZoom = pickMetadataValue(
|
||||
saveProperties.Camera,
|
||||
[
|
||||
core.camera.position.distanceTo(core.controls.target)
|
||||
],
|
||||
originalMetadata.controlsZoom
|
||||
);
|
||||
|
||||
metadata.lightPosition = pickMetadataValue(
|
||||
saveProperties.DirectionalLight,
|
||||
[
|
||||
core.dirLight.position.x,
|
||||
core.dirLight.position.y,
|
||||
core.dirLight.position.z
|
||||
],
|
||||
originalMetadata.lightPosition
|
||||
);
|
||||
|
||||
metadata.lightTarget = pickMetadataValue(
|
||||
saveProperties.DirectionalLight,
|
||||
[
|
||||
core.dirLight.rotation._x,
|
||||
core.dirLight.rotation._y,
|
||||
core.dirLight.rotation._z
|
||||
],
|
||||
originalMetadata.lightTarget
|
||||
);
|
||||
|
||||
metadata.lightColor = pickMetadataValue(
|
||||
saveProperties.DirectionalLight,
|
||||
["#" + core.dirLight.color.getHexString().toUpperCase()],
|
||||
originalMetadata.lightColor
|
||||
);
|
||||
|
||||
metadata.lightIntensity = pickMetadataValue(
|
||||
saveProperties.DirectionalLight,
|
||||
[core.dirLight.intensity],
|
||||
originalMetadata.lightIntensity
|
||||
);
|
||||
|
||||
metadata.lightAmbientColor = pickMetadataValue(
|
||||
saveProperties.AmbientLight,
|
||||
["#" + core.ambientLight.color.getHexString().toUpperCase()],
|
||||
originalMetadata.lightAmbientColor
|
||||
);
|
||||
|
||||
metadata.lightAmbientIntensity = pickMetadataValue(
|
||||
saveProperties.AmbientLight,
|
||||
[core.ambientLight.intensity],
|
||||
originalMetadata.lightAmbientIntensity
|
||||
);
|
||||
|
||||
metadata.lightCameraColor = pickMetadataValue(
|
||||
saveProperties.CameraLight,
|
||||
["#" + core.cameraLight.color.getHexString().toUpperCase()],
|
||||
originalMetadata.lightCameraColor
|
||||
);
|
||||
|
||||
metadata.lightCameraIntensity = pickMetadataValue(
|
||||
saveProperties.CameraLight,
|
||||
[core.cameraLight.intensity],
|
||||
originalMetadata.lightCameraIntensity
|
||||
);
|
||||
|
||||
metadata.background = saveProperties.BackgroundColor
|
||||
? [window.getComputedStyle(viewer.mainCanvas).background]
|
||||
: originalMetadata.background;
|
||||
|
||||
metadata.annotationEntries = viewer.getAnnotationEntriesForPersistence();
|
||||
metadata.iiifAnnotationsXml = viewer.exportAnnotationsToIIIFXml();
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function saveEditorMetadata(viewer) {
|
||||
if (!core.EDITOR || core.isLightweight || !core.helperObjects?.[0]) return;
|
||||
|
||||
const rotateMetadata = new THREE.Vector3(
|
||||
THREE.MathUtils.radToDeg(core.helperObjects[0].rotation.x),
|
||||
THREE.MathUtils.radToDeg(core.helperObjects[0].rotation.y),
|
||||
THREE.MathUtils.radToDeg(core.helperObjects[0].rotation.z)
|
||||
);
|
||||
|
||||
if (core.CONFIG.entity.proxyPath !== undefined) {
|
||||
core.CONFIG.metadataUrl = core.getProxyPath(core.CONFIG.metadataUrl);
|
||||
}
|
||||
|
||||
let fetchedMetadata = {};
|
||||
|
||||
try {
|
||||
if (core.CONFIG?.metadataUrl) {
|
||||
const response = await fetch(core.CONFIG.metadataUrl, { cache: "no-cache" });
|
||||
if (response.ok) {
|
||||
fetchedMetadata = await response.json();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Metadata fetch failed, continuing with save", err);
|
||||
}
|
||||
|
||||
viewer.originalMetadata = {
|
||||
...viewer.originalMetadata,
|
||||
...fetchedMetadata
|
||||
};
|
||||
|
||||
const newMetadata = buildEditorMetadata(viewer, rotateMetadata);
|
||||
|
||||
try {
|
||||
const token = await fetch("/session/token").then((response) => response.text());
|
||||
|
||||
await fetch(core.CONFIG.mainUrl + "/api/editor/save-metadata", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": token
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: core.fileObject.filename,
|
||||
path:
|
||||
viewer.archiveType !== ""
|
||||
? core.fileObject.relativePath + core.fileObject.basename + core.loadedFile
|
||||
: core.fileObject.relativePath,
|
||||
content: JSON.stringify(newMetadata, null, "\t")
|
||||
})
|
||||
});
|
||||
|
||||
toastHelper("settingsSaved", "success");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toastHelper("settingsSaveError", "error");
|
||||
}
|
||||
}
|
||||
468
viewer/editor/picking.js
Normal file
468
viewer/editor/picking.js
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import { core } from "../core.js";
|
||||
import THREE from "../init.js";
|
||||
|
||||
function getNormalizedPointerPosition(Viewer, clientX, clientY, targetVector) {
|
||||
targetVector.x =
|
||||
((clientX - Viewer.mainCanvas.getBoundingClientRect().left) /
|
||||
core.renderer.domElement.clientWidth) *
|
||||
2 -
|
||||
1;
|
||||
targetVector.y =
|
||||
-(
|
||||
(clientY - Viewer.mainCanvas.getBoundingClientRect().top) /
|
||||
core.renderer.domElement.clientHeight
|
||||
) *
|
||||
2 +
|
||||
1;
|
||||
}
|
||||
|
||||
function getPrimaryModelIntersection(Viewer, pointerVector) {
|
||||
Viewer.raycaster.setFromCamera(pointerVector, core.camera);
|
||||
let intersects = [];
|
||||
|
||||
if (core.mainObject.length > 1) {
|
||||
for (let ii = 0; ii < core.mainObject.length; ii++) {
|
||||
intersects.push(
|
||||
...Viewer.raycaster.intersectObjects(
|
||||
core.mainObject[ii].children,
|
||||
true
|
||||
)
|
||||
);
|
||||
}
|
||||
if (intersects.length <= 0) {
|
||||
intersects = Viewer.raycaster.intersectObjects(core.mainObject, true);
|
||||
}
|
||||
} else if (core.mainObject[0]) {
|
||||
intersects = Viewer.raycaster.intersectObject(core.mainObject[0], true);
|
||||
}
|
||||
|
||||
return Viewer.getPrimaryIntersection(intersects);
|
||||
}
|
||||
|
||||
function getPoiHit(Viewer, pointerVector) {
|
||||
Viewer.raycaster.setFromCamera(pointerVector, core.camera);
|
||||
const poiIntersects = Viewer.raycaster.intersectObjects(
|
||||
Viewer.annotationPOIMarkers || [],
|
||||
true
|
||||
);
|
||||
return poiIntersects.find(
|
||||
(entry) => entry?.object?.userData?.isAnnotationPOI === true
|
||||
) || null;
|
||||
}
|
||||
|
||||
export function attachPicking(Viewer) {
|
||||
Object.assign(Viewer, {
|
||||
createTriangleGeometry(intersection) {
|
||||
const position = intersection?.object?.geometry?.attributes?.position;
|
||||
const face = intersection?.face;
|
||||
|
||||
if (!position || !face) return null;
|
||||
|
||||
const trianglePositions = new Float32Array([
|
||||
position.getX(face.a), position.getY(face.a), position.getZ(face.a),
|
||||
position.getX(face.b), position.getY(face.b), position.getZ(face.b),
|
||||
position.getX(face.c), position.getY(face.c), position.getZ(face.c),
|
||||
]);
|
||||
|
||||
const triangleGeometry = new THREE.BufferGeometry();
|
||||
triangleGeometry.setAttribute("position", new THREE.BufferAttribute(trianglePositions, 3));
|
||||
triangleGeometry.computeVertexNormals();
|
||||
|
||||
return triangleGeometry;
|
||||
},
|
||||
|
||||
createPickingFaceOverlay(intersection, options = {}) {
|
||||
const triangleGeometry = Viewer.createTriangleGeometry(intersection);
|
||||
if (!triangleGeometry) return null;
|
||||
|
||||
const fillColor = options.fillColor ?? 0xff0000;
|
||||
const lineColor = options.lineColor ?? 0xffffff;
|
||||
const opacity = options.opacity ?? 0.65;
|
||||
|
||||
const overlayMaterial = new THREE.MeshBasicMaterial({
|
||||
color: fillColor,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -2,
|
||||
toneMapped: false,
|
||||
});
|
||||
|
||||
const fillMesh = new THREE.Mesh(triangleGeometry, overlayMaterial);
|
||||
fillMesh.renderOrder = 999;
|
||||
|
||||
const lineGeometry = new THREE.EdgesGeometry(triangleGeometry);
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: lineColor,
|
||||
transparent: true,
|
||||
opacity: Math.min(opacity + 0.2, 1),
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
});
|
||||
const lineSegments = new THREE.LineSegments(lineGeometry, lineMaterial);
|
||||
lineSegments.renderOrder = 1000;
|
||||
|
||||
const overlayGroup = new THREE.Group();
|
||||
overlayGroup.name = "picking-face-overlay";
|
||||
overlayGroup.userData.isPickingOverlay = true;
|
||||
fillMesh.userData.isPickingOverlay = true;
|
||||
lineSegments.userData.isPickingOverlay = true;
|
||||
overlayGroup.add(fillMesh);
|
||||
overlayGroup.add(lineSegments);
|
||||
|
||||
return overlayGroup;
|
||||
},
|
||||
|
||||
isPickingOverlayObject(object) {
|
||||
let current = object;
|
||||
|
||||
while (current) {
|
||||
if (current.userData?.isPickingOverlay === true || current.name === "picking-face-overlay") {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
getPrimaryIntersection(intersections) {
|
||||
if (!Array.isArray(intersections) || intersections.length === 0) return null;
|
||||
|
||||
return intersections.find((entry) => !Viewer.isPickingOverlayObject(entry?.object)) ?? null;
|
||||
},
|
||||
|
||||
getFaceSelectionKey(targetId, faceIndex) {
|
||||
if (!targetId || faceIndex === null || faceIndex === undefined) return "";
|
||||
return `${targetId}:${faceIndex}`;
|
||||
},
|
||||
|
||||
findSelectedFaceIndex(targetId, faceIndex) {
|
||||
const key = Viewer.getFaceSelectionKey(targetId, faceIndex);
|
||||
return Viewer.selectedFaces.findIndex((entry) => entry.key === key);
|
||||
},
|
||||
|
||||
updateSelectedFacesCount() {
|
||||
Viewer.pickingStats["Selected faces"] = Array.isArray(Viewer.selectedFaces)
|
||||
? Viewer.selectedFaces.length
|
||||
: 0;
|
||||
const selectedFacesCount = Array.isArray(Viewer.selectedFaces) ? Viewer.selectedFaces.length : 0;
|
||||
if (selectedFacesCount < 1 && Viewer.annotationDialog && Viewer.annotationDialog.hidden === false) {
|
||||
Viewer.closeAnnotationDialog();
|
||||
}
|
||||
Viewer.updateAddAnnotationControllerState();
|
||||
Viewer.updatePickingHintVisibility();
|
||||
},
|
||||
|
||||
toStableIdToken(value) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
return normalized || "target";
|
||||
},
|
||||
|
||||
getSelectionRootObject(object) {
|
||||
let current = object;
|
||||
while (current?.parent && current.parent !== core.scene) {
|
||||
current = current.parent;
|
||||
}
|
||||
return current || object;
|
||||
},
|
||||
|
||||
getSelectionRootSlot(rootObject) {
|
||||
if (!rootObject || !Array.isArray(core.mainObject)) return -1;
|
||||
return core.mainObject.findIndex((entry) => {
|
||||
if (entry === rootObject) return true;
|
||||
return Array.isArray(entry) && entry.includes(rootObject);
|
||||
});
|
||||
},
|
||||
|
||||
getObjectHierarchyPath(object, rootObject) {
|
||||
if (!object || !rootObject) return "";
|
||||
const path = [];
|
||||
let current = object;
|
||||
while (current && current !== rootObject) {
|
||||
const parent = current.parent;
|
||||
if (!parent) break;
|
||||
const index = parent.children.indexOf(current);
|
||||
path.push(index >= 0 ? String(index) : "x");
|
||||
current = parent;
|
||||
}
|
||||
return path.reverse().join(".") || "root";
|
||||
},
|
||||
|
||||
resolveFaceTargetId(object) {
|
||||
if (!object) return "";
|
||||
|
||||
const explicitId = object.userData?.annotationTargetId || object.userData?.id;
|
||||
if (explicitId) return String(explicitId);
|
||||
|
||||
const rootObject = Viewer.getSelectionRootObject(object);
|
||||
const rootSlot = Viewer.getSelectionRootSlot(rootObject);
|
||||
const path = Viewer.getObjectHierarchyPath(object, rootObject);
|
||||
let rootTag = rootSlot >= 0 ? `m${rootSlot}` : "m0";
|
||||
if (rootSlot >= 0 && Array.isArray(core.mainObject?.[rootSlot])) {
|
||||
const rootIndex = core.mainObject[rootSlot].indexOf(rootObject);
|
||||
if (rootIndex >= 0) {
|
||||
rootTag = `${rootTag}.${rootIndex}`;
|
||||
}
|
||||
}
|
||||
const targetId = `${rootTag}:${path}`;
|
||||
|
||||
object.userData ??= {};
|
||||
object.userData.annotationTargetId = targetId;
|
||||
return targetId;
|
||||
},
|
||||
|
||||
resolveObjectByTargetId(targetId) {
|
||||
const raw = String(targetId || "").trim();
|
||||
const match = raw.match(/^m(\d+)(?:\.(\d+))?:(.+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const slot = Number.parseInt(match[1], 10);
|
||||
const rootIndex = Number.parseInt(match[2] || "0", 10);
|
||||
const path = match[3] || "root";
|
||||
if (!Number.isInteger(slot) || slot < 0) return null;
|
||||
|
||||
const entry = core.mainObject?.[slot];
|
||||
if (!entry) return null;
|
||||
let rootObject = Array.isArray(entry) ? entry[rootIndex] : entry;
|
||||
if (!rootObject) return null;
|
||||
|
||||
if (path === "root") return rootObject;
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
const childIndex = Number.parseInt(segment, 10);
|
||||
if (!Number.isInteger(childIndex) || childIndex < 0) return null;
|
||||
rootObject = rootObject.children?.[childIndex];
|
||||
if (!rootObject) return null;
|
||||
}
|
||||
|
||||
return rootObject;
|
||||
},
|
||||
|
||||
getFaceCentroidWorld(object, faceIndex) {
|
||||
const geometry = object?.geometry;
|
||||
if (!geometry || !geometry.getAttribute) return null;
|
||||
const position = geometry.getAttribute("position");
|
||||
if (!position) return null;
|
||||
const face = Number(faceIndex);
|
||||
if (!Number.isInteger(face) || face < 0) return null;
|
||||
|
||||
let ia = face * 3;
|
||||
let ib = ia + 1;
|
||||
let ic = ia + 2;
|
||||
const index = geometry.getIndex?.() || geometry.index || null;
|
||||
if (index?.array) {
|
||||
const arr = index.array;
|
||||
if (ic >= arr.length) return null;
|
||||
ia = arr[ia];
|
||||
ib = arr[ib];
|
||||
ic = arr[ic];
|
||||
} else if (ic >= position.count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const va = new THREE.Vector3().fromBufferAttribute(position, ia);
|
||||
const vb = new THREE.Vector3().fromBufferAttribute(position, ib);
|
||||
const vc = new THREE.Vector3().fromBufferAttribute(position, ic);
|
||||
const center = va.add(vb).add(vc).multiplyScalar(1 / 3);
|
||||
object.updateMatrixWorld?.(true);
|
||||
center.applyMatrix4(object.matrixWorld);
|
||||
return center;
|
||||
},
|
||||
|
||||
clearSelectedFaces() {
|
||||
if (!Array.isArray(Viewer.selectedFaces) || Viewer.selectedFaces.length === 0) {
|
||||
Viewer.updateSelectedFacesCount();
|
||||
return;
|
||||
}
|
||||
|
||||
Viewer.selectedFaces.forEach((entry) => {
|
||||
Viewer.disposeFaceOverlay(entry);
|
||||
});
|
||||
Viewer.selectedFaces.length = 0;
|
||||
Viewer.updateSelectedFacesCount();
|
||||
},
|
||||
|
||||
restoreLastPickedFace() {
|
||||
if (!Viewer.lastPickedFace.overlay) {
|
||||
Viewer.lastPickedFace = { id: "", object: "", faceIndex: null, overlay: null };
|
||||
return;
|
||||
}
|
||||
|
||||
Viewer.disposeFaceOverlay(Viewer.lastPickedFace);
|
||||
Viewer.lastPickedFace = { id: "", object: "", faceIndex: null, overlay: null };
|
||||
},
|
||||
|
||||
pickFaces(intersection) {
|
||||
const hoveredObjectId = intersection?.object?.id ?? "";
|
||||
const hoveredFaceIndex = intersection?.faceIndex ?? null;
|
||||
if (!hoveredObjectId) {
|
||||
Viewer.restoreLastPickedFace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Viewer.lastPickedFace.object === hoveredObjectId &&
|
||||
Viewer.lastPickedFace.faceIndex === hoveredFaceIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Viewer.restoreLastPickedFace();
|
||||
const overlay = Viewer.createPickingFaceOverlay(intersection, {
|
||||
fillColor: 0xff3b30,
|
||||
lineColor: 0xffffff,
|
||||
opacity: 0.4,
|
||||
});
|
||||
if (!overlay) return;
|
||||
|
||||
Viewer.lastPickedFace = {
|
||||
id: hoveredObjectId,
|
||||
object: hoveredObjectId,
|
||||
faceIndex: hoveredFaceIndex,
|
||||
overlay,
|
||||
};
|
||||
|
||||
intersection.object.add(overlay);
|
||||
},
|
||||
|
||||
toggleSelectedFace(intersection, options = {}) {
|
||||
const targetId = Viewer.resolveFaceTargetId(intersection?.object);
|
||||
const runtimeObjectId = intersection?.object?.id ?? "";
|
||||
const faceIndex = intersection?.faceIndex ?? null;
|
||||
if (!targetId || faceIndex === null) return;
|
||||
|
||||
const multiSelect = options.multiSelect === true;
|
||||
const selectedFaceIndex = Viewer.findSelectedFaceIndex(targetId, faceIndex);
|
||||
|
||||
if (!multiSelect) {
|
||||
const clickedFaceKey = Viewer.getFaceSelectionKey(targetId, faceIndex);
|
||||
const clickedFaceAlreadySelected =
|
||||
selectedFaceIndex >= 0 && Viewer.selectedFaces.length === 1 &&
|
||||
Viewer.selectedFaces[0]?.key === clickedFaceKey;
|
||||
|
||||
Viewer.clearSelectedFaces();
|
||||
|
||||
if (clickedFaceAlreadySelected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFaceIndex >= 0) {
|
||||
const [selectedFace] = Viewer.selectedFaces.splice(selectedFaceIndex, 1);
|
||||
Viewer.disposeFaceOverlay(selectedFace);
|
||||
Viewer.updateSelectedFacesCount();
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = Viewer.createPickingFaceOverlay(intersection, {
|
||||
fillColor: 0x00c853,
|
||||
lineColor: 0xe8ffe8,
|
||||
opacity: 0.5,
|
||||
});
|
||||
if (!overlay) return;
|
||||
|
||||
intersection.object.add(overlay);
|
||||
Viewer.selectedFaces.push({
|
||||
key: Viewer.getFaceSelectionKey(targetId, faceIndex),
|
||||
targetId,
|
||||
object: targetId,
|
||||
runtimeObjectId,
|
||||
faceIndex,
|
||||
overlay,
|
||||
});
|
||||
Viewer.updateSelectedFacesCount();
|
||||
},
|
||||
|
||||
onPointerDown(e) {
|
||||
Viewer.disableInteractionHint();
|
||||
e.stopPropagation();
|
||||
if (e.button === 0) {
|
||||
getNormalizedPointerPosition(Viewer, e.clientX, e.clientY, Viewer.onDownPosition);
|
||||
}
|
||||
},
|
||||
|
||||
onPointerUp(e) {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
getNormalizedPointerPosition(Viewer, e.clientX, e.clientY, Viewer.onUpPosition);
|
||||
if (
|
||||
Viewer.onUpPosition.x !== Viewer.onDownPosition.x ||
|
||||
Viewer.onUpPosition.y !== Viewer.onDownPosition.y
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Viewer.pickingMode && !Viewer.RULER_MODE) {
|
||||
const poiHit = getPoiHit(Viewer, Viewer.onUpPosition);
|
||||
if (poiHit?.object) {
|
||||
Viewer.openAnnotationDialogFromPOIMarker(poiHit.object);
|
||||
return;
|
||||
}
|
||||
Viewer.closeAnnotationPOITooltip();
|
||||
}
|
||||
|
||||
if (Viewer.pickingMode || Viewer.RULER_MODE) {
|
||||
const primaryIntersection = getPrimaryModelIntersection(Viewer, Viewer.onUpPosition);
|
||||
if (!primaryIntersection) return;
|
||||
|
||||
if (Viewer.RULER_MODE) {
|
||||
Viewer.buildRuler(primaryIntersection);
|
||||
} else if (Viewer.pickingMode) {
|
||||
Viewer.toggleSelectedFace(primaryIntersection, {
|
||||
multiSelect: e.shiftKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onPointerMove(e) {
|
||||
getNormalizedPointerPosition(Viewer, e.clientX, e.clientY, Viewer.pointer);
|
||||
if (e.buttons !== 0) {
|
||||
Viewer.disableInteractionHint();
|
||||
Viewer.closeAnnotationPOITooltip();
|
||||
}
|
||||
if (e.buttons == 1) {
|
||||
if (Viewer.pointer.x !== Viewer.onDownPosition.x && Viewer.pointer.y !== Viewer.onDownPosition.y) {
|
||||
Viewer.cameraLight.position.set(
|
||||
core.camera.position.x,
|
||||
core.camera.position.y,
|
||||
core.camera.position.z
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Viewer.pickingMode && !Viewer.RULER_MODE) {
|
||||
if (Viewer.annotationDialog && Viewer.annotationDialog.hidden === false) {
|
||||
Viewer.closeAnnotationPOITooltip();
|
||||
} else {
|
||||
const poiHit = getPoiHit(Viewer, Viewer.pointer);
|
||||
if (poiHit?.object) {
|
||||
Viewer.openAnnotationPOITooltip(poiHit.object);
|
||||
} else {
|
||||
Viewer.closeAnnotationPOITooltip();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Viewer.pickingMode) {
|
||||
const primaryIntersection = getPrimaryModelIntersection(Viewer, Viewer.pointer);
|
||||
if (primaryIntersection) {
|
||||
Viewer.pickFaces(primaryIntersection);
|
||||
} else {
|
||||
Viewer.pickFaces("");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
52
viewer/editor/thumbnail-capture.js
Normal file
52
viewer/editor/thumbnail-capture.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { core } from "../core.js";
|
||||
|
||||
export function captureAndUploadThumbnail(viewer) {
|
||||
core.camera.aspect = 1;
|
||||
core.camera.updateProjectionMatrix();
|
||||
core.renderer.setSize(256, 256);
|
||||
core.renderer.render(core.scene, core.camera);
|
||||
|
||||
viewer.mainCanvas.toBlob((imgBlob) => {
|
||||
if (!imgBlob) {
|
||||
console.error("Failed to capture screenshot");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(imgBlob instanceof Blob) || imgBlob.size === 0) {
|
||||
console.error("Invalid blob data");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["image/png", "image/jpeg"].includes(imgBlob.type)) {
|
||||
console.error("Invalid blob type:", imgBlob.type);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileform = new FormData();
|
||||
fileform.append("path", core.fileObject.path);
|
||||
fileform.append("filename", core.fileObject.basename);
|
||||
fileform.append("data", imgBlob, "thumbnail.png");
|
||||
console.log("Uploading thumbnail for entity ID:", core.CONFIG.entity.id);
|
||||
fileform.append("wisski_individual", core.CONFIG.entity.id);
|
||||
|
||||
fetch(core.CONFIG.mainUrl + "/api/editor/upload-thumbnail", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-CSRF-Token": window.CSRF_TOKEN || window.drupalSettings?.dfg_3dviewer?.csrfToken
|
||||
},
|
||||
body: fileform
|
||||
})
|
||||
.then(async (res) => {
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!res.ok) throw new Error(data.error || "Upload failed");
|
||||
return data;
|
||||
});
|
||||
}, "image/png");
|
||||
|
||||
core.renderer.setPixelRatio(devicePixelRatio);
|
||||
core.camera.aspect = core.CONFIG.viewer.canvasDimensions.x / core.CONFIG.viewer.canvasDimensions.y;
|
||||
core.camera.updateProjectionMatrix();
|
||||
core.renderer.setSize(core.CONFIG.viewer.canvasDimensions.x, core.CONFIG.viewer.canvasDimensions.y);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue