lib/osut/utils.rb in osut-0.4.0 vs lib/osut/utils.rb in osut-0.5.0

- old
+ new

@@ -1,8 +1,8 @@ # BSD 3-Clause License # -# Copyright (c) 2022-2023, Denis Bourgeois +# Copyright (c) 2022-2024, Denis Bourgeois # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # @@ -51,88 +51,87 @@ :top, # e.g. roof/ceiling :north, # NORTH :east, # EAST :south, # SOUTH :west # WEST - ].freeze + ].freeze # This first set of utilities support OpenStudio materials, constructions, # construction sets, etc. If relying on default StandardOpaqueMaterial: # - roughness (rgh) : "Smooth" # - thickness : 0.1 m # - thermal conductivity (k ) : 0.1 W/m.K # - density (rho) : 0.1 kg/m3 - # - specific heat (cp ) : 1400.0 J/kg.K + # - specific heat (cp ) : 1400.0 J/kg•K # # https://s3.amazonaws.com/openstudio-sdk-documentation/cpp/ # OpenStudio-3.6.1-doc/model/html/ # classopenstudio_1_1model_1_1_standard_opaque_material.html # # ... apart from surface roughness, rarely would these material properties be - # suitable - and are therefore explicitely set below. On roughness: + # suitable - and are therefore explicitly set below. On roughness: # - "Very Rough" : stucco # - "Rough" : brick # - "Medium Rough" : concrete # - "Medium Smooth" : clear pine # - "Smooth" : smooth plaster # - "Very Smooth" : glass - # thermal mass categories (e.g. exterior cladding, interior finish, framing) + # Thermal mass categories (e.g. exterior cladding, interior finish, framing). @@mass = [ :none, # token for 'no user selection', resort to defaults :light, # e.g. 16mm drywall interior :medium, # e.g. 100mm brick cladding :heavy # e.g. 200mm poured concrete ].freeze - # basic materials (StandardOpaqueMaterials only) + # Basic materials (StandardOpaqueMaterials only). @@mats = { + material: {}, # generic, e.g. lightweight cladding over furring, fibreboard sand: {}, concrete: {}, brick: {}, - cladding: {}, # e.g. lightweight cladding over furring - sheathing: {}, # e.g. plywood + drywall: {}, # e.g. finished drywall, intermediate sheating + mineral: {}, # e.g. light, semi-rigid rock wool insulation polyiso: {}, # e.g. polyisocyanurate panel (or similar) - cellulose: {}, # e.g. blown, dry/stabilized fiber - mineral: {}, # e.g. semi-rigid rock wool insulation - drywall: {}, + cellulose: {}, # e.g. blown, dry/stabilized fibre door: {} # single composite material (45mm insulated steel door) }.freeze - # default inside+outside air film resistances (m2.K/W) + # default inside + outside air film resistances (m2.K/W) @@film = { shading: 0.000, # NA - partition: 0.000, - wall: 0.150, - roof: 0.140, - floor: 0.190, - basement: 0.120, - slab: 0.160, - door: 0.150, - window: 0.150, # ignored if SimpleGlazingMaterial - skylight: 0.140 # ignored if SimpleGlazingMaterial + partition: 0.150, # uninsulated wood- or steel-framed wall + wall: 0.150, # un/insulated wall + roof: 0.140, # un/insulated roof + floor: 0.190, # un/insulated (exposed) floor + basement: 0.120, # un/insulated basement wall + slab: 0.160, # un/insulated basement slab or slab-on-grade + door: 0.150, # standard, 45mm insulated steel (opaque) door + window: 0.150, # vertical fenestration, e.g. glazed doors, windows + skylight: 0.140 # e.g. domed 4' x 4' skylight }.freeze # default (~1980s) envelope Uo (W/m2•K), based on surface type @@uo = { - shading: 0.000, # N/A - partition: 0.000, # N/A - wall: 0.384, # rated Ro ~14.8 hr•ft2F/Btu - roof: 0.327, # rated Ro ~17.6 hr•ft2F/Btu - floor: 0.317, # rated Ro ~17.9 hr•ft2F/Btu (exposed floor) - basement: 0.000, # uninsulated - slab: 0.000, # uninsulated + shading: nil, # N/A + partition: nil, # N/A + wall: 0.384, # rated R14.8 hr•ft2F/Btu + roof: 0.327, # rated R17.6 hr•ft2F/Btu + floor: 0.317, # rated R17.9 hr•ft2F/Btu (exposed floor) + basement: nil, + slab: nil, door: 1.800, # insulated, unglazed steel door (single layer) window: 2.800, # e.g. patio doors (simple glazing) skylight: 3.500 # all skylight technologies }.freeze # Standard opaque materials, taken from a variety of sources (e.g. energy # codes, NREL's BCL). Material identifiers are symbols, e.g.: - # - :brick # - :sand # - :concrete + # - :brick # # Material properties remain largely constant between projects. What does # tend to vary (between projects) are thicknesses. Actual OpenStudio opaque # material objects can be (re)set in more than one way by class methods. # In genConstruction, OpenStudio object identifiers are later suffixed with @@ -144,11 +143,16 @@ # Surface absorptances are also defaulted in OpenStudio: # - thermal, long-wave (thm) : 90% # - solar (sol) : 70% # - visible (vis) : 70% # - # These can also be explicitly set, here (e.g. a redundant 'sand' example): + # These can also be explicitly set (see :sand). + @@mats[:material ][:rgh] = "MediumSmooth" + @@mats[:material ][:k ] = 0.115 + @@mats[:material ][:rho] = 540.000 + @@mats[:material ][:cp ] = 1200.000 + @@mats[:sand ][:rgh] = "Rough" @@mats[:sand ][:k ] = 1.290 @@mats[:sand ][:rho] = 2240.000 @@mats[:sand ][:cp ] = 830.000 @@mats[:sand ][:thm] = 0.900 @@ -163,49 +167,40 @@ @@mats[:brick ][:rgh] = "Rough" @@mats[:brick ][:k ] = 0.675 @@mats[:brick ][:rho] = 1600.000 @@mats[:brick ][:cp ] = 790.000 - @@mats[:cladding ][:rgh] = "MediumSmooth" - @@mats[:cladding ][:k ] = 0.115 - @@mats[:cladding ][:rho] = 540.000 - @@mats[:cladding ][:cp ] = 1200.000 + @@mats[:drywall ][:k ] = 0.160 + @@mats[:drywall ][:rho] = 785.000 + @@mats[:drywall ][:cp ] = 1090.000 - @@mats[:sheathing][:k ] = 0.160 - @@mats[:sheathing][:rho] = 545.000 - @@mats[:sheathing][:cp ] = 1210.000 + @@mats[:mineral ][:k ] = 0.050 + @@mats[:mineral ][:rho] = 19.000 + @@mats[:mineral ][:cp ] = 960.000 @@mats[:polyiso ][:k ] = 0.025 @@mats[:polyiso ][:rho] = 25.000 @@mats[:polyiso ][:cp ] = 1590.000 @@mats[:cellulose][:rgh] = "VeryRough" @@mats[:cellulose][:k ] = 0.050 @@mats[:cellulose][:rho] = 80.000 @@mats[:cellulose][:cp ] = 835.000 - @@mats[:mineral ][:k ] = 0.050 - @@mats[:mineral ][:rho] = 19.000 - @@mats[:mineral ][:cp ] = 960.000 - - @@mats[:drywall ][:k ] = 0.160 - @@mats[:drywall ][:rho] = 785.000 - @@mats[:drywall ][:cp ] = 1090.000 - @@mats[:door ][:rgh] = "MediumSmooth" @@mats[:door ][:k ] = 0.080 @@mats[:door ][:rho] = 600.000 @@mats[:door ][:cp ] = 1000.000 ## - # Generates an OpenStudio multilayered construction; materials if needed. + # Generates an OpenStudio multilayered construction, + materials if needed. # # @param model [OpenStudio::Model::Model] a model # @param [Hash] specs OpenStudio construction specifications # @option specs [#to_s] :id ("") construction identifier # @option specs [Symbol] :type (:wall), see @@uo - # @option specs [Numeric] :uo clear-field Uo, in W/m2.K, see @@uo + # @option specs [Numeric] :uo assembly clear-field Uo, in W/m2•K, see @@uo # @option specs [Symbol] :clad (:light) exterior cladding, see @@mass # @option specs [Symbol] :frame (:light) assembly framing, see @@mass # @option specs [Symbol] :finish (:light) interior finishing, see @@mass # # @return [OpenStudio::Model::Construction] generated construction @@ -215,23 +210,27 @@ cl1 = OpenStudio::Model::Model cl2 = Hash return mismatch("model", model, cl1, mth) unless model.is_a?(cl1) return mismatch("specs", specs, cl2, mth) unless specs.is_a?(cl2) - specs[:id ] = "" unless specs.key?(:id ) + specs[:id] = "" unless specs.key?(:id) + id = trim(specs[:id]) + id = "OSut|CON|#{specs[:type]}" if id.empty? + specs[:type] = :wall unless specs.key?(:type) chk = @@uo.keys.include?(specs[:type]) return invalid("surface type", mth, 2, ERR) unless chk - id = trim(specs[:id]) - id = "OSut|CON|#{specs[:type]}" if id.empty? specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) u = specs[:uo] - return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric) - return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678 - return negative("#{id} Uo" , mth, ERR) if u < 0 + if u + return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric) + return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678 + return negative("#{id} Uo" , mth, ERR) if u < 0 + end + # Optional specs. Log/reset if invalid. specs[:clad ] = :light unless specs.key?(:clad ) # exterior specs[:frame ] = :light unless specs.key?(:frame ) specs[:finish] = :light unless specs.key?(:finish) # interior log(WRN, "Reset to light cladding") unless @@mass.include?(specs[:clad ]) @@ -250,56 +249,71 @@ # - interior finish a = {clad: {}, sheath: {}, compo: {}, finish: {}, glazing: {}} case specs[:type] when :shading - mt = :sheathing + mt = :material d = 0.015 a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" when :partition - d = 0.015 - mt = :drywall - a[:clad][:mat] = @@mats[mt] - a[:clad][:d ] = d - a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + unless specs[:clad] == :none + d = 0.015 + mt = :drywall + a[:clad][:mat] = @@mats[mt] + a[:clad][:d ] = d + a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end - mt = :sheathing + d = 0.015 + d = 0.100 if specs[:frame] == :medium + d = 0.200 if specs[:frame] == :heavy + d = 0.100 if u + mt = :concrete + mt = :material if specs[:frame] == :light + mt = :mineral if u a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" - mt = :drywall - a[:finish][:mat] = @@mats[mt] - a[:finish][:d ] = d - a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + unless specs[:finish] == :none + d = 0.015 + mt = :drywall + a[:finish][:mat] = @@mats[mt] + a[:finish][:d ] = d + a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" + end when :wall unless specs[:clad] == :none - mt = :cladding + mt = :material mt = :brick if specs[:clad] == :medium mt = :concrete if specs[:clad] == :heavy d = 0.100 d = 0.015 if specs[:clad] == :light a[:clad][:mat] = @@mats[mt] a[:clad][:d ] = d a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end mt = :drywall - mt = :polyiso if specs[:frame] == :medium - mt = :mineral if specs[:frame] == :heavy + mt = :mineral if specs[:frame] == :medium + mt = :polyiso if specs[:frame] == :heavy d = 0.100 - d = 0.015 if specs[:frame] == :light + d = 0.015 if specs[:frame] == :light a[:sheath][:mat] = @@mats[mt] a[:sheath][:d ] = d a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" - mt = :concrete - mt = :mineral if specs[:frame] == :light + mt = :mineral + mt = :cellulose if specs[:frame] == :medium + mt = :concrete if specs[:frame] == :heavy + mt = :material unless u d = 0.100 d = 0.200 if specs[:frame] == :heavy + d = 0.015 unless u + a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none @@ -313,29 +327,25 @@ a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end when :roof unless specs[:clad] == :none mt = :concrete - mt = :cladding if specs[:clad] == :light + mt = :material if specs[:clad] == :light d = 0.015 d = 0.100 if specs[:clad] == :medium # e.g. terrace d = 0.200 if specs[:clad] == :heavy # e.g. parking garage a[:clad][:mat] = @@mats[mt] a[:clad][:d ] = d a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" - - mt = :sheathing - d = 0.015 - a[:sheath][:mat] = @@mats[mt] - a[:sheath][:d ] = d - a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end - mt = :cellulose + mt = :mineral mt = :polyiso if specs[:frame] == :medium - mt = :mineral if specs[:frame] == :heavy + mt = :cellulose if specs[:frame] == :heavy + mt = :material unless u d = 0.100 + d = 0.015 unless u a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none @@ -346,44 +356,40 @@ d = 0.200 if specs[:finish] == :heavy a[:finish][:mat] = @@mats[mt] a[:finish][:d ] = d a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end - when :floor # exposed + when :floor unless specs[:clad] == :none - mt = :cladding + mt = :material d = 0.015 a[:clad][:mat] = @@mats[mt] a[:clad][:d ] = d a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" - - mt = :sheathing - d = 0.015 - a[:sheath][:mat] = @@mats[mt] - a[:sheath][:d ] = d - a[:sheath][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end - mt = :cellulose + mt = :mineral mt = :polyiso if specs[:frame] == :medium - mt = :mineral if specs[:frame] == :heavy - d = 0.100 # possibly an insulating layer to reset + mt = :cellulose if specs[:frame] == :heavy + mt = :material unless u + d = 0.100 + d = 0.015 unless u a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none mt = :concrete - mt = :sheathing if specs[:finish] == :light + mt = :material if specs[:finish] == :light d = 0.015 d = 0.100 if specs[:finish] == :medium d = 0.200 if specs[:finish] == :heavy a[:finish][:mat] = @@mats[mt] a[:finish][:d ] = d a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end - when :slab # basement slab or slab-on-grade + when :slab mt = :sand d = 0.100 a[:clad][:mat] = @@mats[mt] a[:clad][:d ] = d a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" @@ -402,22 +408,22 @@ a[:compo][:mat] = @@mats[mt] a[:compo][:d ] = d a[:compo][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" unless specs[:finish] == :none - mt = :sheathing + mt = :material d = 0.015 a[:finish][:mat] = @@mats[mt] a[:finish][:d ] = d a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end - when :basement # wall + when :basement unless specs[:clad] == :none mt = :concrete - mt = :sheathing if specs[:clad] == :light + mt = :material if specs[:clad] == :light d = 0.100 - d = 0.015 if specs[:clad] == :light + d = 0.015 if specs[:clad] == :light a[:clad][:mat] = @@mats[mt] a[:clad][:d ] = d a[:clad][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" mt = :polyiso @@ -450,28 +456,25 @@ a[:finish][:mat] = @@mats[mt] a[:finish][:d ] = d a[:finish][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" end end - when :door # opaque - # 45mm insulated (composite) steel door. + when :door mt = :door d = 0.045 a[:compo ][:mat ] = @@mats[mt] a[:compo ][:d ] = d a[:compo ][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}" - when :window # e.g. patio doors (simple glazing) - # SimpleGlazingMaterial. + when :window a[:glazing][:u ] = specs[:uo ] a[:glazing][:shgc] = 0.450 a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc) a[:glazing][:id ] = "OSut|window" a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}" a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}" when :skylight - # SimpleGlazingMaterial. a[:glazing][:u ] = specs[:uo ] a[:glazing][:shgc] = 0.450 a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc) a[:glazing][:id ] = "OSut|skylight" a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}" @@ -527,11 +530,11 @@ c.setName(id) # Adjust insulating layer thickness or conductivity to match requested Uo. unless glazed ro = 0 - ro = 1 / specs[:uo] - @@film[ specs[:type] ] if specs[:uo] > 0 + ro = 1 / specs[:uo] - @@film[ specs[:type] ] if specs[:uo] if specs[:type] == :door # 1x layer, adjust conductivity layer = c.getLayer(0).to_StandardOpaqueMaterial return invalid("#{id} standard material?", mth, 0) if layer.empty? @@ -1097,10 +1100,11 @@ return invalid("surface", mth, 1, DBG, false) unless s.respond_to?(NS) id = s.nameString m1 = "#{id}:spandrel" m2 = "#{id}:spandrel:boolean" + return mismatch(id, s, cl, mth) unless s.is_a?(cl) if s.additionalProperties.hasFeature("spandrel") val = s.additionalProperties.getFeatureAsBoolean("spandrel") return invalid(m1, mth, 1, ERR, false) if val.empty? @@ -1110,10 +1114,40 @@ end id.downcase.include?("spandrel") end + ## + # Validates whether a sub surface is fenestrated. + # + # @param s [OpenStudio::Model::SubSurface] a sub surface + # + # @return [Bool] whether subsurface can be considered 'fenestrated' + # @return [false] if invalid input (see logs) + def fenestration?(s = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::SubSurface + return invalid("subsurface", mth, 1, DBG, false) unless s.respond_to?(NS) + + id = s.nameString + return mismatch(id, s, cl, mth, false) unless s.is_a?(cl) + + # OpenStudio::Model::SubSurface.validSubSurfaceTypeValues + # "FixedWindow" : fenestration + # "OperableWindow" : fenestration + # "Door" + # "GlassDoor" : fenestration + # "OverheadDoor" + # "Skylight" : fenestration + # "TubularDaylightDome" : fenestration + # "TubularDaylightDiffuser" : fenestration + return false if s.subSurfaceType.downcase == "door" + return false if s.subSurfaceType.downcase == "overheaddoor" + + true + end + # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- # # This next set of utilities (~850 lines) help distinguish spaces that are # directly vs indirectly CONDITIONED, vs SEMIHEATED. The solution here # relies as much as possible on space conditioning categories found in @@ -1879,18 +1913,18 @@ # UNCONDITIONED or UNENCLOSED attic or crawlspace - good. Yet "isPlenum" # would also ignore dead air spaces integrating ducted return air. The # SDK's "partofTotalFloorArea" would be more suitable in such cases, as # long as modellers have, a priori, set this parameter to FALSE. # - # OpenStudio-Standards' "space_plenum?" catches a MUCH WIDER range of - # spaces, which aren't caught by "isPlenum". This includes attics, - # crawlspaces, non-plenum air spaces above ceiling tiles, and any other - # UNOCCUPIED space in a model. The term "plenum" in this context is more - # of a catch-all shorthand - to be used with caution. For instance, - # "space_plenum?" shouldn't be used (in isolation) to determine whether an - # UNOCCUPIED space should have its envelope insulated ("plenum") or not - # ("attic"). + # By initially relying on the SDK's "partofTotalFloorArea", "space_plenum?" + # ends up catching a MUCH WIDER range of spaces, which aren't caught by + # "isPlenum". This includes attics, crawlspaces, non-plenum air spaces above + # ceiling tiles, and any other UNOCCUPIED space in a model. The term + # "plenum" in this context is more of a catch-all shorthand - to be used + # with caution. For instance, "space_plenum?" shouldn't be used (in + # isolation) to determine whether an UNOCCUPIED space should have its + # envelope insulated ("plenum") or not ("attic"). # # In contrast to OpenStudio-Standards' "space_plenum?", this method # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It # also returns FALSE if the space is a vestibule. Otherwise, it needs more # information to determine if such an UNOCCUPIED space is indeed a @@ -1913,14 +1947,10 @@ cl = OpenStudio::Model::Space return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl) return false if space.partofTotalFloorArea return false if vestibule?(space) - id = space.nameString - m1 = "#{id}:plenum" - m1 = "#{id}:plenum boolean" - # CASE A: "plenum" spaceType. unless space.spaceType.empty? type = space.spaceType.get return true if type.nameString.downcase.include?("plenum") @@ -2257,91 +2287,32 @@ res end ## - # Returns true if 2 OpenStudio 3D points are nearly equal + # Returns the site/true outward normal vector of a surface. # - # @param p1 [OpenStudio::Point3d] 1st 3D point - # @param p2 [OpenStudio::Point3d] 2nd 3D point + # @param s [OpenStudio::Model::PlanarSurface] a surface + # @param r [#to_f] a group/site rotation angle [0,2PI) radians # - # @return [Bool] whether equal points (within TOL) - # @return [false] if invalid input (see logs) - def same?(p1 = nil, p2 = nil) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Point3d - return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) - return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) + # @return [OpenStudio::Vector3d] true normal vector + # @return [nil] if invalid input (see logs) + def trueNormal(s = nil, r = 0) + mth = "TBD::#{__callee__}" + cl = OpenStudio::Model::PlanarSurface + return mismatch("surface", s, cl, mth) unless s.is_a?(cl) + return invalid("rotation angle", mth, 2) unless r.respond_to?(:to_f) - # OpenStudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards. - (p1.x-p2.x).abs < TOL && (p1.y-p2.y).abs < TOL && (p1.z-p2.z).abs < TOL - end + r = -r.to_f * Math::PI / 180.0 + vx = s.outwardNormal.x * Math.cos(r) - s.outwardNormal.y * Math.sin(r) + vy = s.outwardNormal.x * Math.sin(r) + s.outwardNormal.y * Math.cos(r) + vz = s.outwardNormal.z - ## - # Returns true if a line segment is along the X-axis. - # - # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment - # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment - # @param strict [Bool] whether segment shouldn't hold Y- or Z-axis components - # - # @return [Bool] whether along the X-axis - # @return [false] if invalid input (see logs) - def xx?(p1 = nil, p2 = nil, strict = true) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Point3d - strict = true unless [true, false].include?(strict) - return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) - return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) - return false if (p1.y - p2.y).abs > TOL && strict - return false if (p1.z - p2.z).abs > TOL && strict - - (p1.x - p2.x).abs > TOL + OpenStudio::Point3d.new(vx, vy, vz) - OpenStudio::Point3d.new(0, 0, 0) end ## - # Returns true if a line segment is along the Y-axis. - # - # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment - # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment - # @param strict [Bool] whether segment shouldn't hold X- or Z-axis components - # - # @return [Bool] whether along the Y-axis - # @return [false] if invalid input (see logs) - def yy?(p1 = nil, p2 = nil, strict = true) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Point3d - strict = true unless [true, false].include?(strict) - return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) - return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) - return false if (p1.x - p2.x).abs > TOL && strict - return false if (p1.z - p2.z).abs > TOL && strict - - (p1.y - p2.y).abs > TOL - end - - ## - # Returns true if a line segment is along the Z-axis. - # - # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment - # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment - # @param strict [Bool] whether segment shouldn't hold X- or Y-axis components - # - # @return [Bool] whether along the Z-axis - # @return [false] if invalid input (see logs) - def zz?(p1 = nil, p2 = nil, strict = true) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Point3d - strict = true unless [true, false].include?(strict) - return mismatch("point 1", p1, cl, mth, DBG, false) unless p1.is_a?(cl) - return mismatch("point 2", p2, cl, mth, DBG, false) unless p2.is_a?(cl) - return false if (p1.x - p2.x).abs > TOL && strict - return false if (p1.y - p2.y).abs > TOL && strict - - (p1.z - p2.z).abs > TOL - end - - ## # Returns a scalar product of an OpenStudio Vector3d. # # @param v [OpenStudio::Vector3d] a vector # @param m [#to_f] a scalar # @@ -2364,29 +2335,93 @@ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points # # @return [OpenStudio::Point3dVector] 3D vector (see logs if empty) def to_p3Dv(pts = nil) mth = "OSut::#{__callee__}" - cl1 = Array + cl1 = OpenStudio::Point3d cl2 = OpenStudio::Point3dVector cl3 = OpenStudio::Model::PlanarSurface - cl4 = OpenStudio::Point3d + cl4 = Array v = OpenStudio::Point3dVector.new - return pts if pts.is_a?(cl2) - return pts.vertices if pts.is_a?(cl3) - return mismatch("points", pts, cl1, mth, DBG, v) unless pts.is_a?(cl1) + if pts.is_a?(cl1) + v << pts + return v + end + + return pts if pts.is_a?(cl2) + return pts.vertices if pts.is_a?(cl3) + + return mismatch("points", pts, cl1, mth, DBG, v) unless pts.is_a?(cl4) + pts.each do |pt| - return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl4) + return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl1) end pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) } v end ## + # Returns true if 2 sets of OpenStudio 3D points are nearly equal. + # + # @param s1 [Set<OpenStudio::Point3d>] 1st set of 3D point(s) + # @param s2 [Set<OpenStudio::Point3d>] 2nd set of 3D point(s) + # @param indexed [Bool] whether to attempt to harmonize vertex sequence + # + # @return [Bool] whether sets are nearly equal (within TOL) + # @return [false] if invalid input (see logs) + def same?(s1 = nil, s2 = nil, indexed = true) + mth = "OSut::#{__callee__}" + s1 = to_p3Dv(s1).to_a + s2 = to_p3Dv(s2).to_a + return false if s1.empty? + return false if s2.empty? + return false unless s1.size == s2.size + + indexed = true unless [true, false].include?(indexed) + + if indexed + xOK = (s1[0].x - s2[0].x).abs < TOL + yOK = (s1[0].y - s2[0].y).abs < TOL + zOK = (s1[0].z - s2[0].z).abs < TOL + + if xOK && yOK && zOK && s1.size == 1 + return true + else + indx = nil + + s2.each_with_index do |pt, i| + break if indx + + xOK = (s1[0].x - s2[i].x).abs < TOL + yOK = (s1[0].y - s2[i].y).abs < TOL + zOK = (s1[0].z - s2[i].z).abs < TOL + + indx = i if xOK && yOK && zOK + end + + return false unless indx + + s2 = to_p3Dv(s2).to_a + s2.rotate!(indx) + end + end + + # OpenStudio.isAlmostEqual3dPt(p1, p2, TOL) # ... from v350 onwards. + s1.size.times.each do |i| + xOK = (s1[i].x - s2[i].x).abs < TOL + yOK = (s1[i].y - s2[i].y).abs < TOL + zOK = (s1[i].z - s2[i].z).abs < TOL + return false unless xOK && yOK && zOK + end + + true + end + + ## # Returns true if an OpenStudio 3D point is part of a set of 3D points. # # @param pts [Set<OpenStudio::Point3dVector>] 3d points # @param p1 [OpenStudio::Point3d] a 3D point # @@ -2402,10 +2437,122 @@ false end ## + # Returns OpenStudio 3D point (in a set) nearest to a point of reference, e.g. + # grid origin. If left unspecified, the method systematically returns the + # bottom-left corner (BLC) of any horizontal set. If more than one point fits + # the initial criteria, the method relies on deterministic sorting through + # triangulation. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # @param p01 [OpenStudio::Point3d] point of reference + # + # @return [Integer] set index of nearest point to point of reference + # @return [nil] if invalid input (see logs) + def nearest(pts = nil, p01 = nil) + mth = "OSut::#{__callee__}" + l = 100 + d01 = 10000 + d02 = 0 + d03 = 0 + idx = nil + pts = to_p3Dv(pts) + return idx if pts.empty? + + p03 = OpenStudio::Point3d.new( l,-l,-l) + p02 = OpenStudio::Point3d.new( l, l, l) + p01 = OpenStudio::Point3d.new(-l,-l,-l) unless p01 + return mismatch("point", p01, cl, mth) unless p01.is_a?(OpenStudio::Point3d) + + pts.each_with_index { |pt, i| return i if same?(pt, p01) } + + pts.each_with_index do |pt, i| + length01 = (pt - p01).length + length02 = (pt - p02).length + length03 = (pt - p03).length + + if length01.round(2) == d01.round(2) + if length02.round(2) == d02.round(2) + if length03.round(2) > d03.round(2) + idx = i + d03 = length03 + end + elsif length02.round(2) > d02.round(2) + idx = i + d03 = length03 + d02 = length02 + end + elsif length01.round(2) < d01.round(2) + idx = i + d01 = length01 + d02 = length02 + d03 = length03 + end + end + + idx + end + + ## + # Returns OpenStudio 3D point (in a set) farthest from a point of reference, + # e.g. grid origin. If left unspecified, the method systematically returns the + # top-right corner (TRC) of any horizontal set. If more than one point fits + # the initial criteria, the method relies on deterministic sorting through + # triangulation. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # @param p01 [OpenStudio::Point3d] point of reference + # + # @return [Integer] set index of farthest point from point of reference + # @return [nil] if invalid input (see logs) + def farthest(pts = nil, p01 = nil) + mth = "OSut::#{__callee__}" + l = 100 + d01 = 0 + d02 = 10000 + d03 = 10000 + idx = nil + pts = to_p3Dv(pts) + return idx if pts.empty? + + p03 = OpenStudio::Point3d.new( l,-l,-l) + p02 = OpenStudio::Point3d.new( l, l, l) + p01 = OpenStudio::Point3d.new(-l,-l,-l) unless p01 + return mismatch("point", p01, cl, mth) unless p01.is_a?(OpenStudio::Point3d) + + pts.each_with_index do |pt, i| + next if same?(pt, p01) + + length01 = (pt - p01).length + length02 = (pt - p02).length + length03 = (pt - p03).length + + if length01.round(2) == d01.round(2) + if length02.round(2) == d02.round(2) + if length03.round(2) < d03.round(2) + idx = i + d03 = length03 + end + elsif length02.round(2) < d02.round(2) + idx = i + d03 = length03 + d02 = length02 + end + elsif length01.round(2) > d01.round(2) + idx = i + d01 = length01 + d02 = length02 + d03 = length03 + end + end + + idx + end + + ## # Flattens OpenStudio 3D points vs X, Y or Z axes. # # @param pts [Set<OpenStudio::Point3d>] 3D points # @param axs [Symbol] :x, :y or :z axis # @param val [#to_f] axis value @@ -2433,11 +2580,11 @@ v end ## - # Returns true if OpenStudio 3D points share X, Y or Z coordinates. + # Validates whether 3D points share X, Y or Z coordinates. # # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points # @param axs [Symbol] if potentially along :x, :y or :z axis # @param val [Numeric] axis value # @@ -2448,11 +2595,11 @@ pts = to_p3Dv(pts) ok1 = val.respond_to?(:to_f) ok2 = [:x, :y, :z].include?(axs) return false if pts.empty? return mismatch("val", val, Numeric, mth, DBG, false) unless ok1 - return invalid("axis (XYZ?)", mth, 2, DBG, false) unless ok2 + return invalid("axis", mth, 2, DBG, false) unless ok2 val = val.to_f case axs when :x @@ -2472,11 +2619,11 @@ # @param pts [OpenStudio::Point3dVector] 3D points # @param pt [OpenStudio::Point3d] a given 3D point # # @return [OpenStudio::Point3d] the next sequential point # @return [nil] if invalid input (see logs) - def next(pts = nil, pt = nil) + def nextUp(pts = nil, pt = nil) mth = "OSut::#{__callee__}" pts = to_p3Dv(pts) cl = OpenStudio::Point3d return mismatch("point", pt, cl, mth) unless pt.is_a?(cl) return invalid("points (2+)", mth, 1, WRN) if pts.size < 2 @@ -2485,10 +2632,92 @@ pair.nil? ? pts.first : pair.last end ## + # Returns 'width' of a set of OpenStudio 3D points. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [Float] width along X-axis, once re/aligned + # @return [0.0] if invalid inputs + def width(pts = nil) + pts = to_p3Dv(pts) + return 0 if pts.size < 2 + + pts.max_by(&:x).x - pts.min_by(&:x).x + end + + ## + # Returns 'height' of a set of OpenStudio 3D points. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [Float] height along Z-axis, or Y-axis if flat + # @return [0.0] if invalid inputs + def height(pts = nil) + pts = to_p3Dv(pts) + return 0 if pts.size < 2 + + min = pts.min_by(&:z).z + max = pts.max_by(&:z).z + return max - min if (max - min).abs > TOL + + pts.max_by(&:y).y - pts.min_by(&:y).y + end + + ## + # Returns midpoint coordinates of line segment. + # + # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment + # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment + # + # @return [OpenStudio::Point3d] midpoint + # @return [nil] if invalid input (see logs) + def midpoint(p1 = nil, p2 = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Point3d + return mismatch("point 1", p1, cl, mth) unless p1.is_a?(cl) + return mismatch("point 2", p2, cl, mth) unless p2.is_a?(cl) + return invalid("same points", mth, 0) if same?(p1, p2) + + midX = p1.x + (p2.x - p1.x)/2 + midY = p1.y + (p2.y - p1.y)/2 + midZ = p1.z + (p2.z - p1.z)/2 + + OpenStudio::Point3d.new(midX, midY, midZ) + end + + ## + # Returns a vertical 3D plane from 2x 3D points, right-hand rule. Input points + # are considered last 2 (of 3) points forming the plane; the first point is + # assumed zenithal. Input points cannot align vertically. + # + # @param p1 [OpenStudio::Point3d] 1st 3D point of a line segment + # @param p2 [OpenStudio::Point3d] 2nd 3D point of a line segment + # + # @return [OpenStudio::Plane] 3D plane + # @return [nil] if invalid input (see logs) + def verticalPlane(p1 = nil, p2 = nil) + mth = "OSut::#{__callee__}" + return mismatch("point 1", p1, cl, mth) unless p1.is_a?(OpenStudio::Point3d) + return mismatch("point 2", p2, cl, mth) unless p2.is_a?(OpenStudio::Point3d) + + if (p1.x - p2.x).abs < TOL && (p1.y - p2.y).abs < TOL + return invalid("vertically aligned points", mth) + end + + zenith = OpenStudio::Point3d.new(p1.x, p1.y, (p2 - p1).length) + points = OpenStudio::Point3dVector.new + points << zenith + points << p1 + points << p2 + + OpenStudio::Plane.new(points) + end + + ## # Returns unique OpenStudio 3D points from an OpenStudio 3D point vector. # # @param pts [Set<OpenStudio::Point3d] 3D points # @param n [#to_i] requested number of unique points (0 returns all) # @@ -2510,103 +2739,70 @@ v end ## - # Returns sequential non-collinear points in an OpenStudio 3D point vector. - # - # @param pts [Set<OpenStudio::Point3d] 3D points - # @param n [#to_i] requested number of non-collinears (0 returns all) - # - # @return [OpenStudio::Point3dVector] non-collinears (see logs if empty) - def getNonCollinears(pts = nil, n = 0) - mth = "OSut::#{__callee__}" - pts = getUniques(pts) - ok = n.respond_to?(:to_i) - v = OpenStudio::Point3dVector.new - a = [] - return pts if pts.size < 2 - return mismatch("n non-collinears", n, Integer, mth, DBG, v) unless ok - - # Evaluate cross product of vectors of 3x sequential points. - pts.each_with_index do |p2, i2| - i1 = i2 - 1 - i3 = i2 + 1 - i3 = 0 if i3 == pts.size - p1 = pts[i1] - p3 = pts[i3] - v13 = p3 - p1 - v12 = p2 - p1 - next if v12.cross(v13).length < TOL - - a << p2 - end - - if holds?(a, pts[0]) - a = a.rotate(-1) unless same?(a[0], pts[0]) - end - - n = n.to_i - n = 0 unless n.abs < pts.size - a = a[0..n] if n > 0 - a = a[n..-1] if n < 0 - - to_p3Dv(a) - end - - ## # Returns paired sequential points as (non-zero length) line segments. If the # set strictly holds 2x unique points, a single segment is returned. # Otherwise, the returned number of segments equals the number of unique - # points. If non-collinearity is requested, then the number of returned - # segments equals the number of non-colliear points. + # points. # # @param pts [Set<OpenStudio::Point3d>] 3D points - # @param co [Bool] whether to keep collinear points # # @return [OpenStudio::Point3dVectorVector] line segments (see logs if empty) - def getSegments(pts = nil, co = false) + def getSegments(pts = nil) mth = "OSut::#{__callee__}" vv = OpenStudio::Point3dVectorVector.new - co = false unless [true, false].include?(co) - pts = getNonCollinears(pts) unless co - pts = getUniques(pts) if co - return vv if pts.size < 2 + pts = getUniques(pts) + return vv if pts.size < 2 pts.each_with_index do |p1, i1| i2 = i1 + 1 i2 = 0 if i2 == pts.size p2 = pts[i2] line = OpenStudio::Point3dVector.new line << p1 line << p2 - vv << line + vv << line break if pts.size == 2 end vv end ## + # Determines if a set of 3D points if a valid segment. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [Bool] whether set is a valid segment + # @return [false] if invalid input (see logs) + def segment?(pts = nil) + pts = to_p3Dv(pts) + return false if pts.empty? + return false unless pts.size == 2 + return false if same?(pts[0], pts[1]) + + true + end + + ## # Returns points as (non-zero length) 'triads', i.e. 3x sequential points. # If the set holds less than 3x unique points, an empty triad is # returned. Otherwise, the returned number of triads equals the number of # unique points. If non-collinearity is requested, then the number of # returned triads equals the number of non-collinear points. # # @param pts [OpenStudio::Point3dVector] 3D points - # @param co [Bool] whether to keep collinear points # # @return [OpenStudio::Point3dVectorVector] triads (see logs if empty) def getTriads(pts = nil, co = false) mth = "OSut::#{__callee__}" vv = OpenStudio::Point3dVectorVector.new - co = false unless [true, false].include?(co) - pts = getNonCollinears(pts) unless co - pts = getUniques(pts) if co - return vv if pts.size < 2 + pts = getUniques(pts) + return vv if pts.size < 2 pts.each_with_index do |p1, i1| i2 = i1 + 1 i2 = 0 if i2 == pts.size i3 = i2 + 1 @@ -2616,85 +2812,352 @@ tri = OpenStudio::Point3dVector.new tri << p1 tri << p2 tri << p3 - vv << tri + vv << tri end vv end ## + # Determines if a set of 3D points if a valid triad. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [Bool] whether set is a valid triad (i.e. a trio of 3D points) + # @return [false] if invalid input (see logs) + def triad?(pts = nil) + pts = to_p3Dv(pts) + return false if pts.empty? + return false unless pts.size == 3 + return false if same?(pts[0], pts[1]) + return false if same?(pts[0], pts[2]) + return false if same?(pts[1], pts[2]) + + true + end + + ## + # Validates whether a 3D point lies ~along a 3D point segment, i.e. less than + # 10mm from any segment. + # + # @param p0 [OpenStudio::Point3d] a 3D point + # @param sg [Set<OpenStudio::Point3d] a 3D point segment + # + # @return [Bool] whether a 3D point lies ~along a 3D point segment + # @return [false] if invalid input (see logs) + def pointAlongSegment?(p0 = nil, sg = []) + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Point3d + cl2 = OpenStudio::Point3dVector + return mismatch( "point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1) + return mismatch("segment", sg, cl2, mth, DBG, false) unless segment?(sg) + + return true if holds?(sg, p0) + + a = sg.first + b = sg.last + ab = b - a + abn = b - a + abn.normalize + ap = p0 - a + sp = ap.dot(abn) + return false if sp < 0 + + apd = scalar(abn, sp) + return false if apd.length > ab.length + TOL + + ap0 = a + apd + return true if (p0 - ap0).length.round(2) <= TOL + + false + end + + ## + # Validates whether a 3D point lies anywhere ~along a set of 3D point + # segments, i.e. less than 10mm from any segment. + # + # @param p0 [OpenStudio::Point3d] a 3D point + # @param sgs [Set<OpenStudio::Point3d] 3D point segments + # + # @return [Bool] whether a 3D point lies ~along a set of 3D point segments + # @return [false] if invalid input (see logs) + def pointAlongSegments?(p0 = nil, sgs = []) + mth = "OSut::#{__callee__}" + cl1 = OpenStudio::Point3d + cl2 = OpenStudio::Point3dVectorVector + sgs = sgs.is_a?(cl2) ? sgs : getSegments(sgs) + return empty("segments", mth, DBG, false) if sgs.empty? + return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl1) + + sgs.each { |sg| return true if pointAlongSegment?(p0, sg) } + + false + end + + ## + # Returns point of intersection of 2x 3D line segments. + # + # @param s1 [Set<OpenStudio::Point3d] 1st 3D line segment + # @param s2 [Set<OpenStudio::Point3d] 2nd 3D line segment + # + # @return [OpenStudio::Point3d] point of intersection of both lines + # @return [nil] if no intersection, equal, or invalid input (see logs) + def getLineIntersection(s1 = [], s2 = []) + s1 = getSegments(s1) + s2 = getSegments(s2) + return nil if s1.empty? + return nil if s2.empty? + + s1 = s1.first + s2 = s2.first + + # Matching segments? + return nil if same?(s1, s2) + return nil if same?(s1, s2.to_a.reverse) + + a1 = s1[0] + a2 = s1[1] + b1 = s2[0] + b2 = s2[1] + + # Matching segment endpoints? + return a1 if same?(a1, b1) + return a2 if same?(a2, b1) + return a1 if same?(a1, b2) + return a2 if same?(a2, b2) + + # Segment endpoint along opposite segment? + return a1 if pointAlongSegments?(a1, s2) + return a2 if pointAlongSegments?(a2, s2) + return b1 if pointAlongSegments?(b1, s1) + return b2 if pointAlongSegments?(b2, s1) + + # Line segments as vectors. Skip if colinear. + a = a2 - a1 + b = b2 - b1 + xab = a.cross(b) + return nil if xab.length.round(4) < TOL2 + + # Link 1st point to other segment endpoints as vectors. Must be coplanar. + a1b1 = b1 - a1 + a1b2 = b2 - a1 + xa1b1 = a.cross(a1b1) + xa1b2 = a.cross(a1b2) + return nil unless xab.cross(xa1b1).length.round(4) < TOL2 + return nil unless xab.cross(xa1b2).length.round(4) < TOL2 + + # Both segment endpoints can't be 'behind' point. + return nil if a.dot(a1b1) < 0 && a.dot(a1b2) < 0 + + # Both in 'front' of point? Pick farthest from 'a'. + if a.dot(a1b1) > 0 && a.dot(a1b2) > 0 + lxa1b1 = xa1b1.length + lxa1b2 = xa1b2.length + + c1 = lxa1b1.round(4) < lxa1b2.round(4) ? b1 : b2 + else + c1 = a.dot(a1b1) > 0 ? b1 : b2 + end + + c1a1 = a1 - c1 + xc1a1 = a.cross(c1a1) + d1 = a1 + xc1a1 + n = a.cross(xc1a1) + dot = b.dot(n) + n = n.reverseVector if dot < 0 + f = c1a1.dot(n) / b.dot(n) + p0 = c1 + scalar(b, f) + + # Intersection can't be 'behind' point. + return nil if a.dot(p0 - a1) < 0 + + # Ensure intersection is sandwiched between endpoints. + return nil unless pointAlongSegments?(p0, s2) && pointAlongSegments?(p0, s1) + + p0 + end + + ## + # Validates whether 3D line segment intersects 3D segments (e.g. polygon). + # + # @param l [Set<OpenStudio::Point3d] 3D line segment + # @param s [Set<OpenStudio::Point3d] 3D segments + # + # @return [Bool] whether 3D line intersects 3D segments + # @return [false] if invalid input (see logs) + def lineIntersects?(l = [], s = []) + l = getSegments(l) + s = getSegments(s) + return nil if l.empty? + return nil if s.empty? + + l = l.first + + s.each { |segment| return true if getLineIntersection(l, segment) } + + false + end + + ## # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise. # # @param pts [OpenStudio::Point3dVector] 3D points # # @return [Bool] whether sequence is clockwise # @return [false] if invalid input (see logs) def clockwise?(pts = nil) mth = "OSut::#{__callee__}" pts = to_p3Dv(pts) n = false - return invalid("points (3+)", mth, 1, DBG, n) if pts.size < 3 - return invalid("points (aligned)", mth, 1, DBG, n) unless xyz?(pts, :z, 0) + return invalid("3+ points" , mth, 1, DBG, n) if pts.size < 3 + return invalid("flat points", mth, 1, DBG, n) unless xyz?(pts, :z) OpenStudio.pointInPolygon(pts.first, pts, TOL) end ## - # Returns 'aligned' OpenStudio 3D points conforming to Openstudio's - # counterclockwise UpperLeftCorner (ULC) convention. + # Returns OpenStudio 3D points (min 3x) conforming to an UpperLeftCorner (ULC) + # convention. Points Z-axis values must be ~= 0. Points are returned + # counterclockwise. # - # @param pts [Set<OpenStudio::Point3d>] aligned 3D points + # @param pts [Set<OpenStudio::Point3d>] 3D points # # @return [OpenStudio::Point3dVector] ULC points (see logs if empty) def ulc(pts = nil) mth = "OSut::#{__callee__}" - pts = to_p3Dv(pts) v = OpenStudio::Point3dVector.new - p0 = OpenStudio::Point3d.new(0,0,0) - i0 = nil + pts = to_p3Dv(pts).to_a + return invalid("points (3+)", mth, 1, DBG, v) if pts.size < 3 + return invalid("points (aligned)", mth, 1, DBG, v) unless xyz?(pts, :z) + # Ensure counterclockwise sequence. + pts = pts.reverse if clockwise?(pts) + minX = pts.min_by(&:x).x + i0 = nearest(pts) + p0 = pts[i0] + + pts_x = pts.select { |pt| pt.x.round(2) == minX.round(2) }.reverse + + p1 = pts_x.max_by { |pt| (pt - p0).length } + i1 = pts.index(p1) + + to_p3Dv(pts.rotate(i1)) + end + + ## + # Returns OpenStudio 3D points (min 3x) conforming to an BottomLeftCorner + # (BLC) convention. Points Z-axis values must be ~= 0. Points are returned + # counterclockwise. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [OpenStudio::Point3dVector] BLC points (see logs if empty) + def blc(pts = nil) + mth = "OSut::#{__callee__}" + v = OpenStudio::Point3dVector.new + pts = to_p3Dv(pts).to_a return invalid("points (3+)", mth, 1, DBG, v) if pts.size < 3 - return invalid("points (aligned)", mth, 1, DBG, v) unless xyz?(pts, :z, 0) + return invalid("points (aligned)", mth, 1, DBG, v) unless xyz?(pts, :z) # Ensure counterclockwise sequence. - pts = pts.to_a - pts = pts.reverse if clockwise?(pts) + pts = pts.reverse if clockwise?(pts) + minX = pts.min_by(&:x).x + i0 = nearest(pts) + p0 = pts[i0] - # Fetch index of candidate (0,0,0) point (i == 1, in most cases). Resort - # to last X == 0 point. Leave as is if failed attempts. - i0 = pts.index { |pt| same?(pt, p0) } - i0 = pts.rindex { |pt| pt.x.abs < TOL } if i0.nil? + pts_x = pts.select { |pt| pt.x.round(2) == minX.round(2) }.reverse - unless i0.nil? - i = pts.size - 1 - i = i0 - 1 unless i0 == 0 - pts = pts.rotate(i) + return to_p3Dv(pts.rotate(i0)) if pts_x.include?(p0) + + p1 = pts_x.min_by { |pt| (pt - p0).length } + i1 = pts.index(p1) + + to_p3Dv(pts.rotate(i1)) + end + + ## + # Returns sequential non-collinear points in an OpenStudio 3D point vector. + # + # @param pts [Set<OpenStudio::Point3d] 3D points + # @param n [#to_i] requested number of non-collinears (0 returns all) + # + # @return [OpenStudio::Point3dVector] non-collinears (see logs if empty) + def getNonCollinears(pts = nil, n = 0) + mth = "OSut::#{__callee__}" + pts = getUniques(pts) + ok = n.respond_to?(:to_i) + v = OpenStudio::Point3dVector.new + a = [] + return pts if pts.size < 3 + return mismatch("n non-collinears", n, Integer, mth, DBG, v) unless ok + + # Evaluate cross product of vectors of 3x sequential points. + pts.each_with_index do |p2, i2| + i1 = i2 - 1 + i3 = i2 + 1 + i3 = 0 if i3 == pts.size + p1 = pts[i1] + p3 = pts[i3] + v13 = p3 - p1 + v12 = p2 - p1 + next if v12.cross(v13).length < TOL2 + + a << p2 end - to_p3Dv(pts) + if holds?(a, pts[0]) + a = a.rotate(-1) unless same?(a[0], pts[0]) + end + + n = n.to_i + a = a[0..n-1] if n > 0 + a = a[n-1..-1] if n < 0 + + to_p3Dv(a) end ## + # Returns sequential collinear points in an OpenStudio 3D point vector. + # + # @param pts [Set<OpenStudio::Point3d] 3D points + # @param n [#to_i] requested number of collinears (0 returns all) + # + # @return [OpenStudio::Point3dVector] collinears (see logs if empty) + def getCollinears(pts = nil, n = 0) + mth = "OSut::#{__callee__}" + pts = getUniques(pts) + ok = n.respond_to?(:to_i) + v = OpenStudio::Point3dVector.new + return pts if pts.size < 3 + return mismatch("n collinears", n, Integer, mth, DBG, v) unless ok + + ncolls = getNonCollinears(pts) + return pts if ncolls.empty? + + to_p3Dv( pts.delete_if { |pt| holds?(ncolls, pt) } ) + end + + ## # Returns an OpenStudio 3D point vector as basis for a valid OpenStudio 3D # polygon. In addition to basic OpenStudio polygon tests (e.g. all points # sharing the same 3D plane, non-self-intersecting), the method can - # optionally check for convexity, or ensure uniqueness and/or collinearity. + # optionally check for convexity, or ensure uniqueness and/or non-collinearity. # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC) # counterclockwise sequence, or in clockwise sequence. # # @param pts [Set<OpenStudio::Point3d>] 3D points # @param vx [Bool] whether to check for convexity # @param uq [Bool] whether to ensure uniqueness # @param co [Bool] whether to ensure non-collinearity # @param tt [Bool, OpenStudio::Transformation] whether to 'align' - # @param sq [:no, :ulc, :cw] unaltered, ULC or clockwise sequence + # @param sq [:no, :ulc, :blc, :cw] unaltered, ULC, BLC or clockwise sequence # # @return [OpenStudio::Point3dVector] 3D points (see logs if empty) - def poly(pts = nil, vx = false, uq = false, co = true, tt = false, sq = :no) + def poly(pts = nil, vx = false, uq = false, co = false, tt = false, sq = :no) mth = "OSut::#{__callee__}" pts = to_p3Dv(pts) cl = OpenStudio::Transformation v = OpenStudio::Point3dVector.new vx = false unless [true, false].include?(vx) @@ -2702,188 +3165,398 @@ co = true unless [true, false].include?(co) # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Exit if mismatched/invalid arguments. ok1 = tt == true || tt == false || tt.is_a?(cl) - ok2 = sq == :no || sq == :ulc || sq == :cw + ok2 = sq == :no || sq == :ulc || sq == :blc || sq == :cw return invalid("transformation", mth, 5, DBG, v) unless ok1 return invalid("sequence", mth, 6, DBG, v) unless ok2 # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - # Basic tests: + # Minimum 3 points? p3 = getNonCollinears(pts, 3) return empty("polygon", mth, ERR, v) if p3.size < 3 + # Coplanar? pln = OpenStudio::Plane.new(p3) pts.each do |pt| return empty("plane", mth, ERR, v) unless pln.pointOnPlane(pt) end - t = tt - t = OpenStudio::Transformation.alignFace(pts) unless tt.is_a?(cl) - a = (t.inverse * pts).reverse + t = OpenStudio::Transformation.alignFace(pts) + at = (t.inverse * pts).reverse if tt.is_a?(cl) - # Using a transformation that is most likely not specific to pts. The - # most probable reason to retain this option is when testing for polygon - # intersections, unions, etc., operations that typically require that - # points remain nonetheless 'aligned'. If re-activated, this logs a - # warning if aligned points aren't @Z =0, before 'flattening'. - # - # invalid("points (non-aligned)", mth, 1, WRN) unless xyz?(a, :z, 0) - a = flatten(a).to_a unless xyz?(a, :z, 0) + att = (tt.inverse * pts).reverse + + if same?(at, att) + a = att + a = ulc(a).to_a if clockwise?(a) + t = nil + else + t = xyz?(att, :z) ? nil : OpenStudio::Transformation.alignFace(att) + a = t ? (t.inverse * att).reverse : att + end + else + a = at end - # The following 2x lines are commented out. This is a very commnon and very - # useful test, yet tested cases are first caught by the 'pointOnPlane' - # test above. Keeping it for possible further testing. - # bad = OpenStudio.selfIntersects(a, TOL) - # return invalid("points (intersecting)", mth, 1, ERR, v) if bad - # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Ensure uniqueness and/or non-collinearity. Preserve original sequence. p0 = a.first - a = OpenStudio.simplify(a, false, TOL) if uq - a = OpenStudio.simplify(a, true, TOL) unless co + a = getUniques(a).to_a if uq + a = getNonCollinears(a).to_a if co i0 = a.index { |pt| same?(pt, p0) } - a = a.rotate(i0) unless i0.nil? + a = a.rotate(i0) unless i0.nil? # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Check for convexity (optional). - if vx - a1 = OpenStudio.simplify(a, true, TOL).reverse - dX = a1.max_by(&:x).x.abs - dY = a1.max_by(&:y).y.abs - d = [dX, dY].max - return false if d < TOL + if vx && a.size > 3 + zen = OpenStudio::Point3d.new(0, 0, 1000) - u = OpenStudio::Vector3d.new(0, 0, d) - - a1.each_with_index do |p1, i1| - i2 = i1 + 1 - i2 = 0 if i2 == a1.size - p2 = a1[i2] - pi = p1 + u - vi = OpenStudio::Point3dVector.new - vi << pi - vi << p1 - vi << p2 - plane = OpenStudio::Plane.new(vi) - normal = plane.outwardNormal - - a1.each do |p3| - next if same?(p1, p3) - next if same?(p2, p3) - next if plane.pointOnPlane(p3) - next if normal.dot(p3 - p1) < 0 - - return invalid("points (non-convex)", mth, 1, ERR, v) - end + getTriads(a).each do |trio| + p1 = trio[0] + p2 = trio[1] + p3 = trio[2] + v12 = p2 - p1 + v13 = p3 - p1 + x = (zen - p1).cross(v12) + return v if x.dot(v13).round(4) > 0 end end # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Alter sequence (optional). - unless tt + if tt.is_a?(cl) case sq when :ulc - a = to_p3Dv(t * ulc(a.reverse)) + a = t ? to_p3Dv(t * ulc(a.reverse)) : to_p3Dv(ulc(a.reverse)) + when :blc + a = t ? to_p3Dv(t * blc(a.reverse)) : to_p3Dv(blc(a.reverse)) when :cw - a = to_p3Dv(t * a) - a = OpenStudio.reverse(a) unless clockwise?(a) + a = t ? to_p3Dv(t * a) : to_p3Dv(a) else - a = to_p3Dv(t * a.reverse) + a = t ? to_p3Dv(t * a.reverse) : to_p3Dv(a.reverse) end else case sq when :ulc - a = ulc(a.reverse) + a = tt ? to_p3Dv(ulc(a.reverse)) : to_p3Dv(t * ulc(a.reverse)) + when :blc + a = tt ? to_p3Dv(blc(a.reverse)) : to_p3Dv(t * blc(a.reverse)) when :cw - a = to_p3Dv(a) - a = OpenStudio.reverse(a) unless clockwise?(a) + a = tt ? to_p3Dv(a) : to_p3Dv(t * a) else - a = to_p3Dv(a.reverse) + a = tt ? to_p3Dv(a.reverse) : to_p3Dv(t * a.reverse) end end a end ## - # Returns 'width' of a set of OpenStudio 3D points (perpendicular view). + # Validates whether 3D point is within a 3D polygon. If option 'entirely' is + # set to true, then the method returns false if point lies along any of the + # polygon edges, or is very near any of its vertices. # - # @param pts [Set<OpenStudio::Point3d>] 3D points + # @param p0 [OpenStudio::Point3d] a 3D point + # @param s [Set<OpenStudio::Point3d] a 3D polygon + # @param entirely [Bool] whether point should be neatly within polygon limits # - # @return [Float] left-to-right width - # @return [0.0] if invalid inputs (see logs) - def width(pts = nil) + # @return [Bool] whether a 3D point lies within a 3D polygon + # @return [false] if invalid input (see logs) + def pointWithinPolygon?(p0 = nil, s = [], entirely = false) mth = "OSut::#{__callee__}" + cl = OpenStudio::Point3d + s = poly(s, false, true, true) + return empty("polygon", mth, DBG, false) if s.empty? + return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl) - poly(pts, false, true, false, true).max_by(&:x).x + n = OpenStudio.getOutwardNormal(s) + return false if n.empty? + + n = n.get + pl = OpenStudio::Plane.new(s.first, n) + return false unless pl.pointOnPlane(p0) + + entirely = false unless [true, false].include?(entirely) + segments = getSegments(s) + + # Along polygon edges, or near vertices? + if pointAlongSegments?(p0, segments) + return false if entirely + return true unless entirely + end + + segments.each do |segment| + # - draw vector from segment midpoint to point + # - scale 1000x (assuming no building surface would be 1km wide) + # - convert vector to an independent line segment + # - loop through polygon segments, tally the number of intersections + # - avoid double-counting polygon vertices as intersections + # - return false if number of intersections is even + mid = midpoint(segment.first, segment.last) + mpV = scalar(mid - p0, 1000) + p1 = p0 + mpV + ctr = 0 + pts = [] + + # Skip if ~collinear. + next if (mpV.cross(segment.last - segment.first).length).round(4) < TOL2 + + segments.each do |sg| + intersect = getLineIntersection([p0, p1], sg) + next unless intersect + + # One of the polygon vertices? + if holds?(s, intersect) + next if holds?(pts, intersect) + + pts << intersect + end + + ctr += 1 + end + + next if ctr.zero? + return false if ctr.even? + end + + true end ## - # Returns 'height' of a set of OpenStudio 3D points (perpendicular view). + # Validates whether 2 polygons are parallel, regardless of their direction. # + # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points + # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points + # + # @return [Bool] whether 2 polygons are parallel + # @return [false] if invalid input (see logs) + def parallel?(p1 = nil, p2 = nil) + p1 = poly(p1, false, true, false) + p2 = poly(p2, false, true, false) + return false if p1.empty? + return false if p2.empty? + + p1 = getNonCollinears(p1, 3) + p2 = getNonCollinears(p2, 3) + return false if p1.empty? + return false if p2.empty? + + pl1 = OpenStudio::Plane.new(p1) + pl2 = OpenStudio::Plane.new(p2) + + pl1.outwardNormal.dot(pl2.outwardNormal).abs > 0.99 + end + + ## + # Validates whether a polygon faces upwards. + # + # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points + # + # @return [Bool] if facing upwards + # @return [false] if invalid input (see logs) + def facingUp?(pts = nil) + up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0) + pts = poly(pts, false, true, false) + return false if pts.empty? + + pts = getNonCollinears(pts, 3) + return false if pts.empty? + + OpenStudio::Plane.new(pts).outwardNormal.dot(up) > 0.99 + end + + ## + # Validates whether a polygon faces downwards. + # + # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points + # + # @return [Bool] if facing downwards + # @return [false] if invalid input (see logs) + def facingDown?(pts = nil) + lo = OpenStudio::Point3d.new(0,0,-1) - OpenStudio::Point3d.new(0,0,0) + pts = poly(pts, false, true, false) + return false if pts.empty? + + pts = getNonCollinears(pts, 3) + return false if pts.empty? + + OpenStudio::Plane.new(pts).outwardNormal.dot(lo) > 0.99 + end + + ## + # Validates whether an OpenStudio polygon is a rectangle (4x sides + 2x + # diagonals of equal length, meeting at midpoints). + # # @param pts [Set<OpenStudio::Point3d>] 3D points # - # @return [Float] top-to-bottom height - # @return [0.0] if invalid inputs (see logs) - def height(pts = nil) - mth = "OSut::#{__callee__}" + # @return [Bool] whether polygon is rectangular + # @return [false] if invalid input (see logs) + def rectangular?(pts = nil) + pts = poly(pts, false, false, false) + return false if pts.empty? + return false unless pts.size == 4 - poly(pts, false, true, false, true).max_by(&:y).y + m1 = midpoint(pts[0], pts[2]) + m2 = midpoint(pts[1], pts[3]) + return false unless same?(m1, m2) + + diag1 = pts[2] - pts[0] + diag2 = pts[3] - pts[1] + return true if (diag1.length - diag2.length).abs < TOL + + false end ## - # Determines whether a 1st OpenStudio polygon fits in a 2nd polygon. + # Validates whether an OpenStudio polygon is a square (rectangular, 4x ~equal + # sides). # + # @param pts [Set<OpenStudio::Point3d>] 3D points + # + # @return [Bool] whether polygon is a square + # @return [false] if invalid input (see logs) + def square?(pts = nil) + d = nil + pts = poly(pts, false, false, false) + return false if pts.empty? + return false unless rectangular?(pts) + + getSegments(pts).each do |pt| + l = (pt[1] - pt[0]).length + d = l unless d + return false unless l.round(2) == d.round(2) + end + + true + end + + ## + # Determines whether a 1st OpenStudio polygon fits in a 2nd polygon. Vertex + # sequencing of both polygons must be counterclockwise. If option 'entirely' + # is set to true, then the method returns false if point lies along any of the + # polygon edges, or is very near any of its vertices. + # # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points - # @param flat [Bool] whether points are to be pre-flattened (Z=0) + # @param entirely [Bool] whether point should be neatly within polygon limits # # @return [Bool] whether 1st polygon fits within the 2nd polygon # @return [false] if invalid input (see logs) - def fits?(p1 = nil, p2 = nil, flat = true) + def fits?(p1 = nil, p2 = nil, entirely = false) + pts = [] + p1 = poly(p1) + p2 = poly(p2) + return false if p1.empty? + return false if p2.empty? + + p1.each { |p0| return false unless pointWithinPolygon?(p0, p2) } + + entirely = false unless [true, false].include?(entirely) + return true unless entirely + + p1.each { |p0| return false unless pointWithinPolygon?(p0, p2, entirely) } + + true + end + + ## + # Returns intersection of overlapping polygons, empty if non intersecting. If + # the optional 3rd argument is left as false, the 2nd polygon may only overlap + # if it shares the 3D plane equation of the 1st one. If the 3rd argument is + # instead set to true, then the 2nd polygon is first cast onto the 3D plane of + # the 1st one; the method therefore returns (as overlap) the intersection of a + # projection of the 2nd polygon onto the 1st one. The method returns the + # smallest of the 2 polygons if either fits within the larger one. + # + # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points + # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points + # @param flat [Bool] whether to first align the 2nd set onto the 1st set plane + # + # @return [OpenStudio::Point3dVector] largest intersection (see logs if empty) + def overlap(p1 = nil, p2 = nil, flat = false) mth = "OSut::#{__callee__}" - p1 = poly(p1, false, true, false) - p2 = poly(p2, false, true, false) - flat = true unless [true, false].include?(flat) - return false if p1.empty? - return false if p2.empty? + flat = false unless [true, false].include?(flat) + face = OpenStudio::Point3dVector.new + p01 = poly(p1) + p02 = poly(p2) + return empty("points 1", mth, DBG, face) if p01.empty? + return empty("points 2", mth, DBG, face) if p02.empty? + return p01 if fits?(p01, p02) + return p02 if fits?(p02, p01) - # Aligned, clockwise points using transformation from 2nd polygon. - t = OpenStudio::Transformation.alignFace(p2) - p1 = poly(p1, false, false, true, t, :cw) - p2 = poly(p2, false, false, true, t, :cw) - p1 = flatten(p1) if flat - p2 = flatten(p2) if flat - return false if p1.empty? - return false if p2.empty? + if xyz?(p01, :z) + t = nil + cw1 = clockwise?(p01) + a1 = cw1 ? p01.to_a.reverse : p01.to_a + a2 = p02.to_a + a2 = flatten(a2).to_a if flat + return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z) - area1 = OpenStudio.getArea(p1) - area2 = OpenStudio.getArea(p2) - return empty("points 1 area", mth, ERR, false) if area1.empty? - return empty("points 2 area", mth, ERR, false) if area2.empty? + cw2 = clockwise?(a2) + a2 = a2.reverse if cw2 + else + t = OpenStudio::Transformation.alignFace(p01) + a1 = t.inverse * p01 + a2 = t.inverse * p02 + a2 = flatten(a2).to_a if flat + return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z) + cw2 = clockwise?(a2) + a2 = a2.reverse if cw2 + end + + # Return either (transformed) polygon if one fits into the other. + p1t = p01 + + if t + p2t = to_p3Dv(cw2 ? t * a2 : t * a2.reverse) + else + if cw1 + p2t = to_p3Dv(cw2 ? a2.reverse : a2) + else + p2t = to_p3Dv(cw2 ? a2 : a2.reverse) + end + end + + return p1t if fits?(a1, a2) + return p2t if fits?(a2, a1) + + area1 = OpenStudio.getArea(a1) + area2 = OpenStudio.getArea(a2) + return empty("points 1 area", mth, ERR, face) if area1.empty? + return empty("points 2 area", mth, ERR, face) if area2.empty? + area1 = area1.get area2 = area2.get - union = OpenStudio.join(p1, p2, TOL2) - return false if union.empty? + union = OpenStudio.join(a1.reverse, a2.reverse, TOL2) + return face if union.empty? union = union.get area = OpenStudio.getArea(union) - return false if area.empty? + return face if area.empty? - area = area.get + area = area.get + delta = area1 + area2 - area if area > TOL - return true if (area - area2).abs < TOL + return face if area.round(2) == area1.round(2) + return face if area.round(2) == area2.round(2) + return face if delta.round(2) == 0 end - false + res = OpenStudio.intersect(a1.reverse, a2.reverse, TOL) + return face if res.empty? + + res = res.get + res1 = res.polygon1 + return face if res1.empty? + + to_p3Dv(t ? t * res1.reverse : res1.reverse) end ## # Determines whether OpenStudio polygons overlap. # @@ -2891,62 +3564,62 @@ # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points # @param flat [Bool] whether points are to be pre-flattened (Z=0) # # @return [Bool] whether polygons overlap (or fit) # @return [false] if invalid input (see logs) - def overlaps?(p1 = nil, p2 = nil, flat = true) + def overlaps?(p1 = nil, p2 = nil, flat = false) + overlap(p1, p2, flat).empty? ? false : true + end + + ## + # Casts an OpenStudio polygon onto the 3D plane of a 2nd polygon, relying on + # an independent 3D ray vector. + # + # @param p1 [Set<OpenStudio::Point3d>] 1st set of 3D points + # @param p2 [Set<OpenStudio::Point3d>] 2nd set of 3D points + # @param ray [OpenStudio::Vector3d] a vector + # + # @return [OpenStudio::Point3dVector] cast of p1 onto p2 (see logs if empty) + def cast(p1 = nil, p2 = nil, ray = nil) mth = "OSut::#{__callee__}" - p1 = poly(p1, false, true, false) - p2 = poly(p2, false, true, false) - flat = true unless [true, false].include?(flat) - return false if p1.empty? - return false if p2.empty? + cl = OpenStudio::Vector3d + face = OpenStudio::Point3dVector.new + p1 = poly(p1) + p2 = poly(p2) + return face if p1.empty? + return face if p2.empty? + return mismatch("ray", ray, cl, mth) unless ray.is_a?(cl) - # Aligned, clockwise & convex points using transformation from 1st polygon. - t = OpenStudio::Transformation.alignFace(p1) - p1 = poly(p1, false, false, true, t, :cw) - p2 = poly(p2, false, false, true, t, :cw) - p1 = flatten(p1) if flat - p2 = flatten(p2) if flat - return false if p1.empty? - return false if p2.empty? + # From OpenStudio SDK v3.7.0 onwards, one could/should rely on: + # + # s3.amazonaws.com/openstudio-sdk-documentation/cpp/OpenStudio-3.7.0-doc/ + # utilities/html/classopenstudio_1_1_plane.html + # #abc4747b1b041a7f09a6887bc0e5abce1 + # + # e.g. p1.each { |pt| face << pl.rayIntersection(pt, ray) } + # + # The following +/- replicates the same solution, based on: + # https://stackoverflow.com/a/65832417 + p0 = p2.first + pl = OpenStudio::Plane.new(getNonCollinears(p2, 3)) + n = pl.outwardNormal + return face if n.dot(ray).abs < TOL - return true if fits?(p1, p2) - return true if fits?(p2, p1) - - area1 = OpenStudio.getArea(p1) - area2 = OpenStudio.getArea(p2) - return empty("points 1 area", mth, ERR, false) if area1.empty? - return empty("points 2 area", mth, ERR, false) if area2.empty? - - area1 = area1.get - area2 = area2.get - union = OpenStudio.join(p1, p2, TOL2) - return false if union.empty? - - union = union.get - area = OpenStudio.getArea(union) - return false if area.empty? - - area = area.get - delta = area1 + area2 - area - - if area > TOL - return false if (area - area1).abs < TOL - return false if (area - area2).abs < TOL - return false if delta.abs < TOL - return true if delta > TOL + p1.each do |pt| + length = n.dot(pt - p0) / n.dot(ray.reverseVector) + face << pt + scalar(ray, length) end - false + face end ## - # Generates offset vertices (by width) for a 3- or 4-sided, convex polygon. + # Generates offset vertices (by width) for a 3- or 4-sided, convex polygon. If + # width is negative, the vertices are contracted inwards. # # @param p1 [Set<OpenStudio::Point3d>] OpenStudio 3D points - # @param w [#to_f] offset width (min: 0.0254m) + # @param w [#to_f] offset width (absolute min: 0.0254m) # @param v [#to_i] OpenStudio SDK version, eg '321' for "v3.2.1" (optional) # # @return [OpenStudio::Point3dVector] offset points (see logs if unaltered) def offset(p1 = nil, w = 0, v = 0) mth = "OSut::#{__callee__}" @@ -2954,26 +3627,23 @@ return invalid("points", mth, 1, DBG, p1) unless [3, 4].include?(pts.size) mismatch("width", w, Numeric, mth) unless w.respond_to?(:to_f) mismatch("version", v, Integer, mth) unless v.respond_to?(:to_i) + iv = pts.size == 4 ? true : false vs = OpenStudio.openStudioVersion.split(".").join.to_i - iv = true if pts.size == 4 - v = v.to_i if v.respond_to?(:to_i) - v = -1 unless v.respond_to?(:to_i) - v = vs if v < 0 - w = w.to_f if w.respond_to?(:to_f) - w = 0 unless w.respond_to?(:to_f) - w = 0 if w < 0.0254 + v = v.respond_to?(:to_i) ? v.to_i : vs + w = w.respond_to?(:to_f) ? w.to_f : 0 + return p1 if w.abs < 0.0254 unless v < 340 t = OpenStudio::Transformation.alignFace(p1) offset = OpenStudio.buffer(pts, w, TOL) return p1 if offset.empty? return to_p3Dv(t * offset.get.reverse) - else # brute force approach + else # brute force approach pz = {} pz[:A] = {} pz[:B] = {} pz[:C] = {} pz[:D] = {} if iv @@ -3182,13 +3852,14 @@ bfr = bfr.to_f if bfr.respond_to?(:to_f) bfr = 0 unless bfr.respond_to?(:to_f) bfr = 0 if bfr < 0.0254 vtx = poly(a.first) - t = OpenStudio::Transformation.alignFace(vtx) unless vtx.empty? - return out if vtx.empty? + return out if vtx.empty? + t = OpenStudio::Transformation.alignFace(vtx) + a.each do |pts| points = poly(pts, false, true, false, t) points = flatten(points) if flat next if points.empty? @@ -3242,56 +3913,1011 @@ to_p3Dv(t * out) end ## - # Returns an array of OpenStudio space-specific surfaces that match criteria, - # e.g. exterior, north-east facing walls in hotel "lobby". Note 'sides' rely - # on space coordinates (not absolute model coordinates). And 'sides' are - # exclusive, not inclusive (e.g. walls strictly north-facing or strictly - # east-facing would not be returned if 'sides' holds [:north, :east]). + # Generates a BLC box from a triad (3D points). Points must be unique and + # non-collinear. # - # @param spaces [Array<OpenStudio::Model::Space>] target spaces + # @param [Set<OpenStudio::Point3d>] a triad (3D points) + # + # @return [Set<OpenStudio::Point3D>] a rectangular ULC box (see logs if empty) + def triadBox(pts = nil) + mth = "OSut::#{__callee__}" + bkp = OpenStudio::Point3dVector.new + box = [] + pts = getNonCollinears(pts) + return bkp if pts.empty? + + t = xyz?(pts, :z) ? nil : OpenStudio::Transformation.alignFace(pts) + pts = poly(pts, false, true, true, t) if t + return bkp if pts.empty? + return invalid("triad", mth, 1, ERR, bkp) unless pts.size == 3 + + pts = to_p3Dv(pts.to_a.reverse) if clockwise?(pts) + p0 = pts[0] + p1 = pts[1] + p2 = pts[2] + + # Cast p0 unto vertical plane defined by p1/p2. + pp0 = verticalPlane(p1, p2).project(p0) + v00 = p0 - pp0 + v11 = pp0 - p1 + v10 = p0 - p1 + v12 = p2 - p1 + + # Reset p0 and/or p1 if obtuse or acute. + if v12.dot(v10) < 0 + p0 = p1 + v00 + elsif v12.dot(v10) > 0 + if v11.length < v12.length + p1 = pp0 + else + p0 = p1 + v00 + end + end + + p3 = p2 + v00 + + box << OpenStudio::Point3d.new(p0.x, p0.y, p0.z) + box << OpenStudio::Point3d.new(p1.x, p1.y, p1.z) + box << OpenStudio::Point3d.new(p2.x, p2.y, p2.z) + box << OpenStudio::Point3d.new(p3.x, p3.y, p3.z) + + box = blc(box) + return bkp unless rectangular?(box) + + box = to_p3Dv(t * box) if t + + box + end + + ## + # Generates a BLC box bounded within a triangle (midpoint theorem). + # + # pts [Set<OpenStudio::Point3d>] triangular polygon + # + # @return [OpenStudio::Point3dVector] medial bounded box (see logs if empty) + def medialBox(pts = nil) + mth = "OSut::#{__callee__}" + bkp = OpenStudio::Point3dVector.new + box = [] + pts = poly(pts, true, true, true) + return bkp if pts.empty? + return invalid("triangle", mth, 1, ERR, bkp) unless pts.size == 3 + + t = xyz?(pts, :z) ? nil : OpenStudio::Transformation.alignFace(pts) + pts = poly(pts, false, false, false, t) if t + return bkp if pts.empty? + + pts = to_p3Dv(pts.to_a.reverse) if clockwise?(pts) + + # Generate vertical plane along longest segment. + mpoints = [] + sgs = getSegments(pts) + longest = sgs.max_by { |s| OpenStudio.getDistanceSquared(s.first, s.last) } + plane = verticalPlane(longest.first, longest.last) + + # Fetch midpoints of other 2 segments. + sgs.each { |s| mpoints << midpoint(s.first, s.last) unless s == longest } + + return bkp unless mpoints.size == 2 + + # Generate medial bounded box. + box << plane.project(mpoints.first) + box << mpoints.first + box << mpoints.last + box << plane.project(mpoints.last) + box = clockwise?(box) ? blc(box.reverse) : blc(box) + return bkp unless rectangular?(box) + return bkp unless fits?(box, pts) + + box = to_p3Dv(t * box) if t + + box + end + + ## + # Generates a BLC bounded box within a polygon. + # + # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points + # + # @return [OpenStudio::Point3dVector] bounded box (see logs if empty) + def boundedBox(pts = nil) + str = ".*(?<!utilities.geometry.join)$" + OpenStudio::Logger.instance.standardOutLogger.setChannelRegex(str) + + mth = "OSut::#{__callee__}" + bkp = OpenStudio::Point3dVector.new + box = [] + pts = poly(pts, false, true, true) + return bkp if pts.empty? + + t = xyz?(pts, :z) ? nil : OpenStudio::Transformation.alignFace(pts) + pts = t.inverse * pts if t + return bkp if pts.empty? + + pts = to_p3Dv(pts.to_a.reverse) if clockwise?(pts) + + # PATH A : Return medial bounded box if polygon is a triangle. + if pts.size == 3 + box = medialBox(pts) + + unless box.empty? + box = to_p3Dv(t * box) if t + return box + end + end + + # PATH B : Return polygon itself if already rectangular. + if rectangular?(pts) + box = t ? to_p3Dv(t * pts) : pts + return box + end + + aire = 0 + + # PATH C : Right-angle, midpoint triad approach. + getSegments(pts).each do |sg| + m0 = midpoint(sg.first, sg.last) + + getSegments(pts).each do |seg| + p1 = seg.first + p2 = seg.last + next if same?(p1, sg.first) + next if same?(p1, sg.last) + next if same?(p2, sg.first) + next if same?(p2, sg.first) + + out = triadBox(OpenStudio::Point3dVector.new([m0, p1, p2])) + next if out.empty? + next unless fits?(out, pts) + + area = OpenStudio.getArea(out) + next if area.empty? + + area = area.get + next if area < TOL + next if area < aire + + aire = area + box = out + end + end + + # PATH D : Right-angle triad approach, may override PATH C boxes. + getSegments(pts).each do |sg| + p0 = sg.first + p1 = sg.last + + pts.each do |p2| + next if same?(p2, p0) + next if same?(p2, p1) + + out = triadBox(OpenStudio::Point3dVector.new([p0, p1, p2])) + next if out.empty? + next unless fits?(out, pts) + + area = OpenStudio.getArea(out) + next if area.empty? + + area = area.get + next if area < TOL + next if area < aire + + aire = area + box = out + end + end + + unless aire < TOL + box = to_p3Dv(t * box) if t + return box + end + + # PATH E : Medial box, segment approach. + aire = 0 + + getSegments(pts).each do |sg| + p0 = sg.first + p1 = sg.last + + pts.each do |p2| + next if same?(p2, p0) + next if same?(p2, p1) + + out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2])) + next if out.empty? + next unless fits?(out, pts) + + area = OpenStudio.getArea(box) + next if area.empty? + + area = area.get + next if area < TOL + next if area < aire + + aire = area + box = out + end + end + + unless aire < TOL + box = to_p3Dv(t * box) if t + return box + end + + # PATH F : Medial box, triad approach. + aire = 0 + + getTriads(pts).each do |sg| + p0 = sg[0] + p1 = sg[1] + p2 = sg[2] + + out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2])) + next if out.empty? + next unless fits?(out, pts) + + area = OpenStudio.getArea(box) + next if area.empty? + + area = area.get + next if area < TOL + next if area < aire + + aire = area + box = out + end + + unless aire < TOL + box = to_p3Dv(t * box) if t + return box + end + + # PATH G : Medial box, triangulated approach. + aire = 0 + outer = to_p3Dv(pts.to_a.reverse) + holes = OpenStudio::Point3dVectorVector.new + + OpenStudio.computeTriangulation(outer, holes).each do |triangle| + getSegments(triangle).each do |sg| + p0 = sg.first + p1 = sg.last + + pts.each do |p2| + next if same?(p2, p0) + next if same?(p2, p1) + + out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2])) + next if out.empty? + next unless fits?(out, pts) + + area = OpenStudio.getArea(out) + next if area.empty? + + area = area.get + next if area < TOL + next if area < aire + + aire = area + box = out + end + end + end + + return bkp if aire < TOL + + box = to_p3Dv(t * box) if t + + box + end + + ## + # Generates re-'aligned' polygon vertices wrt main axis of symmetry of its + # largest bounded box. A Hash is returned with 6x key:value pairs ... + # set: realigned (cloned) polygon vertices, box: its bounded box (wrt to :set), + # bbox: its bounding box, t: its translation transformation, r: its rotation + # transformation, and o: the origin coordinates of its axis of rotation. First, + # cloned polygon vertices are rotated so the longest axis of symmetry of its + # bounded box lies parallel to the X-axis; :o being the midpoint of the narrow + # side (of the bounded box) nearest to grid origin (0,0,0). Once rotated, + # polygon vertices are then translated as to ensure one or more vertices are + # aligned along the X-axis and one or more vertices are aligned along the + # Y-axis (no vertices with negative X or Y coordinate values). To unalign the + # returned set of vertices (or its bounded box, or its bounding box), first + # inverse the translation transformation, then inverse the rotation + # transformation. + # + # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points + # + # @return [Hash] :set, :box, :bbox, :t, :r & :o + # @return [Hash] :set, :box, :bbox, :t, :r & :o (nil) if invalid (see logs) + def getRealignedFace(pts = nil) + mth = "OSut::#{__callee__}" + out = { set: nil, box: nil, bbox: nil, t: nil, r: nil, o: nil } + pts = poly(pts, false, true) + return out if pts.empty? + return invalid("aligned plane", mth, 1, DBG, out) unless xyz?(pts, :z) + return invalid("clockwise pts", mth, 1, DBG, out) if clockwise?(pts) + + o = OpenStudio::Point3d.new(0, 0, 0) + w = width(pts) + h = height(pts) + d = h > w ? h : w + sgs = {} + box = boundedBox(pts) + return invalid("bounded box", mth, 0, DBG, out) if box.empty? + + segments = getSegments(box) + return invalid("bounded box segments", mth, 0, DBG, out) if segments.empty? + + # Deterministic ID of box rotation/translation 'origin'. + segments.each_with_index do |sg, idx| + sgs[sg] = {} + sgs[sg][:idx] = idx + sgs[sg][:mid] = midpoint(sg[0], sg[1]) + sgs[sg][:l ] = (sg[1] - sg[0]).length + sgs[sg][:mo ] = (sgs[sg][:mid] - o).length + end + + sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h if square?(box) + sgs = sgs.sort_by { |sg, s| s[:l ] }.first(2).to_h unless square?(box) + sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h unless square?(box) + + sg0 = sgs.values[0] + sg1 = sgs.values[1] + + if (sg0[:mo]).round(2) == (sg1[:mo]).round(2) + i = sg1[:mid].y.round(2) < sg0[:mid].y.round(2) ? sg1[:idx] : sg0[:idx] + else + i = sg0[:idx] + end + + k = i + 2 < segments.size ? i + 2 : i - 2 + + origin = midpoint(segments[i][0], segments[i][1]) + terminal = midpoint(segments[k][0], segments[k][1]) + seg = terminal - origin + right = OpenStudio::Point3d.new(origin.x + d, origin.y , 0) - origin + north = OpenStudio::Point3d.new(origin.x, origin.y + d, 0) - origin + axis = OpenStudio::Point3d.new(origin.x, origin.y , d) - origin + angle = OpenStudio::getAngle(right, seg) + angle = -angle if north.dot(seg) < 0 + r = OpenStudio.createRotation(origin, axis, angle) + pts = to_p3Dv(r.inverse * pts) + box = to_p3Dv(r.inverse * box) + dX = pts.min_by(&:x).x + dY = pts.min_by(&:y).y + xy = OpenStudio::Point3d.new(origin.x + dX, origin.y + dY, 0) + origin2 = xy - origin + t = OpenStudio.createTranslation(origin2) + set = t.inverse * pts + box = t.inverse * box + bbox = outline([set]) + + out[:set ] = set + out[:box ] = box + out[:bbox] = bbox + out[:t ] = t + out[:r ] = r + out[:o ] = origin + + out + end + + ## + # Returns 'width' of a set of OpenStudio 3D points, once re/aligned. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned + # + # @return [Float] width along X-axis, once re/aligned + # @return [0.0] if invalid inputs + def alignedWidth(pts = nil) + pts = poly(pts, false, true, true, true) + return 0 if pts.size < 2 + + pts = getRealignedFace(pts)[:set] + return 0 if pts.size < 2 + + pts.max_by(&:x).x - pts.min_by(&:x).x + end + + ## + # Returns 'height' of a set of OpenStudio 3D points, once re/aligned. + # + # @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned + # + # @return [Float] height along Y-axis, once re/aligned + # @return [0.0] if invalid inputs + def alignedHeight(pts = nil) + pts = pts = poly(pts, false, true, true, true) + return 0 if pts.size < 2 + + pts = getRealignedFace(pts)[:set] + return 0 if pts.size < 2 + + pts.max_by(&:y).y - pts.min_by(&:y).y + end + + ## + # Generates leader line anchors, linking polygon vertices to one or more sets + # (Hashes) of sequenced vertices. By default, the method seeks to link set + # :vtx (key) vertices (users can select another collection of vertices, e.g. + # tag == :box). The method minimally validates individual sets of vertices + # (e.g. coplanarity, non-self-intersecting, no inter-set conflicts). Potential + # leader lines cannot intersect each other, other 'tagged' set vertices or + # original polygon edges. For highly-articulated cases (e.g. a narrow polygon + # with multiple concavities, holding multiple sets), such leader line + # conflicts will surely occur. The method relies on a 'first-come-first-served' + # approach: sets without leader lines are ignored (check for set :void keys, + # see error logs). It is recommended to sort sets prior to calling the method. + # + # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points + # @param [Array<Hash>] set a collection of sequenced vertices + # @option [Symbol] tag sequence of set vertices to target + # + # @return [Integer] number of successfully-generated anchors (check logs) + def genAnchors(s = nil, set = [], tag = :vtx) + mth = "OSut::#{__callee__}" + dZ = nil + t = nil + id = s.respond_to?(:nameString) ? "#{s.nameString}: " : "" + pts = poly(s) + n = 0 + return n if pts.empty? + return mismatch("set", set, Array, mth, DBG, n) unless set.respond_to?(:to_a) + + set = set.to_a + + # Validate individual sets. Purge surface-specific leader line anchors. + set.each_with_index do |st, i| + str1 = id + "set ##{i+1}" + str2 = str1 + " #{tag.to_s}" + return mismatch(str1, st, Hash, mth, DBG, n) unless st.respond_to?(:key?) + return hashkey( str1, st, tag, mth, DBG, n) unless st.key?(tag) + return empty("#{str2} vertices", mth, DBG, n) if st[tag].empty? + + stt = poly(st[tag]) + return invalid("#{str2} polygon", mth, 0, DBG, n) if stt.empty? + return invalid("#{str2} gap", mth, 0, DBG, n) unless fits?(stt, pts, true) + + if st.key?(:ld) + ld = st[:ld] + return invalid("#{str1} leaders", mth, 0, DBG, n) unless ld.is_a?(Hash) + + ld.reject! { |k, _| k == s } + else + st[:ld] = {} + end + end + + if facingUp?(pts) + if xyz?(pts, :z) + dZ = 0 + else + dZ = pts.first.z + pts = flatten(pts).to_a + end + else + t = OpenStudio::Transformation.alignFace(pts) + pts = t.inverse * pts + end + + # Set leader lines anchors. Gather candidate leader line anchors; select + # anchor with shortest distance to first vertex of 'tagged' set. + set.each_with_index do |st, i| + candidates = [] + break if st[:ld].key?(s) + + stt = dZ ? flatten(st[tag]).to_a : t.inverse * st[tag] + p1 = stt.first + + pts.each_with_index do |pt, k| + ld = [pt, p1] + nb = 0 + + # Check for intersections between leader line and polygon edges. + getSegments(pts).each do |sg| + break unless nb.zero? + next if holds?(sg, pt) + + nb += 1 if lineIntersects?(sg, ld) + end + + next unless nb.zero? + + # Check for intersections between candidate leader line and other sets. + set.each_with_index do |other, j| + break unless nb.zero? + next if i == j + + ost = dZ ? flatten(other[tag]).to_a : t.inverse * other[tag] + sgj = getSegments(ost) + + sgj.each { |sg| nb += 1 if lineIntersects?(ld, sg) } + end + + next unless nb.zero? + + # ... and previous leader lines (first come, first serve basis). + set.each_with_index do |other, j| + break unless nb.zero? + next if i == j + next unless other[:ld].key?(s) + + ost = other[tag] + pj = ost.first + old = other[:ld][s] + ldj = dZ ? flatten([ old, pj ]) : t.inverse * [ old, pj ] + + unless same?(old, pt) + nb += 1 if lineIntersects?(ld, ldj) + end + end + + next unless nb.zero? + + # Finally, check for self-intersections. + getSegments(stt).each do |sg| + break unless nb.zero? + next if holds?(sg, p1) + + nb += 1 if lineIntersects?(sg, ld) + nb += 1 if (sg.first - sg.last).cross(ld.first - ld.last).length < TOL + end + + candidates << pt if nb.zero? + end + + if candidates.empty? + str = id + "set ##{i+1}" + log(ERR, "#{str}: unable to anchor #{tag} leader line (#{mth})") + st[:void] = true + else + p0 = candidates.sort_by! { |pt| (pt - p1).length }.first + + if dZ + st[:ld][s] = OpenStudio::Point3d.new(p0.x, p0.y, p0.z + dZ) + else + st[:ld][s] = t * p0 + end + + n += 1 + end + end + + n + end + + ## + # Generates extended polygon vertices to circumscribe one or more sets + # (Hashes) of sequenced vertices. The method minimally validates individual + # sets of vertices (e.g. coplanarity, non-self-intersecting, no inter-set + # conflicts). Valid leader line anchors (set key :ld) need to be generated + # prior to calling the method (see genAnchors). By default, the method seeks + # to link leader line anchors to set :vtx (key) vertices (users can select + # another collection of vertices, e.g. tag == :box). + # + # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points + # @param [Array<Hash>] set a collection of sequenced vertices + # @option set [Hash] :ld a collection of polygon-specific leader line anchors + # @option [Symbol] tag sequence of set vertices to target + # + # @return [OpenStudio::Point3dVector] extended vertices (see logs if empty) + def genExtendedVertices(s = nil, set = [], tag = :vtx) + mth = "OSut::#{__callee__}" + id = s.respond_to?(:nameString) ? "#{s.nameString}: " : "" + f = false + pts = poly(s) + cl = OpenStudio::Point3d + a = OpenStudio::Point3dVector.new + v = [] + return a if pts.empty? + return mismatch("set", set, Array, mth, DBG, a) unless set.respond_to?(:to_a) + + set = set.to_a + + # Validate individual sets. + set.each_with_index do |st, i| + str1 = id + "set ##{i+1}" + str2 = str1 + " #{tag.to_s}" + return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?) + return hashkey( str1, st, tag, mth, DBG, a) unless st.key?(tag) + return empty("#{str2} vertices", mth, DBG, a) if st[tag].empty? + return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld) + + stt = poly(st[tag]) + return invalid("#{str2} polygon", mth, 0, DBG, a) if stt.empty? + + ld = st[:ld] + return mismatch(str, ld, Hash, mth, DBG, a) unless ld.is_a?(Hash) + return hashkey( str, ld, s, mth, DBG, a) unless ld.key?(s) + return mismatch(str, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl) + end + + # Re-sequence polygon vertices. + pts.each do |pt| + v << pt + + # Loop through each valid set; concatenate circumscribing vertices. + set.each_with_index do |st, i| + next unless same?(st[:ld][s], pt) + next unless st.key?(tag) + + v += st[tag].to_a + v << pt + end + end + + to_p3Dv(v) + end + + ## + # Generates arrays of rectangular polygon inserts within a larger polygon. If + # successful, each set inherits additional key:value pairs: namely :vtx + # (subset of polygon circumscribing vertices), and :vts (collection of + # indivudual polygon insert vertices). Valid leader line anchors (set key :ld) + # need to be generated prior to calling the method (see genAnchors, and + # genExtendedvertices). + # + # @param s [Set<OpenStudio::Point3d>] a larger polygon + # @param [Array<Hash>] set a collection of polygon insert instructions + # @option set [Set<OpenStudio::Point3d>] :box bounding box of each collection + # @option set [Hash] :ld a collection of polygon-specific leader line anchors + # @option set [Integer] :rows (1) number of rows of inserts + # @option set [Integer] :cols (1) number of columns of inserts + # @option set [Numeric] :w0 (1.4) width of individual inserts (wrt cols) min 0.4 + # @option set [Numeric] :d0 (1.4) depth of individual inserts (wrt rows) min 0.4 + # @option set [Numeric] :dX (0) optional left/right X-axis buffer + # @option set [Numeric] :dY (0) optional top/bottom Y-axis buffer + # + # @return [OpenStudio::Point3dVector] new polygon vertices (see logs if empty) + def genInserts(s = nil, set = []) + mth = "OSut::#{__callee__}" + id = s.respond_to?(:nameString) ? "#{s.nameString}:" : "" + pts = poly(s) + cl = OpenStudio::Point3d + a = OpenStudio::Point3dVector.new + return a if pts.empty? + return mismatch("set", set, Array, mth, DBG, a) unless set.respond_to?(:to_a) + + set = set.to_a + gap = 0.1 + gap4 = 0.4 # minimum insert width/depth + + # Validate/reset individual set collections. + set.each_with_index do |st, i| + str1 = id + "set ##{i+1}" + return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?) + return hashkey( str1, st, :box, mth, DBG, a) unless st.key?(:box) + return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld) + + str2 = str1 + " anchor" + ld = st[:ld] + return mismatch(str2, ld, Hash, mth, DBG, a) unless ld.respond_to?(:key?) + return hashkey( str2, ld, s, mth, DBG, a) unless ld.key?(s) + return mismatch(str2, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl) + + # Ensure each set bounding box is safely within larger polygon boundaries. + # TO DO: In line with related addSkylights "TO DO", expand method to + # safely handle 'side' cutouts (i.e. no need for leader lines). In + # so doing, boxes could eventually align along surface edges. + str3 = str1 + " box" + bx = poly(st[:box]) + return invalid(str3, mth, 0, DBG, a) if bx.empty? + return invalid("#{str3} rectangle", mth, 0, DBG, a) unless rectangular?(bx) + return invalid("#{str3} box", mth, 0, DBG, a) unless fits?(bx, pts, true) + + if st.key?(:rows) + rws = st[:rows] + return invalid("#{id} rows", mth, 0, DBG, a) unless rws.is_a?(Integer) + return zero( "#{id} rows", mth, DBG, a) if rws < 1 + else + st[:rows] = 1 + end + + if st.key?(:cols) + cls = st[:cols] + return invalid("#{id} cols", mth, 0, DBG, a) unless cls.is_a?(Integer) + return zero( "#{id} cols", mth, DBG, a) if cls < 1 + else + st[:cols] = 1 + end + + if st.key?(:w0) + w0 = st[:w0] + return invalid("#{id} width", mth, 0, DBG, a) unless w0.is_a?(Numeric) + + w0 = w0.to_f + return zero("#{id} width", mth, DBG, a) if w0.round(2) < gap4 + else + st[:w0] = 1.4 + end + + if st.key?(:d0) + d0 = st[:d0] + return invalid("#{id} depth", mth, 0, DBG, a) unless d0.is_a?(Numeric) + + d0 = d0.to_f + return zero("#{id} depth", mth, DBG, a) if d0.round(2) < gap4 + else + st[:d0] = 1.4 + end + + if st.key?(:dX) + dX = st[:dX] + return invalid( "#{id} dX", mth, 0, DBG, a) unless dX.is_a?(Numeric) + else + st[:dX] = nil + end + + if st.key?(:dY) + dY = st[:dY] + return invalid( "#{id} dY", mth, 0, DBG, a) unless dY.is_a?(Numeric) + else + st[:dY] = nil + end + end + + # Flag conflicts between set bounding boxes. TO DO: ease up for ridges. + set.each_with_index do |st, i| + bx = st[:box] + + set.each_with_index do |other, j| + next if i == j + + bx2 = other[:box] + str4 = id + "set boxes ##{i+1}:##{j+1}" + next unless overlaps?(bx, bx2) + return invalid("#{str4} (overlapping)", mth, 0, DBG, a) + end + end + + # Loop through each 'valid' set (i.e. linking a valid leader line anchor), + # generate set vertex array based on user-provided specs. Reset BLC vertex + # coordinates once completed. + set.each_with_index do |st, i| + str = id + "set ##{i+1}" + dZ = nil + t = nil + bx = st[:box] + + if facingUp?(bx) + if xyz?(bx, :z) + dZ = 0 + else + dZ = bx.first.z + bx = flatten(bx).to_a + end + else + t = OpenStudio::Transformation.alignFace(bx) + bx = t.inverse * bx + end + + o = getRealignedFace(bx) + next unless o[:set] + + st[:out] = o + st[:bx ] = blc(o[:r] * (o[:t] * o[:set])) + + + vts = {} # collection of individual (named) polygon insert vertices + vtx = [] # sequence of circumscribing polygon vertices + + bx = o[:set] + w = width(bx) # overall sandbox width + d = height(bx) # overall sandbox depth + dX = st[:dX ] # left/right buffer (array vs bx) + dY = st[:dY ] # top/bottom buffer (array vs bx) + cols = st[:cols] # number of array columns + rows = st[:rows] # number of array rows + x = st[:w0 ] # width of individual insert + y = st[:d0 ] # depth of indivual insert + gX = 0 # gap between insert columns + gY = 0 # gap between insert rows + + # Gap between insert columns. + if cols > 1 + dX = ( (w - cols * x) / cols) / 2 unless dX + gX = (w - 2 * dX - cols * x) / (cols - 1) + gX = gap if gX.round(2) < gap + dX = (w - cols * x - (cols - 1) * gX) / 2 + else + dX = (w - x) / 2 + end + + if dX.round(2) < 0 + log(ERR, "Skipping #{str}: Negative dX {#{mth}}") + next + end + + # Gap between insert rows. + if rows > 1 + dY = ( (d - rows * y) / rows) / 2 unless dY + gY = (d - 2 * dY - rows * y) / (rows - 1) + gY = gap if gY.round(2) < gap + dY = (d - rows * y - (rows - 1) * gY) / 2 + else + dY = (d - y) / 2 + end + + if dY.round(2) < 0 + log(ERR, "Skipping #{str}: Negative dY {#{mth}}") + next + end + + st[:dX] = dX + st[:gX] = gX + st[:dY] = dY + st[:gY] = gY + + x0 = bx.min_by(&:x).x + dX # X-axis starting point + y0 = bx.min_by(&:y).y + dY # X-axis starting point + xC = x0 # current X-axis position + yC = y0 # current Y-axis position + + # BLC of array. + vtx << OpenStudio::Point3d.new(xC, yC, 0) + + # Move up incrementally along left side of sandbox. + rows.times.each do |iY| + unless iY.zero? + yC += gY + vtx << OpenStudio::Point3d.new(xC, yC, 0) + end + + yC += y + vtx << OpenStudio::Point3d.new(xC, yC, 0) + end + + # Loop through each row: left-to-right, then right-to-left. + rows.times.each do |iY| + (cols - 1).times.each do |iX| + xC += x + vtx << OpenStudio::Point3d.new(xC, yC, 0) + + xC += gX + vtx << OpenStudio::Point3d.new(xC, yC, 0) + end + + # Generate individual polygon inserts, left-to-right. + cols.times.each do |iX| + nom = "#{i}:#{iX}:#{iY}" + vec = [] + vec << OpenStudio::Point3d.new(xC , yC , 0) + vec << OpenStudio::Point3d.new(xC , yC - y, 0) + vec << OpenStudio::Point3d.new(xC + x, yC - y, 0) + vec << OpenStudio::Point3d.new(xC + x, yC , 0) + + # Store. + vtz = ulc(o[:r] * (o[:t] * vec)) + + if dZ + vz = OpenStudio::Point3dVector.new + vtz.each { |v| vz << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) } + vts[nom] = vz + else + vts[nom] = to_p3Dv(t * vtz) + end + + # Add reverse vertices, circumscribing each insert. + vec.reverse! + vec.pop if iX == cols - 1 + vtx += vec + + xC -= gX + x unless iX == cols - 1 + end + + unless iY == rows - 1 + yC -= gY + y + vtx << OpenStudio::Point3d.new(xC, yC, 0) + end + end + + vtx = o[:r] * (o[:t] * vtx) + + if dZ + vz = OpenStudio::Point3dVector.new + vtx.each { |v| vz << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) } + vtx = vz + else + vtx = to_p3Dv(t * vtx) + end + + st[:vts] = vts + st[:vtx] = vtx + end + + # Extended vertex sequence of the larger polygon. + genExtendedVertices(s, set) + end + + ## + # Returns an array of OpenStudio space surfaces or subsurfaces that match + # criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note that + # 'sides' rely on space coordinates (not absolute model coordinates). Also, + # 'sides' are exclusive (not inclusive), e.g. walls strictly north-facing or + # strictly east-facing would not be returned if 'sides' holds [:north, :east]. + # + # @param spaces [Set<OpenStudio::Model::Space>] target spaces # @param boundary [#to_s] OpenStudio outside boundary condition - # @param type [#to_s] OpenStudio surface type - # @param sides [Array<Symbols>] direction keys, e.g. :north (see OSut::SIDZ) + # @param type [#to_s] OpenStudio surface (or subsurface) type + # @param sides [Set<Symbols>] direction keys, e.g. :north (see OSut::SIDZ) # - # @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty) + # @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty, no logs) def facets(spaces = [], boundary = "Outdoors", type = "Wall", sides = []) - return [] unless spaces.respond_to?(:&) - return [] unless sides.respond_to?(:&) - return [] if sides.empty? + spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces + spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [] + return [] if spaces.empty? + sides = sides.respond_to?(:to_sym) ? [sides] : sides + sides = sides.respond_to?(:to_a) ? sides.to_a : [] + faces = [] boundary = trim(boundary).downcase type = trim(type).downcase return [] if boundary.empty? return [] if type.empty? - # Keep valid sides. - sides = sides.select { |side| SIDZ.include?(side) } - return [] if sides.empty? + # Filter sides. If sides is initially empty, return all surfaces of matching + # type and outside boundary condition. + unless sides.empty? + sides = sides.select { |side| SIDZ.include?(side) } + return [] if sides.empty? + end spaces.each do |space| return [] unless space.respond_to?(:setSpaceType) space.surfaces.each do |s| next unless s.outsideBoundaryCondition.downcase == boundary next unless s.surfaceType.downcase == type - orientations = [] - orientations << :top if s.outwardNormal.z > TOL - orientations << :bottom if s.outwardNormal.z < -TOL - orientations << :north if s.outwardNormal.y > TOL - orientations << :east if s.outwardNormal.x > TOL - orientations << :south if s.outwardNormal.y < -TOL - orientations << :west if s.outwardNormal.x < -TOL + if sides.empty? + faces << s + else + orientations = [] + orientations << :top if s.outwardNormal.z > TOL + orientations << :bottom if s.outwardNormal.z < -TOL + orientations << :north if s.outwardNormal.y > TOL + orientations << :east if s.outwardNormal.x > TOL + orientations << :south if s.outwardNormal.y < -TOL + orientations << :west if s.outwardNormal.x < -TOL - faces << s if sides.all? { |o| orientations.include?(o) } + faces << s if sides.all? { |o| orientations.include?(o) } + end end end + # SubSurfaces? + spaces.each do |space| + break unless faces.empty? + + space.surfaces.each do |s| + next unless s.outsideBoundaryCondition.downcase == boundary + + s.subSurfaces.each do |sub| + next unless sub.subSurfaceType.downcase == type + + if sides.empty? + faces << sub + else + orientations = [] + orientations << :top if sub.outwardNormal.z > TOL + orientations << :bottom if sub.outwardNormal.z < -TOL + orientations << :north if sub.outwardNormal.y > TOL + orientations << :east if sub.outwardNormal.x > TOL + orientations << :south if sub.outwardNormal.y < -TOL + orientations << :west if sub.outwardNormal.x < -TOL + + faces << sub if sides.all? { |o| orientations.include?(o) } + end + end + end + end + faces end ## # Generates an OpenStudio 3D point vector of a composite floor "slab", a @@ -3381,58 +5007,109 @@ slb end ## - # Returns outdoor-facing, space-(related) roof/ceiling surfaces. These - # include outdoor-facing roof/ceilings of the space per se, as well as - # any outside-facing roof/ceiling surface of an unoccupied space - # immediately above (e.g. a plenum) overlapping any of the roof/ceilings - # of the space itself. + # Returns outdoor-facing, space-related roof surfaces. These include + # outdoor-facing roofs of each space per se, as well as any outdoor-facing + # roof surface of unoccupied spaces immediately above (e.g. plenums, attics) + # overlapping any of the ceiling surfaces of each space. # - # @param space [OpenStudio::Model::Space] a space + # @param spaces [Set<OpenStudio::Model::Space>] target spaces # - # @return [Array<OpenStudio::Model::Surface>] surfaces (see logs if empty) - def getRoofs(space = nil) - mth = "OSut::#{__callee__}" - cl = OpenStudio::Model::Space - return mismatch("space", space, cl, mth, DBG, []) unless space.is_a?(cl) + # @return [Array<OpenStudio::Model::Surface>] roofs (may be empty) + def getRoofs(spaces = []) + mth = "OSut::#{__callee__}" + up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0) + roofs = [] + spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces + spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [] - roofs = space.surfaces # outdoor-facing roofs of the space - clngs = space.surfaces # surface-facing ceilings of the space + spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) } - roofs = roofs.select {|s| s.surfaceType.downcase == "roofceiling"} - roofs = roofs.select {|s| s.outsideBoundaryCondition.downcase == "outdoors"} + # Space-specific outdoor-facing roof surfaces. + roofs = facets(spaces, "Outdoors", "RoofCeiling") - clngs = clngs.select {|s| s.surfaceType.downcase == "roofceiling"} - clngs = clngs.select {|s| s.outsideBoundaryCondition.downcase == "surface"} + # Outdoor-facing roof surfaces of unoccupied plenums or attics above? + spaces.each do |space| + # When multiple spaces are involved (e.g. plenums, attics), the target + # space may not share the same local transformation as the space(s) above. + # Fetching local transformation. + t0 = transforms(space) + next unless t0[:t] - clngs.each do |ceiling| - floor = ceiling.adjacentSurface - next if floor.empty? + t0 = t0[:t] - other = floor.get.space - next if other.empty? + facets(space, "Surface", "RoofCeiling").each do |ceiling| + cv0 = t0 * ceiling.vertices - rufs = other.get.surfaces + floor = ceiling.adjacentSurface + next if floor.empty? - rufs = rufs.select {|s| s.surfaceType.downcase == "roofceiling"} - rufs = rufs.select {|s| s.outsideBoundaryCondition.downcase == "outdoors"} - next if rufs.empty? + other = floor.get.space + next if other.empty? - # Only keep track of "other" roof(s) that "overlap" ceiling below. - rufs.each do |ruf| - next unless overlaps?(ceiling, ruf) + other = other.get + next if other.partofTotalFloorArea - roofs << ruf unless roofs.include?(ruf) + ti = transforms(other) + next unless ti[:t] + + ti = ti[:t] + + # TO DO: recursive call for stacked spaces as atria (via AirBoundaries). + facets(other, "Outdoors", "RoofCeiling").each do |ruf| + rvi = ti * ruf.vertices + cst = cast(cv0, rvi, up) + next unless overlaps?(cst, rvi, false) + + roofs << ruf unless roofs.include?(ruf) + end end end roofs end ## + # Validates whether space has outdoor-facing surfaces with fenestration. + # + # @param space [OpenStudio::Model::Space] a space + # @param sidelit [Bool] whether to check for sidelighting, e.g. windows + # @param toplit [Bool] whether to check for toplighting, e.g. skylights + # @param baselit [Bool] whether to check for baselighting, e.g. glazed floors + # + # @return [Bool] whether space is daylit + # @return [false] if invalid input (see logs) + def daylit?(space = nil, sidelit = true, toplit = true, baselit = true) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::Space + ck1 = space.is_a?(cl) + ck2 = [true, false].include?(sidelit) + ck3 = [true, false].include?(toplit) + ck4 = [true, false].include?(baselit) + return mismatch("space", space, cl, mth, DBG, false) unless ck1 + return invalid("sidelit" , mth, 2, DBG, false) unless ck2 + return invalid("toplit" , mth, 3, DBG, false) unless ck3 + return invalid("baselit" , mth, 4, DBG, false) unless ck4 + + walls = sidelit ? facets(space, "Outdoors", "Wall") : [] + roofs = toplit ? facets(space, "Outdoors", "RoofCeiling") : [] + floors = baselit ? facets(space, "Outdoors", "Floor") : [] + + (walls + roofs + floors).each do |surface| + surface.subSurfaces.each do |sub| + # All fenestrated subsurface types are considered, as user can set these + # explicitly (e.g. skylight in a wall) in OpenStudio. + return true if fenestration?(sub) + end + end + + false + end + + ## # Adds sub surfaces (e.g. windows, doors, skylights) to surface. # # @param s [OpenStudio::Model::Surface] a model surface # @param [Array<Hash>] subs requested attributes # @option subs [#to_s] :id identifier e.g. "Window 007" @@ -3485,14 +5162,14 @@ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Ensure minimum safety buffer. if bfr.respond_to?(:to_f) bfr = bfr.to_f - return negative("safety buffer", mth, ERR, no) if bfr < 0 + return negative("safety buffer", mth, ERR, no) if bfr.round(2) < 0 msg = "Safety buffer < 5mm may generate invalid geometry (#{mth})" - log(WRN, msg) if bfr < 0.005 + log(WRN, msg) if bfr.round(2) < 0.005 else log(ERR, "Setting safety buffer to 5mm (#{mth})") bfr = 0.005 end @@ -3509,13 +5186,23 @@ type = "FixedWindow" types = OpenStudio::Model::SubSurface.validSubSurfaceTypeValues stype = s.surfaceType # Wall, RoofCeiling or Floor # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # - t = OpenStudio::Transformation.alignFace(s.vertices) - max_x = width(s) - max_y = height(s) + t = OpenStudio::Transformation.alignFace(s.vertices) + s0 = poly(s, false, false, false, t, :ulc) + s00 = nil + + if facingUp?(s) || facingDown?(s) # TODO: redundant check? + s00 = getRealignedFace(s0) + return false unless s00[:set] + + s0 = s00[:set] + end + + max_x = width(s0) + max_y = height(s0) mid_x = max_x / 2 mid_y = max_y / 2 # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # # Assign default values to certain sub keys (if missing), +more validation. @@ -3609,17 +5296,15 @@ # Set-up unique sub parameters: # - Frame & Divider "width" # - minimum "clear glazing" limits # - buffers, etc. id = sub[:id] - frame = 0 - frame = sub[:frame].frameWidth unless sub[:frame].nil? + frame = sub[:frame] ? sub[:frame].frameWidth : 0 frames = 2 * frame buffer = frame + bfr buffers = 2 * buffer - dim = 0.200 unless (3 * frame) > 0.200 - dim = 3 * frame if (3 * frame) > 0.200 + dim = 3 * frame > 0.200 ? 3 * frame : 0.200 glass = dim - frames min_sill = buffer min_head = buffers + glass max_head = max_y - buffer max_sill = max_head - (buffers + glass) @@ -3641,29 +5326,29 @@ typ_sill = mid_y * (1 - sub[:ratio]) unless stype.downcase == "wall" end # Log/reset "height" if beyond min/max. if sub.key?(:height) - unless sub[:height].between?(glass, max_height) + unless sub[:height].between?(glass - TOL2, max_height + TOL2) sub[:height] = glass if sub[:height] < glass sub[:height] = max_height if sub[:height] > max_height log(WRN, "Reset '#{id}' height to #{sub[:height]} m (#{mth})") end end # Log/reset "head" height if beyond min/max. if sub.key?(:head) - unless sub[:head].between?(min_head, max_head) + unless sub[:head].between?(min_head - TOL2, max_head + TOL2) sub[:head] = max_head if sub[:head] > max_head sub[:head] = min_head if sub[:head] < min_head log(WRN, "Reset '#{id}' head height to #{sub[:head]} m (#{mth})") end end # Log/reset "sill" height if beyond min/max. if sub.key?(:sill) - unless sub[:sill].between?(min_sill, max_sill) + unless sub[:sill].between?(min_sill - TOL2, max_sill + TOL2) sub[:sill] = max_sill if sub[:sill] > max_sill sub[:sill] = min_sill if sub[:sill] < min_sill log(WRN, "Reset '#{id}' sill height to #{sub[:sill]} m (#{mth})") end end @@ -3672,11 +5357,11 @@ # validated (and/or have been corrected) independently from one another. # Log/reset "head" & "sill" heights if conflicting. if sub.key?(:head) && sub.key?(:sill) && sub[:head] < sub[:sill] + glass sill = sub[:head] - glass - if sill < min_sill + if sill < min_sill - TOL2 sub[:ratio ] = 0 if sub.key?(:ratio) sub[:count ] = 0 sub[:multiplier] = 0 sub[:height ] = 0 if sub.key?(:height) sub[:width ] = 0 if sub.key?(:width) @@ -3691,20 +5376,20 @@ # Attempt to reconcile "head", "sill" and/or "height". If successful, # all 3x parameters are set (if missing), or reset if invalid. if sub.key?(:head) && sub.key?(:sill) height = sub[:head] - sub[:sill] - if sub.key?(:height) && (sub[:height] - height).abs > TOL + if sub.key?(:height) && (sub[:height] - height).abs > TOL2 log(WRN, "(Re)set '#{id}' height to #{height} m (#{mth})") end sub[:height] = height elsif sub.key?(:head) # no "sill" if sub.key?(:height) sill = sub[:head] - sub[:height] - if sill < min_sill + if sill < min_sill - TOL2 sill = min_sill height = sub[:head] - sill if height < glass sub[:ratio ] = 0 if sub.key?(:ratio) @@ -3728,11 +5413,11 @@ end elsif sub.key?(:sill) # no "head" if sub.key?(:height) head = sub[:sill] + sub[:height] - if head > max_head + if head > max_head - TOL2 head = max_head height = head - sub[:sill] if height < glass sub[:ratio ] = 0 if sub.key?(:ratio) @@ -3753,11 +5438,11 @@ else sub[:head ] = typ_head sub[:height] = sub[:head] - sub[:sill] end elsif sub.key?(:height) # neither "head" nor "sill" - head = typ_head + head = s00 ? mid_y + sub[:height]/2 : typ_head sill = head - sub[:height] if sill < min_sill sill = min_sill head = sill + sub[:height] @@ -3771,11 +5456,11 @@ sub[:height] = sub[:head] - sub[:sill] end # Log/reset "width" if beyond min/max. if sub.key?(:width) - unless sub[:width].between?(glass, max_width) + unless sub[:width].between?(glass - TOL2, max_width + TOL2) sub[:width] = glass if sub[:width] < glass sub[:width] = max_width if sub[:width] > max_width log(WRN, "Reset '#{id}' width to #{sub[:width]} m (#{mth})") end end @@ -3794,19 +5479,19 @@ sub[:count] = 1 unless sub.key?(:count) # Log/reset if left-sided buffer under min jamb position. if sub.key?(:l_buffer) - if sub[:l_buffer] < min_ljamb + if sub[:l_buffer] < min_ljamb - TOL sub[:l_buffer] = min_ljamb log(WRN, "Reset '#{id}' left buffer to #{sub[:l_buffer]} m (#{mth})") end end # Log/reset if right-sided buffer beyond max jamb position. if sub.key?(:r_buffer) - if sub[:r_buffer] > max_rjamb + if sub[:r_buffer] > max_rjamb - TOL sub[:r_buffer] = min_rjamb log(WRN, "Reset '#{id}' right buffer to #{sub[:r_buffer]} m (#{mth})") end end @@ -3866,11 +5551,11 @@ centre = x0 + w/2 end end # Too wide? - if x0 < min_ljamb || xf > max_rjamb + if x0 < min_ljamb - TOL2 || xf > max_rjamb - TOL2 sub[:ratio ] = 0 if sub.key?(:ratio) sub[:count ] = 0 sub[:multiplier] = 0 sub[:height ] = 0 if sub.key?(:height) sub[:width ] = 0 if sub.key?(:width) @@ -3930,11 +5615,11 @@ centre = x0 + w/2 end end # Too wide? - if x0 < bfr || xf > max_x - bfr + if x0 < bfr - TOL2 || xf > max_x - bfr - TOL2 sub[:ratio ] = 0 if sub.key?(:ratio) sub[:count ] = 0 sub[:multiplier] = 0 sub[:height ] = 0 if sub.key?(:height) sub[:width ] = 0 if sub.key?(:width) @@ -3955,16 +5640,17 @@ vec = OpenStudio::Point3dVector.new vec << OpenStudio::Point3d.new(pos, sub[:head], 0) vec << OpenStudio::Point3d.new(pos, sub[:sill], 0) vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:sill], 0) vec << OpenStudio::Point3d.new(pos + sub[:width], sub[:head], 0) - vec = t * vec + vec = s00 ? t * (s00[:r] * (s00[:t] * vec)) : t * vec # Log/skip if conflict between individual sub and base surface. vc = vec vc = offset(vc, fr, 300) if fr > 0 ok = fits?(vc, s) + log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok break unless ok # Log/skip if conflicts with existing subs (even if same array). s.subSurfaces.each do |sb| @@ -3995,9 +5681,1636 @@ pos += sub[:offset] if sub.key?(:offset) end end true + end + + ## + # Validates whether surface is considered a sloped roof (outdoor-facing, + # 10% < tilt < 90%). + # + # @param s [OpenStudio::Model::Surface] a model surface + # + # @return [Bool] whether surface is a sloped roof + # @return [false] if invalid input (see logs) + def slopedRoof?(s = nil) + mth = "OSut::#{__callee__}" + cl = OpenStudio::Model::Surface + return mismatch("surface", s, cl, mth, DBG, false) unless s.is_a?(cl) + + return false if facingUp?(s) + return false if facingDown?(s) + + true + end + + ## + # Returns the "gross roof area" above selected conditioned, occupied spaces. + # This includes all roof surfaces of indirectly-conditioned, unoccupied spaces + # like plenums (if located above any of the selected spaces). This also + # includes roof surfaces of unconditioned or unenclosed spaces like attics, if + # vertically-overlapping any ceiling of occupied spaces below; attic roof + # sections above uninsulated soffits are excluded, for instance. + def grossRoofArea(spaces = []) + mth = "OSut::#{__callee__}" + up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0) + rm2 = 0 + rfs = {} + + spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces + spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [] + spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) } + spaces = spaces.select { |space| space.partofTotalFloorArea } + return invalid("spaces", mth, 1, DBG, 0) if spaces.empty? + + # The method is very similar to OpenStudio-Standards' : + # find_exposed_conditioned_roof_surfaces(model) + # + # github.com/NREL/openstudio-standards/blob/ + # be81bd88dc55a44d8cce3ee6daf29c768032df6a/lib/openstudio-standards/ + # standards/Standards.Surface.rb#L99 + # + # ... yet differs with regards to attics with overhangs/soffits. + + # Start with roof surfaces of occupied spaces. + spaces.each do |space| + facets(space, "Outdoors", "RoofCeiling").each do |roof| + next if rfs.key?(roof) + + rfs[roof] = {m2: roof.grossArea, m: space.multiplier} + end + end + + # Roof surfaces of unoccupied, conditioned spaces above (e.g. plenums)? + # TO DO: recursive call for stacked spaces as atria (via AirBoundaries). + spaces.each do |space| + facets(space, "Surface", "RoofCeiling").each do |ceiling| + floor = ceiling.adjacentSurface + next if floor.empty? + + other = floor.get.space + next if other.empty? + + other = other.get + next if other.partofTotalFloorArea + next if unconditioned?(other) + + facets(other, "Outdoors", "RoofCeiling").each do |roof| + next if rfs.key?(roof) + + rfs[roof] = {m2: roof.grossArea, m: other.multiplier} + end + end + end + + # Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)? + # TO DO: recursive call for stacked spaces as atria (via AirBoundaries). + spaces.each do |space| + # When taking overlaps into account, the target space may not share the + # same local transformation as the space(s) above. + t0 = transforms(space) + next unless t0[:t] + + t0 = t0[:t] + + facets(space, "Surface", "RoofCeiling").each do |ceiling| + cv0 = t0 * ceiling.vertices + + floor = ceiling.adjacentSurface + next if floor.empty? + + other = floor.get.space + next if other.empty? + + other = other.get + next if other.partofTotalFloorArea + next unless unconditioned?(other) + + ti = transforms(other) + next unless ti[:t] + + ti = ti[:t] + + facets(other, "Outdoors", "RoofCeiling").each do |roof| + rvi = ti * roof.vertices + cst = cast(cv0, rvi, up) + next if cst.empty? + + # The overlap calculation fails for roof and ceiling surfaces with + # previously-added leader lines. + # + # TODO: revise approach for attics ONCE skylight wells have been added. + olap = nil + olap = overlap(cst, rvi, false) + next if olap.empty? + + m2 = OpenStudio.getArea(olap) + next if m2.empty? + + m2 = m2.get + next unless m2.round(2) > 0 + + rfs[roof] = {m2: 0, m: other.multiplier} unless rfs.key?(roof) + + rfs[roof][:m2] += m2 + end + end + end + + rfs.values.each { |rf| rm2 += rf[:m2] * rf[:m] } + + rm2 + end + + ## + # Identifies horizontal ridges between 2x sloped roof surfaces (same space). + # If successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an + # :edge (OpenStudio::Point3dVector), the edge :length (Numeric), and :roofs + # (Array of 2x linked roof surfaces). Each roof surface may be linked to more + # than one horizontal ridge. + # + # @param roofs [Array<OpenStudio::Model::Surface>] target roof surfaces + # + # @return [Array] horizontal ridges (see logs if empty) + def getHorizontalRidges(roofs = []) + mth = "OSut::#{__callee__}" + ridges = [] + return ridges unless roofs.is_a?(Array) + + roofs = roofs.select { |s| s.is_a?(OpenStudio::Model::Surface) } + roofs = roofs.select { |s| slopedRoof?(s) } + + roofs.each do |roof| + maxZ = roof.vertices.max_by(&:z).z + next if roof.space.empty? + + space = roof.space.get + + getSegments(roof).each do |edge| + next unless xyz?(edge, :z, maxZ) + + # Skip if already tracked. + match = false + + ridges.each do |ridge| + break if match + + edg = ridge[:edge] + match = same?(edge, edg) || same?(edge, edg.reverse) + end + + next if match + + ridge = { edge: edge, length: (edge[1] - edge[0]).length, roofs: [roof] } + + # Links another roof (same space)? + match = false + + roofs.each do |ruf| + break if match + next if ruf == roof + next if ruf.space.empty? + next unless ruf.space.get == space + + getSegments(ruf).each do |edg| + break if match + next unless same?(edge, edg) || same?(edge, edg.reverse) + + ridge[:roofs] << ruf + ridges << ridge + match = true + end + end + end + end + + ridges + end + + ## + # Adds skylights to toplight selected OpenStudio (occupied, conditioned) + # spaces, based on requested skylight-to-roof (SRR%) options (max 10%). If the + # user selects 0% (0.0) as the :srr while keeping :clear as true, the method + # simply purges all pre-existing roof subsurfaces (whether glazed or not) of + # selected spaces, and exits while returning 0 (without logging an error or + # warning). Pre-toplit spaces are otherwise ignored. Boolean options :attic, + # :plenum, :sloped and :sidelit, further restrict candidate roof surfaces. If + # applicable, options :attic and :plenum add skylight wells. Option :patterns + # restricts preset skylight allocation strategies in order of preference; if + # left empty, all preset patterns are considered, also in order of preference + # (see examples). + # + # @param spaces [Array<OpenStudio::Model::Space>] space(s) to toplight + # @param [Hash] opts requested skylight attributes + # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.10] + # @option opts [#to_f] :size (1.22) template skylight width/depth (min 0.4m) + # @option opts [#frameWidth] :frame (nil) OpenStudio Frame & Divider (optional) + # @option opts [Bool] :clear (true) whether to first purge existing skylights + # @option opts [Bool] :sidelit (true) whether to consider sidelit spaces + # @option opts [Bool] :sloped (true) whether to consider sloped roof surfaces + # @option opts [Bool] :plenum (true) whether to consider plenum wells + # @option opts [Bool] :attic (true) whether to consider attic wells + # @option opts [Array<#to_s>] :patterns requested skylight allocation (3x) + # @example (a) consider 2D array of individual skylights, e.g. n(1.2m x 1.2m) + # opts[:patterns] = ["array"] + # @example (b) consider 'a', then array of 1x(size) x n(size) skylight strips + # opts[:patterns] = ["array", "strips"] + # + # @return [Float] returns gross roof area if successful (see logs if 0 m2) + def addSkyLights(spaces = [], opts = {}) + mth = "OSut::#{__callee__}" + clear = true + srr = 0.0 + frame = nil # FrameAndDivider object + f = 0.0 # FrameAndDivider frame width + gap = 0.1 # min 2" around well (2x), as well as max frame width + gap2 = 0.2 # 2x gap + gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width) + bfr = 0.005 # minimum array perimeter buffer (no wells) + w = 1.22 # default 48" x 48" skylight base + w2 = w * w # m2 + + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Excerpts of ASHRAE 90.1 2022 definitions: + # + # "ROOF": + # + # "the upper portion of the building envelope, including opaque areas and + # fenestration, that is horizontal or tilted at an angle of less than 60 + # degrees from horizontal. For the purposes of determining building + # envelope requirements, the classifications are defined as follows + # (inter alia): + # + # - attic and other roofs: all other roofs, including roofs with + # insulation ENTIRELY BELOW (inside of) the roof structure (i.e., + # attics, cathedral ceilings, and single-rafter ceilings), roofs with + # insulation both above and BELOW the roof structure, and roofs + # without insulation but excluding metal building roofs. [...]" + # + # "ROOF AREA, GROSS": + # + # "the area of the roof measured from the EXTERIOR faces of walls or from + # the centerline of party walls." + # + # + # For the simple case below (steep 4-sided hip roof, UNENCLOSED ventilated + # attic), 90.1 users typically choose between either: + # 1. modelling the ventilated attic explicitly, or + # 2. ignoring the ventilated attic altogether. + # + # If skylights were added to the model, option (1) would require one or more + # skylight wells (light shafts leading to occupied spaces below), with + # insulated well walls separating CONDITIONED spaces from an UNENCLOSED, + # UNCONDITIONED space (i.e. attic). + # + # Determining which roof surfaces (or which portion of roof surfaces) need + # to be considered when calculating "GROSS ROOF AREA" may be subject to some + # interpretation. From the above definitions: + # + # - the uninsulated, tilted hip-roof attic surfaces are considered "ROOF" + # surfaces, provided they 'shelter' insulation below (i.e. insulated + # attic floor). + # - however, only the 'projected' portion of such "ROOF" surfaces, i.e. + # areas between axes AA` and BB` (along exterior walls)) would be + # considered. + # - the portions above uninsulated soffits (illustrated on the right) + # would be excluded from the "GROSS ROOF AREA" as they are beyond the + # exterior wall projections. + # + # A B + # | | + # _________ + # / \ /| |\ + # / \ / | | \ + # /_ ________ _\ = > /_ | | _\ ... excluded portions + # | | + # |__________| + # . . + # A` B` + # + # If the unoccupied space (directly under the hip roof) were instead an + # INDIRECTLY-CONDITIONED plenum (not an attic), then there would be no need + # to exclude portions of any roof surface: all plenum roof surfaces (in + # addition to soffit surfaces) would need to be insulated). The method takes + # such circumstances into account, which requires vertically casting of + # surfaces ontoothers, as well as overlap calculations. If successful, the + # method returns the "GROSS ROOF AREA" (in m2), based on the above rationale. + # + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Excerpts of similar NECB requirements (unchanged from 2011 through 2020): + # + # 3.2.1.4. 2). "The total skylight area shall be less than 2% of the GROSS + # ROOF AREA as determined in Article 3.1.1.6." (5% in earlier versions) + # + # 3.1.1.6. 5). "In the calculation of allowable skylight area, the GROSS + # ROOF AREA shall be calculated as the sum of the areas of insulated + # roof including skylights." + # + # There are NO additional details or NECB appendix notes on the matter. It + # is unclear if the NECB's looser definition of GROSS ROOF AREA includes + # (uninsulated) sloped roof surfaces above (insulated) flat ceilings (e.g. + # attics), as with 90.1. It would be definitely odd if it didn't. For + # instance, if the GROSS ROOF AREA were based on insulated ceiling surfaces, + # there would be a topological disconnect between flat ceiling and sloped + # skylights above. Should NECB users first 'project' (sloped) skylight rough + # openings onto flat ceilings when calculating %SRR? Without much needed + # clarification, the (clearer) 90.1 rules equally apply here to NECB cases. + + # If skylight wells are indeed required, well wall edges are always vertical + # (i.e. never splayed), requiring a vertical ray. + origin = OpenStudio::Point3d.new(0,0,0) + zenith = OpenStudio::Point3d.new(0,0,1) + ray = zenith - origin + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Accept a single 'OpenStudio::Model::Space' (vs an array of spaces). + if spaces.respond_to?(:spaceType) || spaces.respond_to?(:to_a) + spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [spaces] + spaces = spaces.select { |space| space.respond_to?(:spaceType) } + spaces = spaces.select { |space| space.partofTotalFloorArea } + spaces = spaces.reject { |space| unconditioned?(space) } + return empty("spaces", mth, DBG, 0) if spaces.empty? + else + return mismatch("spaces", spaces, Array, mth, DBG, 0) + end + + mdl = spaces.first.model + + # Exit if mismatched or invalid argument classes/keys. + return mismatch("opts", opts, Hash, mth, DBG, 0) unless opts.is_a?(Hash) + return hashkey( "srr", opts, :srr, mth, ERR, 0) unless opts.key?(:srr) + + # Validate requested skylight-to-roof ratio. + if opts[:srr].respond_to?(:to_f) + srr = opts[:srr].to_f + log(WRN, "Resetting srr to 0% (#{mth})") if srr < 0 + log(WRN, "Resetting srr to 10% (#{mth})") if srr > 0.10 + srr = srr.clamp(0.00, 0.10) + else + return mismatch("srr", opts[:srr], Numeric, mth, DBG, 0) + end + + # Validate Frame & Divider object, if provided. + if opts.key?(:frame) + frame = opts[:frame] + + if frame.respond_to?(:frameWidth) + frame = nil if v < 321 + frame = nil if f.frameWidth.round(2) < 0 + frame = nil if f.frameWidth.round(2) > gap + + f = f.frameWidth if frame + log(WRN, "Skip Frame&Divider (#{mth})") unless frame + else + frame = nil + log(ERR, "Skip invalid Frame&Divider object (#{mth})") + end + end + + # Validate skylight size, if provided. + if opts.key?(:size) + if opts[:size].respond_to?(:to_f) + w = opts[:size].to_f + w2 = w * w + return invalid(size, mth, 0, ERR, 0) if w.round(2) < gap4 + else + return mismatch("size", opts[:size], Numeric, mth, DBG, 0) + end + end + + f2 = 2 * f + w0 = w + f2 + w02 = w0 * w0 + wl = w0 + gap + wl2 = wl * wl + + # Validate purge request, if provided. + if opts.key?(:clear) + clear = opts[:clear] + + unless [true, false].include?(clear) + log(WRN, "Purging existing skylights by default (#{mth})") + clear = true + end + end + + getRoofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear + + # Safely exit, e.g. if strictly called to purge existing roof subsurfaces. + return 0 if srr < TOL + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # The method seeks to insert a skylight array within the largest rectangular + # 'bounded box' that neatly 'fits' within a given roof surface. This equally + # applies to any vertically-cast overlap between roof and plenum (or attic) + # floor, which in turn generates skylight wells. Skylight arrays are + # inserted from left/right + top/bottom (as illustrated below), once a roof + # (or cast 3D overlap) is 'aligned' in 2D (possibly also 'realigned'). + # + # Depending on geometric complexity (e.g. building/roof concavity, + # triangulation), the total area of bounded boxes may be significantly less + # than the calculated "GROSS ROOF AREA", which can make it challenging to + # attain the desired %SRR. If :patterns are left unaltered, the method will + # select patterns that maximize the likelihood of attaining the requested + # %SRR, to the detriment of spatial distribution of daylighting. + # + # The default skylight module size is 1.2m x 1.2m (4' x 4'), which be + # overridden by the user, e.g. 2.4m x 2.4m (8' x 8'). + # + # Preset skylight allocation patterns (in order of precedence): + # 1. "array" + # _____________________ + # | _ _ _ | - ?x columns ("cols") >= ?x rows (min 2x2) + # | |_| |_| |_| | - SRR ~5% (1.2m x 1.2m), as illustrated + # | | - SRR ~19% (2.4m x 2.4m) + # | _ _ _ | - +suitable for wide spaces (storage, retail) + # | |_| |_| |_| | - ~1.4x height + skylight width 'ideal' rule + # |_____________________| - better daylight distribution, many wells + # + # 2. "strips" + # _____________________ + # | _ _ _ | - ?x columns (min 2), 1x row + # | | | | | | | | - ~doubles %SRR ... + # | | | | | | | | - SRR ~10% (1.2m x ?1.2m), as illustrated + # | | | | | | | | - SRR ~19% (2.4m x ?1.2m) + # | |_| |_| |_| | - ~roof monitor layout + # |_____________________| - fewer wells + # + # 3. "strip" + # ____________________ + # | | - 1x column, 1x row (min 1x) + # | ______________ | - SRR ~11% (1.2m x ?1.2m) + # | | ............ | | - SRR ~22% (2.4m x ?1.2m), as illustrated + # | |______________| | - +suitable for elongated bounded boxes + # | | - 1x well + # |____________________| + # + # TO-DO: Support strips/strip patterns along ridge of paired roof surfaces. + layouts = ["array", "strips", "strip"] + patterns = [] + + # Validate skylight placement patterns, if provided. + if opts.key?(:patterns) + if opts[:patterns].is_a?(Array) + opts[:patterns].each_with_index do |pattern, i| + pattern = trim(pattern).downcase + + if pattern.empty? + invalid("pattern #{i+1}", mth, 0, ERR) + next + end + + patterns << pattern if layouts.include?(pattern) + end + else + mismatch("patterns", opts[:patterns], Array, mth, DBG) + end + end + + patterns = layouts if patterns.empty? + + # The method first attempts to add skylights in ideal candidate spaces: + # - large roof surface areas (e.g. retail, classrooms ... not corridors) + # - not sidelit (favours core spaces) + # - having flat roofs (avoids sloped roofs) + # - not under plenums, nor attics (avoids wells) + # + # This ideal (albeit stringent) set of conditions is "combo a". + # + # If required %SRR has not yet been achieved, the method decrementally drops + # selection criteria and starts over, e.g.: + # - then considers sidelit spaces + # - then considers sloped roofs + # - then considers skylight wells + # + # A maximum number of skylights are allocated to roof surfaces matching a + # given combo. Priority is always given to larger roof areas. If + # unsuccessful in meeting the required %SRR target, a single criterion is + # then dropped (e.g. b, then c, etc.), and the allocation process is + # relaunched. An error message is logged if the %SRR isn't ultimately met. + # + # Through filters, users may restrict candidate roof surfaces: + # b. above occupied sidelit spaces ('false' restricts to core spaces) + # c. that are sloped ('false' restricts to flat roofs) + # d. above indirectly conditioned spaces (e.g. plenums, uninsulated wells) + # e. above unconditioned spaces (e.g. attics, insulated wells) + filters = ["a", "b", "bc", "bcd", "bcde"] + + # Prune filters, based on user-selected options. + [:sidelit, :sloped, :plenum, :attic].each do |opt| + next unless opts.key?(opt) + next unless opts[opt] == false + + case opt + when :sidelit then filters.map! { |f| f.include?("b") ? f.delete("b") : f } + when :sloped then filters.map! { |f| f.include?("c") ? f.delete("c") : f } + when :plenum then filters.map! { |f| f.include?("d") ? f.delete("d") : f } + when :attic then filters.map! { |f| f.include?("e") ? f.delete("e") : f } + end + end + + filters.reject! { |f| f.empty? } + filters.uniq! + + # Remaining filters may be further reduced (after space/roof processing), + # depending on geometry, e.g.: + # - if there are no sidelit spaces: filter "b" will be pruned away + # - if there are no sloped roofs : filter "c" will be pruned away + # - if no plenums are identified : filter "d" will be pruned away + # - if no attics are identified : filter "e" will be pruned away + + # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- # + # Break down spaces (and connected spaces) into groups. + sets = [] # collection of skylight arrays to deploy + rooms = {} # occupied CONDITIONED spaces to toplight + plenums = {} # unoccupied (INDIRECTLY-) CONDITIONED spaces above rooms + attics = {} # unoccupied UNCONDITIONED spaces above rooms + ceilings = {} # of occupied CONDITIONED space (if plenums/attics) + + # Select candidate 'rooms' to toplit - excludes plenums/attics. + spaces.each do |space| + next if unconditioned?(space) # e.g. attic + next unless space.partofTotalFloorArea # occupied (not plenum) + + # Already toplit? + if daylit?(space, false, true, false) + log(WRN, "#{id} is already toplit, skipping (#{mth})") + next + end + + # When unoccupied spaces are involved (e.g. plenums, attics), the occupied + # space (to toplight) may not share the same local transformation as its + # unoccupied space(s) above. Fetching local transformation. + h = 0 + t0 = transforms(space) + next unless t0[:t] + + toitures = facets(space, "Outdoors", "RoofCeiling") + plafonds = facets(space, "Surface", "RoofCeiling") + + toitures.each { |surf| h = [h, surf.vertices.max_by(&:z).z].max } + plafonds.each { |surf| h = [h, surf.vertices.max_by(&:z).z].max } + + rooms[space] = {} + rooms[space][:t ] = t0[:t] + rooms[space][:m ] = space.multiplier + rooms[space][:h ] = h + rooms[space][:roofs ] = toitures + rooms[space][:sidelit] = daylit?(space, true, false, false) + + # Fetch and process room-specific outdoor-facing roof surfaces, the most + # basic 'set' to track: + # - no skylight wells + # - 1x skylight array per roof surface + # - no need to preprocess space transformation + rooms[space][:roofs].each do |roof| + box = boundedBox(roof) + next if box.empty? + + bm2 = OpenStudio.getArea(box) + next if bm2.empty? + + bm2 = bm2.get + next if bm2.round(2) < w02.round(2) + + # Track if bounded box is significantly smaller than roof. + tight = bm2 < roof.grossArea / 2 ? true : false + + set = {} + set[:box ] = box + set[:bm2 ] = bm2 + set[:tight ] = tight + set[:roof ] = roof + set[:space ] = space + set[:sidelit] = rooms[space][:sidelit] + set[:t ] = rooms[space][:t ] + set[:sloped ] = slopedRoof?(roof) + sets << set + end + end + + # Process outdoor-facing roof surfaces of plenums and attics above. + rooms.each do |space, room| + t0 = room[:t] + toits = getRoofs(space) + rufs = room.key?(:roofs) ? toits - room[:roofs] : toits + next if rufs.empty? + + # Process room ceilings, as 1x or more are overlapping roofs above. Fetch + # vertically-cast overlaps. + rufs.each do |ruf| + espace = ruf.space + next if espace.empty? + + espace = espace.get + next if espace.partofTotalFloorArea + + m = espace.multiplier + ti = transforms(espace) + next unless ti[:t] + + ti = ti[:t] + vtx = ruf.vertices + + # Ensure BLC vertex sequence. + if facingUp?(vtx) + vtx = ti * vtx + + if xyz?(vtx, :z) + vtx = blc(vtx) + else + dZ = vtx.first.z + vtz = blc(flatten(vtx)).to_a + vtx = [] + + vtz.each { |v| vtx << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) } + end + + ruf.setVertices(ti.inverse * vtx) + else + tr = OpenStudio::Transformation.alignFace(vtx) + vtx = blc(tr.inverse * vtx) + ruf.setVertices(tr * vtx) + end + + ri = ti * ruf.vertices + + facets(space, "Surface", "RoofCeiling").each do |tile| + vtx = tile.vertices + + # Ensure BLC vertex sequence. + if facingUp?(vtx) + vtx = t0 * vtx + + if xyz?(vtx, :z) + vtx = blc(vtx) + else + dZ = vtx.first.z + vtz = blc(flatten(vtx)).to_a + vtx = [] + + vtz.each { |v| vtx << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) } + end + + vtx = t0.inverse * vtx + else + tr = OpenStudio::Transformation.alignFace(vtx) + vtx = blc(tr.inverse * vtx) + vtx = tr * vtx + end + + tile.setVertices(vtx) + + ci0 = cast(t0 * tile.vertices, ri, ray) + next if ci0.empty? + + olap = overlap(ri, ci0, false) + next if olap.empty? + + box = boundedBox(olap) + next if box.empty? + + # Adding skylight wells (plenums/attics) is contingent to safely + # linking new base roof 'inserts' through leader lines. Currently, + # this requires an offset from main roof surface edges. + # + # TO DO: expand the method to factor in cases where simple 'side' + # cutouts can be supported (no need for leader lines), e.g. + # skylight strips along roof ridges. + box = offset(box, -gap, 300) + box = poly(box, false, false, false, false, :blc) + next if box.empty? + + bm2 = OpenStudio.getArea(box) + next if bm2.empty? + + bm2 = bm2.get + next if bm2.round(2) < w02.round(2) + + # Vertically-cast box onto ceiling below. + cbox = cast(box, t0 * tile.vertices, ray) + next if cbox.empty? + + cm2 = OpenStudio.getArea(cbox) + next if cm2.empty? + + cm2 = cm2.get + + # Track if bounded boxes are significantly smaller than either roof + # or ceiling. + tight = bm2 < ruf.grossArea / 2 ? true : false + tight = cm2 < tile.grossArea / 2 ? true : tight + + unless ceilings.key?(tile) + floor = tile.adjacentSurface + + if floor.empty? + log(ERR, "#{tile.nameString} adjacent floor? (#{mth})") + next + end + + floor = floor.get + + # Ensure BLC vertex sequence. + vtx = t0 * vtx + floor.setVertices(ti.inverse * vtx.reverse) + + if floor.space.empty? + log(ERR, "#{floor.nameString} space? (#{mth})") + next + end + + espce = floor.space.get + + unless espce == espace + log(ERR, "#{espce.nameString} != #{espace.nameString}? (#{mth})") + next + end + + ceilings[tile] = {} + ceilings[tile][:roofs] = [] + ceilings[tile][:space] = space + ceilings[tile][:floor] = floor + end + + ceilings[tile][:roofs] << ruf + + # More detailed skylight set entries with suspended ceilings. + set = {} + set[:olap ] = olap + set[:box ] = box + set[:cbox ] = cbox + set[:bm2 ] = bm2 + set[:cm2 ] = cm2 + set[:tight ] = tight + set[:roof ] = ruf + set[:space ] = space + set[:clng ] = tile + set[:t ] = t0 + set[:sidelit] = room[:sidelit] + set[:sloped ] = slopedRoof?(ruf) + + if unconditioned?(espace) # e.g. attic + unless attics.key?(espace) + attics[espace] = {t: ti, m: m, bm2: 0, roofs: []} + end + + attics[espace][:bm2 ] += bm2 + attics[espace][:roofs] << ruf + + set[:attic] = espace + + ceilings[tile][:attic] = espace + else # e.g. plenum + unless plenums.key?(espace) + plenums[espace] = {t: ti, m: m, bm2: 0, roofs: []} + end + + plenums[espace][:bm2 ] += bm2 + plenums[espace][:roofs] << ruf + + set[:plenum] = espace + + ceilings[tile][:plenum] = espace + end + + sets << set + break # only 1x unique ruf/ceiling pair. + end + end + end + + # Ensure uniqueness of plenum roofs, and set GROSS ROOF AREA. + attics.values.each do |attic| + attic[:roofs ].uniq! + attic[:ridges] = getHorizontalRidges(attic[:roofs]) # TO-DO + end + + plenums.values.each do |plenum| + plenum[:roofs ].uniq! + # plenum[:m2 ] = plenum[:roofs].sum(&:grossArea) + plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # TO-DO + end + + # Regardless of the selected skylight arrangement pattern, the current + # solution may only consider attic/plenum sets that can be successfully + # linked to leader line anchors, for both roof and ceiling surfaces. + [attics, plenums].each do |greniers| + k = greniers == attics ? :attic : :plenum + + greniers.each do |spce, grenier| + grenier[:roofs].each do |roof| + sts = sets + + sts = sts.select { |st| st.key?(k) } + sts = sts.select { |st| st.key?(:box) } + sts = sts.select { |st| st.key?(:bm2) } + sts = sts.select { |st| st.key?(:roof) } + sts = sts.select { |st| st.key?(:space) } + sts = sts.select { |st| st[k ] == spce } + sts = sts.select { |st| st[:roof] == roof } + next if sts.empty? + + sts = sts.sort_by { |st| st[:bm2] } + genAnchors(roof, sts, :box) + end + end + end + + # Delete voided sets. + sets.reject! { |set| set.key?(:void) } + + # Repeat leader line loop for ceilings. + ceilings.each do |tile, ceiling| + k = ceiling.key?(:attic) ? :attic : :plenum + next unless ceiling.key?(k) + + space = ceiling[:space] + spce = ceiling[k ] + next unless ceiling.key?(:roofs) + next unless rooms.key?(space) + + stz = [] + + ceiling[:roofs].each do |roof| + sts = sets + + sts = sts.select { |st| st.key?(k) } + sts = sts.select { |st| st.key?(:cbox) } + stz = stz.select { |st| st.key?(:cm2) } + sts = sts.select { |st| st.key?(:roof) } + sts = sts.select { |st| st.key?(:clng) } + sts = sts.select { |st| st.key?(:space) } + sts = sts.select { |st| st[k ] == spce } + sts = sts.select { |st| st[:roof ] == roof } + sts = sts.select { |st| st[:clng ] == tile } + sts = sts.select { |st| st[:space] == space } + next unless sts.size == 1 + + stz << sts.first + end + + next if stz.empty? + + genAnchors(tile, stz, :cbox) + end + + # Delete voided sets. + sets.reject! { |set| set.key?(:void) } + + m2 = 0 # existing skylight rough opening area + rm2 = grossRoofArea(spaces) + + # Tally existing skylight rough opening areas (%SRR calculations). + rooms.values.each do |room| + m = room[:m] + + room[:roofs].each do |roof| + roof.subSurfaces.each do |sub| + next unless fenestration?(sub) + + id = sub.nameString + xm2 = sub.grossArea + + if sub.allowWindowPropertyFrameAndDivider + unless sub.windowPropertyFrameAndDivider.empty? + fw = sub.windowPropertyFrameAndDivider.get.frameWidth + vec = offset(sub.vertices, fw, 300) + aire = OpenStudio.getArea(vec) + + if aire.empty? + log(ERR, "Skipping '#{id}': invalid Frame&Divider (#{mth})") + else + xm2 = aire.get + end + end + end + + m2 += xm2 * sub.multiplier * m + end + end + end + + # Required skylight area to add. + sm2 = rm2 * srr - m2 + + # Skip if existing skylights exceed or ~roughly match requested %SRR. + if sm2.round(2) < w02.round(2) + log(INF, "Skipping: existing srr > requested srr (#{mth})") + return 0 + end + + # Any sidelit/sloped roofs being targeted? + # + # TODO: enable double-ridged, sloped roofs have double-sloped + # skylights/wells (patterns "strip"/"strips"). + sidelit = sets.any? { |set| set[:sidelit] } + sloped = sets.any? { |set| set[:sloped ] } + + # Precalculate skylight rows + cols, for each selected pattern. In the case + # of 'cols x rows' arrays of skylights, the method initially overshoots + # with regards to ideal skylight placement, e.g.: + # + # aceee.org/files/proceedings/2004/data/papers/SS04_Panel3_Paper18.pdf + # + # ... yet skylight areas are subsequently contracted to strictly meet SRR%. + sets.each_with_index do |set, i| + id = "set #{i+1}" + well = set.key?(:clng) + space = set[:space] + tight = set[:tight] + factor = tight ? 1.75 : 1.25 + room = rooms[space] + h = room[:h] + t = OpenStudio::Transformation.alignFace(set[:box]) + abox = poly(set[:box], false, false, false, t, :ulc) + obox = getRealignedFace(abox) + next unless obox[:set] + + width = width(obox[:set]) + depth = height(obox[:set]) + area = width * depth + skym2 = srr * area + + # Flag sets if too narrow/shallow to hold a single skylight. + if well + if width.round(2) < wl.round(2) + log(ERR, "#{id}: Too narrow") + set[:void] = true + next + end + + if depth.round(2) < wl.round(2) + log(ERR, "#{id}: Too shallow") + set[:void] = true + next + end + else + if width.round(2) < w0.round(2) + log(ERR, "#{id}: Too narrow") + set[:void] = true + next + end + + if depth.round(2) < w0.round(2) + log(ERR, "#{id}: Too shallow") + set[:void] = true + next + end + end + + # Estimate number of skylight modules per 'pattern'. Default spacing + # varies based on bounded box size (i.e. larger vs smaller rooms). + patterns.each do |pattern| + cols = 1 + rows = 1 + wx = w0 + wy = w0 + wxl = wl + wyl = wl + dX = nil + dY = nil + + case pattern + when "array" # min 2x cols x min 2x rows + cols = 2 + rows = 2 + + if tight + sp = 1.4 * h / 2 + lx = well ? width - cols * wxl : width - cols * wx + ly = well ? depth - rows * wyl : depth - rows * wy + next if lx.round(2) < sp.round(2) + next if ly.round(2) < sp.round(2) + + if well + cols = ((width - wxl) / (wxl + sp)).round(2).to_i + 1 + rows = ((depth - wyl) / (wyl + sp)).round(2).to_i + 1 + else + cols = ((width - wx) / (wx + sp)).round(2).to_i + 1 + rows = ((depth - wy) / (wy + sp)).round(2).to_i + 1 + end + + next if cols < 2 + next if rows < 2 + + dX = well ? 0.0 : bfr + f + dY = well ? 0.0 : bfr + f + else + sp = 1.4 * h + lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols + ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols + next if lx.round(2) < sp.round(2) + next if ly.round(2) < sp.round(2) + + if well + cols = (width / (wxl + sp)).round(2).to_i + rows = (depth / (wyl + sp)).round(2).to_i + else + cols = (width / (wx + sp)).round(2).to_i + rows = (depth / (wy + sp)).round(2).to_i + end + + next if cols < 2 + next if rows < 2 + + ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols + dY = ly / 2 + end + + # Current skylight area. If undershooting, adjust skylight width/depth + # as well as reduce spacing. For geometrical constrained cases, + # undershooting means not reaching 1.75x the required SRR%. Otherwise, + # undershooting means not reaching 1.25x the required SRR%. Any + # consequent overshooting is later corrected. + tm2 = wx * cols * wy * rows + undershot = tm2.round(2) < factor * skym2.round(2) ? true : false + + # Inflate skylight width/depth (and reduce spacing) to reach SRR%. + if undershot + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + ratio = Math.sqrt(ratio2) + + sp = w + wx *= ratio + wy *= ratio + wxl = wx + gap + wyl = wy + gap + + if tight + if well + lx = (width - cols * wxl) / (cols - 1) + ly = (depth - rows * wyl) / (rows - 1) + else + lx = (width - cols * wx) / (cols - 1) + ly = (depth - rows * wy) / (rows - 1) + end + + lx = lx.round(2) < sp.round(2) ? sp : lx + ly = ly.round(2) < sp.round(2) ? sp : ly + + if well + wxl = (width - (cols - 1) * lx) / cols + wyl = (depth - (rows - 1) * ly) / rows + wx = wxl - gap + wy = wyl - gap + else + wx = (width - (cols - 1) * lx) / cols + wy = (depth - (rows - 1) * ly) / rows + wxl = wx + gap + wyl = wy + gap + end + else + if well + lx = (width - cols * wxl) / cols + ly = (depth - rows * wyl) / rows + else + lx = (width - cols * wx) / cols + ly = (depth - rows * wy) / rows + end + + lx = lx.round(2) < sp.round(2) ? sp : lx + ly = ly.round(2) < sp.round(2) ? sp : ly + + if well + wxl = (width - cols * lx) / cols + wyl = (depth - rows * ly) / rows + wx = wxl - gap + wy = wyl - gap + lx = (width - cols * wxl) / cols + ly = (depth - rows * wyl) / rows + else + wx = (width - cols * lx) / cols + wy = (depth - rows * ly) / rows + wxl = wx + gap + wyl = wy + gap + lx = (width - cols * wx) / cols + ly = (depth - rows * wy) / rows + end + end + + dY = ly / 2 + end + when "strips" # min 2x cols x 1x row + cols = 2 + + if tight + sp = h / 2 + lx = well ? width - cols * wxl : width - cols * wx + ly = well ? depth - wyl : depth - wy + next if lx.round(2) < sp.round(2) + next if ly.round(2) < sp.round(2) + + if well + cols = ((width - wxl) / (wxl + sp)).round(2).to_i + 1 + else + cols = ((width - wx) / (wx + sp)).round(2).to_i + 1 + end + + next if cols < 2 + + if well + wyl = depth - ly + wy = wyl - gap + else + wy = depth - ly + wyl = wy + gap + end + + dX = well ? 0 : bfr + f + dY = ly / 2 + else + sp = h + lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols + ly = well ? depth - wyl : depth - wy + next if lx.round(2) < sp.round(2) + next if ly.round(2) < w.round(2) + + if well + cols = (width / (wxl + sp)).round(2).to_i + else + cols = (width / (wx + sp)).round(2).to_i + end + + next if cols < 2 + + if well + wyl = depth - ly + wy = wyl - gap + else + wy = depth - ly + wyl = wy + gap + end + + dY = ly / 2 + end + + tm2 = wx * cols * wy + undershot = tm2.round(2) < factor * skym2.round(2) ? true : false + + # Inflate skylight width (and reduce spacing) to reach SRR%. + if undershot + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + + sp = w + wx *= ratio2 + wxl = wx + gap + + if tight + if well + lx = (width - cols * wxl) / (cols - 1) + else + lx = (width - cols * wx) / (cols - 1) + end + + lx = lx.round(2) < sp.round(2) ? sp : lx + + if well + wxl = (width - (cols - 1) * lx) / cols + wx = wxl - gap + else + wx = (width - (cols - 1) * lx) / cols + wxl = wx + gap + end + else + if well + lx = (width - cols * wxl) / cols + else + lx = (width - cols * wx) / cols + end + + lx = lx.round(2) < sp.round(2) ? sp : lx + + if well + wxl = (width - cols * lx) / cols + wx = wxl - gap + lx = (width - cols * wxl) / cols + else + wx = (width - cols * lx) / cols + wxl = wx + gap + lx = (width - cols * wx) / cols + end + end + end + else # "strip" 1 (long?) row x 1 column + sp = w + lx = well ? width - wxl : width - wx + ly = well ? depth - wyl : depth - wy + + if tight + next if lx.round(2) < sp.round(2) + next if ly.round(2) < sp.round(2) + + if well + wxl = width - lx + wyl = depth - ly + wx = wxl - gap + wy = wyl - gap + else + wx = width - lx + wy = depth - ly + wxl = wx + gap + wyl = wy + gap + end + + dX = well ? 0.0 : bfr + f + dY = ly / 2 + else + next if lx.round(2) < sp.round(2) + next if ly.round(2) < sp.round(2) + + if well + wxl = width - lx + wyl = depth - ly + wx = wxl - gap + wy = wyl - gap + else + wx = width - lx + wy = depth - ly + wxl = wx + gap + wyl = wy + gap + end + + dY = ly / 2 + end + + tm2 = wx * wy + undershot = tm2.round(2) < factor * skym2.round(2) ? true : false + + # Inflate skylight depth to reach SRR%. + if undershot + ratio2 = 1 + (factor * skym2 - tm2) / tm2 + + sp = w + wy *= ratio2 + wyl = wy + gap + + ly = well ? depth - wy : depth - wyl + ly = ly.round(2) < sp.round(2) ? sp : lx + + if well + wyl = depth - ly + wy = wyl - gap + else + wy = depth - ly + wyl = wy + gap + end + end + end + + st = {} + st[:tight] = tight + st[:cols ] = cols + st[:rows ] = rows + st[:wx ] = wx + st[:wy ] = wy + st[:wxl ] = wxl + st[:wyl ] = wyl + st[:dX ] = dX if dX + st[:dY ] = dY if dY + + set[pattern] = st + end + end + + # Delete voided sets. + sets.reject! { |set| set.key?(:void) } + + # Final reset of filters. + filters.map! { |f| f.include?("b") ? f.delete("b") : f } unless sidelit + filters.map! { |f| f.include?("c") ? f.delete("c") : f } unless sloped + filters.map! { |f| f.include?("d") ? f.delete("d") : f } if plenums.empty? + filters.map! { |f| f.include?("e") ? f.delete("e") : f } if attics.empty? + + filters.reject! { |f| f.empty? } + filters.uniq! + + # Initialize skylight area tally. + skm2 = 0 + + # Assign skylight pattern. + filters.each_with_index do |filter, i| + next if skm2.round(2) >= sm2.round(2) + + sts = sets + sts = sts.sort_by { |st| st[:bm2] }.reverse! + sts = sts.reject { |st| st.key?(:pattern) } + + if filter.include?("a") + # Start with the default (ideal) allocation selection: + # - large roof surface areas (e.g. retail, classrooms not corridors) + # - not sidelit (favours core spaces) + # - having flat roofs (avoids sloped roofs) + # - not under plenums, nor attics (avoids wells) + sts = sts.reject { |st| st[:sidelit] } + sts = sts.reject { |st| st[:sloped ] } + sts = sts.reject { |st| st.key?(:clng) } + else + sts = sts.reject { |st| st[:sidelit] } unless filter.include?("b") + sts = sts.reject { |st| st[:sloped] } unless filter.include?("c") + sts = sts.reject { |st| st.key?(:plenum) } unless filter.include?("d") + sts = sts.reject { |st| st.key?(:attic) } unless filter.include?("e") + end + + next if sts.empty? + + # Tally precalculated skylights per pattern (once filtered). + fpm2 = {} + + patterns.each do |pattern| + sts.each do |st| + next unless st.key?(pattern) + + cols = st[pattern][:cols] + rows = st[pattern][:rows] + wx = st[pattern][:wx ] + wy = st[pattern][:wy ] + + fpm2[pattern] = {m2: 0, tight: false} unless fpm2.key?(pattern) + + fpm2[pattern][:m2 ] += wx * wy * cols * rows + fpm2[pattern][:tight] = st[:tight] ? true : false + end + end + + pattern = nil + next if fpm2.empty? + + fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h + + # Select suitable pattern, often overshooting. Favour array unless + # geometrically constrainted. + if fpm2.keys.include?("array") + if (fpm2["array"][:m2]).round(2) >= sm2.round(2) + pattern = "array" unless fpm2[:tight] + end + end + + unless pattern + if fpm2.values.first[:m2].round(2) >= sm2.round(2) + pattern = fpm2.keys.first + elsif fpm2.values.last[:m2].round(2) <= sm2.round(2) + pattern = fpm2.keys.last + else + fpm2.keep_if { |_, fm2| fm2[:m2].round(2) >= sm2.round(2) } + + pattern = fpm2.keys.first + end + end + + skm2 += fpm2[pattern][:m2] + + # Update matching sets. + sts.each do |st| + sets.each do |set| + next unless set.key?(pattern) + next unless st[:roof] == set[:roof] + next unless same?(st[:box], set[:box]) + + if st.key?(:clng) + next unless set.key?(:clng) + next unless st[:clng] == set[:clng] + end + + set[:pattern] = pattern + set[:cols ] = set[pattern][:cols] + set[:rows ] = set[pattern][:rows] + set[:w ] = set[pattern][:wx ] + set[:d ] = set[pattern][:wy ] + set[:w0 ] = set[pattern][:wxl ] + set[:d0 ] = set[pattern][:wyl ] + set[:dX ] = set[pattern][:dX ] if set[pattern][:dX] + set[:dY ] = set[pattern][:dY ] if set[pattern][:dY] + end + end + end + + # Skylight size contraction if overshot (e.g. -13.2% if overshot by +13.2%). + # This is applied on a surface/pattern basis; individual skylight sizes may + # vary from one surface to the next, depending on respective patterns. + if skm2.round(2) > sm2.round(2) + ratio2 = 1 - (skm2 - sm2) / skm2 + ratio = Math.sqrt(ratio2) + skm2 *= ratio2 + + sets.each do |set| + next if set.key?(:void) + next unless set.key?(:pattern) + + pattern = set[:pattern] + next unless set.key?(pattern) + + case pattern + when "array" # equally adjust both width and depth + xr = set[:w] * ratio + yr = set[:d] * ratio + dyr = set[:d] - yr + + set[:w ] = xr + set[:d ] = yr + set[:w0] = set[:w] + gap + set[:d0] = set[:d] + gap + set[:dY] += dyr / 2 + when "strips" # adjust depth + xr2 = set[:w] * ratio2 + + set[:w ] = xr2 + set[:w0] = set[:w] + gap + else # "strip", adjust width + yr2 = set[:d] * ratio2 + dyr = set[:d] - yr2 + + set[:d ] = yr2 + set[:d0] = set[:w] + gap + set[:dY] += dyr / 2 + end + end + end + + # Generate skylight well roofs for attics & plenums. + [attics, plenums].each do |greniers| + k = greniers == attics ? :attic : :plenum + + greniers.each do |spce, grenier| + ti = grenier[:t] + + grenier[:roofs].each do |roof| + sts = sets + sts = sts.select { |st| st.key?(k) } + sts = sts.select { |st| st.key?(:pattern) } + sts = sts.select { |st| st.key?(:clng) } + sts = sts.select { |st| st.key?(:roof) } + sts = sts.select { |st| st.key?(:space) } + sts = sts.select { |st| st[:roof] == roof } + sts = sts.select { |st| st[k ] == spce } + sts = sts.select { |st| st.key?(st[:pattern]) } + sts = sts.select { |st| rooms.key?(st[:space]) } + sts = sts.select { |st| st.key?(:ld) } + sts = sts.select { |st| st[:ld].key?(roof) } + next if sts.empty? + + # If successful, 'genInserts' returns extended roof surface vertices, + # including leader lines to support cutouts. The final selection is + # contingent to successfully inserting corresponding room ceiling + # inserts (vis-à-vis attic/plenum floor below). The method also + # generates new roof inserts. See key:value pair :vts. + vz = genInserts(roof, sts) + next if vz.empty? # TODO log error if empty + + roof.setVertices(ti.inverse * vz) + end + end + end + + # Repeat for ceilings below attic/plenum floors. + ceilings.each do |tile, ceiling| + k = ceiling.key?(:attic) ? :attic : :plenum + next unless ceiling.key?(k) + next unless ceiling.key?(:roofs) + + greniers = ceiling.key?(:attic) ? attics : plenums + space = ceiling[:space] + spce = ceiling[k ] + floor = ceiling[:floor] + next unless rooms.key?(space) + next unless greniers.key?(spce) + + room = rooms[space] + grenier = greniers[spce] + ti = grenier[:t] + t0 = room[:t] + stz = [] + + ceiling[:roofs].each do |roof| + sts = sets + + sts = sts.select { |st| st.key?(k) } + sts = sts.select { |st| st.key?(:clng) } + sts = sts.select { |st| st.key?(:cm2) } + sts = sts.select { |st| st.key?(:roof) } + sts = sts.select { |st| st.key?(:space) } + sts = sts.select { |st| st[:clng] == tile } + sts = sts.select { |st| st[:roof] == roof } + sts = sts.select { |st| st[k ] == spce } + sts = sts.select { |st| rooms.key?(st[:space]) } + sts = sts.select { |st| st.key?(:ld) } + sts = sts.select { |st| st.key?(:vtx) } + sts = sts.select { |st| st.key?(:vts) } + sts = sts.select { |st| st[:ld].key?(roof) } + sts = sts.select { |st| st[:ld].key?(tile) } + next unless sts.size == 1 + + stz << sts.first + end + + next if stz.empty? + + # Vertically-cast set roof :vtx onto ceiling. + stz.each do |st| + cvtx = cast(ti * st[:vtx], t0 * tile.vertices, ray) + st[:cvtx] = t0.inverse * cvtx + end + + # Extended ceiling vertices. + vertices = genExtendedVertices(tile, stz, :cvtx) + next if vertices.empty? + + # Reset ceiling and adjacent floor vertices. + tile.setVertices(t0.inverse * vertices) + floor.setVertices(ti.inverse * vertices.to_a.reverse) + + # Add new roof inserts & skylights for the (now) toplit space. + stz.each_with_index do |st, i| + sub = {} + sub[:type ] = "Skylight" + sub[:width ] = st[:w] - f2 + sub[:height] = st[:d] - f2 + sub[:sill ] = gap / 2 + sub[:frame ] = frame if frame + + st[:vts].each do |id, vt| + roof = OpenStudio::Model::Surface.new(t0.inverse * vt, mdl) + roof.setSpace(space) + roof.setName("#{i}:#{id}:#{space.nameString}") + + # Generate well walls. + v0 = roof.vertices + vX = cast(roof, tile, ray) + s0 = getSegments(v0) + sX = getSegments(vX) + + s0.each_with_index do |sg, j| + sg0 = sg.to_a + sgX = sX[j].to_a + vec = OpenStudio::Point3dVector.new + vec << sg0.first + vec << sg0.last + vec << sgX.last + vec << sgX.first + + grenier_wall = OpenStudio::Model::Surface.new(vec, mdl) + grenier_wall.setSpace(spce) + grenier_wall.setName("#{id}:#{j}:#{spce.nameString}") + + room_wall = OpenStudio::Model::Surface.new(vec.to_a.reverse, mdl) + room_wall.setSpace(space) + room_wall.setName("#{id}:#{j}:#{space.nameString}") + + grenier_wall.setAdjacentSurface(room_wall) + room_wall.setAdjacentSurface(grenier_wall) + end + + # Add individual skylights. + addSubs(roof, [sub]) + end + end + end + + # New direct roof loop. No overlaps, so no need for relative space + # coordinate adjustments. + rooms.each do |space, room| + room[:roofs].each do |roof| + sets.each_with_index do |set, i| + next if set.key?(:clng) + next unless set.key?(:box) + next unless set.key?(:roof) + next unless set.key?(:cols) + next unless set.key?(:rows) + next unless set.key?(:d) + next unless set.key?(:w) + next unless set.key?(:tight) + next unless set[:roof] == roof + + tight = set[:tight] + + d1 = set[:d] - f2 + w1 = set[:w] - f2 + + # Y-axis 'height' of the roof, once re/aligned. + # TODO: retrieve st[:out], +efficient + y = alignedHeight(set[:box]) + dY = set[:dY] if set[:dY] + + set[:rows].times.each do |j| + sub = {} + sub[:type ] = "Skylight" + sub[:count ] = set[:cols] + sub[:width ] = w1 + sub[:height ] = d1 + sub[:frame ] = frame if frame + sub[:id ] = "set #{i+1}:#{j+1}" + sub[:sill ] = dY + j * (2 * dY + d1) + sub[:r_buffer] = set[:dX] if set[:dX] + sub[:l_buffer] = set[:dX] if set[:dX] + addSubs(roof, [sub]) + end + end + end + end + + rm2 end ## # Callback when other modules extend OSlg #