Add custom template path support for WebGL export (#21695)
* Add custom template path support for WebGL export
- Extract hardcoded template into separate file and store in Mod/BIM/Resources/templates
- Remove hardcoded template from code
- Add new section to Import-Export Preference page ("WebGL"). This introduces two new parameters
- Make export() return bool to indicate success or failure for controlled headless export
- Add new unit tests for WebGL export to ensure functionality
- Update CMakeLists.txt to include the new template resource
* Apply suggestions from code review
Changes to literals, suggested after DWG review
Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com>
* Removed unused import
* Removed unused import
---------
Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com>
Co-authored-by: Yorik van Havre <yorik.vanhavre@gmail.com>
This commit is contained in:
@@ -173,6 +173,10 @@ SET(bimcommands_SRCS
|
||||
bimcommands/__init__.py
|
||||
)
|
||||
|
||||
SET(BIM_templates
|
||||
Resources/templates/webgl_export_template.html
|
||||
)
|
||||
|
||||
SET(nativeifc_SRCS
|
||||
nativeifc/ifc_commands.py
|
||||
nativeifc/ifc_diff.py
|
||||
@@ -223,6 +227,8 @@ SET(bimtests_SRCS
|
||||
bimtests/TestArchReference.py
|
||||
bimtests/TestArchSchedule.py
|
||||
bimtests/TestArchTruss.py
|
||||
bimtests/TestWebGLExport.py
|
||||
bimtests/TestWebGLExportGui.py
|
||||
)
|
||||
|
||||
SOURCE_GROUP("" FILES ${Arch_SRCS})
|
||||
@@ -246,6 +252,7 @@ ADD_CUSTOM_TARGET(BIM ALL
|
||||
${bimtests_SRCS}
|
||||
${nativeifc_SRCS}
|
||||
${BIMGuiIcon_SVG}
|
||||
${BIM_templates}
|
||||
)
|
||||
|
||||
ADD_CUSTOM_TARGET(ImporterPythonTestData ALL
|
||||
@@ -266,6 +273,12 @@ fc_target_copy_resource(BIM
|
||||
${Arch_presets}
|
||||
)
|
||||
|
||||
fc_target_copy_resource(BIM
|
||||
${CMAKE_SOURCE_DIR}/src/Mod/BIM
|
||||
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM
|
||||
${BIM_templates}
|
||||
)
|
||||
|
||||
fc_target_copy_resource(ImporterPythonTestData
|
||||
${CMAKE_SOURCE_DIR}/src/Mod/BIM
|
||||
${CMAKE_BINARY_DIR}/Mod/BIM
|
||||
@@ -330,3 +343,9 @@ INSTALL(
|
||||
"${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/icons"
|
||||
)
|
||||
|
||||
INSTALL(
|
||||
FILES
|
||||
${BIM_templates}
|
||||
DESTINATION
|
||||
"${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/templates"
|
||||
)
|
||||
|
||||
@@ -707,6 +707,7 @@ FreeCADGui.addPreferencePage(":/ui/preferences-ifc.ui", t)
|
||||
FreeCADGui.addPreferencePage(":/ui/preferences-ifc-export.ui", t)
|
||||
FreeCADGui.addPreferencePage(":/ui/preferences-dae.ui", t)
|
||||
FreeCADGui.addPreferencePage(":/ui/preferences-sh3d-import.ui", t)
|
||||
FreeCADGui.addPreferencePage(":/ui/preferences-webgl.ui", t)
|
||||
|
||||
# Add unit tests
|
||||
FreeCAD.__unit_test__ += ["TestArchGui"]
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
<file>ui/preferences-ifc-export.ui</file>
|
||||
<file>ui/preferences-ifc.ui</file>
|
||||
<file>ui/preferences-sh3d-import.ui</file>
|
||||
<file>ui/preferences-webgl.ui</file>
|
||||
<file>ui/preferencesNativeIFC.ui</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
||||
652
src/Mod/BIM/Resources/templates/webgl_export_template.html
Normal file
652
src/Mod/BIM/Resources/templates/webgl_export_template.html
Normal file
@@ -0,0 +1,652 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>$pagetitle</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<meta name="generator" content="FreeCAD $version">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
background: #ffffff; /* Old browsers */
|
||||
background: -moz-linear-gradient(top, #e3e9fc 0%, #ffffff 70%, #e2dab3 100%); /* FF3.6-15 */
|
||||
background: -webkit-linear-gradient(top, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* Chrome10-25, Safari5.1-6 */
|
||||
background: linear-gradient(to bottom, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
canvas { display: block; }
|
||||
#mainCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#arrowCanvas {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
z-index: 100;
|
||||
}
|
||||
select { width: 170px; }
|
||||
</style>
|
||||
</head>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@$threejs_version/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@$threejs_version/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<body>
|
||||
<canvas id="mainCanvas"></canvas>
|
||||
<canvas id="arrowCanvas"></canvas>
|
||||
<script type="module">
|
||||
// Direct from mrdoob: https://www.jsdelivr.com/package/npm/three
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
|
||||
import { Line2 } from 'three/addons/lines/Line2.js';
|
||||
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
||||
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
|
||||
import { EdgeSplitModifier } from 'three/addons/modifiers/EdgeSplitModifier.js';
|
||||
|
||||
const data = $data;
|
||||
|
||||
// Z is up for FreeCAD
|
||||
THREE.Object3D.DEFAULT_UP = new THREE.Vector3(0, 0, 1);
|
||||
|
||||
const defaultWireColor = new THREE.Color('rgb(0,0,0)');
|
||||
const defaultWireLineWidth = 2; // in pixels
|
||||
|
||||
const raycasterObj = []; // list of obj that can mouseover highlight
|
||||
|
||||
const canvas = document.querySelector('#mainCanvas');
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
canvas: canvas
|
||||
}); // Clear bg so we can set it with css
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
let renderRequested = false;
|
||||
|
||||
// HemisphereLight gives different colors of light from the top
|
||||
// and bottom simulating reflected light from the 'ground' and
|
||||
// 'sky'
|
||||
scene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 0.4));
|
||||
|
||||
const dLight1 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dLight1.position.set(5, -2, 3);
|
||||
scene.add(dLight1);
|
||||
const dLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dLight2.position.set(-5, 2, 3);
|
||||
scene.add(dLight2);
|
||||
|
||||
if (data.compressed) {
|
||||
const base = data.base;
|
||||
const baseFloat = data.baseFloat;
|
||||
|
||||
function baseDecode(input) {
|
||||
const baseCt = base.length;
|
||||
const output = [];
|
||||
const len = parseInt(input[0]); // num chars of each element
|
||||
for (let i = 1; i < input.length; i += len) {
|
||||
const str = input.substring(i, i + len).trim();
|
||||
let val = 0;
|
||||
for (let s = 0; s < str.length; s++) {
|
||||
const ind = base.indexOf(str[s]);
|
||||
val += ind * Math.pow(baseCt, s);
|
||||
}
|
||||
output.push(val);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function floatDecode(input) {
|
||||
const baseCt = base.length;
|
||||
const baseFloatCt = baseFloat.length;
|
||||
let numString = '';
|
||||
for (let i = 0; i < input.length; i += 4) {
|
||||
const b90chunk = input.substring(i, i + 4).trim();
|
||||
let quotient = 0;
|
||||
for (let s = 0; s < b90chunk.length; s++) {
|
||||
const ind = base.indexOf(b90chunk[s]);
|
||||
quotient += ind * Math.pow(baseCt, s);
|
||||
}
|
||||
let buffer = '';
|
||||
for (let s = 0; s < 7; s++) {
|
||||
buffer = baseFloat[quotient % baseFloatCt] + buffer;
|
||||
quotient = parseInt(quotient / baseFloatCt);
|
||||
}
|
||||
numString += buffer;
|
||||
}
|
||||
let trailingCommas = 0;
|
||||
for (let s = 1; s < 7; s++) {
|
||||
if (numString[numString.length - s] == baseFloat[0]) {
|
||||
trailingCommas++;
|
||||
}
|
||||
}
|
||||
numString = numString.substring(0, numString.length - trailingCommas);
|
||||
return numString;
|
||||
}
|
||||
|
||||
// Decode from base90 and distribute the floats
|
||||
for (const obj of data.objects) {
|
||||
obj.floats = JSON.parse('[' + floatDecode(obj.floats) + ']');
|
||||
obj.verts = baseDecode(obj.verts).map(x => obj.floats[x]);
|
||||
obj.facets = baseDecode(obj.facets);
|
||||
obj.wires = obj.wires.map(w => baseDecode(w).map(x => obj.floats[x]));
|
||||
obj.facesToFacets = obj.facesToFacets.map(x => baseDecode(x));
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const obj of data.objects) {
|
||||
obj.verts = obj.verts.map(x => parseFloat(x));
|
||||
obj.wires = obj.wires.map(w => w.map(x => parseFloat(x)))
|
||||
obj.facesToFacets = obj.facesToFacets.map(w => w.map(x => parseFloat(x)));
|
||||
}
|
||||
}
|
||||
// Get bounds for global clipping
|
||||
const globalMaxMin = [{min: null, max: null},
|
||||
{min: null, max: null},
|
||||
{min: null, max: null}];
|
||||
for (const obj of data.objects) {
|
||||
for (let v = 0; v < obj.verts.length; v++) {
|
||||
if (globalMaxMin[v % 3] === null
|
||||
|| obj.verts[v] < globalMaxMin[v % 3].min) {
|
||||
globalMaxMin[v % 3].min = obj.verts[v];
|
||||
}
|
||||
if (globalMaxMin[v % 3] === null
|
||||
|| obj.verts[v] > globalMaxMin[v % 3].max) {
|
||||
globalMaxMin[v % 3].max = obj.verts[v];
|
||||
}
|
||||
}
|
||||
}
|
||||
let bigrange = 0;
|
||||
// add a little extra
|
||||
for (const i of globalMaxMin) {
|
||||
const range = i.max - i.min;
|
||||
if (range > bigrange) {
|
||||
bigrange = range;
|
||||
}
|
||||
i.min -= range * 0.01;
|
||||
i.max += range * 0.01;
|
||||
}
|
||||
|
||||
const camCenter = new THREE.Vector3(
|
||||
0.5 * (globalMaxMin[0].max - globalMaxMin[0].min) + globalMaxMin[0].min,
|
||||
0.5 * (globalMaxMin[1].max - globalMaxMin[1].min) + globalMaxMin[1].min,
|
||||
0.5 * (globalMaxMin[2].max - globalMaxMin[2].min) + globalMaxMin[2].min );
|
||||
const viewSize = 1.5 * bigrange; // make the view area a little bigger than the object
|
||||
const aspectRatio = canvas.clientWidth / canvas.clientHeight;
|
||||
const originalAspect = aspectRatio;
|
||||
|
||||
function initCam(camera) {
|
||||
// XXX this needs to treat the perspective and orthographic
|
||||
// cameras differently
|
||||
camera.position.set(
|
||||
data.camera.position_x,
|
||||
data.camera.position_y,
|
||||
data.camera.position_z);
|
||||
camera.lookAt(camCenter);
|
||||
camera.updateMatrixWorld();
|
||||
}
|
||||
|
||||
let cameraType = data.camera.type;
|
||||
const persCamera = new THREE.PerspectiveCamera(
|
||||
50, aspectRatio, 1, 100000);
|
||||
initCam(persCamera);
|
||||
const orthCamera = new THREE.OrthographicCamera(
|
||||
-aspectRatio * viewSize / 2, aspectRatio * viewSize / 2,
|
||||
viewSize / 2, -viewSize / 2, -100000, 100000);
|
||||
initCam(orthCamera);
|
||||
|
||||
function assignMesh(positions, color, opacity, faces) {
|
||||
const baseGeometry = new THREE.BufferGeometry();
|
||||
baseGeometry.setAttribute('position', new THREE.BufferAttribute(
|
||||
positions, 3));
|
||||
|
||||
// EdgeSplitModifier is used to combine verts so that smoothing normals can be generated WITHOUT removing the hard edges of the design
|
||||
// REF: https://threejs.org/examples/?q=edge#webgl_modifier_edgesplit - https://github.com/mrdoob/three.js/pull/20535
|
||||
const edgeSplit = new EdgeSplitModifier();
|
||||
const cutOffAngle = 20;
|
||||
const geometry = edgeSplit.modify(
|
||||
baseGeometry, cutOffAngle * Math.PI / 180);
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: color,
|
||||
side: THREE.DoubleSide,
|
||||
vertexColors: false,
|
||||
opacity: opacity,
|
||||
transparent: opacity != 1.0,
|
||||
fog: false
|
||||
});
|
||||
|
||||
const meshobj = new THREE.Mesh(geometry, material);
|
||||
meshobj.name = meshobj.uuid;
|
||||
faces.push(meshobj.uuid);
|
||||
scene.add(meshobj);
|
||||
raycasterObj.push(meshobj);
|
||||
}
|
||||
|
||||
const objects = [];
|
||||
for (const obj of data.objects) {
|
||||
// Each face gets its own material because they each can
|
||||
// have different colors
|
||||
const faces = [];
|
||||
if (obj.facesToFacets.length > 0) {
|
||||
for (let f=0; f < obj.facesToFacets.length; f++) {
|
||||
const facecolor = obj.faceColors.length > 0 ? obj.faceColors[f] : obj.color;
|
||||
const positions = new Float32Array(obj.facesToFacets[f].length * 9);
|
||||
for (let a=0; a < obj.facesToFacets[f].length; a++) {
|
||||
for (let b=0; b < 3; b++) {
|
||||
for (let c=0; c < 3; c++) {
|
||||
positions[9 * a + 3 * b + c] = obj.verts[3 * obj.facets[3 * obj.facesToFacets[f][a] + b ] + c ];
|
||||
}
|
||||
}
|
||||
}
|
||||
assignMesh(positions, facecolor, obj.opacity, faces);
|
||||
}
|
||||
} else {
|
||||
// No facesToFacets means that there was a tessellate()
|
||||
// mismatch inside FreeCAD. Use all facets in object to
|
||||
// create this mesh
|
||||
const positions = new Float32Array(obj.facets.length * 3);
|
||||
for (let a=0; a < obj.facets.length; a++) {
|
||||
for (let b=0; b < 3; b++) {
|
||||
positions[3 * a + b] = obj.verts[3 * obj.facets[a] + b];
|
||||
}
|
||||
}
|
||||
assignMesh(positions, obj.color, obj.opacity, faces);
|
||||
}
|
||||
|
||||
// Wires
|
||||
// cannot have lines in WebGL that are wider than 1px due to browser limitations so Line2 workaround lib is used
|
||||
// REF: https://threejs.org/examples/?q=fat#webgl_lines_fat - https://jsfiddle.net/brLk6aud/1/
|
||||
// This material is shared by all wires in this object
|
||||
const wirematerial = new LineMaterial( {
|
||||
color: defaultWireColor,
|
||||
linewidth: defaultWireLineWidth,
|
||||
dashed: false, dashSize: 1, gapSize: 1, dashScale: 3
|
||||
} );
|
||||
wirematerial.resolution.set(
|
||||
canvas.clientWidth * window.devicePixelRatio,
|
||||
canvas.clientHeight * window.devicePixelRatio);
|
||||
|
||||
const wires = [];
|
||||
for (const w of obj.wires) {
|
||||
const wiregeometry = new LineGeometry();
|
||||
wiregeometry.setPositions(w);
|
||||
const wire = new Line2(wiregeometry, wirematerial);
|
||||
wire.computeLineDistances();
|
||||
wire.scale.set(1, 1, 1);
|
||||
wire.name = wire.uuid;
|
||||
scene.add(wire);
|
||||
wires.push(wire.name);
|
||||
}
|
||||
objects.push({
|
||||
data: obj,
|
||||
faces: faces,
|
||||
wires: wires,
|
||||
wirematerial: wirematerial,
|
||||
gui_link: null
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GUI Init ----
|
||||
const gui = new GUI({ width: 300, closeFolders: true });
|
||||
const addFolder = GUI.prototype.addFolder;
|
||||
GUI.prototype.addFolder = function(...args) {
|
||||
return addFolder.call(this, ...args).close();
|
||||
}
|
||||
const guiparams = {
|
||||
wiretype: 'Normal',
|
||||
wirewidth: defaultWireLineWidth,
|
||||
wirecolor: '#' + defaultWireColor.getHexString(),
|
||||
clippingx: 100,
|
||||
clippingy: 100,
|
||||
clippingz: 100,
|
||||
cameraType: cameraType,
|
||||
navright: function() { navChange([1, 0, 0]); },
|
||||
navtop: function() { navChange([0, 0, 1]); },
|
||||
navfront: function() { navChange([0, -1, 0]); }
|
||||
};
|
||||
|
||||
// ---- Wires ----
|
||||
const wiretypes = { Normal: 'Normal', Dashed: 'Dashed', None: 'None' };
|
||||
|
||||
const wireFolder = gui.addFolder('Wire');
|
||||
wireFolder.add(guiparams, 'wiretype', wiretypes).name('Wire Display').onChange(wireChange);
|
||||
wireFolder.add(guiparams, 'wirewidth').min(1).max(5).step(1).name('Wire Width').onChange(wireChange);
|
||||
wireFolder.addColor(guiparams, 'wirecolor').name('Wire Color').onChange(wireChange);
|
||||
|
||||
function wireChange() {
|
||||
for (const obj of objects) {
|
||||
const m = obj.wirematerial;
|
||||
if (m.dashed) {
|
||||
if (guiparams.wiretype != 'Dashed') {
|
||||
m.dashed = false;
|
||||
delete m.defines.USE_DASH;
|
||||
}
|
||||
} else {
|
||||
if (guiparams.wiretype == 'Dashed') {
|
||||
m.dashed = true;
|
||||
// Dashed lines require this as of r122. delete if not dashed
|
||||
m.defines.USE_DASH = ""; // https://discourse.threejs.org/t/dashed-line2-material/10825
|
||||
}
|
||||
}
|
||||
if (guiparams.wiretype == 'None') {
|
||||
m.visible = false;
|
||||
} else {
|
||||
if ((obj.faces.length == 0) | scene.getObjectByName(obj.faces[0]).material.visible){
|
||||
m.visible = true;
|
||||
}
|
||||
}
|
||||
m.linewidth = guiparams.wirewidth;
|
||||
m.color = new THREE.Color(guiparams.wirecolor);
|
||||
m.needsUpdate = true;
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
wireChange();
|
||||
|
||||
// ---- Clipping ----
|
||||
const clippingFolder = gui.addFolder('Clipping');
|
||||
clippingFolder.add(guiparams, 'clippingx').min(0).max(100).step(1).name('X-Axis Clipping').onChange(clippingChange);
|
||||
clippingFolder.add(guiparams, 'clippingy').min(0).max(100).step(1).name('Y-Axis Clipping').onChange(clippingChange);
|
||||
clippingFolder.add(guiparams, 'clippingz').min(0).max(100).step(1).name('Z-Axis Clipping').onChange(clippingChange);
|
||||
|
||||
const clipPlaneX = new THREE.Plane(new THREE.Vector3( -1, 0, 0 ), 0);
|
||||
const clipPlaneY = new THREE.Plane(new THREE.Vector3( 0, -1, 0 ), 0);
|
||||
const clipPlaneZ = new THREE.Plane(new THREE.Vector3( 0, 0, -1 ), 0);
|
||||
|
||||
function clippingChange() {
|
||||
if (guiparams.clippingx < 100 || guiparams.clippingy < 100 || guiparams.clippingz < 100) {
|
||||
if (renderer.clippingPlanes.length == 0) {
|
||||
renderer.clippingPlanes.push(clipPlaneX, clipPlaneY, clipPlaneZ);
|
||||
}
|
||||
}
|
||||
clipPlaneX.constant = (globalMaxMin[0].max - globalMaxMin[0].min) * guiparams.clippingx / 100.0 + globalMaxMin[0].min;
|
||||
clipPlaneY.constant = (globalMaxMin[1].max - globalMaxMin[1].min) * guiparams.clippingy / 100.0 + globalMaxMin[1].min;
|
||||
clipPlaneZ.constant = (globalMaxMin[2].max - globalMaxMin[2].min) * guiparams.clippingz / 100.0 + globalMaxMin[2].min;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
// ---- Camera & Navigation ----
|
||||
const camFolder = gui.addFolder('Camera');
|
||||
const cameraTypes = { Perspective: 'Perspective', Orthographic: 'Orthographic' };
|
||||
camFolder.add(guiparams, 'cameraType', cameraTypes).name('Camera type').onChange(cameraChange);
|
||||
camFolder.add(guiparams, 'navright').name('View Right');
|
||||
camFolder.add(guiparams, 'navtop').name('View Top');
|
||||
camFolder.add(guiparams, 'navfront').name('View Front');
|
||||
|
||||
function navChange(v) {
|
||||
const t = new THREE.Vector3();
|
||||
new THREE.Box3().setFromObject(scene).getSize(t);
|
||||
persControls.object.position.set(
|
||||
v[0] * t.x * 2 + camCenter.x,
|
||||
v[1] * t.y * 2 + camCenter.y,
|
||||
v[2] * t.z * 2 + camCenter.z);
|
||||
persControls.target = camCenter;
|
||||
persControls.update();
|
||||
orthControls.object.position.set(
|
||||
v[0] * t.x + camCenter.x,
|
||||
v[1] * t.y + camCenter.y,
|
||||
v[2] * t.z + camCenter.z);
|
||||
orthControls.target = camCenter;
|
||||
orthControls.update();
|
||||
// controls.update() implicitly calls requestRender()
|
||||
}
|
||||
|
||||
function cameraChange(v) {
|
||||
cameraType = v;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
const guiObjects = gui.addFolder('Objects');
|
||||
for (const obj of objects) {
|
||||
// Ignore objects with no vertices
|
||||
if (obj.data.verts.length > 0) {
|
||||
const guiObjData = {
|
||||
obj: obj, color: obj.data.color, opacity: obj.data.opacity, show: true };
|
||||
const guiObject = guiObjects.addFolder(obj.data.name);
|
||||
guiObject.addColor(guiObjData, 'color').name('Color').onChange(GUIObjectChange);
|
||||
guiObject.add(guiObjData, 'opacity').min(0.0).max(1.0).step(0.05).name('Opacity').onChange(GUIObjectChange);
|
||||
guiObject.add(guiObjData, 'show').onChange(GUIObjectChange).listen();
|
||||
obj.gui_link = guiObjData
|
||||
}
|
||||
}
|
||||
|
||||
function GUIObjectChange(v) {
|
||||
for (const f of this.object.obj.faces) {
|
||||
const m = scene.getObjectByName(f).material;
|
||||
if (this.property == 'color') {
|
||||
m.color.setStyle(v);
|
||||
}
|
||||
if (this.property == 'opacity') {
|
||||
m.opacity = v;
|
||||
m.transparent = (v != 1.0);
|
||||
}
|
||||
if (this.property == 'show') {
|
||||
m.visible = v
|
||||
}
|
||||
}
|
||||
if (this.property == 'opacity') {
|
||||
const m = this.object.obj.wirematerial;
|
||||
m.opacity = v;
|
||||
m.transparent = (v != 1.0);
|
||||
}
|
||||
if (this.property == 'show') {
|
||||
const m = this.object.obj.wirematerial;
|
||||
m.visible = v
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
|
||||
// Make simple orientation arrows and box - REF: http://jsfiddle.net/b97zd1a3/16/
|
||||
const arrowCanvas = document.querySelector('#arrowCanvas');
|
||||
const arrowRenderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
canvas: arrowCanvas
|
||||
}); // clear
|
||||
arrowRenderer.setClearColor(0x000000, 0);
|
||||
arrowRenderer.setSize(arrowCanvas.clientWidth * window.devicePixelRatio,
|
||||
arrowCanvas.clientHeight * window.devicePixelRatio,
|
||||
false);
|
||||
|
||||
const arrowScene = new THREE.Scene();
|
||||
|
||||
const arrowCamera = new THREE.PerspectiveCamera(
|
||||
50, arrowCanvas.clientWidth / arrowCanvas.clientHeight, 1, 500 );
|
||||
arrowCamera.up = persCamera.up; // important!
|
||||
|
||||
const arrowPos = new THREE.Vector3(0, 0, 0);
|
||||
arrowScene.add(new THREE.ArrowHelper(
|
||||
new THREE.Vector3(1, 0, 0), arrowPos, 60, 0x7F2020, 20, 10));
|
||||
arrowScene.add(new THREE.ArrowHelper(
|
||||
new THREE.Vector3(0, 1, 0), arrowPos, 60, 0x207F20, 20, 10));
|
||||
arrowScene.add(new THREE.ArrowHelper(
|
||||
new THREE.Vector3(0, 0, 1), arrowPos, 60, 0x20207F, 20, 10));
|
||||
arrowScene.add(new THREE.Mesh(
|
||||
new THREE.BoxGeometry(40, 40, 40),
|
||||
new THREE.MeshLambertMaterial({ color: 0xaaaaaa })
|
||||
));
|
||||
arrowScene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 1.2));
|
||||
|
||||
// Controls
|
||||
const persControls = new OrbitControls(persCamera, renderer.domElement);
|
||||
persControls.target = camCenter; // rotate around center of parts
|
||||
// persControls.enablePan = false;
|
||||
// persControls.enableDamping = true;
|
||||
persControls.update();
|
||||
const orthControls = new OrbitControls(orthCamera, renderer.domElement);
|
||||
orthControls.target = camCenter; // rotate around center of parts
|
||||
// orthControls.enablePan = false;
|
||||
// orthControls.enableDamping = true;
|
||||
orthControls.update();
|
||||
|
||||
function render() {
|
||||
renderRequested = false;
|
||||
persControls.update();
|
||||
if (cameraType == 'Perspective') {
|
||||
arrowCamera.position.copy(persCamera.position);
|
||||
arrowCamera.position.sub(persControls.target);
|
||||
}
|
||||
orthControls.update();
|
||||
if (cameraType == 'Orthographic') {
|
||||
arrowCamera.position.copy(orthCamera.position);
|
||||
arrowCamera.position.sub(orthControls.target);
|
||||
}
|
||||
arrowCamera.lookAt(arrowScene.position);
|
||||
arrowCamera.position.setLength(200);
|
||||
|
||||
if (cameraType == 'Perspective') {
|
||||
renderer.render(scene, persCamera);
|
||||
}
|
||||
if (cameraType == 'Orthographic') {
|
||||
renderer.render(scene, orthCamera);
|
||||
}
|
||||
arrowRenderer.render(arrowScene, arrowCamera);
|
||||
};
|
||||
|
||||
function requestRender() {
|
||||
if (!renderRequested) {
|
||||
renderRequested = true;
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
}
|
||||
|
||||
persControls.addEventListener('change', requestRender);
|
||||
orthControls.addEventListener('change', requestRender);
|
||||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.addEventListener('dblclick', onMouseDblClick);
|
||||
window.addEventListener('resize', onMainCanvasResize, false);
|
||||
|
||||
onMainCanvasResize();
|
||||
requestRender();
|
||||
|
||||
function onMainCanvasResize() {
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
const width = canvas.clientWidth * pixelRatio | 0;
|
||||
const height = canvas.clientHeight * pixelRatio | 0;
|
||||
const needResize = canvas.width !== width || canvas.height !== height;
|
||||
const aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
if (needResize) {
|
||||
renderer.setSize(width, height, false);
|
||||
|
||||
// See https://stackoverflow.com/questions/39373113/three-js-resize-window-not-scaling-properly
|
||||
const change = originalAspect / aspect;
|
||||
const newSize = viewSize * change;
|
||||
orthCamera.left = -aspect * newSize / 2;
|
||||
orthCamera.right = aspect * newSize / 2;
|
||||
orthCamera.top = newSize / 2;
|
||||
orthCamera.bottom = -newSize / 2;
|
||||
orthCamera.updateProjectionMatrix();
|
||||
|
||||
persCamera.aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
persCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
for (const obj of objects) {
|
||||
obj.wirematerial.resolution.set(width, height);
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
// Use mouse double click to toggle the gui for the selected object
|
||||
function onMouseDblClick(e){
|
||||
let c = false;
|
||||
if (cameraType == 'Orthographic') {
|
||||
c = orthCamera;
|
||||
}
|
||||
if (cameraType == 'Perspective') {
|
||||
c = persCamera;
|
||||
}
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(new THREE.Vector2(
|
||||
(e.clientX / canvas.clientWidth) * 2 - 1,
|
||||
-(e.clientY / canvas.clientHeight) * 2 + 1),
|
||||
c);
|
||||
const intersects = raycaster.intersectObjects(raycasterObj);
|
||||
|
||||
for (const i of intersects) {
|
||||
const m = i.object;
|
||||
if (!m.material.visible){continue};
|
||||
for (const obj of objects) {
|
||||
for (const face_uuid of obj.faces) {
|
||||
if (face_uuid == m.uuid) {
|
||||
obj.gui_link.show = false
|
||||
obj.wirematerial.visible = false
|
||||
for (const face of obj.faces) {
|
||||
scene.getObjectByName(face).material.visible = false
|
||||
}
|
||||
requestRender();
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onMouseMove(e) {
|
||||
let c = false;
|
||||
if (cameraType == 'Orthographic') {
|
||||
c = orthCamera;
|
||||
}
|
||||
if (cameraType == 'Perspective') {
|
||||
c = persCamera;
|
||||
}
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(new THREE.Vector2(
|
||||
(e.clientX / canvas.clientWidth) * 2 - 1,
|
||||
-(e.clientY / canvas.clientHeight) * 2 + 1),
|
||||
c);
|
||||
const intersects = raycaster.intersectObjects(raycasterObj);
|
||||
|
||||
let chosen = '';
|
||||
for (const i of intersects) {
|
||||
const m = i.object.material;
|
||||
if ((m.opacity > 0) & m.visible) {
|
||||
if (m.emissive.getHex() == 0x000000) {
|
||||
m.emissive.setHex( 0x777777 );
|
||||
m.needsUpdate = true;
|
||||
requestRender();
|
||||
}
|
||||
chosen = i.object.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const r of raycasterObj) {
|
||||
if (r.name == chosen) {
|
||||
continue;
|
||||
}
|
||||
if (r.material.emissive.getHex() != 0x000000) {
|
||||
r.material.emissive.setHex(0x000000);
|
||||
r.material.needsUpdate = true;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
129
src/Mod/BIM/Resources/ui/preferences-webgl.ui
Normal file
129
src/Mod/BIM/Resources/ui/preferences-webgl.ui
Normal file
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Gui::Dialog::DlgSettingsArch</class>
|
||||
<widget class="QWidget" name="Gui::Dialog::DlgSettingsArch">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>555</width>
|
||||
<height>729</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>WebGL</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout">
|
||||
<property name="spacing">
|
||||
<number>6</number>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<number>9</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_3">
|
||||
<property name="title">
|
||||
<string>Export Options</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="Gui::PrefCheckBox" name="checkBox_useCustomWebGLTemplate">
|
||||
<property name="toolTip">
|
||||
<string>A custom WebGL HTML template is used for export. Otherwise, the default template will be used.
|
||||
|
||||
The default template is located at:
|
||||
<FreeCAD installation directory>/Resources/Mod/BIM/templates/webgl_export_template.html </string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Use custom export template</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="prefEntry" stdset="0">
|
||||
<cstring>useCustomWebGLExportTemplate</cstring>
|
||||
</property>
|
||||
<property name="prefPath" stdset="0">
|
||||
<cstring>Mod/BIM</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Path to template</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<widget class="Gui::PrefFileChooser" name="gui::preffilechooser_2">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>The path to the custom WebGL HTML template</string>
|
||||
</property>
|
||||
<property name="prefEntry" stdset="0">
|
||||
<cstring>WebGLTemplateCustomPath</cstring>
|
||||
</property>
|
||||
<property name="prefPath" stdset="0">
|
||||
<cstring>Mod/BIM</cstring>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<layoutdefault spacing="6" margin="11"/>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>Gui::FileChooser</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>Gui/FileDialog.h</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>Gui::PrefFileChooser</class>
|
||||
<extends>Gui::FileChooser</extends>
|
||||
<header>Gui/PrefWidgets.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>checkBox_useCustomWebGLTemplate</sender>
|
||||
<signal>toggled(bool)</signal>
|
||||
<receiver>label_4</receiver>
|
||||
<slot>setEnabled(bool)</slot>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>checkBox_useCustomWebGLTemplate</sender>
|
||||
<signal>toggled(bool)</signal>
|
||||
<receiver>gui::preffilechooser_2</receiver>
|
||||
<slot>setEnabled(bool)</slot>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -47,4 +47,4 @@ from bimtests.TestArchReference import TestArchReference
|
||||
from bimtests.TestArchSchedule import TestArchSchedule
|
||||
from bimtests.TestArchTruss import TestArchTruss
|
||||
from bimtests.TestArchComponent import TestArchComponent
|
||||
|
||||
from bimtests.TestWebGLExport import TestWebGLExport
|
||||
|
||||
@@ -42,6 +42,8 @@ from draftutils.messages import _msg
|
||||
if App.GuiUp:
|
||||
import FreeCADGui
|
||||
|
||||
from bimtests.TestWebGLExportGui import TestWebGLExportGui
|
||||
|
||||
class ArchTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
140
src/Mod/BIM/bimtests/TestWebGLExport.py
Normal file
140
src/Mod/BIM/bimtests/TestWebGLExport.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2025 baidakovil <baidakovil@icloud.com> *
|
||||
# * *
|
||||
# * This file is part of FreeCAD. *
|
||||
# * *
|
||||
# * FreeCAD is free software: you can redistribute it and/or modify it *
|
||||
# * under the terms of the GNU Lesser General Public License as *
|
||||
# * published by the Free Software Foundation, either version 2.1 of the *
|
||||
# * License, or (at your option) any later version. *
|
||||
# * *
|
||||
# * FreeCAD is distributed in the hope that it will be useful, but *
|
||||
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
# * Lesser General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Lesser General Public *
|
||||
# * License along with FreeCAD. If not, see *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
|
||||
"""
|
||||
Unit tests for WebGL export functionality. Gui tests are in `TestWebGLExportGui.py`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, mock_open
|
||||
|
||||
from BIM.importers import importWebGL
|
||||
from .TestArchBase import TestArchBase
|
||||
|
||||
|
||||
class TestWebGLExport(TestArchBase):
|
||||
|
||||
def setUp(self):
|
||||
"""Using TestArchBase setUp to initialize the document for convenience,
|
||||
but also create a temporary directory for tests."""
|
||||
super().setUp()
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.test_template_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>$pagetitle</title></head>
|
||||
<body>WebGL Content: $data</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
super().tearDown()
|
||||
|
||||
def test_actual_default_template_readable_and_valid(self):
|
||||
"""Test that the actual default template can be read and contains
|
||||
required placeholders"""
|
||||
operation = "Testing actual default template reading and validation"
|
||||
self.printTestMessage(operation)
|
||||
# Test with real configuration - no mocks
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.params.get_param", return_value=False
|
||||
): # Disable custom template
|
||||
result = importWebGL.getHTMLTemplate()
|
||||
|
||||
if result is not None: # Only test if template exists
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
# Check for basic HTML structure
|
||||
self.assertIn("<html", result.lower())
|
||||
self.assertIn("$pagetitle", result.lower())
|
||||
self.assertIn("$data", result.lower())
|
||||
|
||||
def test_custom_template_with_real_file(self):
|
||||
"""Test custom template functionality with real temporary file"""
|
||||
operation = "Testing custom template with real file"
|
||||
self.printTestMessage(operation)
|
||||
# Create real temporary template file
|
||||
custom_path = os.path.join(self.test_dir, "custom_template.html")
|
||||
with open(custom_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.test_template_content)
|
||||
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.params.get_param"
|
||||
) as mock_params:
|
||||
mock_params.side_effect = lambda param, path=None: {
|
||||
"useCustomWebGLExportTemplate": True,
|
||||
"WebGLTemplateCustomPath": custom_path,
|
||||
}.get(param, False)
|
||||
|
||||
result = importWebGL.getHTMLTemplate()
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(
|
||||
result.strip(), self.test_template_content.strip()
|
||||
)
|
||||
|
||||
def test_default_template_logic_when_custom_disabled(self):
|
||||
"""Test code path when custom template is disabled"""
|
||||
operation = "Testing default template logic when custom is disabled"
|
||||
self.printTestMessage(operation)
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.params.get_param", return_value=False
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
with patch(
|
||||
"builtins.open",
|
||||
mock_open(read_data=self.test_template_content),
|
||||
):
|
||||
result = importWebGL.getHTMLTemplate()
|
||||
self.assertIsNotNone(result)
|
||||
if result is not None:
|
||||
self.assertIn("WebGL Content", result)
|
||||
|
||||
def test_custom_template_not_found_headless_mode(self):
|
||||
"""Test behavior when custom template not found in headless mode"""
|
||||
operation = "Testing custom template not found in headless mode"
|
||||
self.printTestMessage(operation)
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.params.get_param"
|
||||
) as mock_params:
|
||||
mock_params.side_effect = lambda param, path=None: {
|
||||
"useCustomWebGLExportTemplate": True,
|
||||
"WebGLTemplateCustomPath": "/nonexistent/template.html",
|
||||
}.get(param, False)
|
||||
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.FreeCADGui", None
|
||||
): # Simulate headless mode
|
||||
result = importWebGL.getHTMLTemplate()
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Allow running tests directly
|
||||
unittest.main(verbosity=2)
|
||||
181
src/Mod/BIM/bimtests/TestWebGLExportGui.py
Normal file
181
src/Mod/BIM/bimtests/TestWebGLExportGui.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2025 baidakovil <baidakovil@icloud.com> *
|
||||
# * *
|
||||
# * This file is part of FreeCAD. *
|
||||
# * *
|
||||
# * FreeCAD is free software: you can redistribute it and/or modify it *
|
||||
# * under the terms of the GNU Lesser General Public License as *
|
||||
# * published by the Free Software Foundation, either version 2.1 of the *
|
||||
# * License, or (at your option) any later version. *
|
||||
# * *
|
||||
# * FreeCAD is distributed in the hope that it will be useful, but *
|
||||
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
# * Lesser General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Lesser General Public *
|
||||
# * License along with FreeCAD. If not, see *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
|
||||
"""
|
||||
Unit tests for Gui WebGL export functionality. Tests both the template handling and
|
||||
the main export function. Non-gui tests are in `TestWebGLExport.py`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock, mock_open
|
||||
|
||||
from BIM.importers import importWebGL
|
||||
from .TestArchBase import TestArchBase
|
||||
|
||||
|
||||
class TestWebGLExportGui(TestArchBase):
|
||||
|
||||
def setUp(self):
|
||||
"""Using TestArchBase setUp to initialize the document for convenience,
|
||||
but also create a temporary directory for tests."""
|
||||
super().setUp()
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.test_template_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>$pagetitle</title></head>
|
||||
<body>WebGL Content: $data</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
super().tearDown()
|
||||
|
||||
@patch("BIM.importers.importWebGL.FreeCADGui")
|
||||
def test_custom_template_not_found_gui_user_accepts_fallback(
|
||||
self, mock_gui
|
||||
):
|
||||
"""Test GUI dialog when custom template not found -
|
||||
user accepts fallback"""
|
||||
operation = "Testing GUI custom template not found - user accepts fallback"
|
||||
self.printTestMessage(operation)
|
||||
from PySide import QtWidgets
|
||||
|
||||
mock_gui.getMainWindow.return_value = MagicMock()
|
||||
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.params.get_param"
|
||||
) as mock_params:
|
||||
mock_params.side_effect = lambda param, path=None: {
|
||||
"useCustomWebGLExportTemplate": True,
|
||||
"WebGLTemplateCustomPath": "/nonexistent/template.html",
|
||||
}.get(param, False)
|
||||
|
||||
with patch(
|
||||
"PySide.QtWidgets.QMessageBox.question",
|
||||
return_value=QtWidgets.QMessageBox.Yes,
|
||||
):
|
||||
with patch("os.path.isfile", return_value=True):
|
||||
with patch(
|
||||
"builtins.open",
|
||||
mock_open(read_data=self.test_template_content),
|
||||
):
|
||||
result = importWebGL.getHTMLTemplate()
|
||||
self.assertIsNotNone(result)
|
||||
|
||||
@patch("BIM.importers.importWebGL.FreeCADGui")
|
||||
def test_custom_template_not_found_gui_user_rejects_fallback(
|
||||
self, mock_gui
|
||||
):
|
||||
"""Test GUI dialog when custom template not found -
|
||||
user rejects fallback"""
|
||||
operation = "Testing GUI custom template not found - user rejects fallback"
|
||||
self.printTestMessage(operation)
|
||||
from PySide import QtWidgets
|
||||
|
||||
mock_gui.getMainWindow.return_value = MagicMock()
|
||||
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.params.get_param"
|
||||
) as mock_params:
|
||||
mock_params.side_effect = lambda param, path=None: {
|
||||
"useCustomWebGLExportTemplate": True,
|
||||
"WebGLTemplateCustomPath": "/nonexistent/template.html",
|
||||
}.get(param, False)
|
||||
|
||||
with patch(
|
||||
"PySide.QtWidgets.QMessageBox.question",
|
||||
return_value=QtWidgets.QMessageBox.No,
|
||||
):
|
||||
result = importWebGL.getHTMLTemplate()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_export_returns_false_when_no_template(self):
|
||||
"""Test that export function returns False when
|
||||
no template is available"""
|
||||
operation = "Testing export returns False when no template available"
|
||||
self.printTestMessage(operation)
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.getHTMLTemplate", return_value=None
|
||||
):
|
||||
with patch("BIM.importers.importWebGL.FreeCADGui") as mock_gui:
|
||||
# Mock the GUI components that might be accessed
|
||||
mock_active_doc = MagicMock()
|
||||
mock_active_doc.ActiveView = MagicMock()
|
||||
mock_gui.ActiveDocument = mock_active_doc
|
||||
|
||||
result = importWebGL.export(
|
||||
[], os.path.join(self.test_dir, "test.html")
|
||||
)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_export_returns_true_when_template_available(self):
|
||||
"""Test that export function returns True when template is available"""
|
||||
operation = "Testing export returns True when template available"
|
||||
self.printTestMessage(operation)
|
||||
mock_template = """<html><body>
|
||||
$pagetitle $version $data $threejs_version
|
||||
</body></html>"""
|
||||
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.getHTMLTemplate",
|
||||
return_value=mock_template,
|
||||
):
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.FreeCAD.ActiveDocument"
|
||||
) as mock_doc:
|
||||
mock_doc.Label = "Test Document"
|
||||
with patch("BIM.importers.importWebGL.FreeCADGui") as mock_gui:
|
||||
# Mock the GUI components that might be accessed
|
||||
mock_active_doc = MagicMock()
|
||||
mock_active_doc.ActiveView = MagicMock()
|
||||
mock_gui.ActiveDocument = mock_active_doc
|
||||
|
||||
# Mock the functions that populate data to return JSON-serializable values
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.populate_camera"
|
||||
) as mock_populate_camera:
|
||||
with patch(
|
||||
"BIM.importers.importWebGL.Draft.get_group_contents",
|
||||
return_value=[],
|
||||
):
|
||||
mock_populate_camera.return_value = (
|
||||
None # Modifies data dict in place
|
||||
)
|
||||
|
||||
result = importWebGL.export(
|
||||
[], os.path.join(self.test_dir, "test.html")
|
||||
)
|
||||
self.assertTrue(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Allow running tests directly
|
||||
unittest.main(verbosity=2)
|
||||
@@ -4,6 +4,7 @@
|
||||
# * *
|
||||
# * Copyright (c) 2013 Yorik van Havre <yorik@uncreated.net> *
|
||||
# * Copyright (c) 2020 Travis Apple <travisapple@gmail.com> *
|
||||
# * Copyright (c) 2025 baidakovil <baidakovil@icloud.com> *
|
||||
# * *
|
||||
# * This file is part of FreeCAD. *
|
||||
# * *
|
||||
@@ -44,13 +45,16 @@
|
||||
#
|
||||
# This module provides tools to export HTML files containing the
|
||||
# exported objects in WebGL format and a simple three.js-based viewer.
|
||||
# Tests are provided in src/Mod/BIM/bimtests/TestWebGLExport.py.
|
||||
# The template is provided in src/Mod/BIM/Resources/templates/webgl_export_template.html.
|
||||
|
||||
"""FreeCAD WebGL Exporter"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
from builtins import open as pyopen
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -59,9 +63,11 @@ import Draft
|
||||
import Mesh
|
||||
import OfflineRenderingUtils
|
||||
import Part
|
||||
from draftutils import params
|
||||
|
||||
if FreeCAD.GuiUp:
|
||||
import FreeCADGui
|
||||
from PySide import QtWidgets
|
||||
from draftutils.translate import translate
|
||||
else:
|
||||
FreeCADGui = None
|
||||
@@ -77,666 +83,137 @@ threejs_version = "0.172.0"
|
||||
|
||||
|
||||
def getHTMLTemplate():
|
||||
return textwrap.dedent("""\
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>$pagetitle</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
|
||||
<meta name="generator" content="FreeCAD $version">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
background: #ffffff; /* Old browsers */
|
||||
background: -moz-linear-gradient(top, #e3e9fc 0%, #ffffff 70%, #e2dab3 100%); /* FF3.6-15 */
|
||||
background: -webkit-linear-gradient(top, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* Chrome10-25, Safari5.1-6 */
|
||||
background: linear-gradient(to bottom, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
canvas { display: block; }
|
||||
#mainCanvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#arrowCanvas {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
z-index: 100;
|
||||
}
|
||||
select { width: 170px; }
|
||||
</style>
|
||||
</head>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@$threejs_version/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@$threejs_version/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<body>
|
||||
<canvas id="mainCanvas"></canvas>
|
||||
<canvas id="arrowCanvas"></canvas>
|
||||
<script type="module">
|
||||
// Direct from mrdoob: https://www.jsdelivr.com/package/npm/three
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
|
||||
import { Line2 } from 'three/addons/lines/Line2.js';
|
||||
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
|
||||
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
|
||||
import { EdgeSplitModifier } from 'three/addons/modifiers/EdgeSplitModifier.js';
|
||||
"""Returns the HTML template from external file.
|
||||
The custom template path can be set in the Preferences.
|
||||
Returns None if no valid template is found.
|
||||
"""
|
||||
|
||||
const data = $data;
|
||||
def try_read_template(path, description):
|
||||
"""Helper function to safely read a template file."""
|
||||
if not os.path.isfile(path):
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"{description.capitalize()} file '{path}' does not "
|
||||
"exist or is not a file.\n"
|
||||
)
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Failed to read {description} file '{path}' "
|
||||
f"due to: {str(e)}\n"
|
||||
)
|
||||
return None
|
||||
|
||||
// Z is up for FreeCAD
|
||||
THREE.Object3D.DEFAULT_UP = new THREE.Vector3(0, 0, 1);
|
||||
using_custom_template = params.get_param(
|
||||
"useCustomWebGLExportTemplate", path="Mod/BIM"
|
||||
)
|
||||
|
||||
const defaultWireColor = new THREE.Color('rgb(0,0,0)');
|
||||
const defaultWireLineWidth = 2; // in pixels
|
||||
# Try to use custom template if enabled
|
||||
if using_custom_template:
|
||||
custom_path = params.get_param(
|
||||
"WebGLTemplateCustomPath", path="Mod/BIM"
|
||||
)
|
||||
custom_content = try_read_template(
|
||||
custom_path, "custom WebGL template"
|
||||
)
|
||||
if custom_content:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Using custom template file '{custom_path}'.\n"
|
||||
)
|
||||
return custom_content
|
||||
else:
|
||||
# Custom template failed - ask user or auto-fallback
|
||||
if not FreeCADGui:
|
||||
# In non-GUI mode, cancel export when custom template fails
|
||||
FreeCAD.Console.PrintError(
|
||||
f"Export cancelled: Custom template '{custom_path}' "
|
||||
"not available.\n"
|
||||
)
|
||||
return None
|
||||
|
||||
const raycasterObj = []; // list of obj that can mouseover highlight
|
||||
# In GUI mode, ask the user
|
||||
message = translate(
|
||||
"BIM",
|
||||
"Custom WebGL template file '{}' could not be read.\n\n"
|
||||
"Do you want to proceed using the default template?",
|
||||
).format(custom_path)
|
||||
|
||||
const canvas = document.querySelector('#mainCanvas');
|
||||
reply = QtWidgets.QMessageBox.question(
|
||||
FreeCADGui.getMainWindow(),
|
||||
translate("BIM", "WebGL Template Not Found"),
|
||||
message,
|
||||
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
||||
QtWidgets.QMessageBox.Yes,
|
||||
)
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
if reply != QtWidgets.QMessageBox.Yes:
|
||||
# User chose not to proceed - return None to indicate failure
|
||||
FreeCAD.Console.PrintError(
|
||||
f"Export cancelled: Custom template '{custom_path}' "
|
||||
"not available.\n"
|
||||
)
|
||||
return None
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
canvas: canvas
|
||||
}); // Clear bg so we can set it with css
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
# Try to use default template
|
||||
default_template_path = os.path.join(
|
||||
FreeCAD.getResourceDir(),
|
||||
"Mod",
|
||||
"BIM",
|
||||
"Resources",
|
||||
"templates",
|
||||
"webgl_export_template.html",
|
||||
)
|
||||
default_content = try_read_template(
|
||||
default_template_path, "default WebGL template"
|
||||
)
|
||||
if default_content:
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Using default template file '{default_template_path}'.\n"
|
||||
)
|
||||
return default_content
|
||||
|
||||
let renderRequested = false;
|
||||
# No template available - export cannot proceed
|
||||
if FreeCADGui:
|
||||
# In GUI mode, show Qt error dialog
|
||||
message = translate(
|
||||
"BIM",
|
||||
"The default WebGL export template is not available at path:"
|
||||
" {}\n\nPlease check your FreeCAD installation or provide a "
|
||||
"custom template under menu Preferences -> Import-Export -> WebGL.",
|
||||
).format(default_template_path)
|
||||
|
||||
// HemisphereLight gives different colors of light from the top
|
||||
// and bottom simulating reflected light from the 'ground' and
|
||||
// 'sky'
|
||||
scene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 0.4));
|
||||
# Use getMainWindow() as parent following FreeCAD patterns
|
||||
parent = FreeCADGui.getMainWindow()
|
||||
title = translate("BIM", "WebGL Export Template Error")
|
||||
|
||||
const dLight1 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dLight1.position.set(5, -2, 3);
|
||||
scene.add(dLight1);
|
||||
const dLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
dLight2.position.set(-5, 2, 3);
|
||||
scene.add(dLight2);
|
||||
QtWidgets.QMessageBox.critical(parent, title, message)
|
||||
else:
|
||||
# In headless mode, print to console
|
||||
FreeCAD.Console.PrintError(
|
||||
"Default WebGL export template not available at"
|
||||
f"path: {default_template_path}\n"
|
||||
)
|
||||
|
||||
if (data.compressed) {
|
||||
const base = data.base;
|
||||
const baseFloat = data.baseFloat;
|
||||
|
||||
function baseDecode(input) {
|
||||
const baseCt = base.length;
|
||||
const output = [];
|
||||
const len = parseInt(input[0]); // num chars of each element
|
||||
for (let i = 1; i < input.length; i += len) {
|
||||
const str = input.substring(i, i + len).trim();
|
||||
let val = 0;
|
||||
for (let s = 0; s < str.length; s++) {
|
||||
const ind = base.indexOf(str[s]);
|
||||
val += ind * Math.pow(baseCt, s);
|
||||
}
|
||||
output.push(val);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function floatDecode(input) {
|
||||
const baseCt = base.length;
|
||||
const baseFloatCt = baseFloat.length;
|
||||
let numString = '';
|
||||
for (let i = 0; i < input.length; i += 4) {
|
||||
const b90chunk = input.substring(i, i + 4).trim();
|
||||
let quotient = 0;
|
||||
for (let s = 0; s < b90chunk.length; s++) {
|
||||
const ind = base.indexOf(b90chunk[s]);
|
||||
quotient += ind * Math.pow(baseCt, s);
|
||||
}
|
||||
let buffer = '';
|
||||
for (let s = 0; s < 7; s++) {
|
||||
buffer = baseFloat[quotient % baseFloatCt] + buffer;
|
||||
quotient = parseInt(quotient / baseFloatCt);
|
||||
}
|
||||
numString += buffer;
|
||||
}
|
||||
let trailingCommas = 0;
|
||||
for (let s = 1; s < 7; s++) {
|
||||
if (numString[numString.length - s] == baseFloat[0]) {
|
||||
trailingCommas++;
|
||||
}
|
||||
}
|
||||
numString = numString.substring(0, numString.length - trailingCommas);
|
||||
return numString;
|
||||
}
|
||||
|
||||
// Decode from base90 and distribute the floats
|
||||
for (const obj of data.objects) {
|
||||
obj.floats = JSON.parse('[' + floatDecode(obj.floats) + ']');
|
||||
obj.verts = baseDecode(obj.verts).map(x => obj.floats[x]);
|
||||
obj.facets = baseDecode(obj.facets);
|
||||
obj.wires = obj.wires.map(w => baseDecode(w).map(x => obj.floats[x]));
|
||||
obj.facesToFacets = obj.facesToFacets.map(x => baseDecode(x));
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const obj of data.objects) {
|
||||
obj.verts = obj.verts.map(x => parseFloat(x));
|
||||
obj.wires = obj.wires.map(w => w.map(x => parseFloat(x)))
|
||||
obj.facesToFacets = obj.facesToFacets.map(w => w.map(x => parseFloat(x)));
|
||||
}
|
||||
}
|
||||
// Get bounds for global clipping
|
||||
const globalMaxMin = [{min: null, max: null},
|
||||
{min: null, max: null},
|
||||
{min: null, max: null}];
|
||||
for (const obj of data.objects) {
|
||||
for (let v = 0; v < obj.verts.length; v++) {
|
||||
if (globalMaxMin[v % 3] === null
|
||||
|| obj.verts[v] < globalMaxMin[v % 3].min) {
|
||||
globalMaxMin[v % 3].min = obj.verts[v];
|
||||
}
|
||||
if (globalMaxMin[v % 3] === null
|
||||
|| obj.verts[v] > globalMaxMin[v % 3].max) {
|
||||
globalMaxMin[v % 3].max = obj.verts[v];
|
||||
}
|
||||
}
|
||||
}
|
||||
let bigrange = 0;
|
||||
// add a little extra
|
||||
for (const i of globalMaxMin) {
|
||||
const range = i.max - i.min;
|
||||
if (range > bigrange) {
|
||||
bigrange = range;
|
||||
}
|
||||
i.min -= range * 0.01;
|
||||
i.max += range * 0.01;
|
||||
}
|
||||
|
||||
const camCenter = new THREE.Vector3(
|
||||
0.5 * (globalMaxMin[0].max - globalMaxMin[0].min) + globalMaxMin[0].min,
|
||||
0.5 * (globalMaxMin[1].max - globalMaxMin[1].min) + globalMaxMin[1].min,
|
||||
0.5 * (globalMaxMin[2].max - globalMaxMin[2].min) + globalMaxMin[2].min );
|
||||
const viewSize = 1.5 * bigrange; // make the view area a little bigger than the object
|
||||
const aspectRatio = canvas.clientWidth / canvas.clientHeight;
|
||||
const originalAspect = aspectRatio;
|
||||
|
||||
function initCam(camera) {
|
||||
// XXX this needs to treat the perspective and orthographic
|
||||
// cameras differently
|
||||
camera.position.set(
|
||||
data.camera.position_x,
|
||||
data.camera.position_y,
|
||||
data.camera.position_z);
|
||||
camera.lookAt(camCenter);
|
||||
camera.updateMatrixWorld();
|
||||
}
|
||||
|
||||
let cameraType = data.camera.type;
|
||||
const persCamera = new THREE.PerspectiveCamera(
|
||||
50, aspectRatio, 1, 100000);
|
||||
initCam(persCamera);
|
||||
const orthCamera = new THREE.OrthographicCamera(
|
||||
-aspectRatio * viewSize / 2, aspectRatio * viewSize / 2,
|
||||
viewSize / 2, -viewSize / 2, -100000, 100000);
|
||||
initCam(orthCamera);
|
||||
|
||||
function assignMesh(positions, color, opacity, faces) {
|
||||
const baseGeometry = new THREE.BufferGeometry();
|
||||
baseGeometry.setAttribute('position', new THREE.BufferAttribute(
|
||||
positions, 3));
|
||||
|
||||
// EdgeSplitModifier is used to combine verts so that smoothing normals can be generated WITHOUT removing the hard edges of the design
|
||||
// REF: https://threejs.org/examples/?q=edge#webgl_modifier_edgesplit - https://github.com/mrdoob/three.js/pull/20535
|
||||
const edgeSplit = new EdgeSplitModifier();
|
||||
const cutOffAngle = 20;
|
||||
const geometry = edgeSplit.modify(
|
||||
baseGeometry, cutOffAngle * Math.PI / 180);
|
||||
geometry.computeVertexNormals();
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: color,
|
||||
side: THREE.DoubleSide,
|
||||
vertexColors: false,
|
||||
opacity: opacity,
|
||||
transparent: opacity != 1.0,
|
||||
fog: false
|
||||
});
|
||||
|
||||
const meshobj = new THREE.Mesh(geometry, material);
|
||||
meshobj.name = meshobj.uuid;
|
||||
faces.push(meshobj.uuid);
|
||||
scene.add(meshobj);
|
||||
raycasterObj.push(meshobj);
|
||||
}
|
||||
|
||||
const objects = [];
|
||||
for (const obj of data.objects) {
|
||||
// Each face gets its own material because they each can
|
||||
// have different colors
|
||||
const faces = [];
|
||||
if (obj.facesToFacets.length > 0) {
|
||||
for (let f=0; f < obj.facesToFacets.length; f++) {
|
||||
const facecolor = obj.faceColors.length > 0 ? obj.faceColors[f] : obj.color;
|
||||
const positions = new Float32Array(obj.facesToFacets[f].length * 9);
|
||||
for (let a=0; a < obj.facesToFacets[f].length; a++) {
|
||||
for (let b=0; b < 3; b++) {
|
||||
for (let c=0; c < 3; c++) {
|
||||
positions[9 * a + 3 * b + c] = obj.verts[3 * obj.facets[3 * obj.facesToFacets[f][a] + b ] + c ];
|
||||
}
|
||||
}
|
||||
}
|
||||
assignMesh(positions, facecolor, obj.opacity, faces);
|
||||
}
|
||||
} else {
|
||||
// No facesToFacets means that there was a tessellate()
|
||||
// mismatch inside FreeCAD. Use all facets in object to
|
||||
// create this mesh
|
||||
const positions = new Float32Array(obj.facets.length * 3);
|
||||
for (let a=0; a < obj.facets.length; a++) {
|
||||
for (let b=0; b < 3; b++) {
|
||||
positions[3 * a + b] = obj.verts[3 * obj.facets[a] + b];
|
||||
}
|
||||
}
|
||||
assignMesh(positions, obj.color, obj.opacity, faces);
|
||||
}
|
||||
|
||||
// Wires
|
||||
// cannot have lines in WebGL that are wider than 1px due to browser limitations so Line2 workaround lib is used
|
||||
// REF: https://threejs.org/examples/?q=fat#webgl_lines_fat - https://jsfiddle.net/brLk6aud/1/
|
||||
// This material is shared by all wires in this object
|
||||
const wirematerial = new LineMaterial( {
|
||||
color: defaultWireColor,
|
||||
linewidth: defaultWireLineWidth,
|
||||
dashed: false, dashSize: 1, gapSize: 1, dashScale: 3
|
||||
} );
|
||||
wirematerial.resolution.set(
|
||||
canvas.clientWidth * window.devicePixelRatio,
|
||||
canvas.clientHeight * window.devicePixelRatio);
|
||||
|
||||
const wires = [];
|
||||
for (const w of obj.wires) {
|
||||
const wiregeometry = new LineGeometry();
|
||||
wiregeometry.setPositions(w);
|
||||
const wire = new Line2(wiregeometry, wirematerial);
|
||||
wire.computeLineDistances();
|
||||
wire.scale.set(1, 1, 1);
|
||||
wire.name = wire.uuid;
|
||||
scene.add(wire);
|
||||
wires.push(wire.name);
|
||||
}
|
||||
objects.push({
|
||||
data: obj,
|
||||
faces: faces,
|
||||
wires: wires,
|
||||
wirematerial: wirematerial,
|
||||
gui_link: null
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GUI Init ----
|
||||
const gui = new GUI({ width: 300, closeFolders: true });
|
||||
const addFolder = GUI.prototype.addFolder;
|
||||
GUI.prototype.addFolder = function(...args) {
|
||||
return addFolder.call(this, ...args).close();
|
||||
}
|
||||
const guiparams = {
|
||||
wiretype: 'Normal',
|
||||
wirewidth: defaultWireLineWidth,
|
||||
wirecolor: '#' + defaultWireColor.getHexString(),
|
||||
clippingx: 100,
|
||||
clippingy: 100,
|
||||
clippingz: 100,
|
||||
cameraType: cameraType,
|
||||
navright: function() { navChange([1, 0, 0]); },
|
||||
navtop: function() { navChange([0, 0, 1]); },
|
||||
navfront: function() { navChange([0, -1, 0]); }
|
||||
};
|
||||
|
||||
// ---- Wires ----
|
||||
const wiretypes = { Normal: 'Normal', Dashed: 'Dashed', None: 'None' };
|
||||
|
||||
const wireFolder = gui.addFolder('Wire');
|
||||
wireFolder.add(guiparams, 'wiretype', wiretypes).name('Wire Display').onChange(wireChange);
|
||||
wireFolder.add(guiparams, 'wirewidth').min(1).max(5).step(1).name('Wire Width').onChange(wireChange);
|
||||
wireFolder.addColor(guiparams, 'wirecolor').name('Wire Color').onChange(wireChange);
|
||||
|
||||
function wireChange() {
|
||||
for (const obj of objects) {
|
||||
const m = obj.wirematerial;
|
||||
if (m.dashed) {
|
||||
if (guiparams.wiretype != 'Dashed') {
|
||||
m.dashed = false;
|
||||
delete m.defines.USE_DASH;
|
||||
}
|
||||
} else {
|
||||
if (guiparams.wiretype == 'Dashed') {
|
||||
m.dashed = true;
|
||||
// Dashed lines require this as of r122. delete if not dashed
|
||||
m.defines.USE_DASH = ""; // https://discourse.threejs.org/t/dashed-line2-material/10825
|
||||
}
|
||||
}
|
||||
if (guiparams.wiretype == 'None') {
|
||||
m.visible = false;
|
||||
} else {
|
||||
if ((obj.faces.length == 0) | scene.getObjectByName(obj.faces[0]).material.visible){
|
||||
m.visible = true;
|
||||
}
|
||||
}
|
||||
m.linewidth = guiparams.wirewidth;
|
||||
m.color = new THREE.Color(guiparams.wirecolor);
|
||||
m.needsUpdate = true;
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
wireChange();
|
||||
|
||||
// ---- Clipping ----
|
||||
const clippingFolder = gui.addFolder('Clipping');
|
||||
clippingFolder.add(guiparams, 'clippingx').min(0).max(100).step(1).name('X-Axis Clipping').onChange(clippingChange);
|
||||
clippingFolder.add(guiparams, 'clippingy').min(0).max(100).step(1).name('Y-Axis Clipping').onChange(clippingChange);
|
||||
clippingFolder.add(guiparams, 'clippingz').min(0).max(100).step(1).name('Z-Axis Clipping').onChange(clippingChange);
|
||||
|
||||
const clipPlaneX = new THREE.Plane(new THREE.Vector3( -1, 0, 0 ), 0);
|
||||
const clipPlaneY = new THREE.Plane(new THREE.Vector3( 0, -1, 0 ), 0);
|
||||
const clipPlaneZ = new THREE.Plane(new THREE.Vector3( 0, 0, -1 ), 0);
|
||||
|
||||
function clippingChange() {
|
||||
if (guiparams.clippingx < 100 || guiparams.clippingy < 100 || guiparams.clippingz < 100) {
|
||||
if (renderer.clippingPlanes.length == 0) {
|
||||
renderer.clippingPlanes.push(clipPlaneX, clipPlaneY, clipPlaneZ);
|
||||
}
|
||||
}
|
||||
clipPlaneX.constant = (globalMaxMin[0].max - globalMaxMin[0].min) * guiparams.clippingx / 100.0 + globalMaxMin[0].min;
|
||||
clipPlaneY.constant = (globalMaxMin[1].max - globalMaxMin[1].min) * guiparams.clippingy / 100.0 + globalMaxMin[1].min;
|
||||
clipPlaneZ.constant = (globalMaxMin[2].max - globalMaxMin[2].min) * guiparams.clippingz / 100.0 + globalMaxMin[2].min;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
// ---- Camera & Navigation ----
|
||||
const camFolder = gui.addFolder('Camera');
|
||||
const cameraTypes = { Perspective: 'Perspective', Orthographic: 'Orthographic' };
|
||||
camFolder.add(guiparams, 'cameraType', cameraTypes).name('Camera type').onChange(cameraChange);
|
||||
camFolder.add(guiparams, 'navright').name('View Right');
|
||||
camFolder.add(guiparams, 'navtop').name('View Top');
|
||||
camFolder.add(guiparams, 'navfront').name('View Front');
|
||||
|
||||
function navChange(v) {
|
||||
const t = new THREE.Vector3();
|
||||
new THREE.Box3().setFromObject(scene).getSize(t);
|
||||
persControls.object.position.set(
|
||||
v[0] * t.x * 2 + camCenter.x,
|
||||
v[1] * t.y * 2 + camCenter.y,
|
||||
v[2] * t.z * 2 + camCenter.z);
|
||||
persControls.target = camCenter;
|
||||
persControls.update();
|
||||
orthControls.object.position.set(
|
||||
v[0] * t.x + camCenter.x,
|
||||
v[1] * t.y + camCenter.y,
|
||||
v[2] * t.z + camCenter.z);
|
||||
orthControls.target = camCenter;
|
||||
orthControls.update();
|
||||
// controls.update() implicitly calls requestRender()
|
||||
}
|
||||
|
||||
function cameraChange(v) {
|
||||
cameraType = v;
|
||||
requestRender();
|
||||
}
|
||||
|
||||
const guiObjects = gui.addFolder('Objects');
|
||||
for (const obj of objects) {
|
||||
// Ignore objects with no vertices
|
||||
if (obj.data.verts.length > 0) {
|
||||
const guiObjData = {
|
||||
obj: obj, color: obj.data.color, opacity: obj.data.opacity, show: true };
|
||||
const guiObject = guiObjects.addFolder(obj.data.name);
|
||||
guiObject.addColor(guiObjData, 'color').name('Color').onChange(GUIObjectChange);
|
||||
guiObject.add(guiObjData, 'opacity').min(0.0).max(1.0).step(0.05).name('Opacity').onChange(GUIObjectChange);
|
||||
guiObject.add(guiObjData, 'show').onChange(GUIObjectChange).listen();
|
||||
obj.gui_link = guiObjData
|
||||
}
|
||||
}
|
||||
|
||||
function GUIObjectChange(v) {
|
||||
for (const f of this.object.obj.faces) {
|
||||
const m = scene.getObjectByName(f).material;
|
||||
if (this.property == 'color') {
|
||||
m.color.setStyle(v);
|
||||
}
|
||||
if (this.property == 'opacity') {
|
||||
m.opacity = v;
|
||||
m.transparent = (v != 1.0);
|
||||
}
|
||||
if (this.property == 'show') {
|
||||
m.visible = v
|
||||
}
|
||||
}
|
||||
if (this.property == 'opacity') {
|
||||
const m = this.object.obj.wirematerial;
|
||||
m.opacity = v;
|
||||
m.transparent = (v != 1.0);
|
||||
}
|
||||
if (this.property == 'show') {
|
||||
const m = this.object.obj.wirematerial;
|
||||
m.visible = v
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
|
||||
// Make simple orientation arrows and box - REF: http://jsfiddle.net/b97zd1a3/16/
|
||||
const arrowCanvas = document.querySelector('#arrowCanvas');
|
||||
const arrowRenderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
canvas: arrowCanvas
|
||||
}); // clear
|
||||
arrowRenderer.setClearColor(0x000000, 0);
|
||||
arrowRenderer.setSize(arrowCanvas.clientWidth * window.devicePixelRatio,
|
||||
arrowCanvas.clientHeight * window.devicePixelRatio,
|
||||
false);
|
||||
|
||||
const arrowScene = new THREE.Scene();
|
||||
|
||||
const arrowCamera = new THREE.PerspectiveCamera(
|
||||
50, arrowCanvas.clientWidth / arrowCanvas.clientHeight, 1, 500 );
|
||||
arrowCamera.up = persCamera.up; // important!
|
||||
|
||||
const arrowPos = new THREE.Vector3(0, 0, 0);
|
||||
arrowScene.add(new THREE.ArrowHelper(
|
||||
new THREE.Vector3(1, 0, 0), arrowPos, 60, 0x7F2020, 20, 10));
|
||||
arrowScene.add(new THREE.ArrowHelper(
|
||||
new THREE.Vector3(0, 1, 0), arrowPos, 60, 0x207F20, 20, 10));
|
||||
arrowScene.add(new THREE.ArrowHelper(
|
||||
new THREE.Vector3(0, 0, 1), arrowPos, 60, 0x20207F, 20, 10));
|
||||
arrowScene.add(new THREE.Mesh(
|
||||
new THREE.BoxGeometry(40, 40, 40),
|
||||
new THREE.MeshLambertMaterial({ color: 0xaaaaaa })
|
||||
));
|
||||
arrowScene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 1.2));
|
||||
|
||||
// Controls
|
||||
const persControls = new OrbitControls(persCamera, renderer.domElement);
|
||||
persControls.target = camCenter; // rotate around center of parts
|
||||
// persControls.enablePan = false;
|
||||
// persControls.enableDamping = true;
|
||||
persControls.update();
|
||||
const orthControls = new OrbitControls(orthCamera, renderer.domElement);
|
||||
orthControls.target = camCenter; // rotate around center of parts
|
||||
// orthControls.enablePan = false;
|
||||
// orthControls.enableDamping = true;
|
||||
orthControls.update();
|
||||
|
||||
function render() {
|
||||
renderRequested = false;
|
||||
persControls.update();
|
||||
if (cameraType == 'Perspective') {
|
||||
arrowCamera.position.copy(persCamera.position);
|
||||
arrowCamera.position.sub(persControls.target);
|
||||
}
|
||||
orthControls.update();
|
||||
if (cameraType == 'Orthographic') {
|
||||
arrowCamera.position.copy(orthCamera.position);
|
||||
arrowCamera.position.sub(orthControls.target);
|
||||
}
|
||||
arrowCamera.lookAt(arrowScene.position);
|
||||
arrowCamera.position.setLength(200);
|
||||
|
||||
if (cameraType == 'Perspective') {
|
||||
renderer.render(scene, persCamera);
|
||||
}
|
||||
if (cameraType == 'Orthographic') {
|
||||
renderer.render(scene, orthCamera);
|
||||
}
|
||||
arrowRenderer.render(arrowScene, arrowCamera);
|
||||
};
|
||||
|
||||
function requestRender() {
|
||||
if (!renderRequested) {
|
||||
renderRequested = true;
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
}
|
||||
|
||||
persControls.addEventListener('change', requestRender);
|
||||
orthControls.addEventListener('change', requestRender);
|
||||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.addEventListener('dblclick', onMouseDblClick);
|
||||
window.addEventListener('resize', onMainCanvasResize, false);
|
||||
|
||||
onMainCanvasResize();
|
||||
requestRender();
|
||||
|
||||
function onMainCanvasResize() {
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
const width = canvas.clientWidth * pixelRatio | 0;
|
||||
const height = canvas.clientHeight * pixelRatio | 0;
|
||||
const needResize = canvas.width !== width || canvas.height !== height;
|
||||
const aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
if (needResize) {
|
||||
renderer.setSize(width, height, false);
|
||||
|
||||
// See https://stackoverflow.com/questions/39373113/three-js-resize-window-not-scaling-properly
|
||||
const change = originalAspect / aspect;
|
||||
const newSize = viewSize * change;
|
||||
orthCamera.left = -aspect * newSize / 2;
|
||||
orthCamera.right = aspect * newSize / 2;
|
||||
orthCamera.top = newSize / 2;
|
||||
orthCamera.bottom = -newSize / 2;
|
||||
orthCamera.updateProjectionMatrix();
|
||||
|
||||
persCamera.aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
persCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
for (const obj of objects) {
|
||||
obj.wirematerial.resolution.set(width, height);
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
// Use mouse double click to toggle the gui for the selected object
|
||||
function onMouseDblClick(e){
|
||||
let c = false;
|
||||
if (cameraType == 'Orthographic') {
|
||||
c = orthCamera;
|
||||
}
|
||||
if (cameraType == 'Perspective') {
|
||||
c = persCamera;
|
||||
}
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(new THREE.Vector2(
|
||||
(e.clientX / canvas.clientWidth) * 2 - 1,
|
||||
-(e.clientY / canvas.clientHeight) * 2 + 1),
|
||||
c);
|
||||
const intersects = raycaster.intersectObjects(raycasterObj);
|
||||
|
||||
for (const i of intersects) {
|
||||
const m = i.object;
|
||||
if (!m.material.visible){continue};
|
||||
for (const obj of objects) {
|
||||
for (const face_uuid of obj.faces) {
|
||||
if (face_uuid == m.uuid) {
|
||||
obj.gui_link.show = false
|
||||
obj.wirematerial.visible = false
|
||||
for (const face of obj.faces) {
|
||||
scene.getObjectByName(face).material.visible = false
|
||||
}
|
||||
requestRender();
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onMouseMove(e) {
|
||||
let c = false;
|
||||
if (cameraType == 'Orthographic') {
|
||||
c = orthCamera;
|
||||
}
|
||||
if (cameraType == 'Perspective') {
|
||||
c = persCamera;
|
||||
}
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(new THREE.Vector2(
|
||||
(e.clientX / canvas.clientWidth) * 2 - 1,
|
||||
-(e.clientY / canvas.clientHeight) * 2 + 1),
|
||||
c);
|
||||
const intersects = raycaster.intersectObjects(raycasterObj);
|
||||
|
||||
let chosen = '';
|
||||
for (const i of intersects) {
|
||||
const m = i.object.material;
|
||||
if ((m.opacity > 0) & m.visible) {
|
||||
if (m.emissive.getHex() == 0x000000) {
|
||||
m.emissive.setHex( 0x777777 );
|
||||
m.needsUpdate = true;
|
||||
requestRender();
|
||||
}
|
||||
chosen = i.object.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const r of raycasterObj) {
|
||||
if (r.name == chosen) {
|
||||
continue;
|
||||
}
|
||||
if (r.material.emissive.getHex() != 0x000000) {
|
||||
r.material.emissive.setHex(0x000000);
|
||||
r.material.needsUpdate = true;
|
||||
requestRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
return None
|
||||
|
||||
|
||||
def export(
|
||||
exportList, filename: str, colors: dict[str, str] | None = None, camera: str | None = None
|
||||
):
|
||||
"""Exports objects to an html file"""
|
||||
) -> bool:
|
||||
"""Exports objects to an html file.
|
||||
|
||||
Returns:
|
||||
bool: True if export was successful, False if not (particulary,
|
||||
False if no template was available).
|
||||
"""
|
||||
|
||||
# Check template availability first, before any processing
|
||||
html = getHTMLTemplate()
|
||||
if html is None:
|
||||
# No template available - export failed
|
||||
return False
|
||||
|
||||
global disableCompression, base, baseFloat
|
||||
|
||||
@@ -894,7 +371,6 @@ def export(
|
||||
|
||||
data["objects"].append(objdata)
|
||||
|
||||
html = getHTMLTemplate()
|
||||
html = html.replace("$pagetitle", FreeCAD.ActiveDocument.Label)
|
||||
version = FreeCAD.Version()
|
||||
html = html.replace("$version", f"{version[0]}.{version[1]}.{version[2]}")
|
||||
@@ -910,6 +386,7 @@ def export(
|
||||
with pyopen(filename, "w", encoding="utf-8") as outfile:
|
||||
outfile.write(html)
|
||||
FreeCAD.Console.PrintMessage(translate("Arch", "Successfully written") + f" {filename}\n")
|
||||
return True
|
||||
|
||||
|
||||
def get_view_properties(obj, label: str, colors: dict[str, str] | None) -> tuple[str, float]:
|
||||
|
||||
@@ -643,7 +643,8 @@ def _get_param_dictionary():
|
||||
":/ui/preferences-dae.ui",
|
||||
":/ui/preferences-ifc.ui",
|
||||
":/ui/preferences-ifc-export.ui",
|
||||
":/ui/preferences-sh3d-import.ui",):
|
||||
":/ui/preferences-sh3d-import.ui",
|
||||
":/ui/preferences-webgl.ui",):
|
||||
|
||||
# https://stackoverflow.com/questions/14750997/load-txt-file-from-resources-in-python
|
||||
fd = QtCore.QFile(fnm)
|
||||
|
||||
Reference in New Issue
Block a user