mirror of
https://github.com/beestat/app.git
synced 2026-02-26 13:10:23 -05:00
411 lines
13 KiB
JavaScript
411 lines
13 KiB
JavaScript
/**
|
|
* Scene methods split from scene.js.
|
|
*/
|
|
|
|
|
|
/**
|
|
* Build an opening cutter mesh for CSG subtraction.
|
|
*
|
|
* @param {object} group The floor plan group.
|
|
* @param {object} opening The opening.
|
|
* @return {?THREE.Mesh} Opening cutter mesh or null if opening is not cuttable.
|
|
*/
|
|
beestat.component.scene.prototype.build_opening_cutter_mesh_ = function(group, opening) {
|
|
const opening_line = this.get_opening_line_params_(opening);
|
|
const width = opening_line.width;
|
|
const height = Math.max(1, Number(opening.height || this.get_opening_default_height_(opening.type)));
|
|
const wall_thickness = Number(beestat.component.scene.wall_thickness || 4);
|
|
// Slightly oversize cutter depth so CSG fully clears wall thickness even when
|
|
// a snapped opening is numerically near-coplanar with one wall face.
|
|
const cutter_depth_padding = 4;
|
|
const depth = Math.max(0.5, wall_thickness + cutter_depth_padding);
|
|
const center_z = this.get_opening_center_z_(group, opening, height);
|
|
|
|
if (this.csg_cutter_material_ === undefined) {
|
|
this.csg_cutter_material_ = new THREE.MeshBasicMaterial({
|
|
'visible': false
|
|
});
|
|
}
|
|
|
|
const geometry = new THREE.BoxGeometry(width, depth, height);
|
|
const cutter = new THREE.Mesh(geometry, this.csg_cutter_material_);
|
|
cutter.position.set(
|
|
opening_line.center_x,
|
|
opening_line.center_y,
|
|
center_z
|
|
);
|
|
cutter.rotation.z = opening_line.rotation_radians;
|
|
cutter.updateMatrix();
|
|
cutter.updateMatrixWorld(true);
|
|
|
|
return cutter;
|
|
};
|
|
|
|
|
|
/**
|
|
* Whether an opening type should be treated as glass-family geometry.
|
|
* Window reuses glass geometry and only adds a crossbar.
|
|
*
|
|
* @param {string} type
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
beestat.component.scene.prototype.is_opening_glass_family_ = function(type) {
|
|
return type === 'window' || type === 'glass';
|
|
};
|
|
|
|
|
|
/**
|
|
* Get default opening width by type.
|
|
*
|
|
* @param {string} type
|
|
*
|
|
* @return {number}
|
|
*/
|
|
beestat.component.scene.prototype.get_opening_default_width_ = function(type) {
|
|
return this.is_opening_glass_family_(type) ? 48 : 36;
|
|
};
|
|
|
|
|
|
/**
|
|
* Get default opening height by type.
|
|
*
|
|
* @param {string} type
|
|
*
|
|
* @return {number}
|
|
*/
|
|
beestat.component.scene.prototype.get_opening_default_height_ = function(type) {
|
|
return this.is_opening_glass_family_(type) ? 60 : 78;
|
|
};
|
|
|
|
|
|
/**
|
|
* Get default opening elevation by type.
|
|
*
|
|
* @param {string} type
|
|
*
|
|
* @return {number}
|
|
*/
|
|
beestat.component.scene.prototype.get_opening_default_elevation_ = function(type) {
|
|
return this.is_opening_glass_family_(type) ? 24 : 0;
|
|
};
|
|
|
|
|
|
/**
|
|
* Resolve opening line parameters from endpoint data.
|
|
*
|
|
* @param {object} opening
|
|
*
|
|
* @return {{center_x:number, center_y:number, width:number, rotation_radians:number}}
|
|
*/
|
|
beestat.component.scene.prototype.get_opening_line_params_ = function(opening) {
|
|
const points = (
|
|
opening.points !== undefined &&
|
|
Array.isArray(opening.points) === true &&
|
|
opening.points.length === 2
|
|
)
|
|
? opening.points
|
|
: null;
|
|
|
|
let p1;
|
|
let p2;
|
|
if (points !== null) {
|
|
p1 = points[0];
|
|
p2 = points[1];
|
|
} else {
|
|
const center_x = Number(opening.x || 0);
|
|
const center_y = Number(opening.y || 0);
|
|
const width = Math.max(12, Number(opening.width || this.get_opening_default_width_(opening.type)));
|
|
const half_width = width / 2;
|
|
p1 = {
|
|
'x': center_x - half_width,
|
|
'y': center_y
|
|
};
|
|
p2 = {
|
|
'x': center_x + half_width,
|
|
'y': center_y
|
|
};
|
|
}
|
|
|
|
const dx = Number(p2.x || 0) - Number(p1.x || 0);
|
|
const dy = Number(p2.y || 0) - Number(p1.y || 0);
|
|
return {
|
|
'center_x': (Number(p1.x || 0) + Number(p2.x || 0)) / 2,
|
|
'center_y': (Number(p1.y || 0) + Number(p2.y || 0)) / 2,
|
|
'width': Math.max(12, Math.sqrt((dx * dx) + (dy * dy))),
|
|
'rotation_radians': Math.atan2(dy, dx)
|
|
};
|
|
};
|
|
|
|
|
|
/**
|
|
* Get the opening center Z. Opening elevation is measured from the bottom of
|
|
* the room reference plane, matching existing floor-plan behavior.
|
|
*
|
|
* @param {object} group The floor plan group.
|
|
* @param {object} opening The opening.
|
|
* @param {number} height The opening height.
|
|
*
|
|
* @return {number}
|
|
*/
|
|
beestat.component.scene.prototype.get_opening_center_z_ = function(group, opening, height) {
|
|
const group_elevation = Number(group.elevation || 0);
|
|
const floor_thickness = Number(beestat.component.scene.room_floor_thickness || 0);
|
|
const opening_elevation = Number(
|
|
opening.elevation !== undefined
|
|
? opening.elevation
|
|
: this.get_opening_default_elevation_(opening.type)
|
|
);
|
|
return -group_elevation - floor_thickness - opening_elevation - (height / 2);
|
|
};
|
|
|
|
|
|
/**
|
|
* Subtract opening cutters from wall meshes.
|
|
*
|
|
* @param {THREE.Group} walls_layer The wall mesh layer.
|
|
* @param {object} floor_plan The floor plan data.
|
|
*/
|
|
beestat.component.scene.prototype.apply_opening_cuts_ = function(
|
|
walls_layer,
|
|
floor_plan
|
|
) {
|
|
if (window.CSG === undefined || typeof window.CSG.subtract !== 'function') {
|
|
return;
|
|
}
|
|
|
|
const wall_meshes = walls_layer.children.filter(function(child) {
|
|
return (
|
|
child !== undefined &&
|
|
child.type === 'Mesh' &&
|
|
child.userData !== undefined &&
|
|
child.userData.wall_cuttable === true
|
|
);
|
|
});
|
|
|
|
floor_plan.data.groups.forEach(function(group) {
|
|
const openings = group.openings || [];
|
|
if (openings.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const group_wall_meshes = wall_meshes.filter(function(mesh) {
|
|
return mesh.userData.group_id === group.group_id;
|
|
});
|
|
if (group_wall_meshes.length === 0) {
|
|
return;
|
|
}
|
|
|
|
openings.forEach((opening) => {
|
|
const cutter = this.build_opening_cutter_mesh_(group, opening);
|
|
if (cutter === null) {
|
|
return;
|
|
}
|
|
|
|
const cutter_box = new THREE.Box3().setFromObject(cutter);
|
|
|
|
group_wall_meshes.forEach(function(wall_mesh) {
|
|
const wall_box = new THREE.Box3().setFromObject(wall_mesh);
|
|
if (wall_box.intersectsBox(cutter_box) !== true) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
wall_mesh.updateMatrix();
|
|
wall_mesh.updateMatrixWorld(true);
|
|
|
|
const result_mesh = window.CSG.subtract(wall_mesh, cutter);
|
|
if (
|
|
result_mesh === undefined ||
|
|
result_mesh.geometry === undefined ||
|
|
result_mesh.geometry.attributes === undefined ||
|
|
result_mesh.geometry.attributes.position === undefined ||
|
|
result_mesh.geometry.attributes.position.count === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
result_mesh.geometry.computeBoundingBox();
|
|
result_mesh.geometry.computeBoundingSphere();
|
|
result_mesh.geometry.computeVertexNormals();
|
|
|
|
const old_geometry = wall_mesh.geometry;
|
|
wall_mesh.geometry = result_mesh.geometry;
|
|
wall_mesh.castShadow = true;
|
|
wall_mesh.receiveShadow = true;
|
|
wall_mesh.layers.set(beestat.component.scene.layer_visible);
|
|
wall_mesh.updateMatrix();
|
|
wall_mesh.updateMatrixWorld(true);
|
|
|
|
if (old_geometry !== undefined) {
|
|
old_geometry.dispose();
|
|
}
|
|
} catch (error) {
|
|
// Keep original wall mesh if CSG subtraction fails.
|
|
}
|
|
});
|
|
|
|
cutter.geometry.dispose();
|
|
});
|
|
}, this);
|
|
};
|
|
|
|
|
|
/**
|
|
* Add 3D opening fixtures.
|
|
*
|
|
* @param {THREE.Group} layer The layer to add fixtures to.
|
|
* @param {object} group The floor plan group.
|
|
*/
|
|
beestat.component.scene.prototype.add_opening_fixtures_ = function(layer, group) {
|
|
if (group.openings === undefined || group.openings.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const wall_thickness = Number(beestat.component.scene.wall_thickness || 4);
|
|
const frame_thickness = Math.max(1, wall_thickness * 0.3);
|
|
|
|
if (this.opening_frame_material_ === undefined) {
|
|
this.opening_frame_material_ = new THREE.MeshStandardMaterial({
|
|
'color': 0xf5f7fb,
|
|
'roughness': 0.92,
|
|
'metalness': 0.0
|
|
});
|
|
}
|
|
if (this.window_pane_material_ === undefined) {
|
|
this.window_pane_material_ = new THREE.MeshPhysicalMaterial({
|
|
'color': 0xcfe0ee,
|
|
'transparent': true,
|
|
'opacity': 0.95,
|
|
'roughness': 0.12,
|
|
'metalness': 0.0,
|
|
'reflectivity': 0.35,
|
|
'clearcoat': 0.3,
|
|
'clearcoatRoughness': 0.12,
|
|
'transmission': 0.15
|
|
});
|
|
}
|
|
|
|
group.openings.forEach(function(opening) {
|
|
const is_glass_family = this.is_opening_glass_family_(opening.type);
|
|
if (opening.type !== 'door' && is_glass_family !== true) {
|
|
return;
|
|
}
|
|
|
|
const opening_line = this.get_opening_line_params_(opening);
|
|
const width = opening_line.width;
|
|
const height = Math.max(1, Number(opening.height || this.get_opening_default_height_(opening.type)));
|
|
const center_z = this.get_opening_center_z_(group, opening, height);
|
|
|
|
const fixture_group = new THREE.Group();
|
|
fixture_group.position.set(
|
|
opening_line.center_x,
|
|
opening_line.center_y,
|
|
center_z
|
|
);
|
|
fixture_group.rotation.z = opening_line.rotation_radians;
|
|
|
|
const side_geometry = new THREE.BoxGeometry(frame_thickness, wall_thickness, height);
|
|
const left_side = new THREE.Mesh(side_geometry, this.opening_frame_material_);
|
|
left_side.position.x = -(width / 2) + (frame_thickness / 2);
|
|
left_side.castShadow = true;
|
|
left_side.receiveShadow = true;
|
|
left_side.userData.is_opening = true;
|
|
fixture_group.add(left_side);
|
|
|
|
const right_side = new THREE.Mesh(side_geometry, this.opening_frame_material_);
|
|
right_side.position.x = (width / 2) - (frame_thickness / 2);
|
|
right_side.castShadow = true;
|
|
right_side.receiveShadow = true;
|
|
right_side.userData.is_opening = true;
|
|
fixture_group.add(right_side);
|
|
|
|
const top_geometry = new THREE.BoxGeometry(width, wall_thickness, frame_thickness);
|
|
const top_frame = new THREE.Mesh(top_geometry, this.opening_frame_material_);
|
|
top_frame.position.z = (height / 2) - (frame_thickness / 2);
|
|
top_frame.castShadow = true;
|
|
top_frame.receiveShadow = true;
|
|
top_frame.userData.is_opening = true;
|
|
fixture_group.add(top_frame);
|
|
|
|
if (is_glass_family === true) {
|
|
const bottom_frame = new THREE.Mesh(top_geometry, this.opening_frame_material_);
|
|
bottom_frame.position.z = -(height / 2) + (frame_thickness / 2);
|
|
bottom_frame.castShadow = true;
|
|
bottom_frame.receiveShadow = true;
|
|
bottom_frame.userData.is_opening = true;
|
|
fixture_group.add(bottom_frame);
|
|
|
|
// Keep pane nearly flush to the inner frame; only leave a tiny inset to
|
|
// avoid edge z-fighting/flicker.
|
|
const pane_inset = 0.05;
|
|
const pane_width = Math.max(6, width - (frame_thickness * 2) - pane_inset);
|
|
const pane_height = Math.max(6, height - (frame_thickness * 2) - pane_inset);
|
|
const pane_depth = Math.max(0.25, wall_thickness * 0.18);
|
|
const pane = new THREE.Mesh(
|
|
new THREE.BoxGeometry(pane_width, pane_depth, pane_height),
|
|
this.window_pane_material_
|
|
);
|
|
pane.castShadow = false;
|
|
pane.receiveShadow = false;
|
|
pane.userData.is_opening = true;
|
|
fixture_group.add(pane);
|
|
|
|
if (opening.type === 'window') {
|
|
const divider_thickness = Math.max(1.4, frame_thickness * 0.7);
|
|
const divider_overhang = Math.max(0.35, wall_thickness * 0.12);
|
|
const divider_depth = wall_thickness + (divider_overhang * 2);
|
|
const divider = new THREE.Mesh(
|
|
new THREE.BoxGeometry(
|
|
pane_width,
|
|
divider_depth,
|
|
divider_thickness
|
|
),
|
|
this.opening_frame_material_
|
|
);
|
|
divider.position.z = 0;
|
|
// Keep centered so the mullion protrudes equally from front/back faces.
|
|
divider.position.y = 0;
|
|
divider.castShadow = true;
|
|
divider.receiveShadow = true;
|
|
divider.userData.is_opening = true;
|
|
fixture_group.add(divider);
|
|
}
|
|
} else if (opening.type === 'door') {
|
|
const bottom_frame = new THREE.Mesh(top_geometry, this.opening_frame_material_);
|
|
bottom_frame.position.z = -(height / 2) + (frame_thickness / 2);
|
|
bottom_frame.castShadow = true;
|
|
bottom_frame.receiveShadow = true;
|
|
bottom_frame.userData.is_opening = true;
|
|
fixture_group.add(bottom_frame);
|
|
|
|
const door_clearance = 0.25;
|
|
const door_width = Math.max(10, width - (frame_thickness * 2) - door_clearance);
|
|
const door_height = Math.max(12, height - (frame_thickness * 2) - door_clearance);
|
|
const door_depth = Math.max(0.6, wall_thickness * 0.35);
|
|
const raw_door_color = String(opening.color || '#7a573b').toLowerCase();
|
|
const door_color = ['#2d2d2d', '#3f3f3f'].includes(raw_door_color)
|
|
? '#4a4a4a'
|
|
: raw_door_color;
|
|
const door_material = new THREE.MeshStandardMaterial({
|
|
'color': door_color,
|
|
'roughness': 0.72,
|
|
'metalness': 0.0
|
|
});
|
|
const door = new THREE.Mesh(
|
|
new THREE.BoxGeometry(door_width, door_depth, door_height),
|
|
door_material
|
|
);
|
|
door.position.z = 0;
|
|
door.castShadow = true;
|
|
door.receiveShadow = true;
|
|
door.userData.is_opening = true;
|
|
fixture_group.add(door);
|
|
|
|
}
|
|
|
|
layer.add(fixture_group);
|
|
}, this);
|
|
};
|
|
|