/** * Scene methods split from scene.js. */ /** * Get the ceiling Z-position for a room. * * @param {object} group The floor plan group * @param {object} room The room * * @return {number} The ceiling Z position */ beestat.component.scene.prototype.get_ceiling_z_ = function(group, room) { const elevation = room.elevation || group.elevation || 0; const height = room.height || group.height || 96; return -(elevation + height); }; /** * Convert room.points (relative coordinates) to absolute coordinates. * * @param {object} room The room * * @return {Array} Array of absolute coordinate points {x, y} */ beestat.component.scene.prototype.convert_room_to_absolute_polygon_ = function(room) { const absolute = []; room.points.forEach(function(point) { absolute.push({ 'x': room.x + point.x, 'y': room.y + point.y }); }); return absolute; }; /** * Compute which ceiling areas are exposed (not covered by floors above). * * @param {object} floor_plan The floor plan * * @return {Array} Array of {ceiling_z, polygons[]} for roof outline rendering */ beestat.component.scene.prototype.compute_exposed_ceiling_areas_ = function(floor_plan) { const self = this; // Step 1: Group ceilings by Z-level const ceiling_levels = {}; // Key: ceiling_z, Value: array of room polygons floor_plan.data.groups.forEach(function(group) { group.rooms.forEach(function(room) { const elevation = room.elevation || group.elevation || 0; // Skip basements (below ground) if (elevation < 0) { return; } const ceiling_z = self.get_ceiling_z_(group, room); if (!ceiling_levels[ceiling_z]) { ceiling_levels[ceiling_z] = []; } ceiling_levels[ceiling_z].push( self.convert_room_to_absolute_polygon_(room) ); }); }); // Step 2: Sort ceiling levels (ascending Z = highest to lowest) const sorted_levels = Object.keys(ceiling_levels) .map(z => parseFloat(z)) .sort((a, b) => a - b); const exposed_areas = []; // Step 3: For each level, compute exposed area sorted_levels.forEach(function(current_ceiling_z, index) { const current_polygons = ceiling_levels[current_ceiling_z]; // Union all rooms at this level const union_clipper = new ClipperLib.Clipper(); current_polygons.forEach(function(polygon) { union_clipper.AddPath(polygon, ClipperLib.PolyType.ptSubject, true); }); const ceiling_area = new ClipperLib.Paths(); union_clipper.Execute( ClipperLib.ClipType.ctUnion, ceiling_area, ClipperLib.PolyFillType.pftNonZero, ClipperLib.PolyFillType.pftNonZero ); // Compute occlusion from all higher levels const occlusion_clipper = new ClipperLib.Clipper(); let has_occlusion = false; for (let i = 0; i < index; i++) { const above_ceiling_z = sorted_levels[i]; const above_polygons = ceiling_levels[above_ceiling_z]; above_polygons.forEach(function(polygon) { occlusion_clipper.AddPath(polygon, ClipperLib.PolyType.ptSubject, true); has_occlusion = true; }); } let exposed; if (!has_occlusion) { // Top floor - no occlusion, entire ceiling is exposed exposed = ceiling_area; } else { // Compute union of all occlusion polygons const occlusion_area = new ClipperLib.Paths(); occlusion_clipper.Execute( ClipperLib.ClipType.ctUnion, occlusion_area, ClipperLib.PolyFillType.pftNonZero, ClipperLib.PolyFillType.pftNonZero ); // Subtract occlusion from ceiling const diff_clipper = new ClipperLib.Clipper(); ceiling_area.forEach(function(path) { diff_clipper.AddPath(path, ClipperLib.PolyType.ptSubject, true); }); occlusion_area.forEach(function(path) { diff_clipper.AddPath(path, ClipperLib.PolyType.ptClip, true); }); exposed = new ClipperLib.Paths(); diff_clipper.Execute( ClipperLib.ClipType.ctDifference, exposed, ClipperLib.PolyFillType.pftNonZero, ClipperLib.PolyFillType.pftNonZero ); } // Filter out tiny polygons (floating-point artifacts) const filtered = exposed.filter(function(path) { return Math.abs(ClipperLib.Clipper.Area(path)) > 1; }); if (filtered.length > 0) { exposed_areas.push({ 'ceiling_z': current_ceiling_z, 'polygons': filtered }); } }); return exposed_areas; }; /** * Return whether the current scene date is Christmas Day (12/25). * * @return {boolean} */ beestat.component.scene.prototype.is_christmas_day_ = function() { if ( this.date_ === undefined || typeof this.date_.month !== 'function' || typeof this.date_.date !== 'function' ) { return false; } return this.date_.month() === 11 && this.date_.date() === 25; }; /** * Toggle Christmas roofline lights visibility based on active date. */ beestat.component.scene.prototype.update_christmas_lights_visibility_ = function() { if (this.christmas_lights_group_ === undefined) { return; } this.christmas_lights_group_.visible = this.is_christmas_day_(); }; /** * Add cheap Christmas roofline bulbs along resolved roof perimeter edges. * * @param {THREE.Group} roofs_layer * @param {Array<{z: number, polygon: Array<{x:number,y:number}>}>} roofline_paths */ beestat.component.scene.prototype.add_christmas_lights_ = function(roofs_layer, roofline_paths) { if ( roofs_layer === undefined || Array.isArray(roofline_paths) !== true || roofline_paths.length === 0 ) { return; } const spacing = Math.max(8, Number(beestat.component.scene.christmas_light_spacing || 28)); const bulb_radius = Math.max(1, Number(beestat.component.scene.christmas_light_size || 4.2)); const colors = Array.isArray(beestat.component.scene.christmas_light_colors) === true && beestat.component.scene.christmas_light_colors.length > 0 ? beestat.component.scene.christmas_light_colors : [0xff2b2b, 0x34d44e, 0x3f7bff, 0xffd93a]; const bulb_z_offset = 2.5; const positions_by_color = {}; colors.forEach(function(color) { positions_by_color[color] = []; }); let bulb_index = 0; roofline_paths.forEach(function(path) { const polygon = path.polygon; if (Array.isArray(polygon) !== true || polygon.length < 2) { return; } for (let i = 0; i < polygon.length; i++) { const a = polygon[i]; const b = polygon[(i + 1) % polygon.length]; const dx = b.x - a.x; const dy = b.y - a.y; const length = Math.sqrt((dx * dx) + (dy * dy)); const count = Math.max(1, Math.round(length / spacing)); for (let j = 0; j < count; j++) { const t = j / count; const x = a.x + (dx * t); const y = a.y + (dy * t); const z = path.z - bulb_z_offset; const color = colors[bulb_index % colors.length]; positions_by_color[color].push({'x': x, 'y': y, 'z': z}); bulb_index++; } } }); if (bulb_index === 0) { return; } const christmas_group = new THREE.Group(); christmas_group.userData.is_roof_christmas_lights = true; christmas_group.layers.set(beestat.component.scene.layer_visible); const matrix = new THREE.Matrix4(); const bulb_geometry = new THREE.SphereGeometry(bulb_radius, 6, 5); colors.forEach(function(color) { const positions = positions_by_color[color]; if (Array.isArray(positions) !== true || positions.length === 0) { return; } const material = new THREE.MeshBasicMaterial({ 'color': color, 'toneMapped': false }); const bulbs = new THREE.InstancedMesh(bulb_geometry, material, positions.length); bulbs.castShadow = false; bulbs.receiveShadow = false; bulbs.layers.set(beestat.component.scene.layer_visible); for (let i = 0; i < positions.length; i++) { const position = positions[i]; matrix.makeTranslation(position.x, position.y, position.z); bulbs.setMatrixAt(i, matrix); } bulbs.instanceMatrix.needsUpdate = true; christmas_group.add(bulbs); }); roofs_layer.add(christmas_group); this.christmas_lights_group_ = christmas_group; this.update_christmas_lights_visibility_(); }; /** * Add roofs to the scene based on the configured roof style. */ beestat.component.scene.prototype.add_roofs_ = function() { // Resolve configured roof mode and available skeleton runtime. const skeleton_builder = this.get_skeleton_builder_(); const roof_style = this.get_appearance_value_('roof_style'); // Prefer requested roof style; fall back to flat until skeleton runtime is ready. if (roof_style === 'flat') { this.add_flat_roofs_(); } else if (roof_style === 'hip' && skeleton_builder !== undefined) { this.add_hip_roofs_(skeleton_builder); } else { if (roof_style === 'hip') { this.listen_for_skeleton_builder_ready_(); } this.add_flat_roofs_(); } }; /** * Add hip roofs using the straight skeleton algorithm. * * @param {object} skeleton_builder */ beestat.component.scene.prototype.add_hip_roofs_ = function(skeleton_builder) { // Gather exposed ceiling polygons and shared roof style settings. const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_]; const exposed_areas = this.compute_exposed_ceiling_areas_(floor_plan); const roof_color = this.get_appearance_value_('roof_color'); // Create layer for generated roof meshes. const roofs_layer = new THREE.Group(); this.floor_plan_group_.add(roofs_layer); this.layers_['roof'] = roofs_layer; const roof_pitch = beestat.component.scene.roof_pitch; const roofline_paths = []; // Build hip roof geometry per exposed polygon. exposed_areas.forEach(function(area) { area.polygons.forEach(function(polygon) { if (polygon.length < 3) { return; } try { // Normalize polygon topology before offset/skeleton operations. const simplified = ClipperLib.Clipper.SimplifyPolygon( polygon, ClipperLib.PolyFillType.pftNonZero ); simplified.forEach(function(simple_polygon) { if (simple_polygon.length < 3) { return; } // Expand polygon to add overhang around the exposed ceiling footprint. const roof_overhang = beestat.component.scene.roof_overhang; const clipper_offset = new ClipperLib.ClipperOffset(); clipper_offset.AddPath( simple_polygon, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon ); const offset_polygons = new ClipperLib.Paths(); clipper_offset.Execute(offset_polygons, roof_overhang); // Use the offset polygon if successful, otherwise use original const roof_polygon = (offset_polygons.length > 0) ? offset_polygons[0] : simple_polygon; roofline_paths.push({ 'z': area.ceiling_z, 'polygon': roof_polygon }); // Add a thin base skirt so eaves have subtle physical thickness. const base_shape = new THREE.Shape(); base_shape.moveTo(roof_polygon[0].x, roof_polygon[0].y); for (let i = 1; i < roof_polygon.length; i++) { base_shape.lineTo(roof_polygon[i].x, roof_polygon[i].y); } base_shape.closePath(); const hip_roof_base_thickness = 4; const base_geometry = new THREE.ExtrudeGeometry(base_shape, { 'depth': hip_roof_base_thickness, 'bevelEnabled': false }); const base_material = new THREE.MeshStandardMaterial({ 'color': roof_color, 'side': THREE.DoubleSide, 'flatShading': false, 'roughness': 0.85, 'metalness': 0.0 }); const base_mesh = new THREE.Mesh(base_geometry, base_material); // Nudge downward so the top cap doesn't z-fight with hip roof faces. base_mesh.position.z = area.ceiling_z + 0.5; base_mesh.userData.is_roof = true; base_mesh.layers.set(beestat.component.scene.layer_visible); base_mesh.castShadow = true; base_mesh.receiveShadow = true; roofs_layer.add(base_mesh); // Convert polygon into straight-skeleton input format. const ring = roof_polygon.map(function(point) { return [point.x, point.y]; }); ring.push([roof_polygon[0].x, roof_polygon[0].y]); const coordinates = [ring]; const result = skeleton_builder.buildFromPolygon(coordinates); if (!result) { return; } // Boundary vertices stay at ceiling level; interior vertices get raised by pitch. const boundary_vertex_count = roof_polygon.length; const boundary_set = new Set(); for (let i = 0; i < boundary_vertex_count; i++) { boundary_set.add(i); } // Helper: compute shortest distance from a point to roof footprint edges. const compute_distance_to_boundary = function(point_x, point_y) { let min_distance = Infinity; for (let i = 0; i < roof_polygon.length; i++) { const p1 = roof_polygon[i]; const p2 = roof_polygon[(i + 1) % roof_polygon.length]; // Calculate perpendicular distance from point to line segment const dx = p2.x - p1.x; const dy = p2.y - p1.y; const length_sq = dx * dx + dy * dy; if (length_sq === 0) { // Point to point distance const dist = Math.sqrt( Math.pow(point_x - p1.x, 2) + Math.pow(point_y - p1.y, 2) ); min_distance = Math.min(min_distance, dist); continue; } // Project point onto line segment let t = ((point_x - p1.x) * dx + (point_y - p1.y) * dy) / length_sq; t = Math.max(0, Math.min(1, t)); const closest_x = p1.x + t * dx; const closest_y = p1.y + t * dy; const dist = Math.sqrt( Math.pow(point_x - closest_x, 2) + Math.pow(point_y - closest_y, 2) ); min_distance = Math.min(min_distance, dist); } return min_distance; }; // Lift interior skeleton vertices to form sloped hip planes. const vertices_3d = result.vertices.map(function(vertex, index) { const is_boundary = boundary_set.has(index); let height = 0; if (!is_boundary) { // Interior skeleton vertex - raise it based on distance to boundary const distance = compute_distance_to_boundary(vertex[0], vertex[1]); height = distance * roof_pitch; } return new THREE.Vector3( vertex[0], vertex[1], area.ceiling_z - height // Negative Z = higher in world coords ); }); // Triangulate each skeleton face and emit a renderable mesh. result.polygons.forEach(function(face) { if (face.length < 3) { return; } // Create triangulated mesh for this face const face_vertices = face.map(function(idx) { return vertices_3d[idx]; }); // Triangulate the face (simple fan triangulation from first vertex) const triangles = []; for (let i = 1; i < face_vertices.length - 1; i++) { triangles.push( face_vertices[0], face_vertices[i], face_vertices[i + 1] ); } // Create geometry const geometry = new THREE.BufferGeometry().setFromPoints(triangles); geometry.computeVertexNormals(); // Create material - use appearance roof color const material = new THREE.MeshStandardMaterial({ 'color': roof_color, 'side': THREE.DoubleSide, 'flatShading': false, 'roughness': 0.8, 'metalness': 0.0 }); const mesh = new THREE.Mesh(geometry, material); mesh.userData.is_roof = true; mesh.layers.set(beestat.component.scene.layer_visible); mesh.castShadow = true; mesh.receiveShadow = true; roofs_layer.add(mesh); }); }); } catch (error) { console.error('Error generating roof:', error, polygon); } }); }); this.add_christmas_lights_(roofs_layer, roofline_paths); }; /** * Add flat roofs to the scene. */ beestat.component.scene.prototype.add_flat_roofs_ = function() { // Gather exposed ceiling polygons and shared roof style settings. const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_]; const exposed_areas = this.compute_exposed_ceiling_areas_(floor_plan); const roof_color = this.get_appearance_value_('roof_color'); // Create layer for generated roof meshes. const roofs_layer = new THREE.Group(); this.floor_plan_group_.add(roofs_layer); this.layers_['roof'] = roofs_layer; const roofline_paths = []; // Build flat roof geometry per exposed polygon. exposed_areas.forEach(function(area) { area.polygons.forEach(function(polygon) { if (polygon.length < 3) { return; } try { // Normalize polygon topology before offset/extrusion. const simplified = ClipperLib.Clipper.SimplifyPolygon( polygon, ClipperLib.PolyFillType.pftNonZero ); simplified.forEach(function(simple_polygon) { if (simple_polygon.length < 3) { return; } // Expand polygon to add overhang around the exposed ceiling footprint. const roof_overhang = beestat.component.scene.roof_overhang; const clipper_offset = new ClipperLib.ClipperOffset(); clipper_offset.AddPath( simple_polygon, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon ); const offset_polygons = new ClipperLib.Paths(); clipper_offset.Execute(offset_polygons, roof_overhang); // Use the offset polygon if successful, otherwise use original const roof_polygon = (offset_polygons.length > 0) ? offset_polygons[0] : simple_polygon; roofline_paths.push({ 'z': area.ceiling_z, 'polygon': roof_polygon }); // Build the flat roof footprint shape for extrusion. const shape = new THREE.Shape(); shape.moveTo(roof_polygon[0].x, roof_polygon[0].y); for (let i = 1; i < roof_polygon.length; i++) { shape.lineTo(roof_polygon[i].x, roof_polygon[i].y); } shape.closePath(); // Extrude the footprint so the flat roof has physical depth. const flat_roof_depth = 6; // 6 inches of depth const geometry = new THREE.ExtrudeGeometry(shape, { 'depth': flat_roof_depth, 'bevelEnabled': false }); // Create material - use appearance roof color const material = new THREE.MeshStandardMaterial({ 'color': roof_color, 'side': THREE.DoubleSide, 'flatShading': false, 'roughness': 0.9, // Slightly higher roughness for flat roofs 'metalness': 0.0 }); const mesh = new THREE.Mesh(geometry, material); mesh.position.z = area.ceiling_z - flat_roof_depth; // Position so top is at ceiling level mesh.userData.is_roof = true; mesh.layers.set(beestat.component.scene.layer_visible); mesh.castShadow = true; mesh.receiveShadow = true; roofs_layer.add(mesh); }); } catch (error) { console.error('Error generating flat roof:', error, polygon); } }); }); this.add_christmas_lights_(roofs_layer, roofline_paths); }; /** * Get the straight-skeleton runtime when it has finished initializing. * * @return {object|undefined} */ beestat.component.scene.prototype.get_skeleton_builder_ = function() { if (window.SkeletonBuilderInitialized === true) { return window.SkeletonBuilder; } return undefined; }; /** * If the skeleton runtime is still loading, listen for readiness and rerender * once so hip roofs replace fallback flat roofs. */ beestat.component.scene.prototype.listen_for_skeleton_builder_ready_ = function() { const self = this; if (this.skeleton_builder_ready_handler_ !== undefined) { return; } this.skeleton_builder_ready_handler_ = function() { if (self.skeleton_builder_ready_handler_ !== undefined) { window.removeEventListener('skeleton_builder_ready', self.skeleton_builder_ready_handler_); delete self.skeleton_builder_ready_handler_; } if (self.rendered_ === true) { self.rerender(); } }; window.addEventListener('skeleton_builder_ready', this.skeleton_builder_ready_handler_); };