osut 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/osut/utils.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # BSD 3-Clause License
2
2
  #
3
- # Copyright (c) 2022-2024, Denis Bourgeois
3
+ # Copyright (c) 2022-2025, Denis Bourgeois
4
4
  # All rights reserved.
5
5
  #
6
6
  # Redistribution and use in source and binary forms, with or without
@@ -34,20 +34,20 @@ module OSut
34
34
  # DEBUG for devs; WARN/ERROR for users (bad OS input), see OSlg
35
35
  extend OSlg
36
36
 
37
- TOL = 0.01 # default distance tolerance (m)
38
- TOL2 = TOL * TOL # default area tolerance (m2)
39
- DBG = OSlg::DEBUG # see github.com/rd2/oslg
40
- INF = OSlg::INFO # see github.com/rd2/oslg
41
- WRN = OSlg::WARN # see github.com/rd2/oslg
42
- ERR = OSlg::ERROR # see github.com/rd2/oslg
43
- FTL = OSlg::FATAL # see github.com/rd2/oslg
44
- NS = "nameString" # OpenStudio object identifier method
37
+ TOL = 0.01 # default distance tolerance (m)
38
+ TOL2 = TOL * TOL # default area tolerance (m2)
39
+ DBG = OSlg::DEBUG.dup # see github.com/rd2/oslg
40
+ INF = OSlg::INFO.dup # see github.com/rd2/oslg
41
+ WRN = OSlg::WARN.dup # see github.com/rd2/oslg
42
+ ERR = OSlg::ERROR.dup # see github.com/rd2/oslg
43
+ FTL = OSlg::FATAL.dup # see github.com/rd2/oslg
44
+ NS = "nameString" # OpenStudio object identifier method
45
45
 
46
46
  HEAD = 2.032 # standard 80" door
47
47
  SILL = 0.762 # standard 30" window sill
48
48
 
49
49
  # General surface orientations (see facets method)
50
- SIDZ = [:bottom, # e.g. ground-facing, exposed floros
50
+ SIDZ = [:bottom, # e.g. ground-facing, exposed floors
51
51
  :top, # e.g. roof/ceiling
52
52
  :north, # NORTH
53
53
  :east, # EAST
@@ -220,13 +220,14 @@ module OSut
220
220
  chk = @@uo.keys.include?(specs[:type])
221
221
  return invalid("surface type", mth, 2, ERR) unless chk
222
222
 
223
- specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo)
223
+ specs[:uo] = @@uo[ specs[:type] ] unless specs.key?(:uo) # can be nil
224
224
  u = specs[:uo]
225
225
 
226
226
  if u
227
- return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric)
228
- return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678
229
- return negative("#{id} Uo" , mth, ERR) if u < 0
227
+ return mismatch("#{id} Uo", u, Numeric, mth) unless u.is_a?(Numeric)
228
+ return invalid("#{id} Uo (> 5.678)", mth, 2, ERR) if u > 5.678
229
+ return zero("#{id} Uo", mth, ERR) if u.round(2) == 0.00
230
+ return negative("#{id} Uo", mth, ERR) if u < 0
230
231
  end
231
232
 
232
233
  # Optional specs. Log/reset if invalid.
@@ -466,14 +467,14 @@ module OSut
466
467
  a[:compo ][:d ] = d
467
468
  a[:compo ][:id ] = "OSut|#{mt}|#{format('%03d', d*1000)[-3..-1]}"
468
469
  when :window
469
- a[:glazing][:u ] = specs[:uo ]
470
+ a[:glazing][:u ] = u ? u : @@uo[:window]
470
471
  a[:glazing][:shgc] = 0.450
471
472
  a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc)
472
473
  a[:glazing][:id ] = "OSut|window"
473
474
  a[:glazing][:id ] += "|U#{format('%.1f', a[:glazing][:u])}"
474
475
  a[:glazing][:id ] += "|SHGC#{format('%d', a[:glazing][:shgc]*100)}"
475
476
  when :skylight
476
- a[:glazing][:u ] = specs[:uo ]
477
+ a[:glazing][:u ] = u ? u : @@uo[:skylight]
477
478
  a[:glazing][:shgc] = 0.450
478
479
  a[:glazing][:shgc] = specs[:shgc] if specs.key?(:shgc)
479
480
  a[:glazing][:id ] = "OSut|skylight"
@@ -482,25 +483,11 @@ module OSut
482
483
  end
483
484
 
484
485
  # Initiate layers.
485
- glazed = true
486
- glazed = false if a[:glazing].empty?
487
- layers = OpenStudio::Model::OpaqueMaterialVector.new unless glazed
488
- layers = OpenStudio::Model::FenestrationMaterialVector.new if glazed
486
+ unglazed = a[:glazing].empty? ? true : false
489
487
 
490
- if glazed
491
- u = a[:glazing][:u ]
492
- shgc = a[:glazing][:shgc]
493
- lyr = model.getSimpleGlazingByName(a[:glazing][:id])
494
-
495
- if lyr.empty?
496
- lyr = OpenStudio::Model::SimpleGlazing.new(model, u, shgc)
497
- lyr.setName(a[:glazing][:id])
498
- else
499
- lyr = lyr.get
500
- end
488
+ if unglazed
489
+ layers = OpenStudio::Model::OpaqueMaterialVector.new
501
490
 
502
- layers << lyr
503
- else
504
491
  # Loop through each layer spec, and generate construction.
505
492
  a.each do |i, l|
506
493
  next if l.empty?
@@ -524,44 +511,68 @@ module OSut
524
511
 
525
512
  layers << lyr
526
513
  end
514
+ else
515
+ layers = OpenStudio::Model::FenestrationMaterialVector.new
516
+
517
+ u0 = a[:glazing][:u ]
518
+ shgc = a[:glazing][:shgc]
519
+ lyr = model.getSimpleGlazingByName(a[:glazing][:id])
520
+
521
+ if lyr.empty?
522
+ lyr = OpenStudio::Model::SimpleGlazing.new(model, u0, shgc)
523
+ lyr.setName(a[:glazing][:id])
524
+ else
525
+ lyr = lyr.get
526
+ end
527
+
528
+ layers << lyr
527
529
  end
528
530
 
529
531
  c = OpenStudio::Model::Construction.new(layers)
530
532
  c.setName(id)
531
533
 
532
534
  # Adjust insulating layer thickness or conductivity to match requested Uo.
533
- unless glazed
534
- ro = 0
535
- ro = 1 / specs[:uo] - @@film[ specs[:type] ] if specs[:uo]
536
-
537
- if specs[:type] == :door # 1x layer, adjust conductivity
538
- layer = c.getLayer(0).to_StandardOpaqueMaterial
539
- return invalid("#{id} standard material?", mth, 0) if layer.empty?
540
-
541
- layer = layer.get
542
- k = layer.thickness / ro
543
- layer.setConductivity(k)
544
- elsif ro > 0 # multiple layers, adjust insulating layer thickness
545
- lyr = insulatingLayer(c)
546
- return invalid("#{id} construction", mth, 0) if lyr[:index].nil?
547
- return invalid("#{id} construction", mth, 0) if lyr[:type ].nil?
548
- return invalid("#{id} construction", mth, 0) if lyr[:r ].zero?
549
-
550
- index = lyr[:index]
551
- layer = c.getLayer(index).to_StandardOpaqueMaterial
552
- return invalid("#{id} material @#{index}", mth, 0) if layer.empty?
553
-
554
- layer = layer.get
555
- k = layer.conductivity
556
- d = (ro - rsi(c) + lyr[:r]) * k
557
- return invalid("#{id} adjusted m", mth, 0) if d < 0.03
558
-
559
- nom = "OSut|"
560
- nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "")
561
- nom += "|"
562
- nom += format("%03d", d*1000)[-3..-1]
563
- layer.setName(nom) if model.getStandardOpaqueMaterialByName(nom).empty?
564
- layer.setThickness(d)
535
+ if u and unglazed
536
+ ro = 1 / u - film
537
+
538
+ if ro > 0
539
+ if specs[:type] == :door # 1x layer, adjust conductivity
540
+ layer = c.getLayer(0).to_StandardOpaqueMaterial
541
+ return invalid("#{id} standard material?", mth, 0) if layer.empty?
542
+
543
+ layer = layer.get
544
+ k = layer.thickness / ro
545
+ layer.setConductivity(k)
546
+ else # multiple layers, adjust insulating layer thickness
547
+ lyr = insulatingLayer(c)
548
+ return invalid("#{id} construction", mth, 0) if lyr[:index].nil?
549
+ return invalid("#{id} construction", mth, 0) if lyr[:type ].nil?
550
+ return invalid("#{id} construction", mth, 0) if lyr[:r ].zero?
551
+
552
+ index = lyr[:index]
553
+ layer = c.getLayer(index).to_StandardOpaqueMaterial
554
+ return invalid("#{id} material @#{index}", mth, 0) if layer.empty?
555
+
556
+ layer = layer.get
557
+ k = layer.conductivity
558
+ d = (ro - rsi(c) + lyr[:r]) * k
559
+ return invalid("#{id} adjusted m", mth, 0) if d < 0.03
560
+
561
+ nom = "OSut|"
562
+ nom += layer.nameString.gsub(/[^a-z]/i, "").gsub("OSut", "")
563
+ nom += "|"
564
+ nom += format("%03d", d*1000)[-3..-1]
565
+
566
+ lyr = model.getStandardOpaqueMaterialByName(nom)
567
+
568
+ if lyr.empty?
569
+ layer.setName(nom)
570
+ layer.setThickness(d)
571
+ else
572
+ omat = lyr.get
573
+ c.setLayer(index, omat)
574
+ end
575
+ end
565
576
  end
566
577
  end
567
578
 
@@ -746,7 +757,7 @@ module OSut
746
757
  # Validates if a default construction set holds a base construction.
747
758
  #
748
759
  # @param set [OpenStudio::Model::DefaultConstructionSet] a default set
749
- # @param bse [OpensStudio::Model::ConstructionBase] a construction base
760
+ # @param bse [OpenStudio::Model::ConstructionBase] a construction base
750
761
  # @param gr [Bool] if ground-facing surface
751
762
  # @param ex [Bool] if exterior-facing surface
752
763
  # @param tp [#to_s] a surface type
@@ -1048,7 +1059,7 @@ module OSut
1048
1059
  return invalid("lc", mth, 1, DBG, res) unless lc.respond_to?(NS)
1049
1060
 
1050
1061
  id = lc.nameString
1051
- return mismatch(id, lc, cl1, mth, DBG, res) unless lc.is_a?(cl)
1062
+ return mismatch(id, lc, cl, mth, DBG, res) unless lc.is_a?(cl)
1052
1063
 
1053
1064
  lc.layers.each do |m|
1054
1065
  unless m.to_MasslessOpaqueMaterial.empty?
@@ -1102,7 +1113,7 @@ module OSut
1102
1113
  id = s.nameString
1103
1114
  m1 = "#{id}:spandrel"
1104
1115
  m2 = "#{id}:spandrel:boolean"
1105
- return mismatch(id, s, cl, mth) unless s.is_a?(cl)
1116
+ return mismatch(id, s, cl, mth, false) unless s.is_a?(cl)
1106
1117
 
1107
1118
  if s.additionalProperties.hasFeature("spandrel")
1108
1119
  val = s.additionalProperties.getFeatureAsBoolean("spandrel")
@@ -1129,7 +1140,7 @@ module OSut
1129
1140
  return invalid("subsurface", mth, 1, DBG, false) unless s.respond_to?(NS)
1130
1141
 
1131
1142
  id = s.nameString
1132
- return mismatch(id, s, cl, mth, false) unless s.is_a?(cl)
1143
+ return mismatch(id, s, cl, mth, DBG, false) unless s.is_a?(cl)
1133
1144
 
1134
1145
  # OpenStudio::Model::SubSurface.validSubSurfaceTypeValues
1135
1146
  # "FixedWindow" : fenestration
@@ -1422,7 +1433,15 @@ module OSut
1422
1433
  id = sched.nameString
1423
1434
  return mismatch(id, sched, cl, mth, DBG, res) unless sched.is_a?(cl)
1424
1435
 
1425
- vals = sched.timeSeries.values
1436
+ values = sched.timeSeries.values
1437
+
1438
+ values.each do |value|
1439
+ if value.respond_to?(:to_f)
1440
+ vals << value.to_f
1441
+ else
1442
+ invalid("numerical at #{i}", mth, 1, ERR)
1443
+ end
1444
+ end
1426
1445
 
1427
1446
  res[:min] = vals.min.is_a?(Numeric) ? vals.min : nil
1428
1447
  res[:max] = vals.max.is_a?(Numeric) ? vals.min : nil
@@ -1529,6 +1548,16 @@ module OSut
1529
1548
  res[:spt] = max if res[:spt] < max
1530
1549
  end
1531
1550
  end
1551
+
1552
+ unless sched.to_ScheduleInterval.empty?
1553
+ sched = sched.to_ScheduleInterval.get
1554
+ max = scheduleIntervalMinMax(sched)[:max]
1555
+
1556
+ if max
1557
+ res[:spt] = max unless res[:spt]
1558
+ res[:spt] = max if res[:spt] < max
1559
+ end
1560
+ end
1532
1561
  end
1533
1562
 
1534
1563
  return res if zone.thermostat.empty?
@@ -1586,6 +1615,16 @@ module OSut
1586
1615
  end
1587
1616
  end
1588
1617
 
1618
+ unless sched.to_ScheduleInterval.empty?
1619
+ sched = sched.to_ScheduleInterval.get
1620
+ max = scheduleIntervalMinMax(sched)[:max]
1621
+
1622
+ if max
1623
+ res[:spt] = max unless res[:spt]
1624
+ res[:spt] = max if res[:spt] < max
1625
+ end
1626
+ end
1627
+
1589
1628
  unless sched.to_ScheduleYear.empty?
1590
1629
  sched = sched.to_ScheduleYear.get
1591
1630
 
@@ -1707,6 +1746,16 @@ module OSut
1707
1746
  res[:spt] = min if res[:spt] > min
1708
1747
  end
1709
1748
  end
1749
+
1750
+ unless sched.to_ScheduleInterval.empty?
1751
+ sched = sched.to_ScheduleInterval.get
1752
+ min = scheduleIntervalMinMax(sched)[:min]
1753
+
1754
+ if min
1755
+ res[:spt] = min unless res[:spt]
1756
+ res[:spt] = min if res[:spt] > min
1757
+ end
1758
+ end
1710
1759
  end
1711
1760
 
1712
1761
  return res if zone.thermostat.empty?
@@ -1764,6 +1813,16 @@ module OSut
1764
1813
  end
1765
1814
  end
1766
1815
 
1816
+ unless sched.to_ScheduleInterval.empty?
1817
+ sched = sched.to_ScheduleInerval.get
1818
+ min = scheduleIntervalMinMax(sched)[:min]
1819
+
1820
+ if min
1821
+ res[:spt] = min unless res[:spt]
1822
+ res[:spt] = min if res[:spt] > min
1823
+ end
1824
+ end
1825
+
1767
1826
  unless sched.to_ScheduleYear.empty?
1768
1827
  sched = sched.to_ScheduleYear.get
1769
1828
 
@@ -1924,7 +1983,7 @@ module OSut
1924
1983
  # isolation) to determine whether an UNOCCUPIED space should have its
1925
1984
  # envelope insulated ("plenum") or not ("attic").
1926
1985
  #
1927
- # In contrast to OpenStudio-Standards' "space_plenum?", this method
1986
+ # In contrast to OpenStudio-Standards' "space_plenum?", the method below
1928
1987
  # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It
1929
1988
  # also returns FALSE if the space is a vestibule. Otherwise, it needs more
1930
1989
  # information to determine if such an UNOCCUPIED space is indeed a
@@ -1933,7 +1992,7 @@ module OSut
1933
1992
  # CASE A: it includes the substring "plenum" (case insensitive) in its
1934
1993
  # spaceType's name, or in the latter's standardsSpaceType string;
1935
1994
  #
1936
- # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops: OR
1995
+ # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops; OR
1937
1996
  #
1938
1997
  # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid
1939
1998
  # setpoints) in an OpenStudio model with setpoint temperatures.
@@ -2054,7 +2113,7 @@ module OSut
2054
2113
  res[:heating] = nil
2055
2114
  res[:cooling] = nil
2056
2115
  elsif cnd.downcase == "semiheated"
2057
- res[:heating] = 15.0 if res[:heating].nil?
2116
+ res[:heating] = 14.0 if res[:heating].nil?
2058
2117
  res[:cooling] = nil
2059
2118
  elsif cnd.downcase.include?("conditioned")
2060
2119
  # "nonresconditioned", "resconditioned" or "indirectlyconditioned"
@@ -2090,6 +2149,60 @@ module OSut
2090
2149
  ok
2091
2150
  end
2092
2151
 
2152
+ ##
2153
+ # Validates whether a space can be considered as REFRIGERATED.
2154
+ #
2155
+ # @param space [OpenStudio::Model::Space] a space
2156
+ #
2157
+ # @return [Bool] whether space is considered REFRIGERATED
2158
+ # @return [false] if invalid input (see logs)
2159
+ def refrigerated?(space = nil)
2160
+ mth = "OSut::#{__callee__}"
2161
+ cl = OpenStudio::Model::Space
2162
+ tg0 = "refrigerated"
2163
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
2164
+
2165
+ # 1. First check OSut's REFRIGERATED status.
2166
+ status = space.additionalProperties.getFeatureAsString(tg0)
2167
+
2168
+ unless status.empty?
2169
+ status = status.get
2170
+ return status if [true, false].include?(status)
2171
+
2172
+ log(ERR, "Unknown #{space.nameString} REFRIGERATED #{status} (#{mth})")
2173
+ end
2174
+
2175
+ # 2. Else, compare design heating/cooling setpoints.
2176
+ stps = setpoints(space)
2177
+ return false unless stps[:heating].nil?
2178
+ return false if stps[:cooling].nil?
2179
+ return true if stps[:cooling] < 15
2180
+
2181
+ false
2182
+ end
2183
+
2184
+ ##
2185
+ # Validates whether a space can be considered as SEMIHEATED as per NECB 2020
2186
+ # 1.2.1.2. 2): design heating setpoint < 15°C (and non-REFRIGERATED).
2187
+ #
2188
+ # @param space [OpenStudio::Model::Space] a space
2189
+ #
2190
+ # @return [Bool] whether space is considered SEMIHEATED
2191
+ # @return [false] if invalid input (see logs)
2192
+ def semiheated?(space = nil)
2193
+ mth = "OSut::#{__callee__}"
2194
+ cl = OpenStudio::Model::Space
2195
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
2196
+ return false if refrigerated?(space)
2197
+
2198
+ stps = setpoints(space)
2199
+ return false unless stps[:cooling].nil?
2200
+ return false if stps[:heating].nil?
2201
+ return true if stps[:heating] < 15
2202
+
2203
+ false
2204
+ end
2205
+
2093
2206
  ##
2094
2207
  # Generates an HVAC availability schedule.
2095
2208
  #
@@ -2256,9 +2369,9 @@ module OSut
2256
2369
  # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
2257
2370
  # This final set of utilities targets OpenStudio geometry. Many of the
2258
2371
  # following geometry methods rely on Boost as an OpenStudio dependency.
2259
- # As per Boost requirements, points (e.g. polygons) must first be 'aligned':
2372
+ # As per Boost requirements, points (e.g. vertical polygon) must be 'aligned':
2260
2373
  # - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0)
2261
- # - initial Z-axis values are represented as Y-axis values
2374
+ # - initial Z-axis values now become Y-axis values
2262
2375
  # - points with the lowest X-axis values are 'aligned' along X-axis (0)
2263
2376
  # - points with the lowest Z-axis values are 'aligned' along Y-axis (0)
2264
2377
  # - for several Boost methods, points must be clockwise in sequence
@@ -2297,7 +2410,7 @@ module OSut
2297
2410
  # @return [OpenStudio::Vector3d] true normal vector
2298
2411
  # @return [nil] if invalid input (see logs)
2299
2412
  def trueNormal(s = nil, r = 0)
2300
- mth = "TBD::#{__callee__}"
2413
+ mth = "OSut::#{__callee__}"
2301
2414
  cl = OpenStudio::Model::PlanarSurface
2302
2415
  return mismatch("surface", s, cl, mth) unless s.is_a?(cl)
2303
2416
  return invalid("rotation angle", mth, 2) unless r.respond_to?(:to_f)
@@ -2330,31 +2443,31 @@ module OSut
2330
2443
 
2331
2444
  ##
2332
2445
  # Returns OpenStudio 3D points as an OpenStudio point vector, validating
2333
- # points in the process (if Array).
2446
+ # points in the process (e.g. if Array).
2334
2447
  #
2335
2448
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
2336
2449
  #
2337
2450
  # @return [OpenStudio::Point3dVector] 3D vector (see logs if empty)
2338
2451
  def to_p3Dv(pts = nil)
2339
2452
  mth = "OSut::#{__callee__}"
2340
- cl1 = OpenStudio::Point3d
2341
- cl2 = OpenStudio::Point3dVector
2342
- cl3 = OpenStudio::Model::PlanarSurface
2343
- cl4 = Array
2344
2453
  v = OpenStudio::Point3dVector.new
2345
2454
 
2346
- if pts.is_a?(cl1)
2455
+ if pts.is_a?(OpenStudio::Point3d)
2347
2456
  v << pts
2348
2457
  return v
2458
+ elsif pts.is_a?(OpenStudio::Point3dVector)
2459
+ return pts
2460
+ elsif pts.is_a?(OpenStudio::Model::PlanarSurface)
2461
+ pts.vertices.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) }
2462
+ return v
2349
2463
  end
2350
2464
 
2351
- return pts if pts.is_a?(cl2)
2352
- return pts.vertices if pts.is_a?(cl3)
2353
-
2354
- return mismatch("points", pts, cl1, mth, DBG, v) unless pts.is_a?(cl4)
2465
+ return mismatch("points", pts, Array, mth, DBG, v) unless pts.is_a?(Array)
2355
2466
 
2356
2467
  pts.each do |pt|
2357
- return mismatch("point", pt, cl4, mth, DBG, v) unless pt.is_a?(cl1)
2468
+ unless pt.is_a?(OpenStudio::Point3d)
2469
+ return mismatch("point", pt, OpenStudio::Point3d, mth, DBG, v)
2470
+ end
2358
2471
  end
2359
2472
 
2360
2473
  pts.each { |pt| v << OpenStudio::Point3d.new(pt.x, pt.y, pt.z) }
@@ -2630,7 +2743,7 @@ module OSut
2630
2743
 
2631
2744
  pair = pts.each_cons(2).find { |p1, _| same?(p1, pt) }
2632
2745
 
2633
- pair.nil? ? pts.first : pair.last
2746
+ pair.nil? ? pts[0] : pair[-1]
2634
2747
  end
2635
2748
 
2636
2749
  ##
@@ -2721,21 +2834,25 @@ module OSut
2721
2834
  # @param pts [Set<OpenStudio::Point3d] 3D points
2722
2835
  # @param n [#to_i] requested number of unique points (0 returns all)
2723
2836
  #
2724
- # @return [OpenStudio::Point3dVector] unique points (see logs if empty)
2725
- def getUniques(pts = nil, n = 0)
2837
+ # @return [OpenStudio::Point3dVector] unique points (see logs)
2838
+ def uniques(pts = nil, n = 0)
2726
2839
  mth = "OSut::#{__callee__}"
2727
2840
  pts = to_p3Dv(pts)
2728
- ok = n.respond_to?(:to_i)
2729
2841
  v = OpenStudio::Point3dVector.new
2730
2842
  return v if pts.empty?
2731
- return mismatch("n unique points", n, Integer, mth, DBG, v) unless ok
2843
+
2844
+ if n.is_a?(Numeric)
2845
+ n = n.to_i
2846
+ else
2847
+ mismatch("n points", n, Integer, mth, DBG)
2848
+ n = 0
2849
+ end
2732
2850
 
2733
2851
  pts.each { |pt| v << pt unless holds?(v, pt) }
2734
2852
 
2735
- n = n.to_i
2736
- n = 0 unless n.abs < v.size
2737
- v = v[0..n] if n > 0
2738
- v = v[n..-1] if n < 0
2853
+ n = 0 if n.abs > v.size
2854
+ v = v[0..n-1] if n > 0
2855
+ v = v[n..-1] if n < 0
2739
2856
 
2740
2857
  v
2741
2858
  end
@@ -2749,10 +2866,10 @@ module OSut
2749
2866
  # @param pts [Set<OpenStudio::Point3d>] 3D points
2750
2867
  #
2751
2868
  # @return [OpenStudio::Point3dVectorVector] line segments (see logs if empty)
2752
- def getSegments(pts = nil)
2869
+ def segments(pts = nil)
2753
2870
  mth = "OSut::#{__callee__}"
2754
2871
  vv = OpenStudio::Point3dVectorVector.new
2755
- pts = getUniques(pts)
2872
+ pts = uniques(pts)
2756
2873
  return vv if pts.size < 2
2757
2874
 
2758
2875
  pts.each_with_index do |p1, i1|
@@ -2779,7 +2896,6 @@ module OSut
2779
2896
  # @return [false] if invalid input (see logs)
2780
2897
  def segment?(pts = nil)
2781
2898
  pts = to_p3Dv(pts)
2782
- return false if pts.empty?
2783
2899
  return false unless pts.size == 2
2784
2900
  return false if same?(pts[0], pts[1])
2785
2901
 
@@ -2796,10 +2912,10 @@ module OSut
2796
2912
  # @param pts [OpenStudio::Point3dVector] 3D points
2797
2913
  #
2798
2914
  # @return [OpenStudio::Point3dVectorVector] triads (see logs if empty)
2799
- def getTriads(pts = nil, co = false)
2915
+ def triads(pts = nil, co = false)
2800
2916
  mth = "OSut::#{__callee__}"
2801
2917
  vv = OpenStudio::Point3dVectorVector.new
2802
- pts = getUniques(pts)
2918
+ pts = uniques(pts)
2803
2919
  return vv if pts.size < 2
2804
2920
 
2805
2921
  pts.each_with_index do |p1, i1|
@@ -2826,7 +2942,7 @@ module OSut
2826
2942
  # @param pts [Set<OpenStudio::Point3d>] 3D points
2827
2943
  #
2828
2944
  # @return [Bool] whether set is a valid triad (i.e. a trio of 3D points)
2829
- # @return [false] if invalid input (see logs)
2945
+ # @return [false] if invalid input (see 'to_p3Dv' logs)
2830
2946
  def triad?(pts = nil)
2831
2947
  pts = to_p3Dv(pts)
2832
2948
  return false if pts.empty?
@@ -2851,18 +2967,17 @@ module OSut
2851
2967
  mth = "OSut::#{__callee__}"
2852
2968
  cl1 = OpenStudio::Point3d
2853
2969
  cl2 = OpenStudio::Point3dVector
2854
- return mismatch( "point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1)
2855
- return mismatch("segment", sg, cl2, mth, DBG, false) unless segment?(sg)
2856
-
2970
+ return mismatch("point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1)
2971
+ return false unless segment?(sg)
2857
2972
  return true if holds?(sg, p0)
2858
2973
 
2859
- a = sg.first
2860
- b = sg.last
2974
+ a = sg[ 0]
2975
+ b = sg[-1]
2861
2976
  ab = b - a
2862
2977
  abn = b - a
2863
2978
  abn.normalize
2864
2979
  ap = p0 - a
2865
- sp = ap.dot(abn)
2980
+ sp = ap.dot(abn)
2866
2981
  return false if sp < 0
2867
2982
 
2868
2983
  apd = scalar(abn, sp)
@@ -2887,9 +3002,9 @@ module OSut
2887
3002
  mth = "OSut::#{__callee__}"
2888
3003
  cl1 = OpenStudio::Point3d
2889
3004
  cl2 = OpenStudio::Point3dVectorVector
2890
- sgs = sgs.is_a?(cl2) ? sgs : getSegments(sgs)
2891
- return empty("segments", mth, DBG, false) if sgs.empty?
2892
- return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl1)
3005
+ sgs = sgs.is_a?(cl2) ? sgs : segments(sgs)
3006
+ return empty("segments", mth, DBG, false) if sgs.empty?
3007
+ return mismatch("point", p0, cl1, mth, DBG, false) unless p0.is_a?(cl1)
2893
3008
 
2894
3009
  sgs.each { |sg| return true if pointAlongSegment?(p0, sg) }
2895
3010
 
@@ -2904,9 +3019,9 @@ module OSut
2904
3019
  #
2905
3020
  # @return [OpenStudio::Point3d] point of intersection of both lines
2906
3021
  # @return [nil] if no intersection, equal, or invalid input (see logs)
2907
- def getLineIntersection(s1 = [], s2 = [])
2908
- s1 = getSegments(s1)
2909
- s2 = getSegments(s2)
3022
+ def lineIntersection(s1 = [], s2 = [])
3023
+ s1 = segments(s1)
3024
+ s2 = segments(s2)
2910
3025
  return nil if s1.empty?
2911
3026
  return nil if s2.empty?
2912
3027
 
@@ -2917,10 +3032,10 @@ module OSut
2917
3032
  return nil if same?(s1, s2)
2918
3033
  return nil if same?(s1, s2.to_a.reverse)
2919
3034
 
2920
- a1 = s1[0]
2921
- a2 = s1[1]
2922
- b1 = s2[0]
2923
- b2 = s2[1]
3035
+ a1 = s1.first
3036
+ b1 = s2.first
3037
+ a2 = s1.last
3038
+ b2 = s2.last
2924
3039
 
2925
3040
  # Matching segment endpoints?
2926
3041
  return a1 if same?(a1, b1)
@@ -2929,25 +3044,32 @@ module OSut
2929
3044
  return a2 if same?(a2, b2)
2930
3045
 
2931
3046
  # Segment endpoint along opposite segment?
2932
- return a1 if pointAlongSegments?(a1, s2)
2933
- return a2 if pointAlongSegments?(a2, s2)
2934
- return b1 if pointAlongSegments?(b1, s1)
2935
- return b2 if pointAlongSegments?(b2, s1)
3047
+ return a1 if pointAlongSegment?(a1, s2)
3048
+ return a2 if pointAlongSegment?(a2, s2)
3049
+ return b1 if pointAlongSegment?(b1, s1)
3050
+ return b2 if pointAlongSegment?(b2, s1)
2936
3051
 
2937
- # Line segments as vectors. Skip if colinear.
3052
+ # Line segments as vectors. Skip if collinear or parallel.
2938
3053
  a = a2 - a1
2939
3054
  b = b2 - b1
2940
3055
  xab = a.cross(b)
2941
3056
  return nil if xab.length.round(4) < TOL2
2942
3057
 
2943
- # Link 1st point to other segment endpoints as vectors. Must be coplanar.
3058
+ # Link 1st point to other segment endpoints, as vectors. Must be coplanar.
2944
3059
  a1b1 = b1 - a1
2945
3060
  a1b2 = b2 - a1
2946
3061
  xa1b1 = a.cross(a1b1)
2947
3062
  xa1b2 = a.cross(a1b2)
3063
+ xa1b1.normalize
3064
+ xa1b2.normalize
3065
+ xab.normalize
2948
3066
  return nil unless xab.cross(xa1b1).length.round(4) < TOL2
2949
3067
  return nil unless xab.cross(xa1b2).length.round(4) < TOL2
2950
3068
 
3069
+ # Reset.
3070
+ xa1b1 = a.cross(a1b1)
3071
+ xa1b2 = a.cross(a1b2)
3072
+
2951
3073
  # Both segment endpoints can't be 'behind' point.
2952
3074
  return nil if a.dot(a1b1) < 0 && a.dot(a1b2) < 0
2953
3075
 
@@ -2974,7 +3096,7 @@ module OSut
2974
3096
  return nil if a.dot(p0 - a1) < 0
2975
3097
 
2976
3098
  # Ensure intersection is sandwiched between endpoints.
2977
- return nil unless pointAlongSegments?(p0, s2) && pointAlongSegments?(p0, s1)
3099
+ return nil unless pointAlongSegment?(p0, s2) && pointAlongSegment?(p0, s1)
2978
3100
 
2979
3101
  p0
2980
3102
  end
@@ -2988,33 +3110,36 @@ module OSut
2988
3110
  # @return [Bool] whether 3D line intersects 3D segments
2989
3111
  # @return [false] if invalid input (see logs)
2990
3112
  def lineIntersects?(l = [], s = [])
2991
- l = getSegments(l)
2992
- s = getSegments(s)
3113
+ l = segments(l)
3114
+ s = segments(s)
2993
3115
  return nil if l.empty?
2994
3116
  return nil if s.empty?
2995
3117
 
2996
3118
  l = l.first
2997
3119
 
2998
- s.each { |segment| return true if getLineIntersection(l, segment) }
3120
+ s.each { |segment| return true if lineIntersection(l, segment) }
2999
3121
 
3000
3122
  false
3001
3123
  end
3002
3124
 
3003
3125
  ##
3004
- # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise.
3126
+ # Validates whether OpenStudio 3D points are listed clockwise, assuming points
3127
+ # have been pre-'aligned' - not just flattened along XY (i.e. Z = 0).
3005
3128
  #
3006
- # @param pts [OpenStudio::Point3dVector] 3D points
3129
+ # @param pts [OpenStudio::Point3dVector] pre-aligned 3D points
3007
3130
  #
3008
3131
  # @return [Bool] whether sequence is clockwise
3009
3132
  # @return [false] if invalid input (see logs)
3010
3133
  def clockwise?(pts = nil)
3011
3134
  mth = "OSut::#{__callee__}"
3012
3135
  pts = to_p3Dv(pts)
3013
- n = false
3014
- return invalid("3+ points" , mth, 1, DBG, n) if pts.size < 3
3015
- return invalid("flat points", mth, 1, DBG, n) unless xyz?(pts, :z)
3136
+ return invalid("3+ points" , mth, 1, DBG, false) if pts.size < 3
3137
+ return invalid("flat points", mth, 1, DBG, false) unless xyz?(pts, :z)
3138
+
3139
+ n = OpenStudio.getOutwardNormal(pts)
3140
+ return invalid("polygon", mth, 1, DBG, false) if n.empty?
3016
3141
 
3017
- OpenStudio.pointInPolygon(pts.first, pts, TOL)
3142
+ n.get.z > 0 ? false : true
3018
3143
  end
3019
3144
 
3020
3145
  ##
@@ -3078,28 +3203,33 @@ module OSut
3078
3203
  end
3079
3204
 
3080
3205
  ##
3081
- # Returns sequential non-collinear points in an OpenStudio 3D point vector.
3206
+ # Returns non-collinear points in an OpenStudio 3D point vector.
3082
3207
  #
3083
3208
  # @param pts [Set<OpenStudio::Point3d] 3D points
3084
3209
  # @param n [#to_i] requested number of non-collinears (0 returns all)
3085
3210
  #
3086
- # @return [OpenStudio::Point3dVector] non-collinears (see logs if empty)
3087
- def getNonCollinears(pts = nil, n = 0)
3211
+ # @return [OpenStudio::Point3dVector] non-collinears (see logs)
3212
+ def nonCollinears(pts = nil, n = 0)
3088
3213
  mth = "OSut::#{__callee__}"
3089
- pts = getUniques(pts)
3090
- ok = n.respond_to?(:to_i)
3091
- v = OpenStudio::Point3dVector.new
3092
3214
  a = []
3215
+ pts = uniques(pts)
3093
3216
  return pts if pts.size < 3
3094
- return mismatch("n non-collinears", n, Integer, mth, DBG, v) unless ok
3217
+
3218
+ if n.is_a?(Numeric)
3219
+ n = n.to_i
3220
+ else
3221
+ mismatch("n points", n, Integer, mth, DBG)
3222
+ n = 0
3223
+ end
3095
3224
 
3096
3225
  # Evaluate cross product of vectors of 3x sequential points.
3097
3226
  pts.each_with_index do |p2, i2|
3098
- i1 = i2 - 1
3099
- i3 = i2 + 1
3100
- i3 = 0 if i3 == pts.size
3101
- p1 = pts[i1]
3102
- p3 = pts[i3]
3227
+ i1 = i2 - 1
3228
+ i3 = i2 + 1
3229
+ i3 = 0 if i3 == pts.size
3230
+ p1 = pts[i1]
3231
+ p3 = pts[i3]
3232
+
3103
3233
  v13 = p3 - p1
3104
3234
  v12 = p2 - p1
3105
3235
  next if v12.cross(v13).length < TOL2
@@ -3107,36 +3237,47 @@ module OSut
3107
3237
  a << p2
3108
3238
  end
3109
3239
 
3110
- if holds?(a, pts[0])
3240
+ if a.include?(pts[0])
3111
3241
  a = a.rotate(-1) unless same?(a[0], pts[0])
3112
3242
  end
3113
3243
 
3114
- n = n.to_i
3115
- a = a[0..n-1] if n > 0
3116
- a = a[n-1..-1] if n < 0
3244
+ n = 0 if n.abs > a.size
3245
+ a = a[0..n-1] if n > 0
3246
+ a = a[n..-1] if n < 0
3117
3247
 
3118
3248
  to_p3Dv(a)
3119
3249
  end
3120
3250
 
3121
3251
  ##
3122
- # Returns sequential collinear points in an OpenStudio 3D point vector.
3252
+ # Returns collinear points in an OpenStudio 3D point vector.
3123
3253
  #
3124
3254
  # @param pts [Set<OpenStudio::Point3d] 3D points
3125
3255
  # @param n [#to_i] requested number of collinears (0 returns all)
3126
3256
  #
3127
- # @return [OpenStudio::Point3dVector] collinears (see logs if empty)
3128
- def getCollinears(pts = nil, n = 0)
3257
+ # @return [OpenStudio::Point3dVector] collinears (see logs)
3258
+ def collinears(pts = nil, n = 0)
3129
3259
  mth = "OSut::#{__callee__}"
3130
- pts = getUniques(pts)
3131
- ok = n.respond_to?(:to_i)
3132
- v = OpenStudio::Point3dVector.new
3260
+ a = OpenStudio::Point3dVector.new
3261
+ pts = uniques(pts)
3133
3262
  return pts if pts.size < 3
3134
- return mismatch("n collinears", n, Integer, mth, DBG, v) unless ok
3135
3263
 
3136
- ncolls = getNonCollinears(pts)
3137
- return pts if ncolls.empty?
3264
+ if n.is_a?(Numeric)
3265
+ n = n.to_i
3266
+ else
3267
+ mismatch("n points", n, Integer, mth, DBG, pts)
3268
+ n = 0
3269
+ end
3270
+
3271
+ ncolls = nonCollinears(pts)
3272
+ return a if ncolls.empty?
3273
+
3274
+ a = pts.delete_if { |pt| holds?(ncolls, pt) }
3138
3275
 
3139
- to_p3Dv( pts.delete_if { |pt| holds?(ncolls, pt) } )
3276
+ n = 0 if n.abs > a.size
3277
+ a = a[0..n-1] if n > 0
3278
+ a = a[n..-1] if n < 0
3279
+
3280
+ a
3140
3281
  end
3141
3282
 
3142
3283
  ##
@@ -3144,8 +3285,8 @@ module OSut
3144
3285
  # polygon. In addition to basic OpenStudio polygon tests (e.g. all points
3145
3286
  # sharing the same 3D plane, non-self-intersecting), the method can
3146
3287
  # optionally check for convexity, or ensure uniqueness and/or non-collinearity.
3147
- # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC)
3148
- # counterclockwise sequence, or in clockwise sequence.
3288
+ # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC),
3289
+ # BottomLeftCorner (BLC), in clockwise (or counterclockwise) sequence.
3149
3290
  #
3150
3291
  # @param pts [Set<OpenStudio::Point3d>] 3D points
3151
3292
  # @param vx [Bool] whether to check for convexity
@@ -3162,7 +3303,7 @@ module OSut
3162
3303
  v = OpenStudio::Point3dVector.new
3163
3304
  vx = false unless [true, false].include?(vx)
3164
3305
  uq = false unless [true, false].include?(uq)
3165
- co = true unless [true, false].include?(co)
3306
+ co = false unless [true, false].include?(co)
3166
3307
 
3167
3308
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3168
3309
  # Exit if mismatched/invalid arguments.
@@ -3173,8 +3314,8 @@ module OSut
3173
3314
 
3174
3315
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3175
3316
  # Minimum 3 points?
3176
- p3 = getNonCollinears(pts, 3)
3177
- return empty("polygon", mth, ERR, v) if p3.size < 3
3317
+ p3 = nonCollinears(pts, 3)
3318
+ return empty("polygon (non-collinears < 3)", mth, ERR, v) if p3.size < 3
3178
3319
 
3179
3320
  # Coplanar?
3180
3321
  pln = OpenStudio::Plane.new(p3)
@@ -3204,8 +3345,8 @@ module OSut
3204
3345
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3205
3346
  # Ensure uniqueness and/or non-collinearity. Preserve original sequence.
3206
3347
  p0 = a.first
3207
- a = getUniques(a).to_a if uq
3208
- a = getNonCollinears(a).to_a if co
3348
+ a = uniques(a).to_a if uq
3349
+ a = nonCollinears(a).to_a if co
3209
3350
  i0 = a.index { |pt| same?(pt, p0) }
3210
3351
  a = a.rotate(i0) unless i0.nil?
3211
3352
 
@@ -3214,7 +3355,7 @@ module OSut
3214
3355
  if vx && a.size > 3
3215
3356
  zen = OpenStudio::Point3d.new(0, 0, 1000)
3216
3357
 
3217
- getTriads(a).each do |trio|
3358
+ triads(a).each do |trio|
3218
3359
  p1 = trio[0]
3219
3360
  p2 = trio[1]
3220
3361
  p3 = trio[2]
@@ -3273,49 +3414,47 @@ module OSut
3273
3414
  return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl)
3274
3415
 
3275
3416
  n = OpenStudio.getOutwardNormal(s)
3276
- return false if n.empty?
3417
+ return invalid("plane/normal", mth, 2, DBG, false) if n.empty?
3277
3418
 
3278
3419
  n = n.get
3279
3420
  pl = OpenStudio::Plane.new(s.first, n)
3280
3421
  return false unless pl.pointOnPlane(p0)
3281
3422
 
3282
3423
  entirely = false unless [true, false].include?(entirely)
3283
- segments = getSegments(s)
3424
+ sgments = segments(s)
3284
3425
 
3285
3426
  # Along polygon edges, or near vertices?
3286
- if pointAlongSegments?(p0, segments)
3427
+ if pointAlongSegments?(p0, sgments)
3287
3428
  return false if entirely
3288
3429
  return true unless entirely
3289
3430
  end
3290
3431
 
3291
- segments.each do |segment|
3432
+ sgments.each do |sgment|
3292
3433
  # - draw vector from segment midpoint to point
3293
3434
  # - scale 1000x (assuming no building surface would be 1km wide)
3294
3435
  # - convert vector to an independent line segment
3295
3436
  # - loop through polygon segments, tally the number of intersections
3296
3437
  # - avoid double-counting polygon vertices as intersections
3297
3438
  # - return false if number of intersections is even
3298
- mid = midpoint(segment.first, segment.last)
3439
+ mid = midpoint(sgment.first, sgment.last)
3299
3440
  mpV = scalar(mid - p0, 1000)
3300
3441
  p1 = p0 + mpV
3301
3442
  ctr = 0
3302
- pts = []
3303
3443
 
3304
3444
  # Skip if ~collinear.
3305
- next if (mpV.cross(segment.last - segment.first).length).round(4) < TOL2
3445
+ next if mpV.cross(sgment.last - sgment.first).length.round(4) < TOL2
3306
3446
 
3307
- segments.each do |sg|
3308
- intersect = getLineIntersection([p0, p1], sg)
3447
+ sgments.each do |sg|
3448
+ intersect = lineIntersection([p0, p1], sg)
3309
3449
  next unless intersect
3310
3450
 
3311
- # One of the polygon vertices?
3451
+ # Skip test altogether if one of the polygon vertices.
3312
3452
  if holds?(s, intersect)
3313
- next if holds?(pts, intersect)
3314
-
3315
- pts << intersect
3453
+ ctr = 0
3454
+ break
3455
+ else
3456
+ ctr += 1
3316
3457
  end
3317
-
3318
- ctr += 1
3319
3458
  end
3320
3459
 
3321
3460
  next if ctr.zero?
@@ -3334,56 +3473,88 @@ module OSut
3334
3473
  # @return [Bool] whether 2 polygons are parallel
3335
3474
  # @return [false] if invalid input (see logs)
3336
3475
  def parallel?(p1 = nil, p2 = nil)
3337
- p1 = poly(p1, false, true, false)
3338
- p2 = poly(p2, false, true, false)
3476
+ p1 = poly(p1, false, true)
3477
+ p2 = poly(p2, false, true)
3339
3478
  return false if p1.empty?
3340
3479
  return false if p2.empty?
3341
3480
 
3342
- p1 = getNonCollinears(p1, 3)
3343
- p2 = getNonCollinears(p2, 3)
3344
- return false if p1.empty?
3345
- return false if p2.empty?
3481
+ n1 = OpenStudio.getOutwardNormal(p1)
3482
+ n2 = OpenStudio.getOutwardNormal(p2)
3483
+ return false if n1.empty?
3484
+ return false if n2.empty?
3485
+
3486
+ n1.get.dot(n2.get).abs > 0.99
3487
+ end
3488
+
3489
+ ##
3490
+ # Validates whether a polygon can be considered a valid 'roof' surface, as per
3491
+ # ASHRAE 90.1 & Canadian NECBs, i.e. outward normal within 60° from vertical
3492
+ #
3493
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3494
+ #
3495
+ # @return [Bool] if considered a roof surface
3496
+ # @return [false] if invalid input (see logs)
3497
+ def roof?(pts = nil)
3498
+ ray = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
3499
+ dut = Math.cos(60 * Math::PI / 180)
3500
+ pts = poly(pts, false, true, true)
3501
+ return false if pts.empty?
3346
3502
 
3347
- pl1 = OpenStudio::Plane.new(p1)
3348
- pl2 = OpenStudio::Plane.new(p2)
3503
+ dot = ray.dot(OpenStudio.getOutwardNormal(pts).get)
3504
+ return false if dot.round(2) <= 0
3505
+ return true if dot.round(2) == 1
3349
3506
 
3350
- pl1.outwardNormal.dot(pl2.outwardNormal).abs > 0.99
3507
+ dot.round(4) >= dut.round(4)
3351
3508
  end
3352
3509
 
3353
3510
  ##
3354
- # Validates whether a polygon faces upwards.
3511
+ # Validates whether a polygon faces upwards, harmonized with OpenStudio
3512
+ # Utilities' "alignZPrime" function.
3355
3513
  #
3356
3514
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3357
3515
  #
3358
3516
  # @return [Bool] if facing upwards
3359
3517
  # @return [false] if invalid input (see logs)
3360
3518
  def facingUp?(pts = nil)
3361
- up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
3362
- pts = poly(pts, false, true, false)
3363
- return false if pts.empty?
3364
-
3365
- pts = getNonCollinears(pts, 3)
3519
+ ray = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
3520
+ pts = poly(pts, false, true, true)
3366
3521
  return false if pts.empty?
3367
3522
 
3368
- OpenStudio::Plane.new(pts).outwardNormal.dot(up) > 0.99
3523
+ OpenStudio.getOutwardNormal(pts).get.dot(ray) > 0.99
3369
3524
  end
3370
3525
 
3371
3526
  ##
3372
- # Validates whether a polygon faces downwards.
3527
+ # Validates whether a polygon faces downwards, harmonized with OpenStudio
3528
+ # Utilities' "alignZPrime" function.
3373
3529
  #
3374
3530
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3375
3531
  #
3376
3532
  # @return [Bool] if facing downwards
3377
3533
  # @return [false] if invalid input (see logs)
3378
3534
  def facingDown?(pts = nil)
3379
- lo = OpenStudio::Point3d.new(0,0,-1) - OpenStudio::Point3d.new(0,0,0)
3380
- pts = poly(pts, false, true, false)
3535
+ ray = OpenStudio::Point3d.new(0,0,-1) - OpenStudio::Point3d.new(0,0,0)
3536
+ pts = poly(pts, false, true, true)
3381
3537
  return false if pts.empty?
3382
3538
 
3383
- pts = getNonCollinears(pts, 3)
3539
+ OpenStudio.getOutwardNormal(pts).get.dot(ray) > 0.99
3540
+ end
3541
+
3542
+ ##
3543
+ # Validates whether surface can be considered 'sloped' (i.e. not ~flat, as per
3544
+ # OpenStudio Utilities' "alignZPrime"). A vertical polygon returns true.
3545
+ #
3546
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3547
+ #
3548
+ # @return [Bool] whether surface is sloped
3549
+ # @return [false] if invalid input (see logs)
3550
+ def sloped?(pts = nil)
3551
+ mth = "OSut::#{__callee__}"
3552
+ pts = poly(pts, false, true, true)
3384
3553
  return false if pts.empty?
3554
+ return false if facingUp?(pts)
3555
+ return false if facingDown?(pts)
3385
3556
 
3386
- OpenStudio::Plane.new(pts).outwardNormal.dot(lo) > 0.99
3557
+ true
3387
3558
  end
3388
3559
 
3389
3560
  ##
@@ -3424,7 +3595,7 @@ module OSut
3424
3595
  return false if pts.empty?
3425
3596
  return false unless rectangular?(pts)
3426
3597
 
3427
- getSegments(pts).each do |pt|
3598
+ segments(pts).each do |pt|
3428
3599
  l = (pt[1] - pt[0]).length
3429
3600
  d = l unless d
3430
3601
  return false unless l.round(2) == d.round(2)
@@ -3454,6 +3625,15 @@ module OSut
3454
3625
 
3455
3626
  p1.each { |p0| return false unless pointWithinPolygon?(p0, p2) }
3456
3627
 
3628
+ # Although p2 points may lie ALONG p1, none may lie entirely WITHIN p1.
3629
+ p2.each { |p0| return false if pointWithinPolygon?(p0, p1, true) }
3630
+
3631
+ # p1 segment mid-points must not lie OUTSIDE of p2.
3632
+ segments(p1).each do |sg|
3633
+ mp = midpoint(sg.first, sg.last)
3634
+ return false unless pointWithinPolygon?(mp, p2)
3635
+ end
3636
+
3457
3637
  entirely = false unless [true, false].include?(entirely)
3458
3638
  return true unless entirely
3459
3639
 
@@ -3492,22 +3672,17 @@ module OSut
3492
3672
  cw1 = clockwise?(p01)
3493
3673
  a1 = cw1 ? p01.to_a.reverse : p01.to_a
3494
3674
  a2 = p02.to_a
3495
- a2 = flatten(a2).to_a if flat
3496
- return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z)
3497
-
3498
- cw2 = clockwise?(a2)
3499
- a2 = a2.reverse if cw2
3500
3675
  else
3501
3676
  t = OpenStudio::Transformation.alignFace(p01)
3502
3677
  a1 = t.inverse * p01
3503
3678
  a2 = t.inverse * p02
3504
- a2 = flatten(a2).to_a if flat
3505
- return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z)
3506
-
3507
- cw2 = clockwise?(a2)
3508
- a2 = a2.reverse if cw2
3509
3679
  end
3510
3680
 
3681
+ a2 = flatten(a2).to_a if flat
3682
+ cw2 = clockwise?(a2)
3683
+ a2 = a2.reverse if cw2
3684
+ return invalid("points 2", mth, 2, DBG, face) unless xyz?(a2, :z)
3685
+
3511
3686
  # Return either (transformed) polygon if one fits into the other.
3512
3687
  p1t = p01
3513
3688
 
@@ -3587,7 +3762,7 @@ module OSut
3587
3762
  p2 = poly(p2)
3588
3763
  return face if p1.empty?
3589
3764
  return face if p2.empty?
3590
- return mismatch("ray", ray, cl, mth) unless ray.is_a?(cl)
3765
+ return mismatch("ray", ray, cl, mth, face) unless ray.is_a?(cl)
3591
3766
 
3592
3767
  # From OpenStudio SDK v3.7.0 onwards, one could/should rely on:
3593
3768
  #
@@ -3600,7 +3775,7 @@ module OSut
3600
3775
  # The following +/- replicates the same solution, based on:
3601
3776
  # https://stackoverflow.com/a/65832417
3602
3777
  p0 = p2.first
3603
- pl = OpenStudio::Plane.new(getNonCollinears(p2, 3))
3778
+ pl = OpenStudio::Plane.new(p2)
3604
3779
  n = pl.outwardNormal
3605
3780
  return face if n.dot(ray).abs < TOL
3606
3781
 
@@ -3920,12 +4095,12 @@ module OSut
3920
4095
  #
3921
4096
  # @param [Set<OpenStudio::Point3d>] a triad (3D points)
3922
4097
  #
3923
- # @return [Set<OpenStudio::Point3D>] a rectangular ULC box (see logs if empty)
4098
+ # @return [Set<OpenStudio::Point3D>] a rectangular BLC box (see logs if empty)
3924
4099
  def triadBox(pts = nil)
3925
4100
  mth = "OSut::#{__callee__}"
3926
4101
  bkp = OpenStudio::Point3dVector.new
3927
4102
  box = []
3928
- pts = getNonCollinears(pts)
4103
+ pts = nonCollinears(pts)
3929
4104
  return bkp if pts.empty?
3930
4105
 
3931
4106
  t = xyz?(pts, :z) ? nil : OpenStudio::Transformation.alignFace(pts)
@@ -3962,6 +4137,8 @@ module OSut
3962
4137
  box << OpenStudio::Point3d.new(p1.x, p1.y, p1.z)
3963
4138
  box << OpenStudio::Point3d.new(p2.x, p2.y, p2.z)
3964
4139
  box << OpenStudio::Point3d.new(p3.x, p3.y, p3.z)
4140
+ box = nonCollinears(box, 4)
4141
+ return bkp unless box.size == 4
3965
4142
 
3966
4143
  box = blc(box)
3967
4144
  return bkp unless rectangular?(box)
@@ -3993,7 +4170,7 @@ module OSut
3993
4170
 
3994
4171
  # Generate vertical plane along longest segment.
3995
4172
  mpoints = []
3996
- sgs = getSegments(pts)
4173
+ sgs = segments(pts)
3997
4174
  longest = sgs.max_by { |s| OpenStudio.getDistanceSquared(s.first, s.last) }
3998
4175
  plane = verticalPlane(longest.first, longest.last)
3999
4176
 
@@ -4007,6 +4184,9 @@ module OSut
4007
4184
  box << mpoints.first
4008
4185
  box << mpoints.last
4009
4186
  box << plane.project(mpoints.last)
4187
+ box = nonCollinears(box).to_a
4188
+ return bkp unless box.size == 4
4189
+
4010
4190
  box = clockwise?(box) ? blc(box.reverse) : blc(box)
4011
4191
  return bkp unless rectangular?(box)
4012
4192
  return bkp unless fits?(box, pts)
@@ -4057,20 +4237,21 @@ module OSut
4057
4237
  aire = 0
4058
4238
 
4059
4239
  # PATH C : Right-angle, midpoint triad approach.
4060
- getSegments(pts).each do |sg|
4240
+ segments(pts).each do |sg|
4061
4241
  m0 = midpoint(sg.first, sg.last)
4062
4242
 
4063
- getSegments(pts).each do |seg|
4243
+ segments(pts).each do |seg|
4064
4244
  p1 = seg.first
4065
4245
  p2 = seg.last
4066
4246
  next if same?(p1, sg.first)
4067
4247
  next if same?(p1, sg.last)
4068
4248
  next if same?(p2, sg.first)
4069
- next if same?(p2, sg.first)
4249
+ next if same?(p2, sg.last)
4070
4250
 
4071
4251
  out = triadBox(OpenStudio::Point3dVector.new([m0, p1, p2]))
4072
4252
  next if out.empty?
4073
4253
  next unless fits?(out, pts)
4254
+ next if fits?(pts, out)
4074
4255
 
4075
4256
  area = OpenStudio.getArea(out)
4076
4257
  next if area.empty?
@@ -4085,7 +4266,7 @@ module OSut
4085
4266
  end
4086
4267
 
4087
4268
  # PATH D : Right-angle triad approach, may override PATH C boxes.
4088
- getSegments(pts).each do |sg|
4269
+ segments(pts).each do |sg|
4089
4270
  p0 = sg.first
4090
4271
  p1 = sg.last
4091
4272
 
@@ -4096,6 +4277,7 @@ module OSut
4096
4277
  out = triadBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4097
4278
  next if out.empty?
4098
4279
  next unless fits?(out, pts)
4280
+ next if fits?(pts, out)
4099
4281
 
4100
4282
  area = OpenStudio.getArea(out)
4101
4283
  next if area.empty?
@@ -4117,7 +4299,7 @@ module OSut
4117
4299
  # PATH E : Medial box, segment approach.
4118
4300
  aire = 0
4119
4301
 
4120
- getSegments(pts).each do |sg|
4302
+ segments(pts).each do |sg|
4121
4303
  p0 = sg.first
4122
4304
  p1 = sg.last
4123
4305
 
@@ -4128,6 +4310,7 @@ module OSut
4128
4310
  out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4129
4311
  next if out.empty?
4130
4312
  next unless fits?(out, pts)
4313
+ next if fits?(pts, out)
4131
4314
 
4132
4315
  area = OpenStudio.getArea(box)
4133
4316
  next if area.empty?
@@ -4149,7 +4332,7 @@ module OSut
4149
4332
  # PATH F : Medial box, triad approach.
4150
4333
  aire = 0
4151
4334
 
4152
- getTriads(pts).each do |sg|
4335
+ triads(pts).each do |sg|
4153
4336
  p0 = sg[0]
4154
4337
  p1 = sg[1]
4155
4338
  p2 = sg[2]
@@ -4157,6 +4340,7 @@ module OSut
4157
4340
  out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4158
4341
  next if out.empty?
4159
4342
  next unless fits?(out, pts)
4343
+ next if fits?(pts, out)
4160
4344
 
4161
4345
  area = OpenStudio.getArea(box)
4162
4346
  next if area.empty?
@@ -4180,7 +4364,7 @@ module OSut
4180
4364
  holes = OpenStudio::Point3dVectorVector.new
4181
4365
 
4182
4366
  OpenStudio.computeTriangulation(outer, holes).each do |triangle|
4183
- getSegments(triangle).each do |sg|
4367
+ segments(triangle).each do |sg|
4184
4368
  p0 = sg.first
4185
4369
  p1 = sg.last
4186
4370
 
@@ -4191,6 +4375,7 @@ module OSut
4191
4375
  out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4192
4376
  next if out.empty?
4193
4377
  next unless fits?(out, pts)
4378
+ next if fits?(pts, out)
4194
4379
 
4195
4380
  area = OpenStudio.getArea(out)
4196
4381
  next if area.empty?
@@ -4214,25 +4399,29 @@ module OSut
4214
4399
 
4215
4400
  ##
4216
4401
  # Generates re-'aligned' polygon vertices wrt main axis of symmetry of its
4217
- # largest bounded box. A Hash is returned with 6x key:value pairs ...
4402
+ # largest bounded box. Input polygon vertex Z-axis values must equal 0, and be
4403
+ # counterclockwise. A Hash is returned with 6x key:value pairs ...
4218
4404
  # set: realigned (cloned) polygon vertices, box: its bounded box (wrt to :set),
4219
4405
  # bbox: its bounding box, t: its translation transformation, r: its rotation
4220
4406
  # transformation, and o: the origin coordinates of its axis of rotation. First,
4221
4407
  # cloned polygon vertices are rotated so the longest axis of symmetry of its
4222
4408
  # bounded box lies parallel to the X-axis; :o being the midpoint of the narrow
4223
- # side (of the bounded box) nearest to grid origin (0,0,0). Once rotated,
4409
+ # side (of the bounded box) nearest to grid origin (0,0,0). If the axis of
4410
+ # symmetry of the bounded box is already parallel to the X-axis, then the
4411
+ # rotation step is skipped (unless force == true). Whether rotated or not,
4224
4412
  # polygon vertices are then translated as to ensure one or more vertices are
4225
4413
  # aligned along the X-axis and one or more vertices are aligned along the
4226
- # Y-axis (no vertices with negative X or Y coordinate values). To unalign the
4227
- # returned set of vertices (or its bounded box, or its bounding box), first
4228
- # inverse the translation transformation, then inverse the rotation
4414
+ # Y-axis (no vertices with negative X or Y coordinate values). To unalign
4415
+ # the returned set of vertices (or its bounded box, or its bounding box),
4416
+ # first inverse the translation transformation, then inverse the rotation
4229
4417
  # transformation.
4230
4418
  #
4231
4419
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
4420
+ # @param force [Bool] whether to force rotation for aligned yet narrow boxes
4232
4421
  #
4233
4422
  # @return [Hash] :set, :box, :bbox, :t, :r & :o
4234
4423
  # @return [Hash] :set, :box, :bbox, :t, :r & :o (nil) if invalid (see logs)
4235
- def getRealignedFace(pts = nil)
4424
+ def realignedFace(pts = nil, force = false)
4236
4425
  mth = "OSut::#{__callee__}"
4237
4426
  out = { set: nil, box: nil, bbox: nil, t: nil, r: nil, o: nil }
4238
4427
  pts = poly(pts, false, true)
@@ -4240,6 +4429,13 @@ module OSut
4240
4429
  return invalid("aligned plane", mth, 1, DBG, out) unless xyz?(pts, :z)
4241
4430
  return invalid("clockwise pts", mth, 1, DBG, out) if clockwise?(pts)
4242
4431
 
4432
+ # Optionally force rotation so bounded box ends up wider than taller.
4433
+ # Strongly suggested for flat surfaces like roofs (see 'sloped?').
4434
+ unless [true, false].include?(force)
4435
+ log(DBG, "Ignoring force input (#{mth})")
4436
+ force = false
4437
+ end
4438
+
4243
4439
  o = OpenStudio::Point3d.new(0, 0, 0)
4244
4440
  w = width(pts)
4245
4441
  h = height(pts)
@@ -4248,11 +4444,11 @@ module OSut
4248
4444
  box = boundedBox(pts)
4249
4445
  return invalid("bounded box", mth, 0, DBG, out) if box.empty?
4250
4446
 
4251
- segments = getSegments(box)
4252
- return invalid("bounded box segments", mth, 0, DBG, out) if segments.empty?
4447
+ sgments = segments(box)
4448
+ return invalid("bounded box segments", mth, 0, DBG, out) if sgments.empty?
4253
4449
 
4254
4450
  # Deterministic ID of box rotation/translation 'origin'.
4255
- segments.each_with_index do |sg, idx|
4451
+ sgments.each_with_index do |sg, idx|
4256
4452
  sgs[sg] = {}
4257
4453
  sgs[sg][:idx] = idx
4258
4454
  sgs[sg][:mid] = midpoint(sg[0], sg[1])
@@ -4263,7 +4459,6 @@ module OSut
4263
4459
  sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h if square?(box)
4264
4460
  sgs = sgs.sort_by { |sg, s| s[:l ] }.first(2).to_h unless square?(box)
4265
4461
  sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h unless square?(box)
4266
-
4267
4462
  sg0 = sgs.values[0]
4268
4463
  sg1 = sgs.values[1]
4269
4464
 
@@ -4273,16 +4468,23 @@ module OSut
4273
4468
  i = sg0[:idx]
4274
4469
  end
4275
4470
 
4276
- k = i + 2 < segments.size ? i + 2 : i - 2
4471
+ k = i + 2 < sgments.size ? i + 2 : i - 2
4277
4472
 
4278
- origin = midpoint(segments[i][0], segments[i][1])
4279
- terminal = midpoint(segments[k][0], segments[k][1])
4473
+ origin = midpoint(sgments[i][0], sgments[i][1])
4474
+ terminal = midpoint(sgments[k][0], sgments[k][1])
4280
4475
  seg = terminal - origin
4281
4476
  right = OpenStudio::Point3d.new(origin.x + d, origin.y , 0) - origin
4282
4477
  north = OpenStudio::Point3d.new(origin.x, origin.y + d, 0) - origin
4283
4478
  axis = OpenStudio::Point3d.new(origin.x, origin.y , d) - origin
4284
4479
  angle = OpenStudio::getAngle(right, seg)
4285
4480
  angle = -angle if north.dot(seg) < 0
4481
+
4482
+ # Skip rotation if bounded box is already aligned along XY grid (albeit
4483
+ # 'narrow'), i.e. if the angle is 90°.
4484
+ if angle.round(3) == (Math::PI/2).round(3)
4485
+ angle = 0 unless force
4486
+ end
4487
+
4286
4488
  r = OpenStudio.createRotation(origin, axis, angle)
4287
4489
  pts = to_p3Dv(r.inverse * pts)
4288
4490
  box = to_p3Dv(r.inverse * box)
@@ -4291,13 +4493,13 @@ module OSut
4291
4493
  xy = OpenStudio::Point3d.new(origin.x + dX, origin.y + dY, 0)
4292
4494
  origin2 = xy - origin
4293
4495
  t = OpenStudio.createTranslation(origin2)
4294
- set = t.inverse * pts
4295
- box = t.inverse * box
4496
+ set = to_p3Dv(t.inverse * pts)
4497
+ box = to_p3Dv(t.inverse * box)
4296
4498
  bbox = outline([set])
4297
4499
 
4298
- out[:set ] = set
4299
- out[:box ] = box
4300
- out[:bbox] = bbox
4500
+ out[:set ] = blc(set)
4501
+ out[:box ] = blc(box)
4502
+ out[:bbox] = blc(bbox)
4301
4503
  out[:t ] = t
4302
4504
  out[:r ] = r
4303
4505
  out[:o ] = origin
@@ -4309,14 +4511,21 @@ module OSut
4309
4511
  # Returns 'width' of a set of OpenStudio 3D points, once re/aligned.
4310
4512
  #
4311
4513
  # @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned
4514
+ # @param force [Bool] whether to force rotation of (narrow) bounded box
4312
4515
  #
4313
4516
  # @return [Float] width along X-axis, once re/aligned
4314
4517
  # @return [0.0] if invalid inputs
4315
- def alignedWidth(pts = nil)
4518
+ def alignedWidth(pts = nil, force = false)
4519
+ mth = "OSut::#{__callee__}"
4316
4520
  pts = poly(pts, false, true, true, true)
4317
4521
  return 0 if pts.size < 2
4318
4522
 
4319
- pts = getRealignedFace(pts)[:set]
4523
+ unless [true, false].include?(force)
4524
+ log(DBG, "Ignoring force input (#{mth})")
4525
+ force = false
4526
+ end
4527
+
4528
+ pts = realignedFace(pts, force)[:set]
4320
4529
  return 0 if pts.size < 2
4321
4530
 
4322
4531
  pts.max_by(&:x).x - pts.min_by(&:x).x
@@ -4326,57 +4535,149 @@ module OSut
4326
4535
  # Returns 'height' of a set of OpenStudio 3D points, once re/aligned.
4327
4536
  #
4328
4537
  # @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned
4538
+ # @param force [Bool] whether to force rotation of (narrow) bounded box
4329
4539
  #
4330
4540
  # @return [Float] height along Y-axis, once re/aligned
4331
4541
  # @return [0.0] if invalid inputs
4332
- def alignedHeight(pts = nil)
4333
- pts = pts = poly(pts, false, true, true, true)
4542
+ def alignedHeight(pts = nil, force = false)
4543
+ mth = "OSut::#{__callee__}"
4544
+ pts = poly(pts, false, true, true, true)
4334
4545
  return 0 if pts.size < 2
4335
4546
 
4336
- pts = getRealignedFace(pts)[:set]
4547
+ unless [true, false].include?(force)
4548
+ log(DBG, "Ignoring force input (#{mth})")
4549
+ force = false
4550
+ end
4551
+
4552
+ pts = realignedFace(pts, force)[:set]
4337
4553
  return 0 if pts.size < 2
4338
4554
 
4339
4555
  pts.max_by(&:y).y - pts.min_by(&:y).y
4340
4556
  end
4341
4557
 
4342
4558
  ##
4343
- # Generates leader line anchors, linking polygon vertices to one or more sets
4344
- # (Hashes) of sequenced vertices. By default, the method seeks to link set
4345
- # :vtx (key) vertices (users can select another collection of vertices, e.g.
4346
- # tag == :box). The method minimally validates individual sets of vertices
4347
- # (e.g. coplanarity, non-self-intersecting, no inter-set conflicts). Potential
4348
- # leader lines cannot intersect each other, other 'tagged' set vertices or
4349
- # original polygon edges. For highly-articulated cases (e.g. a narrow polygon
4350
- # with multiple concavities, holding multiple sets), such leader line
4351
- # conflicts will surely occur. The method relies on a 'first-come-first-served'
4352
- # approach: sets without leader lines are ignored (check for set :void keys,
4353
- # see error logs). It is recommended to sort sets prior to calling the method.
4559
+ # Fetch a space's full height (in space coordinates). The solution considers
4560
+ # all surface types ("Floor" vs "Wall" vs "RoofCeiling").
4354
4561
  #
4355
- # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
4356
- # @param [Array<Hash>] set a collection of sequenced vertices
4357
- # @option [Symbol] tag sequence of set vertices to target
4562
+ # @param space [OpenStudio::Model::Space] a space
4563
+ #
4564
+ # @return [Float] full height of space (0 if invalid input)
4565
+ def spaceHeight(space = nil)
4566
+ return 0 unless space.is_a?(OpenStudio::Model::Space)
4567
+
4568
+ minZ = 10000
4569
+ maxZ = -10000
4570
+
4571
+ space.surfaces.each do |surface|
4572
+ minZ = [surface.vertices.min_by(&:z).z, minZ].min
4573
+ maxZ = [surface.vertices.max_by(&:z).z, maxZ].max
4574
+ end
4575
+
4576
+ maxZ < minZ ? 0 : maxZ - minZ
4577
+ end
4578
+
4579
+ ##
4580
+ # Fetch a space's width, based on the geometry of space floors.
4581
+ #
4582
+ # @param space [OpenStudio::Model::Space] a space
4358
4583
  #
4359
- # @return [Integer] number of successfully-generated anchors (check logs)
4360
- def genAnchors(s = nil, set = [], tag = :vtx)
4584
+ # @return [Float] width of a space (0 if invalid input)
4585
+ def spaceWidth(space = nil)
4586
+ return 0 unless space.is_a?(OpenStudio::Model::Space)
4587
+
4588
+ floors = facets(space, "all", "Floor")
4589
+ return 0 if floors.empty?
4590
+
4591
+ # Automatically determining a space's "width" is not straightforward:
4592
+ # - a space may hold multiple floor surfaces at various Z-axis levels
4593
+ # - a space may hold multiple floor surfaces, with unique "widths"
4594
+ # - a floor surface may expand/contract (in "width") along its length.
4595
+ #
4596
+ # First, attempt to merge all floor surfaces together as 1x polygon:
4597
+ # - select largest floor surface (in area)
4598
+ # - determine its 3D plane
4599
+ # - retain only other floor surfaces sharing same 3D plane
4600
+ # - recover potential union between floor surfaces
4601
+ # - fall back to largest floor surface if invalid union
4602
+ # - return width of largest bounded box
4603
+ floors = floors.sort_by(&:grossArea).reverse
4604
+ floor = floors.first
4605
+ plane = floor.plane
4606
+ t = OpenStudio::Transformation.alignFace(floor.vertices)
4607
+ polyg = poly(floor, false, true, true, t, :ulc).to_a.reverse
4608
+ return 0 if polyg.empty?
4609
+
4610
+ if floors.size > 1
4611
+ floors = floors.select { |flr| plane.equal(flr.plane, 0.001) }
4612
+
4613
+ if floors.size > 1
4614
+ polygs = floors.map { |flr| poly(flr, false, true, true, t, :ulc) }
4615
+ polygs = polygs.reject { |plg| plg.empty? }
4616
+ polygs = polygs.map { |plg| plg.to_a.reverse }
4617
+ union = OpenStudio.joinAll(polygs, 0.01).first
4618
+ polyg = poly(union, false, true, true)
4619
+ return 0 if polyg.empty?
4620
+ end
4621
+ end
4622
+
4623
+ res = realignedFace(polyg.to_a.reverse)
4624
+ return 0 if res[:box].nil?
4625
+
4626
+ # A bounded box's 'height', at its narrowest, is its 'width'.
4627
+ height(res[:box])
4628
+ end
4629
+
4630
+ ##
4631
+ # Identifies 'leader line anchors', i.e. specific 3D points of a (larger) set
4632
+ # (e.g. delineating a larger, parent polygon), each anchor linking the BLC
4633
+ # corner of one or more (smaller) subsets (free-floating within the parent)
4634
+ # - see follow-up 'genInserts'. Subsets may hold several 'tagged' vertices
4635
+ # (e.g. :box, :cbox). By default, the solution seeks to anchor subset :box
4636
+ # vertices. Users can select other tags, e.g. tag == :cbox. The solution
4637
+ # minimally validates individual subsets (e.g. no self-intersecting polygons,
4638
+ # coplanarity, no inter-subset conflicts, must fit within larger set).
4639
+ # Potential leader lines cannot intersect each other, similarly tagged subsets
4640
+ # or (parent) polygon edges. For highly-articulated cases (e.g. a narrow
4641
+ # parent polygon with multiple concavities, holding multiple subsets), such
4642
+ # leader line conflicts are likely unavoidable. It is recommended to first
4643
+ # sort subsets (e.g. areas), given the solution's 'first-come-first-served'
4644
+ # policy. Subsets without valid leader lines are ultimately ignored (check
4645
+ # for new set :void keys, see error logs). The larger set of points is
4646
+ # expected to be in space coordinates - not building or site coordinates,
4647
+ # while subset points are expected to 'fit?' in the larger set.
4648
+ #
4649
+ # @param s [Set<OpenStudio::Point3d>] a (larger) parent set of points
4650
+ # @param [Array<Hash>] set a collection of (smaller) sequenced points
4651
+ # @option [Symbol] tag sequence of subset vertices to target
4652
+ #
4653
+ # @return [Integer] number of successfully anchored subsets (see logs)
4654
+ def genAnchors(s = nil, set = [], tag = :box)
4361
4655
  mth = "OSut::#{__callee__}"
4362
- dZ = nil
4363
- t = nil
4656
+ n = 0
4364
4657
  id = s.respond_to?(:nameString) ? "#{s.nameString}: " : ""
4365
4658
  pts = poly(s)
4366
- n = 0
4367
- return n if pts.empty?
4659
+ return invalid("#{id} polygon", mth, 1, DBG, n) if pts.empty?
4368
4660
  return mismatch("set", set, Array, mth, DBG, n) unless set.respond_to?(:to_a)
4369
4661
 
4370
- set = set.to_a
4662
+ origin = OpenStudio::Point3d.new(0,0,0)
4663
+ zenith = OpenStudio::Point3d.new(0,0,1)
4664
+ ray = zenith - origin
4665
+ set = set.to_a
4371
4666
 
4372
- # Validate individual sets. Purge surface-specific leader line anchors.
4667
+ # Validate individual subsets. Purge surface-specific leader line anchors.
4373
4668
  set.each_with_index do |st, i|
4374
- str1 = id + "set ##{i+1}"
4669
+ str1 = id + "subset ##{i+1}"
4375
4670
  str2 = str1 + " #{tag.to_s}"
4376
4671
  return mismatch(str1, st, Hash, mth, DBG, n) unless st.respond_to?(:key?)
4377
4672
  return hashkey( str1, st, tag, mth, DBG, n) unless st.key?(tag)
4378
4673
  return empty("#{str2} vertices", mth, DBG, n) if st[tag].empty?
4379
4674
 
4675
+ if st.key?(:out)
4676
+ return hashkey( str1, st, :t, mth, DBG, n) unless st.key?(:t)
4677
+ return hashkey( str1, st, :ti, mth, DBG, n) unless st.key?(:ti)
4678
+ return hashkey( str1, st, :t0, mth, DBG, n) unless st.key?(:t0)
4679
+ end
4680
+
4380
4681
  stt = poly(st[tag])
4381
4682
  return invalid("#{str2} polygon", mth, 0, DBG, n) if stt.empty?
4382
4683
  return invalid("#{str2} gap", mth, 0, DBG, n) unless fits?(stt, pts, true)
@@ -4391,76 +4692,87 @@ module OSut
4391
4692
  end
4392
4693
  end
4393
4694
 
4394
- if facingUp?(pts)
4395
- if xyz?(pts, :z)
4396
- dZ = 0
4695
+ set.each_with_index do |st, i|
4696
+ # When a subset already holds a leader line anchor (from an initial call
4697
+ # to 'genAnchors'), it inherits key :out - a Hash holding (among others) a
4698
+ # 'realigned' set of points (by default a 'realigned' :box). The latter is
4699
+ # typically generated from an outdoor-facing roof (e.g. when called from
4700
+ # 'lights'). Subsequent calls to 'genAnchors' may send (as first
4701
+ # argument) a corresponding ceiling tile below (also from 'addSkylights').
4702
+ # Roof vs ceiling may neither share alignment transformation nor space
4703
+ # site transformation identities. All subsequent calls to 'genAnchors'
4704
+ # shall recover the :out points, apply a succession of de/alignments and
4705
+ # transformations in sync , and overwrite tagged points.
4706
+ #
4707
+ # Although 'genAnchors' and 'genInserts' have both been developed to
4708
+ # support anchor insertions in other cases (e.g. bay window in a wall),
4709
+ # variables and terminology here continue pertain to roofs, ceilings,
4710
+ # skylights and wells - less abstract, simpler to follow.
4711
+ if st.key?(:out)
4712
+ ti = st[:ti ] # unoccupied attic/plenum space site transformation
4713
+ t0 = st[:t0 ] # occupied space site transformation
4714
+ t = st[:t ] # initial alignment transformation of roof surface
4715
+ o = st[:out]
4716
+ tpts = t0.inverse * (ti * (t * (o[:r] * (o[:t] * o[:set]))))
4717
+ tpts = cast(tpts, pts, ray)
4718
+
4719
+ st[tag] = tpts
4397
4720
  else
4398
- dZ = pts.first.z
4399
- pts = flatten(pts).to_a
4721
+ st[:t] = OpenStudio::Transformation.alignFace(pts) unless st.key?(:t)
4722
+ tpts = st[:t].inverse * st[tag]
4723
+ o = realignedFace(tpts, true)
4724
+ tpts = st[:t] * (o[:r] * (o[:t] * o[:set]))
4725
+
4726
+ st[:out] = o
4727
+ st[tag ] = tpts
4400
4728
  end
4401
- else
4402
- t = OpenStudio::Transformation.alignFace(pts)
4403
- pts = t.inverse * pts
4404
4729
  end
4405
4730
 
4406
- # Set leader lines anchors. Gather candidate leader line anchors; select
4407
- # anchor with shortest distance to first vertex of 'tagged' set.
4731
+ # Identify candidate leader line anchors for each subset.
4408
4732
  set.each_with_index do |st, i|
4409
4733
  candidates = []
4410
- break if st[:ld].key?(s)
4734
+ tpts = st[tag]
4411
4735
 
4412
- stt = dZ ? flatten(st[tag]).to_a : t.inverse * st[tag]
4413
- p1 = stt.first
4736
+ pts.each do |pt|
4737
+ ld = [pt, tpts.first]
4738
+ nb = 0
4414
4739
 
4415
- pts.each_with_index do |pt, k|
4416
- ld = [pt, p1]
4417
- nb = 0
4418
-
4419
- # Check for intersections between leader line and polygon edges.
4420
- getSegments(pts).each do |sg|
4740
+ # Check for intersections between leader line and larger polygon edges.
4741
+ segments(pts).each do |sg|
4421
4742
  break unless nb.zero?
4422
4743
  next if holds?(sg, pt)
4423
4744
 
4424
4745
  nb += 1 if lineIntersects?(sg, ld)
4425
4746
  end
4426
4747
 
4427
- next unless nb.zero?
4428
-
4429
- # Check for intersections between candidate leader line and other sets.
4430
- set.each_with_index do |other, j|
4748
+ # Check for intersections between candidate leader line vs other subsets.
4749
+ set.each do |other|
4431
4750
  break unless nb.zero?
4432
- next if i == j
4751
+ next if st == other
4433
4752
 
4434
- ost = dZ ? flatten(other[tag]).to_a : t.inverse * other[tag]
4435
- sgj = getSegments(ost)
4753
+ ost = other[tag]
4436
4754
 
4437
- sgj.each { |sg| nb += 1 if lineIntersects?(ld, sg) }
4755
+ segments(ost).each { |sg| nb += 1 if lineIntersects?(ld, sg) }
4438
4756
  end
4439
4757
 
4440
- next unless nb.zero?
4441
-
4442
4758
  # ... and previous leader lines (first come, first serve basis).
4443
- set.each_with_index do |other, j|
4759
+ set.each do |other|
4444
4760
  break unless nb.zero?
4445
- next if i == j
4761
+ next if st == other
4762
+ next unless other.key?(:ld)
4446
4763
  next unless other[:ld].key?(s)
4447
4764
 
4448
4765
  ost = other[tag]
4449
- pj = ost.first
4450
- old = other[:ld][s]
4451
- ldj = dZ ? flatten([ old, pj ]) : t.inverse * [ old, pj ]
4766
+ pld = other[:ld][s]
4767
+ next if same?(pld, pt)
4452
4768
 
4453
- unless same?(old, pt)
4454
- nb += 1 if lineIntersects?(ld, ldj)
4455
- end
4769
+ nb += 1 if lineIntersects?(ld, [pld, ost.first])
4456
4770
  end
4457
4771
 
4458
- next unless nb.zero?
4459
-
4460
4772
  # Finally, check for self-intersections.
4461
- getSegments(stt).each do |sg|
4773
+ segments(tpts).each do |sg|
4462
4774
  break unless nb.zero?
4463
- next if holds?(sg, p1)
4775
+ next if holds?(sg, tpts.first)
4464
4776
 
4465
4777
  nb += 1 if lineIntersects?(sg, ld)
4466
4778
  nb += 1 if (sg.first - sg.last).cross(ld.first - ld.last).length < TOL
@@ -4471,18 +4783,13 @@ module OSut
4471
4783
 
4472
4784
  if candidates.empty?
4473
4785
  str = id + "set ##{i+1}"
4474
- log(ERR, "#{str}: unable to anchor #{tag} leader line (#{mth})")
4786
+ log(WRN, "#{str}: unable to anchor #{tag} leader line (#{mth})")
4475
4787
  st[:void] = true
4476
4788
  else
4477
- p0 = candidates.sort_by! { |pt| (pt - p1).length }.first
4478
-
4479
- if dZ
4480
- st[:ld][s] = OpenStudio::Point3d.new(p0.x, p0.y, p0.z + dZ)
4481
- else
4482
- st[:ld][s] = t * p0
4483
- end
4484
-
4789
+ p0 = candidates.sort_by { |pt| (pt - tpts.first).length }.first
4485
4790
  n += 1
4791
+
4792
+ st[:ld][s] = p0
4486
4793
  end
4487
4794
  end
4488
4795
 
@@ -4490,17 +4797,19 @@ module OSut
4490
4797
  end
4491
4798
 
4492
4799
  ##
4493
- # Generates extended polygon vertices to circumscribe one or more sets
4494
- # (Hashes) of sequenced vertices. The method minimally validates individual
4495
- # sets of vertices (e.g. coplanarity, non-self-intersecting, no inter-set
4496
- # conflicts). Valid leader line anchors (set key :ld) need to be generated
4497
- # prior to calling the method (see genAnchors). By default, the method seeks
4498
- # to link leader line anchors to set :vtx (key) vertices (users can select
4499
- # another collection of vertices, e.g. tag == :box).
4800
+ # Extends (larger) polygon vertices to circumscribe one or more (smaller)
4801
+ # subsets of vertices, based on previously-generated 'leader line' anchors.
4802
+ # The solution minimally validates individual subsets (e.g. no
4803
+ # self-intersecting polygons, coplanarity, no inter-subset conflicts, must fit
4804
+ # within larger set). Valid leader line anchors (set key :ld) need to be
4805
+ # generated prior to calling the method - see 'genAnchors'. Subsets may hold
4806
+ # several 'tag'ged vertices (e.g. :box, :vtx). By default, the solution
4807
+ # seeks to anchor subset :vtx vertices. Users can select other tags, e.g.
4808
+ # tag == :box).
4500
4809
  #
4501
4810
  # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
4502
- # @param [Array<Hash>] set a collection of sequenced vertices
4503
- # @option set [Hash] :ld a collection of polygon-specific leader line anchors
4811
+ # @param [Array<Hash>] set a collection of (smaller) sequenced vertices
4812
+ # @option set [Hash] :ld a polygon-specific leader line anchors
4504
4813
  # @option [Symbol] tag sequence of set vertices to target
4505
4814
  #
4506
4815
  # @return [OpenStudio::Point3dVector] extended vertices (see logs if empty)
@@ -4519,9 +4828,11 @@ module OSut
4519
4828
 
4520
4829
  # Validate individual sets.
4521
4830
  set.each_with_index do |st, i|
4522
- str1 = id + "set ##{i+1}"
4831
+ str1 = id + "subset ##{i+1}"
4523
4832
  str2 = str1 + " #{tag.to_s}"
4524
4833
  return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?)
4834
+ next if st.key?(:void) && st[:void]
4835
+
4525
4836
  return hashkey( str1, st, tag, mth, DBG, a) unless st.key?(tag)
4526
4837
  return empty("#{str2} vertices", mth, DBG, a) if st[tag].empty?
4527
4838
  return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld)
@@ -4530,9 +4841,9 @@ module OSut
4530
4841
  return invalid("#{str2} polygon", mth, 0, DBG, a) if stt.empty?
4531
4842
 
4532
4843
  ld = st[:ld]
4533
- return mismatch(str, ld, Hash, mth, DBG, a) unless ld.is_a?(Hash)
4534
- return hashkey( str, ld, s, mth, DBG, a) unless ld.key?(s)
4535
- return mismatch(str, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl)
4844
+ return mismatch(str2, ld, Hash, mth, DBG, a) unless ld.is_a?(Hash)
4845
+ return hashkey( str2, ld, s, mth, DBG, a) unless ld.key?(s)
4846
+ return mismatch(str2, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl)
4536
4847
  end
4537
4848
 
4538
4849
  # Re-sequence polygon vertices.
@@ -4540,7 +4851,8 @@ module OSut
4540
4851
  v << pt
4541
4852
 
4542
4853
  # Loop through each valid set; concatenate circumscribing vertices.
4543
- set.each_with_index do |st, i|
4854
+ set.each do |st|
4855
+ next if st.key?(:void) && st[:void]
4544
4856
  next unless same?(st[:ld][s], pt)
4545
4857
  next unless st.key?(tag)
4546
4858
 
@@ -4553,21 +4865,22 @@ module OSut
4553
4865
  end
4554
4866
 
4555
4867
  ##
4556
- # Generates arrays of rectangular polygon inserts within a larger polygon. If
4557
- # successful, each set inherits additional key:value pairs: namely :vtx
4558
- # (subset of polygon circumscribing vertices), and :vts (collection of
4559
- # indivudual polygon insert vertices). Valid leader line anchors (set key :ld)
4560
- # need to be generated prior to calling the method (see genAnchors, and
4561
- # genExtendedvertices).
4562
- #
4563
- # @param s [Set<OpenStudio::Point3d>] a larger polygon
4564
- # @param [Array<Hash>] set a collection of polygon insert instructions
4565
- # @option set [Set<OpenStudio::Point3d>] :box bounding box of each collection
4566
- # @option set [Hash] :ld a collection of polygon-specific leader line anchors
4868
+ # Generates (1D or 2D) arrays of (smaller) rectangular collection of points,
4869
+ # (e.g. arrays of polygon inserts) from subset parameters, within a (larger)
4870
+ # set (e.g. parent polygon). If successful, each subset inherits additional
4871
+ # key:value pairs: namely :vtx (collection of circumscribing vertices), and
4872
+ # :vts (collection of individual insert vertices). Valid leader line anchors
4873
+ # (set key :ld) need to be generated prior to calling the solution
4874
+ # - see 'genAnchors'.
4875
+ #
4876
+ # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
4877
+ # @param [Array<Hash>] set a collection of (smaller) sequenced vertices
4878
+ # @option set [Set<OpenStudio::Point3d>] :box bounding box of each subset
4879
+ # @option set [Hash] :ld a collection of leader line anchors
4567
4880
  # @option set [Integer] :rows (1) number of rows of inserts
4568
4881
  # @option set [Integer] :cols (1) number of columns of inserts
4569
- # @option set [Numeric] :w0 (1.4) width of individual inserts (wrt cols) min 0.4
4570
- # @option set [Numeric] :d0 (1.4) depth of individual inserts (wrt rows) min 0.4
4882
+ # @option set [Numeric] :w0 width of individual inserts (wrt cols) min 0.4
4883
+ # @option set [Numeric] :d0 depth of individual inserts (wrt rows) min 0.4
4571
4884
  # @option set [Numeric] :dX (0) optional left/right X-axis buffer
4572
4885
  # @option set [Numeric] :dY (0) optional top/bottom Y-axis buffer
4573
4886
  #
@@ -4587,10 +4900,12 @@ module OSut
4587
4900
 
4588
4901
  # Validate/reset individual set collections.
4589
4902
  set.each_with_index do |st, i|
4590
- str1 = id + "set ##{i+1}"
4903
+ str1 = id + "subset ##{i+1}"
4904
+ next if st.key?(:void) && st[:void]
4591
4905
  return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?)
4592
4906
  return hashkey( str1, st, :box, mth, DBG, a) unless st.key?(:box)
4593
4907
  return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld)
4908
+ return hashkey( str1, st, :out, mth, DBG, a) unless st.key?(:out)
4594
4909
 
4595
4910
  str2 = str1 + " anchor"
4596
4911
  ld = st[:ld]
@@ -4599,7 +4914,7 @@ module OSut
4599
4914
  return mismatch(str2, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl)
4600
4915
 
4601
4916
  # Ensure each set bounding box is safely within larger polygon boundaries.
4602
- # TO DO: In line with related addSkylights "TO DO", expand method to
4917
+ # @todo: In line with related addSkylights' @todo, expand solution to
4603
4918
  # safely handle 'side' cutouts (i.e. no need for leader lines). In
4604
4919
  # so doing, boxes could eventually align along surface edges.
4605
4920
  str3 = str1 + " box"
@@ -4659,9 +4974,10 @@ module OSut
4659
4974
  end
4660
4975
  end
4661
4976
 
4662
- # Flag conflicts between set bounding boxes. TO DO: ease up for ridges.
4977
+ # Flag conflicts between set bounding boxes. @todo: ease up for ridges.
4663
4978
  set.each_with_index do |st, i|
4664
4979
  bx = st[:box]
4980
+ next if st.key?(:void) && st[:void]
4665
4981
 
4666
4982
  set.each_with_index do |other, j|
4667
4983
  next if i == j
@@ -4673,37 +4989,18 @@ module OSut
4673
4989
  end
4674
4990
  end
4675
4991
 
4676
- # Loop through each 'valid' set (i.e. linking a valid leader line anchor),
4677
- # generate set vertex array based on user-provided specs. Reset BLC vertex
4678
- # coordinates once completed.
4679
- set.each_with_index do |st, i|
4680
- str = id + "set ##{i+1}"
4681
- dZ = nil
4682
- t = nil
4683
- bx = st[:box]
4684
-
4685
- if facingUp?(bx)
4686
- if xyz?(bx, :z)
4687
- dZ = 0
4688
- else
4689
- dZ = bx.first.z
4690
- bx = flatten(bx).to_a
4691
- end
4692
- else
4693
- t = OpenStudio::Transformation.alignFace(bx)
4694
- bx = t.inverse * bx
4695
- end
4696
-
4697
- o = getRealignedFace(bx)
4698
- next unless o[:set]
4699
-
4700
- st[:out] = o
4701
- st[:bx ] = blc(o[:r] * (o[:t] * o[:set]))
4992
+ t = OpenStudio::Transformation.alignFace(pts)
4993
+ rpts = t.inverse * pts
4702
4994
 
4995
+ # Loop through each 'valid' subset (i.e. linking a valid leader line anchor),
4996
+ # generate subset vertex array based on user-provided specs.
4997
+ set.each_with_index do |st, i|
4998
+ str = id + "subset ##{i+1}"
4999
+ next if st.key?(:void) && st[:void]
4703
5000
 
5001
+ o = st[:out]
4704
5002
  vts = {} # collection of individual (named) polygon insert vertices
4705
5003
  vtx = [] # sequence of circumscribing polygon vertices
4706
-
4707
5004
  bx = o[:set]
4708
5005
  w = width(bx) # overall sandbox width
4709
5006
  d = height(bx) # overall sandbox depth
@@ -4712,7 +5009,7 @@ module OSut
4712
5009
  cols = st[:cols] # number of array columns
4713
5010
  rows = st[:rows] # number of array rows
4714
5011
  x = st[:w0 ] # width of individual insert
4715
- y = st[:d0 ] # depth of indivual insert
5012
+ y = st[:d0 ] # depth of individual insert
4716
5013
  gX = 0 # gap between insert columns
4717
5014
  gY = 0 # gap between insert rows
4718
5015
 
@@ -4790,15 +5087,7 @@ module OSut
4790
5087
  vec << OpenStudio::Point3d.new(xC + x, yC , 0)
4791
5088
 
4792
5089
  # Store.
4793
- vtz = ulc(o[:r] * (o[:t] * vec))
4794
-
4795
- if dZ
4796
- vz = OpenStudio::Point3dVector.new
4797
- vtz.each { |v| vz << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
4798
- vts[nom] = vz
4799
- else
4800
- vts[nom] = to_p3Dv(t * vtz)
4801
- end
5090
+ vts[nom] = to_p3Dv(t * ulc(o[:r] * (o[:t] * vec)))
4802
5091
 
4803
5092
  # Add reverse vertices, circumscribing each insert.
4804
5093
  vec.reverse!
@@ -4814,18 +5103,8 @@ module OSut
4814
5103
  end
4815
5104
  end
4816
5105
 
4817
- vtx = o[:r] * (o[:t] * vtx)
4818
-
4819
- if dZ
4820
- vz = OpenStudio::Point3dVector.new
4821
- vtx.each { |v| vz << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
4822
- vtx = vz
4823
- else
4824
- vtx = to_p3Dv(t * vtx)
4825
- end
4826
-
4827
5106
  st[:vts] = vts
4828
- st[:vtx] = vtx
5107
+ st[:vtx] = to_p3Dv(t * (o[:r] * (o[:t] * vtx)))
4829
5108
  end
4830
5109
 
4831
5110
  # Extended vertex sequence of the larger polygon.
@@ -4835,9 +5114,11 @@ module OSut
4835
5114
  ##
4836
5115
  # Returns an array of OpenStudio space surfaces or subsurfaces that match
4837
5116
  # criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note that
4838
- # 'sides' rely on space coordinates (not absolute model coordinates). Also,
5117
+ # 'sides' rely on space coordinates (not building or site coordinates). Also,
4839
5118
  # 'sides' are exclusive (not inclusive), e.g. walls strictly north-facing or
4840
5119
  # strictly east-facing would not be returned if 'sides' holds [:north, :east].
5120
+ # No outside boundary condition filters if 'boundary' argument == "all". No
5121
+ # surface type filters if 'type' argument == "all".
4841
5122
  #
4842
5123
  # @param spaces [Set<OpenStudio::Model::Space>] target spaces
4843
5124
  # @param boundary [#to_s] OpenStudio outside boundary condition
@@ -4845,7 +5126,7 @@ module OSut
4845
5126
  # @param sides [Set<Symbols>] direction keys, e.g. :north (see OSut::SIDZ)
4846
5127
  #
4847
5128
  # @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty, no logs)
4848
- def facets(spaces = [], boundary = "Outdoors", type = "Wall", sides = [])
5129
+ def facets(spaces = [], boundary = "all", type = "all", sides = [])
4849
5130
  spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces
4850
5131
  spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
4851
5132
  return [] if spaces.empty?
@@ -4859,8 +5140,8 @@ module OSut
4859
5140
  return [] if boundary.empty?
4860
5141
  return [] if type.empty?
4861
5142
 
4862
- # Filter sides. If sides is initially empty, return all surfaces of matching
4863
- # type and outside boundary condition.
5143
+ # Filter sides. If 'sides' is initially empty, return all surfaces of
5144
+ # matching type and outside boundary condition.
4864
5145
  unless sides.empty?
4865
5146
  sides = sides.select { |side| SIDZ.include?(side) }
4866
5147
  return [] if sides.empty?
@@ -4870,8 +5151,13 @@ module OSut
4870
5151
  return [] unless space.respond_to?(:setSpaceType)
4871
5152
 
4872
5153
  space.surfaces.each do |s|
4873
- next unless s.outsideBoundaryCondition.downcase == boundary
4874
- next unless s.surfaceType.downcase == type
5154
+ unless boundary == "all"
5155
+ next unless s.outsideBoundaryCondition.downcase == boundary
5156
+ end
5157
+
5158
+ unless type == "all"
5159
+ next unless s.surfaceType.downcase == type
5160
+ end
4875
5161
 
4876
5162
  if sides.empty?
4877
5163
  faces << s
@@ -4891,13 +5177,15 @@ module OSut
4891
5177
 
4892
5178
  # SubSurfaces?
4893
5179
  spaces.each do |space|
4894
- break unless faces.empty?
4895
-
4896
5180
  space.surfaces.each do |s|
4897
- next unless s.outsideBoundaryCondition.downcase == boundary
5181
+ unless boundary == "all"
5182
+ next unless s.outsideBoundaryCondition.downcase == boundary
5183
+ end
4898
5184
 
4899
5185
  s.subSurfaces.each do |sub|
4900
- next unless sub.subSurfaceType.downcase == type
5186
+ unless type == "all"
5187
+ next unless sub.subSurfaceType.downcase == type
5188
+ end
4901
5189
 
4902
5190
  if sides.empty?
4903
5191
  faces << sub
@@ -4948,7 +5236,7 @@ module OSut
4948
5236
  pltz.each_with_index do |plt, i|
4949
5237
  id = "plate # #{i+1} (index #{i})"
4950
5238
 
4951
- return mismatch(id, plt, cl1, mth, DBG, slb) unless plt.is_a?(cl2)
5239
+ return mismatch(id, plt, cl2, mth, DBG, slb) unless plt.is_a?(cl2)
4952
5240
  return hashkey( id, plt, :x, mth, DBG, slb) unless plt.key?(:x )
4953
5241
  return hashkey( id, plt, :y, mth, DBG, slb) unless plt.key?(:y )
4954
5242
  return hashkey( id, plt, :dx, mth, DBG, slb) unless plt.key?(:dx)
@@ -4999,7 +5287,7 @@ module OSut
4999
5287
  end
5000
5288
 
5001
5289
  # Once joined, re-adjust Z-axis coordinates.
5002
- unless z.zero?
5290
+ unless z.round(2) == 0.00
5003
5291
  vtx = OpenStudio::Point3dVector.new
5004
5292
  slb.each { |pt| vtx << OpenStudio::Point3d.new(pt.x, pt.y, z) }
5005
5293
  slb = vtx
@@ -5012,28 +5300,30 @@ module OSut
5012
5300
  # Returns outdoor-facing, space-related roof surfaces. These include
5013
5301
  # outdoor-facing roofs of each space per se, as well as any outdoor-facing
5014
5302
  # roof surface of unoccupied spaces immediately above (e.g. plenums, attics)
5015
- # overlapping any of the ceiling surfaces of each space.
5303
+ # overlapping any of the ceiling surfaces of each space. It does not include
5304
+ # surfaces labelled as 'RoofCeiling', which do not comply with ASHRAE 90.1 or
5305
+ # NECB tilt criteria - see 'roof?'.
5016
5306
  #
5017
5307
  # @param spaces [Set<OpenStudio::Model::Space>] target spaces
5018
5308
  #
5019
5309
  # @return [Array<OpenStudio::Model::Surface>] roofs (may be empty)
5020
- def getRoofs(spaces = [])
5310
+ def roofs(spaces = [])
5021
5311
  mth = "OSut::#{__callee__}"
5022
5312
  up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
5023
- roofs = []
5313
+ rfs = []
5024
5314
  spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces
5025
5315
  spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
5026
5316
 
5027
5317
  spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) }
5028
5318
 
5029
5319
  # Space-specific outdoor-facing roof surfaces.
5030
- roofs = facets(spaces, "Outdoors", "RoofCeiling")
5320
+ rfs = facets(spaces, "Outdoors", "RoofCeiling")
5321
+ rfs = rfs.select { |rf| roof?(rf) }
5031
5322
 
5032
- # Outdoor-facing roof surfaces of unoccupied plenums or attics above?
5033
5323
  spaces.each do |space|
5034
- # When multiple spaces are involved (e.g. plenums, attics), the target
5324
+ # When unoccupied spaces are involved (e.g. plenums, attics), the target
5035
5325
  # space may not share the same local transformation as the space(s) above.
5036
- # Fetching local transformation.
5326
+ # Fetching site transformation.
5037
5327
  t0 = transforms(space)
5038
5328
  next unless t0[:t]
5039
5329
 
@@ -5056,18 +5346,20 @@ module OSut
5056
5346
 
5057
5347
  ti = ti[:t]
5058
5348
 
5059
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
5349
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
5060
5350
  facets(other, "Outdoors", "RoofCeiling").each do |ruf|
5351
+ next unless roof?(ruf)
5352
+
5061
5353
  rvi = ti * ruf.vertices
5062
5354
  cst = cast(cv0, rvi, up)
5063
5355
  next unless overlaps?(cst, rvi, false)
5064
5356
 
5065
- roofs << ruf unless roofs.include?(ruf)
5357
+ rfs << ruf unless rfs.include?(ruf)
5066
5358
  end
5067
5359
  end
5068
5360
  end
5069
5361
 
5070
- roofs
5362
+ rfs
5071
5363
  end
5072
5364
 
5073
5365
  ##
@@ -5093,10 +5385,10 @@ module OSut
5093
5385
  return invalid("baselit" , mth, 4, DBG, false) unless ck4
5094
5386
 
5095
5387
  walls = sidelit ? facets(space, "Outdoors", "Wall") : []
5096
- roofs = toplit ? facets(space, "Outdoors", "RoofCeiling") : []
5388
+ rufs = toplit ? facets(space, "Outdoors", "RoofCeiling") : []
5097
5389
  floors = baselit ? facets(space, "Outdoors", "Floor") : []
5098
5390
 
5099
- (walls + roofs + floors).each do |surface|
5391
+ (walls + rufs + floors).each do |surface|
5100
5392
  surface.subSurfaces.each do |sub|
5101
5393
  # All fenestrated subsurface types are considered, as user can set these
5102
5394
  # explicitly (e.g. skylight in a wall) in OpenStudio.
@@ -5128,11 +5420,13 @@ module OSut
5128
5420
  # @option subs [#to_f] :r_buffer gap between sub/array and right corner
5129
5421
  # @option subs [#to_f] :l_buffer gap between sub/array and left corner
5130
5422
  # @param clear [Bool] whether to remove current sub surfaces
5423
+ # @param bound [Bool] whether to add subs wrt surface's bounded box
5424
+ # @param realign [Bool] whether to first realign bounded box
5131
5425
  # @param bfr [#to_f] safety buffer, to maintain near other edges
5132
5426
  #
5133
5427
  # @return [Bool] whether addition is successful
5134
5428
  # @return [false] if invalid input (see logs)
5135
- def addSubs(s = nil, subs = [], clear = false, bfr = 0.005)
5429
+ def addSubs(s = nil, subs = [], clear = false, bound = false, realign = false, bfr = 0.005)
5136
5430
  mth = "OSut::#{__callee__}"
5137
5431
  v = OpenStudio.openStudioVersion.split(".").join.to_i
5138
5432
  cl1 = OpenStudio::Model::Surface
@@ -5144,15 +5438,18 @@ module OSut
5144
5438
 
5145
5439
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5146
5440
  # Exit if mismatched or invalid argument classes.
5147
- return mismatch("surface", s, cl2, mth, DBG, no) unless s.is_a?(cl1)
5148
- return mismatch("subs", subs, cl3, mth, DBG, no) unless subs.is_a?(cl2)
5441
+ sbs = subs.is_a?(cl3) ? [subs] : subs
5442
+ sbs = sbs.respond_to?(:to_a) ? sbs.to_a : []
5443
+ return mismatch("surface", s, cl1, mth, DBG, no) unless s.is_a?(cl1)
5444
+ return mismatch("subs", subs, cl2, mth, DBG, no) if sbs.empty?
5149
5445
  return empty("surface points", mth, DBG, no) if poly(s).empty?
5150
5446
 
5151
- # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5152
- # Clear existing sub surfaces if requested.
5153
- nom = s.nameString
5154
- mdl = s.model
5447
+ subs = sbs
5448
+ nom = s.nameString
5449
+ mdl = s.model
5155
5450
 
5451
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5452
+ # Purge existing sub surfaces?
5156
5453
  unless [true, false].include?(clear)
5157
5454
  log(WRN, "#{nom}: Keeping existing sub surfaces (#{mth})")
5158
5455
  clear = false
@@ -5161,9 +5458,30 @@ module OSut
5161
5458
  s.subSurfaces.map(&:remove) if clear
5162
5459
 
5163
5460
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5164
- # Ensure minimum safety buffer.
5165
- if bfr.respond_to?(:to_f)
5166
- bfr = bfr.to_f
5461
+ # Add sub surfaces with respect to base surface's bounded box? This is
5462
+ # often useful (in some cases necessary) with irregular or concave surfaces.
5463
+ # If true, sub surface parameters (e.g. height, offset, centreline) no
5464
+ # longer apply to the original surface 'bounding' box, but instead to its
5465
+ # largest 'bounded' box. This can be combined with the 'realign' parameter.
5466
+ unless [true, false].include?(bound)
5467
+ log(WRN, "#{nom}: Ignoring bounded box (#{mth})")
5468
+ bound = false
5469
+ end
5470
+
5471
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5472
+ # Force re-alignment of base surface (or its 'bounded' box)? False by
5473
+ # default (ideal for vertical/tilted walls & sloped roofs). If set to true
5474
+ # for a narrow wall for instance, an array of sub surfaces will be added
5475
+ # from bottom to top (rather from left to right).
5476
+ unless [true, false].include?(realign)
5477
+ log(WRN, "#{nom}: Ignoring realignment (#{mth})")
5478
+ realign = false
5479
+ end
5480
+
5481
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5482
+ # Ensure minimum safety buffer.
5483
+ if bfr.respond_to?(:to_f)
5484
+ bfr = bfr.to_f
5167
5485
  return negative("safety buffer", mth, ERR, no) if bfr.round(2) < 0
5168
5486
 
5169
5487
  msg = "Safety buffer < 5mm may generate invalid geometry (#{mth})"
@@ -5192,15 +5510,21 @@ module OSut
5192
5510
  s0 = poly(s, false, false, false, t, :ulc)
5193
5511
  s00 = nil
5194
5512
 
5195
- if facingUp?(s) || facingDown?(s) # TODO: redundant check?
5196
- s00 = getRealignedFace(s0)
5197
- return false unless s00[:set]
5513
+ # Adapt sandbox if user selects to 'bound' and/or 'realign'.
5514
+ if bound
5515
+ box = boundedBox(s0)
5198
5516
 
5199
- s0 = s00[:set]
5517
+ if realign
5518
+ s00 = realignedFace(box, true)
5519
+ return invalid("bound realignment", mth, 0, DBG, false) unless s00[:set]
5520
+ end
5521
+ elsif realign
5522
+ s00 = realignedFace(s0, false)
5523
+ return invalid("unbound realignment", mth, 0, DBG, false) unless s00[:set]
5200
5524
  end
5201
5525
 
5202
- max_x = width(s0)
5203
- max_y = height(s0)
5526
+ max_x = s00 ? width( s00[:set]) : width(s0)
5527
+ max_y = s00 ? height(s00[:set]) : height(s0)
5204
5528
  mid_x = max_x / 2
5205
5529
  mid_y = max_y / 2
5206
5530
 
@@ -5219,7 +5543,7 @@ module OSut
5219
5543
  sub[:type ] = trim(sub[:type])
5220
5544
  sub[:id ] = trim(sub[:id])
5221
5545
  sub[:type ] = type if sub[:type].empty?
5222
- sub[:id ] = "OSut|#{nom}|#{index}" if sub[:id ].empty?
5546
+ sub[:id ] = "OSut:#{nom}:#{index}" if sub[:id ].empty?
5223
5547
  sub[:count ] = 1 unless sub[:count ].respond_to?(:to_i)
5224
5548
  sub[:multiplier] = 1 unless sub[:multiplier].respond_to?(:to_i)
5225
5549
  sub[:count ] = sub[:count ].to_i
@@ -5271,8 +5595,10 @@ module OSut
5271
5595
  next if key == :frame
5272
5596
  next if key == :assembly
5273
5597
 
5274
- ok = value.respond_to?(:to_f)
5275
- return mismatch(key, value, Float, mth, DBG, no) unless ok
5598
+ unless value.respond_to?(:to_f)
5599
+ return mismatch(key, value, Float, mth, DBG, no)
5600
+ end
5601
+
5276
5602
  next if key == :centreline
5277
5603
 
5278
5604
  negative(key, mth, WRN) if value < 0
@@ -5329,27 +5655,27 @@ module OSut
5329
5655
  # Log/reset "height" if beyond min/max.
5330
5656
  if sub.key?(:height)
5331
5657
  unless sub[:height].between?(glass - TOL2, max_height + TOL2)
5332
- sub[:height] = glass if sub[:height] < glass
5333
- sub[:height] = max_height if sub[:height] > max_height
5334
- log(WRN, "Reset '#{id}' height to #{sub[:height]} m (#{mth})")
5658
+ log(WRN, "Reset '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5659
+ sub[:height] = sub[:height].clamp(glass, max_height)
5660
+ log(WRN, "Height '#{id}' reset to #{sub[:height].round(3)}m (#{mth})")
5335
5661
  end
5336
5662
  end
5337
5663
 
5338
5664
  # Log/reset "head" height if beyond min/max.
5339
5665
  if sub.key?(:head)
5340
5666
  unless sub[:head].between?(min_head - TOL2, max_head + TOL2)
5341
- sub[:head] = max_head if sub[:head] > max_head
5342
- sub[:head] = min_head if sub[:head] < min_head
5343
- log(WRN, "Reset '#{id}' head height to #{sub[:head]} m (#{mth})")
5667
+ log(WRN, "Reset '#{id}' head #{sub[:head].round(3)}m (#{mth})")
5668
+ sub[:head] = sub[:head].clamp(min_head, max_head)
5669
+ log(WRN, "Head '#{id}' reset to #{sub[:head].round(3)}m (#{mth})")
5344
5670
  end
5345
5671
  end
5346
5672
 
5347
5673
  # Log/reset "sill" height if beyond min/max.
5348
5674
  if sub.key?(:sill)
5349
5675
  unless sub[:sill].between?(min_sill - TOL2, max_sill + TOL2)
5350
- sub[:sill] = max_sill if sub[:sill] > max_sill
5351
- sub[:sill] = min_sill if sub[:sill] < min_sill
5352
- log(WRN, "Reset '#{id}' sill height to #{sub[:sill]} m (#{mth})")
5676
+ log(WRN, "Reset '#{id}' sill #{sub[:sill].round(3)}m (#{mth})")
5677
+ sub[:sill] = sub[:sill].clamp(min_sill, max_sill)
5678
+ log(WRN, "Sill '#{id}' reset to #{sub[:sill].round(3)}m (#{mth})")
5353
5679
  end
5354
5680
  end
5355
5681
 
@@ -5368,8 +5694,9 @@ module OSut
5368
5694
  log(ERR, "Skip: invalid '#{id}' head/sill combo (#{mth})")
5369
5695
  next
5370
5696
  else
5697
+ log(WRN, "(Re)set '#{id}' sill #{sub[:sill].round(3)}m (#{mth})")
5371
5698
  sub[:sill] = sill
5372
- log(WRN, "(Re)set '#{id}' sill height to #{sub[:sill]} m (#{mth})")
5699
+ log(WRN, "Sill '#{id}' (re)set to #{sub[:sill].round(3)}m (#{mth})")
5373
5700
  end
5374
5701
  end
5375
5702
 
@@ -5379,7 +5706,8 @@ module OSut
5379
5706
  height = sub[:head] - sub[:sill]
5380
5707
 
5381
5708
  if sub.key?(:height) && (sub[:height] - height).abs > TOL2
5382
- log(WRN, "(Re)set '#{id}' height to #{height} m (#{mth})")
5709
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5710
+ log(WRN, "Height '#{id}' (re)set to #{height.round(3)}m (#{mth})")
5383
5711
  end
5384
5712
 
5385
5713
  sub[:height] = height
@@ -5400,9 +5728,10 @@ module OSut
5400
5728
  log(ERR, "Skip: invalid '#{id}' head/height combo (#{mth})")
5401
5729
  next
5402
5730
  else
5731
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5403
5732
  sub[:sill ] = sill
5404
5733
  sub[:height] = height
5405
- log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
5734
+ log(WRN, "Height '#{id}' re(set) #{sub[:height].round(3)}m (#{mth})")
5406
5735
  end
5407
5736
  else
5408
5737
  sub[:sill] = sill
@@ -5428,9 +5757,10 @@ module OSut
5428
5757
  log(ERR, "Skip: invalid '#{id}' sill/height combo (#{mth})")
5429
5758
  next
5430
5759
  else
5760
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5431
5761
  sub[:head ] = head
5432
5762
  sub[:height] = height
5433
- log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
5763
+ log(WRN, "Height '#{id}' reset to #{sub[:height].round(3)}m (#{mth})")
5434
5764
  end
5435
5765
  else
5436
5766
  sub[:head] = head
@@ -5459,9 +5789,9 @@ module OSut
5459
5789
  # Log/reset "width" if beyond min/max.
5460
5790
  if sub.key?(:width)
5461
5791
  unless sub[:width].between?(glass - TOL2, max_width + TOL2)
5462
- sub[:width] = glass if sub[:width] < glass
5463
- sub[:width] = max_width if sub[:width] > max_width
5464
- log(WRN, "Reset '#{id}' width to #{sub[:width]} m (#{mth})")
5792
+ log(WRN, "Reset '#{id}' width #{sub[:width].round(3)}m (#{mth})")
5793
+ sub[:width] = sub[:width].clamp(glass, max_width)
5794
+ log(WRN, "Width '#{id}' reset to #{sub[:width].round(3)}m (#{mth})")
5465
5795
  end
5466
5796
  end
5467
5797
 
@@ -5471,7 +5801,7 @@ module OSut
5471
5801
 
5472
5802
  if sub[:count] < 1
5473
5803
  sub[:count] = 1
5474
- log(WRN, "Reset '#{id}' count to #{sub[:count]} (#{mth})")
5804
+ log(WRN, "Reset '#{id}' count to min 1 (#{mth})")
5475
5805
  end
5476
5806
  else
5477
5807
  sub[:count] = 1
@@ -5482,16 +5812,18 @@ module OSut
5482
5812
  # Log/reset if left-sided buffer under min jamb position.
5483
5813
  if sub.key?(:l_buffer)
5484
5814
  if sub[:l_buffer] < min_ljamb - TOL
5815
+ log(WRN, "Reset '#{id}' left buffer #{sub[:l_buffer].round(3)}m (#{mth})")
5485
5816
  sub[:l_buffer] = min_ljamb
5486
- log(WRN, "Reset '#{id}' left buffer to #{sub[:l_buffer]} m (#{mth})")
5817
+ log(WRN, "Left buffer '#{id}' reset to #{sub[:l_buffer].round(3)}m (#{mth})")
5487
5818
  end
5488
5819
  end
5489
5820
 
5490
5821
  # Log/reset if right-sided buffer beyond max jamb position.
5491
5822
  if sub.key?(:r_buffer)
5492
5823
  if sub[:r_buffer] > max_rjamb - TOL
5824
+ log(WRN, "Reset '#{id}' right buffer #{sub[:r_buffer].round(3)}m (#{mth})")
5493
5825
  sub[:r_buffer] = min_rjamb
5494
- log(WRN, "Reset '#{id}' right buffer to #{sub[:r_buffer]} m (#{mth})")
5826
+ log(WRN, "Right buffer '#{id}' reset to #{sub[:r_buffer].round(3)}m (#{mth})")
5495
5827
  end
5496
5828
  end
5497
5829
 
@@ -5511,15 +5843,15 @@ module OSut
5511
5843
  sub[:multiplier] = 0
5512
5844
  sub[:height ] = 0 if sub.key?(:height)
5513
5845
  sub[:width ] = 0 if sub.key?(:width)
5514
- log(ERR, "Skip: '#{id}' ratio ~0 (#{mth})")
5846
+ log(ERR, "Skip: ratio ~0 (#{mth})")
5515
5847
  next
5516
5848
  end
5517
5849
 
5518
5850
  # Log/reset if "ratio" beyond min/max?
5519
5851
  unless sub[:ratio].between?(min, max)
5520
- sub[:ratio] = min if sub[:ratio] < min
5521
- sub[:ratio] = max if sub[:ratio] > max
5522
- log(WRN, "Reset ratio (min/max) to #{sub[:ratio]} (#{mth})")
5852
+ log(WRN, "Reset ratio #{sub[:ratio].round(3)} (#{mth})")
5853
+ sub[:ratio] = sub[:ratio].clamp(min, max)
5854
+ log(WRN, "Ratio reset to #{sub[:ratio].round(3)} (#{mth})")
5523
5855
  end
5524
5856
 
5525
5857
  # Log/reset "count" unless 1.
@@ -5536,7 +5868,7 @@ module OSut
5536
5868
 
5537
5869
  if sub.key?(:l_buffer)
5538
5870
  if sub.key?(:centreline)
5539
- log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
5871
+ log(WRN, "Skip '#{id}' left buffer (vs centreline) (#{mth})")
5540
5872
  else
5541
5873
  x0 = sub[:l_buffer] - frame
5542
5874
  xf = x0 + w
@@ -5544,7 +5876,7 @@ module OSut
5544
5876
  end
5545
5877
  elsif sub.key?(:r_buffer)
5546
5878
  if sub.key?(:centreline)
5547
- log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
5879
+ log(WRN, "Skip '#{id}' right buffer (vs centreline) (#{mth})")
5548
5880
  else
5549
5881
  xf = max_x - sub[:r_buffer] + frame
5550
5882
  x0 = xf - w
@@ -5559,13 +5891,14 @@ module OSut
5559
5891
  sub[:multiplier] = 0
5560
5892
  sub[:height ] = 0 if sub.key?(:height)
5561
5893
  sub[:width ] = 0 if sub.key?(:width)
5562
- log(ERR, "Skip: invalid (ratio) width/centreline (#{mth})")
5894
+ log(ERR, "Skip '#{id}': invalid (ratio) width/centreline (#{mth})")
5563
5895
  next
5564
5896
  end
5565
5897
 
5566
5898
  if sub.key?(:width) && (sub[:width] - width).abs > TOL
5899
+ log(WRN, "Reset '#{id}' width (ratio) #{sub[:width].round(2)}m (#{mth})")
5567
5900
  sub[:width] = width
5568
- log(WRN, "Reset width (ratio) to #{sub[:width]} (#{mth})")
5901
+ log(WRN, "Width (ratio) '#{id}' reset to #{sub[:width].round(2)}m (#{mth})")
5569
5902
  end
5570
5903
 
5571
5904
  sub[:width] = width unless sub.key?(:width)
@@ -5583,12 +5916,13 @@ module OSut
5583
5916
  width = sub[:width] + frames
5584
5917
  gap = (max_x - n * width) / (n + 1)
5585
5918
  gap = sub[:offset] - width if sub.key?(:offset)
5586
- gap = 0 if gap < bfr
5919
+ gap = 0 if gap < buffer
5587
5920
  offset = gap + width
5588
5921
 
5589
5922
  if sub.key?(:offset) && (offset - sub[:offset]).abs > TOL
5923
+ log(WRN, "Reset '#{id}' sub offset #{sub[:offset].round(2)}m (#{mth})")
5590
5924
  sub[:offset] = offset
5591
- log(WRN, "Reset sub offset to #{sub[:offset]} m (#{mth})")
5925
+ log(WRN, "Sub offset (#{id}) reset to #{sub[:offset].round(2)}m (#{mth})")
5592
5926
  end
5593
5927
 
5594
5928
  sub[:offset] = offset unless sub.key?(:offset)
@@ -5600,7 +5934,7 @@ module OSut
5600
5934
 
5601
5935
  if sub.key?(:l_buffer)
5602
5936
  if sub.key?(:centreline)
5603
- log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
5937
+ log(WRN, "Skip '#{id}' left buffer (vs centreline) (#{mth})")
5604
5938
  else
5605
5939
  x0 = sub[:l_buffer] - frame
5606
5940
  xf = x0 + w
@@ -5608,7 +5942,7 @@ module OSut
5608
5942
  end
5609
5943
  elsif sub.key?(:r_buffer)
5610
5944
  if sub.key?(:centreline)
5611
- log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
5945
+ log(WRN, "Skip '#{id}' right buffer (vs centreline) (#{mth})")
5612
5946
  else
5613
5947
  xf = max_x - sub[:r_buffer] + frame
5614
5948
  x0 = xf - w
@@ -5617,7 +5951,7 @@ module OSut
5617
5951
  end
5618
5952
 
5619
5953
  # Too wide?
5620
- if x0 < bfr - TOL2 || xf > max_x - bfr - TOL2
5954
+ if x0 < buffer - TOL2 || xf > max_x - buffer - TOL2
5621
5955
  sub[:ratio ] = 0 if sub.key?(:ratio)
5622
5956
  sub[:count ] = 0
5623
5957
  sub[:multiplier] = 0
@@ -5633,10 +5967,9 @@ module OSut
5633
5967
 
5634
5968
  # Generate sub(s).
5635
5969
  sub[:count].times do |i|
5636
- name = "#{id}|#{i}"
5970
+ name = "#{id}:#{i}"
5637
5971
  fr = 0
5638
5972
  fr = sub[:frame].frameWidth if sub[:frame]
5639
-
5640
5973
  vec = OpenStudio::Point3dVector.new
5641
5974
  vec << OpenStudio::Point3d.new(pos, sub[:head], 0)
5642
5975
  vec << OpenStudio::Point3d.new(pos, sub[:sill], 0)
@@ -5647,34 +5980,41 @@ module OSut
5647
5980
  # Log/skip if conflict between individual sub and base surface.
5648
5981
  vc = vec
5649
5982
  vc = offset(vc, fr, 300) if fr > 0
5650
- ok = fits?(vc, s)
5651
5983
 
5652
- log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok
5653
- break unless ok
5984
+ unless fits?(vc, s)
5985
+ log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})")
5986
+ break
5987
+ end
5654
5988
 
5655
5989
  # Log/skip if conflicts with existing subs (even if same array).
5990
+ conflict = false
5991
+
5656
5992
  s.subSurfaces.each do |sb|
5657
5993
  nome = sb.nameString
5658
5994
  fd = sb.windowPropertyFrameAndDivider
5659
- fr = 0 if fd.empty?
5660
- fr = fd.get.frameWidth unless fd.empty?
5995
+ fr = fd.empty? ? 0 : fd.get.frameWidth
5661
5996
  vk = sb.vertices
5662
5997
  vk = offset(vk, fr, 300) if fr > 0
5663
- oops = overlaps?(vc, vk)
5664
- log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})") if oops
5665
- ok = false if oops
5666
- break if oops
5998
+
5999
+ if overlaps?(vc, vk)
6000
+ log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})")
6001
+ conflict = true
6002
+ break
6003
+ end
5667
6004
  end
5668
6005
 
5669
- break unless ok
6006
+ break if conflict
5670
6007
 
5671
6008
  sb = OpenStudio::Model::SubSurface.new(vec, mdl)
5672
6009
  sb.setName(name)
5673
6010
  sb.setSubSurfaceType(sub[:type])
5674
- sb.setConstruction(sub[:assembly]) if sub[:assembly]
5675
- ok = sb.allowWindowPropertyFrameAndDivider
5676
- sb.setWindowPropertyFrameAndDivider(sub[:frame]) if sub[:frame] && ok
5677
- sb.setMultiplier(sub[:multiplier]) if sub[:multiplier] > 1
6011
+ sb.setConstruction(sub[:assembly]) if sub[:assembly]
6012
+ sb.setMultiplier(sub[:multiplier]) if sub[:multiplier] > 1
6013
+
6014
+ if sub[:frame] && sb.allowWindowPropertyFrameAndDivider
6015
+ sb.setWindowPropertyFrameAndDivider(sub[:frame])
6016
+ end
6017
+
5678
6018
  sb.setSurface(s)
5679
6019
 
5680
6020
  # Reset "pos" if array.
@@ -5685,32 +6025,15 @@ module OSut
5685
6025
  true
5686
6026
  end
5687
6027
 
5688
- ##
5689
- # Validates whether surface is considered a sloped roof (outdoor-facing,
5690
- # 10% < tilt < 90%).
5691
- #
5692
- # @param s [OpenStudio::Model::Surface] a model surface
5693
- #
5694
- # @return [Bool] whether surface is a sloped roof
5695
- # @return [false] if invalid input (see logs)
5696
- def slopedRoof?(s = nil)
5697
- mth = "OSut::#{__callee__}"
5698
- cl = OpenStudio::Model::Surface
5699
- return mismatch("surface", s, cl, mth, DBG, false) unless s.is_a?(cl)
5700
-
5701
- return false if facingUp?(s)
5702
- return false if facingDown?(s)
5703
-
5704
- true
5705
- end
5706
-
5707
6028
  ##
5708
6029
  # Returns the "gross roof area" above selected conditioned, occupied spaces.
5709
6030
  # This includes all roof surfaces of indirectly-conditioned, unoccupied spaces
5710
6031
  # like plenums (if located above any of the selected spaces). This also
5711
6032
  # includes roof surfaces of unconditioned or unenclosed spaces like attics, if
5712
6033
  # vertically-overlapping any ceiling of occupied spaces below; attic roof
5713
- # sections above uninsulated soffits are excluded, for instance.
6034
+ # sections above uninsulated soffits are excluded, for instance. It does not
6035
+ # include surfaces labelled as 'RoofCeiling', which do not comply with ASHRAE
6036
+ # 90.1 or NECB tilt criteria - see 'roof?'.
5714
6037
  def grossRoofArea(spaces = [])
5715
6038
  mth = "OSut::#{__callee__}"
5716
6039
  up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
@@ -5721,6 +6044,7 @@ module OSut
5721
6044
  spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
5722
6045
  spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) }
5723
6046
  spaces = spaces.select { |space| space.partofTotalFloorArea }
6047
+ spaces = spaces.reject { |space| unconditioned?(space) }
5724
6048
  return invalid("spaces", mth, 1, DBG, 0) if spaces.empty?
5725
6049
 
5726
6050
  # The method is very similar to OpenStudio-Standards' :
@@ -5732,17 +6056,18 @@ module OSut
5732
6056
  #
5733
6057
  # ... yet differs with regards to attics with overhangs/soffits.
5734
6058
 
5735
- # Start with roof surfaces of occupied spaces.
6059
+ # Start with roof surfaces of occupied, conditioned spaces.
5736
6060
  spaces.each do |space|
5737
6061
  facets(space, "Outdoors", "RoofCeiling").each do |roof|
5738
6062
  next if rfs.key?(roof)
6063
+ next unless roof?(roof)
5739
6064
 
5740
6065
  rfs[roof] = {m2: roof.grossArea, m: space.multiplier}
5741
6066
  end
5742
6067
  end
5743
6068
 
5744
6069
  # Roof surfaces of unoccupied, conditioned spaces above (e.g. plenums)?
5745
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
6070
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
5746
6071
  spaces.each do |space|
5747
6072
  facets(space, "Surface", "RoofCeiling").each do |ceiling|
5748
6073
  floor = ceiling.adjacentSurface
@@ -5757,6 +6082,7 @@ module OSut
5757
6082
 
5758
6083
  facets(other, "Outdoors", "RoofCeiling").each do |roof|
5759
6084
  next if rfs.key?(roof)
6085
+ next unless roof?(roof)
5760
6086
 
5761
6087
  rfs[roof] = {m2: roof.grossArea, m: other.multiplier}
5762
6088
  end
@@ -5764,7 +6090,7 @@ module OSut
5764
6090
  end
5765
6091
 
5766
6092
  # Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)?
5767
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
6093
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
5768
6094
  spaces.each do |space|
5769
6095
  # When taking overlaps into account, the target space may not share the
5770
6096
  # same local transformation as the space(s) above.
@@ -5792,6 +6118,8 @@ module OSut
5792
6118
  ti = ti[:t]
5793
6119
 
5794
6120
  facets(other, "Outdoors", "RoofCeiling").each do |roof|
6121
+ next unless roof?(roof)
6122
+
5795
6123
  rvi = ti * roof.vertices
5796
6124
  cst = cast(cv0, rvi, up)
5797
6125
  next if cst.empty?
@@ -5799,8 +6127,7 @@ module OSut
5799
6127
  # The overlap calculation fails for roof and ceiling surfaces with
5800
6128
  # previously-added leader lines.
5801
6129
  #
5802
- # TODO: revise approach for attics ONCE skylight wells have been added.
5803
- olap = nil
6130
+ # @todo: revise approach for attics ONCE skylight wells have been added.
5804
6131
  olap = overlap(cst, rvi, false)
5805
6132
  next if olap.empty?
5806
6133
 
@@ -5823,30 +6150,31 @@ module OSut
5823
6150
  end
5824
6151
 
5825
6152
  ##
5826
- # Identifies horizontal ridges between 2x sloped roof surfaces (same space).
5827
- # If successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an
6153
+ # Identifies horizontal ridges along 2x sloped (roof?) surfaces (same space).
6154
+ # The concept of 'sloped' is harmonized with OpenStudio's "alignZPrime". If
6155
+ # successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an
5828
6156
  # :edge (OpenStudio::Point3dVector), the edge :length (Numeric), and :roofs
5829
- # (Array of 2x linked roof surfaces). Each roof surface may be linked to more
5830
- # than one horizontal ridge.
6157
+ # (Array of 2x linked surfaces). Each surface may be linked to more than one
6158
+ # horizontal ridge.
5831
6159
  #
5832
- # @param roofs [Array<OpenStudio::Model::Surface>] target roof surfaces
6160
+ # @param rfs [Array<OpenStudio::Model::Surface>] target surfaces
5833
6161
  #
5834
6162
  # @return [Array] horizontal ridges (see logs if empty)
5835
- def getHorizontalRidges(roofs = [])
6163
+ def horizontalRidges(rfs = [])
5836
6164
  mth = "OSut::#{__callee__}"
5837
6165
  ridges = []
5838
- return ridges unless roofs.is_a?(Array)
6166
+ return ridges unless rfs.is_a?(Array)
5839
6167
 
5840
- roofs = roofs.select { |s| s.is_a?(OpenStudio::Model::Surface) }
5841
- roofs = roofs.select { |s| slopedRoof?(s) }
6168
+ rfs = rfs.select { |s| s.is_a?(OpenStudio::Model::Surface) }
6169
+ rfs = rfs.select { |s| sloped?(s) }
5842
6170
 
5843
- roofs.each do |roof|
5844
- maxZ = roof.vertices.max_by(&:z).z
5845
- next if roof.space.empty?
6171
+ rfs.each do |rf|
6172
+ maxZ = rf.vertices.max_by(&:z).z
6173
+ next if rf.space.empty?
5846
6174
 
5847
- space = roof.space.get
6175
+ space = rf.space.get
5848
6176
 
5849
- getSegments(roof).each do |edge|
6177
+ segments(rf).each do |edge|
5850
6178
  next unless xyz?(edge, :z, maxZ)
5851
6179
 
5852
6180
  # Skip if already tracked.
@@ -5861,19 +6189,19 @@ module OSut
5861
6189
 
5862
6190
  next if match
5863
6191
 
5864
- ridge = { edge: edge, length: (edge[1] - edge[0]).length, roofs: [roof] }
6192
+ ridge = { edge: edge, length: (edge[1] - edge[0]).length, roofs: [rf] }
5865
6193
 
5866
6194
  # Links another roof (same space)?
5867
6195
  match = false
5868
6196
 
5869
- roofs.each do |ruf|
6197
+ rfs.each do |ruf|
5870
6198
  break if match
5871
- next if ruf == roof
6199
+ next if ruf == rf
5872
6200
  next if ruf.space.empty?
5873
6201
  next unless ruf.space.get == space
5874
6202
 
5875
- getSegments(ruf).each do |edg|
5876
- break if match
6203
+ segments(ruf).each do |edg|
6204
+ break if match
5877
6205
  next unless same?(edge, edg) || same?(edge, edg.reverse)
5878
6206
 
5879
6207
  ridge[:roofs] << ruf
@@ -5887,49 +6215,180 @@ module OSut
5887
6215
  ridges
5888
6216
  end
5889
6217
 
6218
+ ##
6219
+ # Preselects ideal spaces to toplight, based on 'addSkylights' options and key
6220
+ # building model geometry attributes. Can be called from within 'addSkylights'
6221
+ # by setting :ration (opts key:value argument) to 'true' ('false' by default).
6222
+ # Alternatively, the method can be called prior to 'addSkylights'. The set of
6223
+ # filters stems from previous rounds of 'addSkylights' stress testing. It is
6224
+ # intended as an option to prune away less ideal candidate spaces (irregular,
6225
+ # smaller) in favour of (larger) candidates (notably with more suitable
6226
+ # roof geometries). This is key when dealing with attic and plenums, where
6227
+ # 'addSkylights' seeks to add skylight wells (relying on roof cut-outs and
6228
+ # leader lines). Another check/outcome is whether to prioritize skylight
6229
+ # allocation in already sidelit spaces - opts[:sidelit] may be reset to 'true'.
6230
+ #
6231
+ # @param spaces [Array<OpenStudio::Model::Space>] candidate(s) to toplight
6232
+ # @param [Hash] opts requested skylight attributes (same as 'addSkylights')
6233
+ # @option opts [#to_f] :size (1.22m) template skylight width/depth (min 0.4m)
6234
+ #
6235
+ # @return [Array<OpenStudio::Model::Space>] candidates (see logs if empty)
6236
+ def toToplit(spaces = [], opts = {})
6237
+ mth = "OSut::#{__callee__}"
6238
+ gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width)
6239
+ w = 1.22 # default 48" x 48" skylight base
6240
+ w2 = w * w
6241
+
6242
+ # Validate skylight size, if provided.
6243
+ if opts.key?(:size)
6244
+ if opts[:size].respond_to?(:to_f)
6245
+ w = opts[:size].to_f
6246
+ w2 = w * w
6247
+ return invalid("size", mth, 0, ERR, []) if w.round(2) < gap4
6248
+ else
6249
+ return mismatch("size", opts[:size], Numeric, mth, DBG, [])
6250
+ end
6251
+ end
6252
+
6253
+ # Accept single 'OpenStudio::Model::Space' (vs an array of spaces). Filter.
6254
+ #
6255
+ # Whether individual spaces are UNCONDITIONED (e.g. attics, unheated areas)
6256
+ # or flagged as NOT being part of the total floor area (e.g. unoccupied
6257
+ # plenums), should of course reflect actual design intentions. It's up to
6258
+ # modellers to correctly flag such cases - can't safely guess in lieu of
6259
+ # design/modelling team.
6260
+ #
6261
+ # A friendly reminder: 'addSkylights' should be called separately for
6262
+ # strictly SEMIHEATED spaces vs REGRIGERATED spaces vs all other CONDITIONED
6263
+ # spaces, as per 90.1 and NECB requirements.
6264
+ if spaces.respond_to?(:spaceType) || spaces.respond_to?(:to_a)
6265
+ spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [spaces]
6266
+ spaces = spaces.select { |sp| sp.respond_to?(:spaceType) }
6267
+ spaces = spaces.select { |sp| sp.partofTotalFloorArea }
6268
+ spaces = spaces.reject { |sp| unconditioned?(sp) }
6269
+ spaces = spaces.reject { |sp| vestibule?(sp) }
6270
+ spaces = spaces.reject { |sp| roofs(sp).empty? }
6271
+ spaces = spaces.reject { |sp| sp.floorArea < 4 * w2 }
6272
+ spaces = spaces.sort_by(&:floorArea).reverse
6273
+ return empty("spaces", mth, WRN, []) if spaces.empty?
6274
+ else
6275
+ return mismatch("spaces", spaces, Array, mth, DBG, [])
6276
+ end
6277
+
6278
+ # Unfenestrated spaces have no windows, glazed doors or skylights. By
6279
+ # default, 'addSkylights' will prioritize unfenestrated spaces (over all
6280
+ # existing sidelit ones) and maximize skylight sizes towards achieving the
6281
+ # required skylight area target. This concentrates skylights for instance in
6282
+ # typical (large) core spaces, vs (narrower) perimeter spaces. However, for
6283
+ # less conventional spatial layouts, this default approach can produce less
6284
+ # optimal skylight distributions. A balance is needed to prioritize large
6285
+ # unfenestrated spaces when appropriate on one hand, while excluding smaller
6286
+ # unfenestrated ones on the other. Here, exclusion is based on the average
6287
+ # floor area of spaces to toplight.
6288
+ fm2 = spaces.sum(&:floorArea)
6289
+ afm2 = fm2 / spaces.size
6290
+
6291
+ unfen = spaces.reject { |sp| daylit?(sp) }.sort_by(&:floorArea).reverse
6292
+
6293
+ # Target larger unfenestrated spaces, if sufficient in area.
6294
+ if unfen.empty?
6295
+ opts[:sidelit] = true
6296
+ else
6297
+ if spaces.size > unfen.size
6298
+ ufm2 = unfen.sum(&:floorArea)
6299
+ u0fm2 = unfen.first.floorArea
6300
+
6301
+ if ufm2 > 0.33 * fm2 && u0fm2 > 3 * afm2
6302
+ unfen = unfen.reject { |sp| sp.floorArea > 0.25 * afm2 }
6303
+ spaces = spaces.reject { |sp| unfen.include?(sp) }
6304
+ else
6305
+ opts[:sidelit] = true
6306
+ end
6307
+ end
6308
+ end
6309
+
6310
+ espaces = {}
6311
+ rooms = []
6312
+ toits = []
6313
+
6314
+ # Gather roof surfaces - possibly those of attics or plenums above.
6315
+ spaces.each do |sp|
6316
+ roofs(sp).each do |rf|
6317
+ espaces[sp] = {roofs: []} unless espaces.key?(sp)
6318
+ espaces[sp][:roofs] << rf unless espaces[sp][:roofs].include?(rf)
6319
+ end
6320
+ end
6321
+
6322
+ # Priortize larger spaces.
6323
+ espaces = espaces.sort_by { |espace, _| espace.floorArea }.reverse
6324
+
6325
+ # Prioritize larger roof surfaces.
6326
+ espaces.each do |_, datum|
6327
+ datum[:roofs] = datum[:roofs].sort_by(&:grossArea).reverse
6328
+ end
6329
+
6330
+ # Single out largest roof in largest space, key when dealing with shared
6331
+ # attics or plenum roofs.
6332
+ espaces.each do |espace, datum|
6333
+ rfs = datum[:roofs].reject { |ruf| toits.include?(ruf) }
6334
+ next if rfs.empty?
6335
+
6336
+ toits << rfs.sort { |ruf| ruf.grossArea }.reverse.first
6337
+ rooms << espace
6338
+ end
6339
+
6340
+ log(INF, "No ideal toplit candidates (#{mth})") if rooms.empty?
6341
+
6342
+ rooms
6343
+ end
6344
+
5890
6345
  ##
5891
6346
  # Adds skylights to toplight selected OpenStudio (occupied, conditioned)
5892
- # spaces, based on requested skylight-to-roof (SRR%) options (max 10%). If the
5893
- # user selects 0% (0.0) as the :srr while keeping :clear as true, the method
5894
- # simply purges all pre-existing roof subsurfaces (whether glazed or not) of
5895
- # selected spaces, and exits while returning 0 (without logging an error or
5896
- # warning). Pre-toplit spaces are otherwise ignored. Boolean options :attic,
5897
- # :plenum, :sloped and :sidelit, further restrict candidate roof surfaces. If
5898
- # applicable, options :attic and :plenum add skylight wells. Option :patterns
5899
- # restricts preset skylight allocation strategies in order of preference; if
5900
- # left empty, all preset patterns are considered, also in order of preference
5901
- # (see examples).
6347
+ # spaces, based on requested skylight area, or a skylight-to-roof ratio (SRR%).
6348
+ # If the user selects 0m2 as the requested :area (or 0 as the requested :srr),
6349
+ # while setting the option :clear as true, the method simply purges all
6350
+ # pre-existing roof fenestrated subsurfaces of selected spaces, and exits while
6351
+ # returning 0 (without logging an error or warning). Pre-existing skylight
6352
+ # wells are not cleared however. Pre-toplit spaces are otherwise ignored.
6353
+ # Boolean options :attic, :plenum, :sloped and :sidelit further restrict
6354
+ # candidate spaces to toplight. If applicable, options :attic and :plenum add
6355
+ # skylight wells. Option :patterns restricts preset skylight allocation
6356
+ # layouts in order of preference; if left empty, all preset patterns are
6357
+ # considered, also in order of preference (see examples).
5902
6358
  #
5903
6359
  # @param spaces [Array<OpenStudio::Model::Space>] space(s) to toplight
5904
6360
  # @param [Hash] opts requested skylight attributes
5905
- # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.10]
6361
+ # @option opts [#to_f] :area overall skylight area
6362
+ # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.90]
5906
6363
  # @option opts [#to_f] :size (1.22) template skylight width/depth (min 0.4m)
5907
6364
  # @option opts [#frameWidth] :frame (nil) OpenStudio Frame & Divider (optional)
5908
6365
  # @option opts [Bool] :clear (true) whether to first purge existing skylights
6366
+ # @option opts [Bool] :ration (true) finer selection of candidates to toplight
5909
6367
  # @option opts [Bool] :sidelit (true) whether to consider sidelit spaces
5910
6368
  # @option opts [Bool] :sloped (true) whether to consider sloped roof surfaces
5911
6369
  # @option opts [Bool] :plenum (true) whether to consider plenum wells
5912
6370
  # @option opts [Bool] :attic (true) whether to consider attic wells
5913
6371
  # @option opts [Array<#to_s>] :patterns requested skylight allocation (3x)
5914
- # @example (a) consider 2D array of individual skylights, e.g. n(1.2m x 1.2m)
6372
+ # @example (a) consider 2D array of individual skylights, e.g. n(1.22m x 1.22m)
5915
6373
  # opts[:patterns] = ["array"]
5916
6374
  # @example (b) consider 'a', then array of 1x(size) x n(size) skylight strips
5917
6375
  # opts[:patterns] = ["array", "strips"]
5918
6376
  #
5919
- # @return [Float] returns gross roof area if successful (see logs if 0 m2)
6377
+ # @return [Float] returns gross roof area if successful (see logs if 0m2)
5920
6378
  def addSkyLights(spaces = [], opts = {})
5921
6379
  mth = "OSut::#{__callee__}"
5922
6380
  clear = true
5923
- srr = 0.0
6381
+ srr = nil
6382
+ area = nil
5924
6383
  frame = nil # FrameAndDivider object
5925
6384
  f = 0.0 # FrameAndDivider frame width
5926
- gap = 0.1 # min 2" around well (2x), as well as max frame width
6385
+ gap = 0.1 # min 2" around well (2x == 4"), as well as max frame width
5927
6386
  gap2 = 0.2 # 2x gap
5928
6387
  gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width)
5929
6388
  bfr = 0.005 # minimum array perimeter buffer (no wells)
5930
6389
  w = 1.22 # default 48" x 48" skylight base
5931
6390
  w2 = w * w # m2
5932
-
6391
+ v = OpenStudio.openStudioVersion.split(".").join.to_i
5933
6392
 
5934
6393
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5935
6394
  # Excerpts of ASHRAE 90.1 2022 definitions:
@@ -5994,7 +6453,7 @@ module OSut
5994
6453
  # to exclude portions of any roof surface: all plenum roof surfaces (in
5995
6454
  # addition to soffit surfaces) would need to be insulated). The method takes
5996
6455
  # such circumstances into account, which requires vertically casting of
5997
- # surfaces ontoothers, as well as overlap calculations. If successful, the
6456
+ # surfaces onto others, as well as overlap calculations. If successful, the
5998
6457
  # method returns the "GROSS ROOF AREA" (in m2), based on the above rationale.
5999
6458
  #
6000
6459
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
@@ -6014,7 +6473,7 @@ module OSut
6014
6473
  # instance, if the GROSS ROOF AREA were based on insulated ceiling surfaces,
6015
6474
  # there would be a topological disconnect between flat ceiling and sloped
6016
6475
  # skylights above. Should NECB users first 'project' (sloped) skylight rough
6017
- # openings onto flat ceilings when calculating %SRR? Without much needed
6476
+ # openings onto flat ceilings when calculating SRR%? Without much needed
6018
6477
  # clarification, the (clearer) 90.1 rules equally apply here to NECB cases.
6019
6478
 
6020
6479
  # If skylight wells are indeed required, well wall edges are always vertical
@@ -6037,19 +6496,8 @@ module OSut
6037
6496
 
6038
6497
  mdl = spaces.first.model
6039
6498
 
6040
- # Exit if mismatched or invalid argument classes/keys.
6499
+ # Exit if mismatched or invalid options.
6041
6500
  return mismatch("opts", opts, Hash, mth, DBG, 0) unless opts.is_a?(Hash)
6042
- return hashkey( "srr", opts, :srr, mth, ERR, 0) unless opts.key?(:srr)
6043
-
6044
- # Validate requested skylight-to-roof ratio.
6045
- if opts[:srr].respond_to?(:to_f)
6046
- srr = opts[:srr].to_f
6047
- log(WRN, "Resetting srr to 0% (#{mth})") if srr < 0
6048
- log(WRN, "Resetting srr to 10% (#{mth})") if srr > 0.10
6049
- srr = srr.clamp(0.00, 0.10)
6050
- else
6051
- return mismatch("srr", opts[:srr], Numeric, mth, DBG, 0)
6052
- end
6053
6501
 
6054
6502
  # Validate Frame & Divider object, if provided.
6055
6503
  if opts.key?(:frame)
@@ -6057,10 +6505,10 @@ module OSut
6057
6505
 
6058
6506
  if frame.respond_to?(:frameWidth)
6059
6507
  frame = nil if v < 321
6060
- frame = nil if f.frameWidth.round(2) < 0
6061
- frame = nil if f.frameWidth.round(2) > gap
6508
+ frame = nil if frame.frameWidth.round(2) < 0
6509
+ frame = nil if frame.frameWidth.round(2) > gap
6062
6510
 
6063
- f = f.frameWidth if frame
6511
+ f = frame.frameWidth if frame
6064
6512
  log(WRN, "Skip Frame&Divider (#{mth})") unless frame
6065
6513
  else
6066
6514
  frame = nil
@@ -6085,6 +6533,27 @@ module OSut
6085
6533
  wl = w0 + gap
6086
6534
  wl2 = wl * wl
6087
6535
 
6536
+ # Validate requested skylight-to-roof ratio (or overall area).
6537
+ if opts.key?(:area)
6538
+ if opts[:area].respond_to?(:to_f)
6539
+ area = opts[:area].to_f
6540
+ log(WRN, "Area reset to 0.0m2 (#{mth})") if area < 0
6541
+ else
6542
+ return mismatch("area", opts[:area], Numeric, mth, DBG, 0)
6543
+ end
6544
+ elsif opts.key?(:srr)
6545
+ if opts[:srr].respond_to?(:to_f)
6546
+ srr = opts[:srr].to_f
6547
+ log(WRN, "SRR (#{srr.round(2)}) reset to 0% (#{mth})") if srr < 0
6548
+ log(WRN, "SRR (#{srr.round(2)}) reset to 90% (#{mth})") if srr > 0.90
6549
+ srr = srr.clamp(0.00, 0.10)
6550
+ else
6551
+ return mismatch("srr", opts[:srr], Numeric, mth, DBG, 0)
6552
+ end
6553
+ else
6554
+ return hashkey("area", opts, :area, mth, ERR, 0)
6555
+ end
6556
+
6088
6557
  # Validate purge request, if provided.
6089
6558
  if opts.key?(:clear)
6090
6559
  clear = opts[:clear]
@@ -6095,35 +6564,97 @@ module OSut
6095
6564
  end
6096
6565
  end
6097
6566
 
6098
- getRoofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear
6567
+ # Purge if requested.
6568
+ roofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear
6099
6569
 
6100
6570
  # Safely exit, e.g. if strictly called to purge existing roof subsurfaces.
6101
- return 0 if srr < TOL
6571
+ return 0 if area && area.round(2) == 0
6572
+ return 0 if srr && srr.round(2) == 0
6573
+
6574
+ m2 = 0 # total existing skylight rough opening area
6575
+ rm2 = grossRoofArea(spaces) # excludes e.g. overhangs
6576
+
6577
+ # Tally existing skylight rough opening areas.
6578
+ spaces.each do |space|
6579
+ m = space.multiplier
6580
+
6581
+ facets(space, "Outdoors", "RoofCeiling").each do |roof|
6582
+ roof.subSurfaces.each do |sub|
6583
+ next unless fenestration?(sub)
6584
+
6585
+ id = sub.nameString
6586
+ xm2 = sub.grossArea
6587
+
6588
+ if sub.allowWindowPropertyFrameAndDivider
6589
+ unless sub.windowPropertyFrameAndDivider.empty?
6590
+ fw = sub.windowPropertyFrameAndDivider.get.frameWidth
6591
+ vec = offset(sub.vertices, fw, 300)
6592
+ aire = OpenStudio.getArea(vec)
6593
+
6594
+ if aire.empty?
6595
+ log(ERR, "Skipping '#{id}': invalid Frame&Divider (#{mth})")
6596
+ else
6597
+ xm2 = aire.get
6598
+ end
6599
+ end
6600
+ end
6601
+
6602
+ m2 += xm2 * sub.multiplier * m
6603
+ end
6604
+ end
6605
+ end
6606
+
6607
+ # Required skylight area to add.
6608
+ sm2 = area ? area : rm2 * srr - m2
6609
+
6610
+ # Warn/skip if existing skylights exceed or ~roughly match targets.
6611
+ if sm2.round(2) < w02.round(2)
6612
+ if m2 > 0
6613
+ log(INF, "Skipping: existing skylight area > request (#{mth})")
6614
+ return rm2
6615
+ else
6616
+ log(INF, "Requested skylight area < min size (#{mth})")
6617
+ end
6618
+ elsif 0.9 * rm2.round(2) < sm2.round(2)
6619
+ log(INF, "Skipping: requested skylight area > 90% of GRA (#{mth})")
6620
+ return rm2
6621
+ end
6622
+
6623
+ opts[:ration] = true unless opts.key?(:ration)
6624
+
6625
+ # By default, seek ideal candidate spaces/roofs. Bail out if unsuccessful.
6626
+ unless opts[:ration] == false
6627
+ spaces = toToplit(spaces, opts)
6628
+ return rm2 if spaces.empty?
6629
+ end
6102
6630
 
6103
6631
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
6104
6632
  # The method seeks to insert a skylight array within the largest rectangular
6105
6633
  # 'bounded box' that neatly 'fits' within a given roof surface. This equally
6106
6634
  # applies to any vertically-cast overlap between roof and plenum (or attic)
6107
6635
  # floor, which in turn generates skylight wells. Skylight arrays are
6108
- # inserted from left/right + top/bottom (as illustrated below), once a roof
6109
- # (or cast 3D overlap) is 'aligned' in 2D (possibly also 'realigned').
6636
+ # inserted from left-to-right & top-to-bottom (as illustrated below), once a
6637
+ # roof (or cast 3D overlap) is 'aligned' in 2D.
6110
6638
  #
6111
6639
  # Depending on geometric complexity (e.g. building/roof concavity,
6112
6640
  # triangulation), the total area of bounded boxes may be significantly less
6113
6641
  # than the calculated "GROSS ROOF AREA", which can make it challenging to
6114
- # attain the desired %SRR. If :patterns are left unaltered, the method will
6115
- # select patterns that maximize the likelihood of attaining the requested
6116
- # %SRR, to the detriment of spatial distribution of daylighting.
6642
+ # attain the requested skylight area. If :patterns are left unaltered, the
6643
+ # method will select those that maximize the likelihood of attaining the
6644
+ # requested target, to the detriment of spatial daylighting distribution.
6117
6645
  #
6118
- # The default skylight module size is 1.2m x 1.2m (4' x 4'), which be
6119
- # overridden by the user, e.g. 2.4m x 2.4m (8' x 8').
6646
+ # The default skylight module size is 1.22m x 1.22m (4' x 4'), which can be
6647
+ # overridden by the user, e.g. 2.44m x 2.44m (8' x 8'). However, skylight
6648
+ # sizes usually end up either contracted or inflated to exactly meet a
6649
+ # request skylight area or SRR%,
6120
6650
  #
6121
6651
  # Preset skylight allocation patterns (in order of precedence):
6652
+ #
6122
6653
  # 1. "array"
6123
6654
  # _____________________
6124
6655
  # | _ _ _ | - ?x columns ("cols") >= ?x rows (min 2x2)
6125
- # | |_| |_| |_| | - SRR ~5% (1.2m x 1.2m), as illustrated
6126
- # | | - SRR ~19% (2.4m x 2.4m)
6656
+ # | |_| |_| |_| | - SRR ~5% (1.22m x 1.22m), as illustrated
6657
+ # | | - SRR ~19% (2.44m x 2.44m)
6127
6658
  # | _ _ _ | - +suitable for wide spaces (storage, retail)
6128
6659
  # | |_| |_| |_| | - ~1.4x height + skylight width 'ideal' rule
6129
6660
  # |_____________________| - better daylight distribution, many wells
@@ -6132,21 +6663,21 @@ module OSut
6132
6663
  # _____________________
6133
6664
  # | _ _ _ | - ?x columns (min 2), 1x row
6134
6665
  # | | | | | | | | - ~doubles %SRR ...
6135
- # | | | | | | | | - SRR ~10% (1.2m x ?1.2m), as illustrated
6136
- # | | | | | | | | - SRR ~19% (2.4m x ?1.2m)
6666
+ # | | | | | | | | - SRR ~10% (1.22m x ?1.22m), as illustrated
6667
+ # | | | | | | | | - SRR ~19% (2.44m x ?1.22m)
6137
6668
  # | |_| |_| |_| | - ~roof monitor layout
6138
6669
  # |_____________________| - fewer wells
6139
6670
  #
6140
6671
  # 3. "strip"
6141
6672
  # ____________________
6142
6673
  # | | - 1x column, 1x row (min 1x)
6143
- # | ______________ | - SRR ~11% (1.2m x ?1.2m)
6144
- # | | ............ | | - SRR ~22% (2.4m x ?1.2m), as illustrated
6674
+ # | ______________ | - SRR ~11% (1.22m x ?1.22m)
6675
+ # | | ............ | | - SRR ~22% (2.44m x ?1.22m), as illustrated
6145
6676
  # | |______________| | - +suitable for elongated bounded boxes
6146
6677
  # | | - 1x well
6147
6678
  # |____________________|
6148
6679
  #
6149
- # TO-DO: Support strips/strip patterns along ridge of paired roof surfaces.
6680
+ # @todo: Support strips/strip patterns along ridge of paired roof surfaces.
6150
6681
  layouts = ["array", "strips", "strip"]
6151
6682
  patterns = []
6152
6683
 
@@ -6174,27 +6705,26 @@ module OSut
6174
6705
  # - large roof surface areas (e.g. retail, classrooms ... not corridors)
6175
6706
  # - not sidelit (favours core spaces)
6176
6707
  # - having flat roofs (avoids sloped roofs)
6177
- # - not under plenums, nor attics (avoids wells)
6708
+ # - neither under plenums, nor attics (avoids wells)
6178
6709
  #
6179
6710
  # This ideal (albeit stringent) set of conditions is "combo a".
6180
6711
  #
6181
- # If required %SRR has not yet been achieved, the method decrementally drops
6182
- # selection criteria and starts over, e.g.:
6712
+ # If the requested skylight area has not yet been achieved (after initially
6713
+ # applying "combo a"), the method decrementally drops selection criteria and
6714
+ # starts over, e.g.:
6183
6715
  # - then considers sidelit spaces
6184
6716
  # - then considers sloped roofs
6185
6717
  # - then considers skylight wells
6186
6718
  #
6187
6719
  # A maximum number of skylights are allocated to roof surfaces matching a
6188
- # given combo. Priority is always given to larger roof areas. If
6189
- # unsuccessful in meeting the required %SRR target, a single criterion is
6190
- # then dropped (e.g. b, then c, etc.), and the allocation process is
6191
- # relaunched. An error message is logged if the %SRR isn't ultimately met.
6720
+ # given combo, all the while giving priority to larger roof areas. An error
6721
+ # message is logged if the target isn't ultimately achieved.
6192
6722
  #
6193
- # Through filters, users may restrict candidate roof surfaces:
6723
+ # Through filters, users may in advance restrict candidate roof surfaces:
6194
6724
  # b. above occupied sidelit spaces ('false' restricts to core spaces)
6195
6725
  # c. that are sloped ('false' restricts to flat roofs)
6196
- # d. above indirectly conditioned spaces (e.g. plenums, uninsulated wells)
6197
- # e. above unconditioned spaces (e.g. attics, insulated wells)
6726
+ # d. above INDIRECTLY CONDITIONED spaces (e.g. plenums, uninsulated wells)
6727
+ # e. above UNCONDITIONED spaces (e.g. attics, insulated wells)
6198
6728
  filters = ["a", "b", "bc", "bcd", "bcde"]
6199
6729
 
6200
6730
  # Prune filters, based on user-selected options.
@@ -6203,18 +6733,18 @@ module OSut
6203
6733
  next unless opts[opt] == false
6204
6734
 
6205
6735
  case opt
6206
- when :sidelit then filters.map! { |f| f.include?("b") ? f.delete("b") : f }
6207
- when :sloped then filters.map! { |f| f.include?("c") ? f.delete("c") : f }
6208
- when :plenum then filters.map! { |f| f.include?("d") ? f.delete("d") : f }
6209
- when :attic then filters.map! { |f| f.include?("e") ? f.delete("e") : f }
6736
+ when :sidelit then filters.map! { |fl| fl.include?("b") ? fl.delete("b") : fl }
6737
+ when :sloped then filters.map! { |fl| fl.include?("c") ? fl.delete("c") : fl }
6738
+ when :plenum then filters.map! { |fl| fl.include?("d") ? fl.delete("d") : fl }
6739
+ when :attic then filters.map! { |fl| fl.include?("e") ? fl.delete("e") : fl }
6210
6740
  end
6211
6741
  end
6212
6742
 
6213
- filters.reject! { |f| f.empty? }
6743
+ filters.reject! { |fl| fl.empty? }
6214
6744
  filters.uniq!
6215
6745
 
6216
- # Remaining filters may be further reduced (after space/roof processing),
6217
- # depending on geometry, e.g.:
6746
+ # Remaining filters may be further pruned automatically after space/roof
6747
+ # processing, depending on geometry, e.g.:
6218
6748
  # - if there are no sidelit spaces: filter "b" will be pruned away
6219
6749
  # - if there are no sloped roofs : filter "c" will be pruned away
6220
6750
  # - if no plenums are identified : filter "d" will be pruned away
@@ -6228,12 +6758,10 @@ module OSut
6228
6758
  attics = {} # unoccupied UNCONDITIONED spaces above rooms
6229
6759
  ceilings = {} # of occupied CONDITIONED space (if plenums/attics)
6230
6760
 
6231
- # Select candidate 'rooms' to toplit - excludes plenums/attics.
6761
+ # Candidate 'rooms' to toplit - excludes plenums/attics.
6232
6762
  spaces.each do |space|
6233
- next if unconditioned?(space) # e.g. attic
6234
- next unless space.partofTotalFloorArea # occupied (not plenum)
6763
+ id = space.nameString
6235
6764
 
6236
- # Already toplit?
6237
6765
  if daylit?(space, false, true, false)
6238
6766
  log(WRN, "#{id} is already toplit, skipping (#{mth})")
6239
6767
  next
@@ -6241,30 +6769,40 @@ module OSut
6241
6769
 
6242
6770
  # When unoccupied spaces are involved (e.g. plenums, attics), the occupied
6243
6771
  # space (to toplight) may not share the same local transformation as its
6244
- # unoccupied space(s) above. Fetching local transformation.
6245
- h = 0
6772
+ # unoccupied space(s) above. Fetching site transformation.
6246
6773
  t0 = transforms(space)
6247
6774
  next unless t0[:t]
6248
6775
 
6249
- toitures = facets(space, "Outdoors", "RoofCeiling")
6250
- plafonds = facets(space, "Surface", "RoofCeiling")
6776
+ # Calculate space height.
6777
+ hMIN = 10000
6778
+ hMAX = 0
6779
+ surfs = facets(space)
6780
+
6781
+ surfs.each { |surf| hMAX = [hMAX, surf.vertices.max_by(&:z).z].max }
6782
+ surfs.each { |surf| hMIN = [hMIN, surf.vertices.min_by(&:z).z].min }
6783
+
6784
+ h = hMAX - hMIN
6251
6785
 
6252
- toitures.each { |surf| h = [h, surf.vertices.max_by(&:z).z].max }
6253
- plafonds.each { |surf| h = [h, surf.vertices.max_by(&:z).z].max }
6786
+ unless h > 0
6787
+ log(ERR, "#{id} height? #{hMIN.round(2)} vs #{hMAX.round(2)} (#{mth})")
6788
+ next
6789
+ end
6254
6790
 
6255
6791
  rooms[space] = {}
6256
- rooms[space][:t ] = t0[:t]
6792
+ rooms[space][:t0 ] = t0[:t]
6257
6793
  rooms[space][:m ] = space.multiplier
6258
6794
  rooms[space][:h ] = h
6259
- rooms[space][:roofs ] = toitures
6795
+ rooms[space][:roofs ] = facets(space, "Outdoors", "RoofCeiling")
6260
6796
  rooms[space][:sidelit] = daylit?(space, true, false, false)
6261
6797
 
6262
6798
  # Fetch and process room-specific outdoor-facing roof surfaces, the most
6263
- # basic 'set' to track:
6264
- # - no skylight wells
6799
+ # basic 'set' to track, e.g.:
6800
+ # - no skylight wells (i.e. no leader lines)
6265
6801
  # - 1x skylight array per roof surface
6266
- # - no need to preprocess space transformation
6802
+ # - no need to consider site transformation
6267
6803
  rooms[space][:roofs].each do |roof|
6804
+ next unless roof?(roof)
6805
+
6268
6806
  box = boundedBox(roof)
6269
6807
  next if box.empty?
6270
6808
 
@@ -6274,133 +6812,112 @@ module OSut
6274
6812
  bm2 = bm2.get
6275
6813
  next if bm2.round(2) < w02.round(2)
6276
6814
 
6277
- # Track if bounded box is significantly smaller than roof.
6815
+ width = alignedWidth(box, true)
6816
+ depth = alignedHeight(box, true)
6817
+ next if width < wl * 3
6818
+ next if depth < wl
6819
+
6820
+ # A set is 'tight' if the area of its bounded box is significantly
6821
+ # smaller than that of its roof. A set is 'thin' if the depth of its
6822
+ # bounded box is (too) narrow. If either is true, some geometry rules
6823
+ # may be relaxed to maximize allocated skylight area. Neither apply to
6824
+ # cases with skylight wells.
6278
6825
  tight = bm2 < roof.grossArea / 2 ? true : false
6826
+ thin = depth.round(2) < (1.5 * wl).round(2) ? true : false
6279
6827
 
6280
6828
  set = {}
6281
6829
  set[:box ] = box
6282
6830
  set[:bm2 ] = bm2
6283
6831
  set[:tight ] = tight
6832
+ set[:thin ] = thin
6284
6833
  set[:roof ] = roof
6285
6834
  set[:space ] = space
6835
+ set[:m ] = space.multiplier
6286
6836
  set[:sidelit] = rooms[space][:sidelit]
6287
- set[:t ] = rooms[space][:t ]
6288
- set[:sloped ] = slopedRoof?(roof)
6837
+ set[:t0 ] = rooms[space][:t0]
6838
+ set[:t ] = OpenStudio::Transformation.alignFace(roof.vertices)
6289
6839
  sets << set
6290
6840
  end
6291
6841
  end
6292
6842
 
6293
6843
  # Process outdoor-facing roof surfaces of plenums and attics above.
6294
6844
  rooms.each do |space, room|
6295
- t0 = room[:t]
6296
- toits = getRoofs(space)
6297
- rufs = room.key?(:roofs) ? toits - room[:roofs] : toits
6298
- next if rufs.empty?
6845
+ t0 = room[:t0]
6846
+ rufs = roofs(space) - room[:roofs]
6299
6847
 
6300
- # Process room ceilings, as 1x or more are overlapping roofs above. Fetch
6301
- # vertically-cast overlaps.
6302
6848
  rufs.each do |ruf|
6849
+ next unless roof?(ruf)
6850
+
6303
6851
  espace = ruf.space
6304
6852
  next if espace.empty?
6305
6853
 
6306
6854
  espace = espace.get
6307
6855
  next if espace.partofTotalFloorArea
6308
6856
 
6309
- m = espace.multiplier
6310
- ti = transforms(espace)
6311
- next unless ti[:t]
6857
+ m = espace.multiplier
6312
6858
 
6313
- ti = ti[:t]
6314
- vtx = ruf.vertices
6315
-
6316
- # Ensure BLC vertex sequence.
6317
- if facingUp?(vtx)
6318
- vtx = ti * vtx
6319
-
6320
- if xyz?(vtx, :z)
6321
- vtx = blc(vtx)
6322
- else
6323
- dZ = vtx.first.z
6324
- vtz = blc(flatten(vtx)).to_a
6325
- vtx = []
6326
-
6327
- vtz.each { |v| vtx << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
6328
- end
6329
-
6330
- ruf.setVertices(ti.inverse * vtx)
6331
- else
6332
- tr = OpenStudio::Transformation.alignFace(vtx)
6333
- vtx = blc(tr.inverse * vtx)
6334
- ruf.setVertices(tr * vtx)
6859
+ if m != space.multiplier
6860
+ log(ERR, "Skipping #{ruf.nameString} (multiplier mismatch) (#{mth})")
6861
+ next
6335
6862
  end
6336
6863
 
6337
- ri = ti * ruf.vertices
6338
-
6339
- facets(space, "Surface", "RoofCeiling").each do |tile|
6340
- vtx = tile.vertices
6341
-
6342
- # Ensure BLC vertex sequence.
6343
- if facingUp?(vtx)
6344
- vtx = t0 * vtx
6345
-
6346
- if xyz?(vtx, :z)
6347
- vtx = blc(vtx)
6348
- else
6349
- dZ = vtx.first.z
6350
- vtz = blc(flatten(vtx)).to_a
6351
- vtx = []
6352
-
6353
- vtz.each { |v| vtx << OpenStudio::Point3d.new(v.x, v.y, v.z + dZ) }
6354
- end
6355
-
6356
- vtx = t0.inverse * vtx
6357
- else
6358
- tr = OpenStudio::Transformation.alignFace(vtx)
6359
- vtx = blc(tr.inverse * vtx)
6360
- vtx = tr * vtx
6361
- end
6864
+ ti = transforms(espace)
6865
+ next unless ti[:t]
6362
6866
 
6363
- tile.setVertices(vtx)
6867
+ ti = ti[:t]
6868
+ rpts = ti * ruf.vertices
6364
6869
 
6365
- ci0 = cast(t0 * tile.vertices, ri, ray)
6870
+ # Process occupied room ceilings, as 1x or more are overlapping roof
6871
+ # surfaces above. Vertically cast, then fetch overlap.
6872
+ facets(space, "Surface", "RoofCeiling").each do |tile|
6873
+ tpts = t0 * tile.vertices
6874
+ ci0 = cast(tpts, rpts, ray)
6366
6875
  next if ci0.empty?
6367
6876
 
6368
- olap = overlap(ri, ci0, false)
6877
+ olap = overlap(rpts, ci0)
6369
6878
  next if olap.empty?
6370
6879
 
6880
+ om2 = OpenStudio.getArea(olap)
6881
+ next if om2.empty?
6882
+
6883
+ om2 = om2.get
6884
+ next if om2.round(2) < w02.round(2)
6885
+
6371
6886
  box = boundedBox(olap)
6372
6887
  next if box.empty?
6373
6888
 
6374
6889
  # Adding skylight wells (plenums/attics) is contingent to safely
6375
- # linking new base roof 'inserts' through leader lines. Currently,
6376
- # this requires an offset from main roof surface edges.
6890
+ # linking new base roof 'inserts' (as well as new ceiling ones)
6891
+ # through 'leader lines'. This requires an offset to ensure no
6892
+ # conflicts with roof or (ceiling) tile edges.
6377
6893
  #
6378
- # TO DO: expand the method to factor in cases where simple 'side'
6894
+ # @todo: Expand the method to factor in cases where simple 'side'
6379
6895
  # cutouts can be supported (no need for leader lines), e.g.
6380
6896
  # skylight strips along roof ridges.
6381
6897
  box = offset(box, -gap, 300)
6382
- box = poly(box, false, false, false, false, :blc)
6383
6898
  next if box.empty?
6384
6899
 
6385
6900
  bm2 = OpenStudio.getArea(box)
6386
6901
  next if bm2.empty?
6387
6902
 
6388
6903
  bm2 = bm2.get
6389
- next if bm2.round(2) < w02.round(2)
6904
+ next if bm2.round(2) < wl2.round(2)
6390
6905
 
6391
- # Vertically-cast box onto ceiling below.
6392
- cbox = cast(box, t0 * tile.vertices, ray)
6906
+ width = alignedWidth(box, true)
6907
+ depth = alignedHeight(box, true)
6908
+ next if width < wl * 3
6909
+ next if depth < wl * 2
6910
+
6911
+ # Vertically cast box onto tile below.
6912
+ cbox = cast(box, tpts, ray)
6393
6913
  next if cbox.empty?
6394
6914
 
6395
6915
  cm2 = OpenStudio.getArea(cbox)
6396
6916
  next if cm2.empty?
6397
6917
 
6398
- cm2 = cm2.get
6399
-
6400
- # Track if bounded boxes are significantly smaller than either roof
6401
- # or ceiling.
6402
- tight = bm2 < ruf.grossArea / 2 ? true : false
6403
- tight = cm2 < tile.grossArea / 2 ? true : tight
6918
+ cm2 = cm2.get
6919
+ box = ti.inverse * box
6920
+ cbox = t0.inverse * cbox
6404
6921
 
6405
6922
  unless ceilings.key?(tile)
6406
6923
  floor = tile.adjacentSurface
@@ -6412,10 +6929,6 @@ module OSut
6412
6929
 
6413
6930
  floor = floor.get
6414
6931
 
6415
- # Ensure BLC vertex sequence.
6416
- vtx = t0 * vtx
6417
- floor.setVertices(ti.inverse * vtx.reverse)
6418
-
6419
6932
  if floor.space.empty?
6420
6933
  log(ERR, "#{floor.nameString} space? (#{mth})")
6421
6934
  next
@@ -6428,32 +6941,40 @@ module OSut
6428
6941
  next
6429
6942
  end
6430
6943
 
6431
- ceilings[tile] = {}
6432
- ceilings[tile][:roofs] = []
6433
- ceilings[tile][:space] = space
6434
- ceilings[tile][:floor] = floor
6944
+ ceilings[tile] = {}
6945
+ ceilings[tile][:roofs ] = []
6946
+ ceilings[tile][:space ] = space
6947
+ ceilings[tile][:floor ] = floor
6435
6948
  end
6436
6949
 
6437
6950
  ceilings[tile][:roofs] << ruf
6438
6951
 
6439
- # More detailed skylight set entries with suspended ceilings.
6952
+ # Skylight set key:values are more detailed with suspended ceilings.
6953
+ # The overlap (:olap) remains in 'transformed' site coordinates (with
6954
+ # regards to the roof). The :box polygon reverts to attic/plenum space
6955
+ # coordinates, while the :cbox polygon is reset with regards to the
6956
+ # occupied space coordinates.
6440
6957
  set = {}
6441
6958
  set[:olap ] = olap
6442
6959
  set[:box ] = box
6443
6960
  set[:cbox ] = cbox
6961
+ set[:om2 ] = om2
6444
6962
  set[:bm2 ] = bm2
6445
6963
  set[:cm2 ] = cm2
6446
- set[:tight ] = tight
6964
+ set[:tight ] = false
6965
+ set[:thin ] = false
6447
6966
  set[:roof ] = ruf
6448
6967
  set[:space ] = space
6968
+ set[:m ] = space.multiplier
6449
6969
  set[:clng ] = tile
6450
- set[:t ] = t0
6970
+ set[:t0 ] = t0
6971
+ set[:ti ] = ti
6972
+ set[:t ] = OpenStudio::Transformation.alignFace(ruf.vertices)
6451
6973
  set[:sidelit] = room[:sidelit]
6452
- set[:sloped ] = slopedRoof?(ruf)
6453
6974
 
6454
6975
  if unconditioned?(espace) # e.g. attic
6455
6976
  unless attics.key?(espace)
6456
- attics[espace] = {t: ti, m: m, bm2: 0, roofs: []}
6977
+ attics[espace] = {ti: ti, m: m, bm2: 0, roofs: []}
6457
6978
  end
6458
6979
 
6459
6980
  attics[espace][:bm2 ] += bm2
@@ -6464,7 +6985,7 @@ module OSut
6464
6985
  ceilings[tile][:attic] = espace
6465
6986
  else # e.g. plenum
6466
6987
  unless plenums.key?(espace)
6467
- plenums[espace] = {t: ti, m: m, bm2: 0, roofs: []}
6988
+ plenums[espace] = {ti: ti, m: m, bm2: 0, roofs: []}
6468
6989
  end
6469
6990
 
6470
6991
  plenums[espace][:bm2 ] += bm2
@@ -6481,21 +7002,20 @@ module OSut
6481
7002
  end
6482
7003
  end
6483
7004
 
6484
- # Ensure uniqueness of plenum roofs, and set GROSS ROOF AREA.
7005
+ # Ensure uniqueness of plenum roofs.
6485
7006
  attics.values.each do |attic|
6486
7007
  attic[:roofs ].uniq!
6487
- attic[:ridges] = getHorizontalRidges(attic[:roofs]) # TO-DO
7008
+ attic[:ridges] = horizontalRidges(attic[:roofs]) # @todo
6488
7009
  end
6489
7010
 
6490
7011
  plenums.values.each do |plenum|
6491
7012
  plenum[:roofs ].uniq!
6492
- # plenum[:m2 ] = plenum[:roofs].sum(&:grossArea)
6493
- plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # TO-DO
7013
+ plenum[:ridges] = horizontalRidges(plenum[:roofs]) # @todo
6494
7014
  end
6495
7015
 
6496
- # Regardless of the selected skylight arrangement pattern, the current
6497
- # solution may only consider attic/plenum sets that can be successfully
6498
- # linked to leader line anchors, for both roof and ceiling surfaces.
7016
+ # Regardless of the selected skylight arrangement pattern, the solution only
7017
+ # considers attic/plenum sets that can be successfully linked to leader line
7018
+ # anchors, for both roof and ceiling surfaces. First, attic/plenum roofs.
6499
7019
  [attics, plenums].each do |greniers|
6500
7020
  k = greniers == attics ? :attic : :plenum
6501
7021
 
@@ -6512,7 +7032,8 @@ module OSut
6512
7032
  sts = sts.select { |st| st[:roof] == roof }
6513
7033
  next if sts.empty?
6514
7034
 
6515
- sts = sts.sort_by { |st| st[:bm2] }
7035
+ sts = sts.sort_by { |st| st[:bm2] }.reverse
7036
+
6516
7037
  genAnchors(roof, sts, :box)
6517
7038
  end
6518
7039
  end
@@ -6527,7 +7048,7 @@ module OSut
6527
7048
  next unless ceiling.key?(k)
6528
7049
 
6529
7050
  space = ceiling[:space]
6530
- spce = ceiling[k ]
7051
+ spce = ceiling[k]
6531
7052
  next unless ceiling.key?(:roofs)
6532
7053
  next unless rooms.key?(space)
6533
7054
 
@@ -6553,108 +7074,72 @@ module OSut
6553
7074
 
6554
7075
  next if stz.empty?
6555
7076
 
7077
+ stz = stz.sort_by { |st| st[:cm2] }.reverse
6556
7078
  genAnchors(tile, stz, :cbox)
6557
7079
  end
6558
7080
 
6559
7081
  # Delete voided sets.
6560
7082
  sets.reject! { |set| set.key?(:void) }
6561
7083
 
6562
- m2 = 0 # existing skylight rough opening area
6563
- rm2 = grossRoofArea(spaces)
6564
-
6565
- # Tally existing skylight rough opening areas (%SRR calculations).
6566
- rooms.values.each do |room|
6567
- m = room[:m]
6568
-
6569
- room[:roofs].each do |roof|
6570
- roof.subSurfaces.each do |sub|
6571
- next unless fenestration?(sub)
6572
-
6573
- id = sub.nameString
6574
- xm2 = sub.grossArea
6575
-
6576
- if sub.allowWindowPropertyFrameAndDivider
6577
- unless sub.windowPropertyFrameAndDivider.empty?
6578
- fw = sub.windowPropertyFrameAndDivider.get.frameWidth
6579
- vec = offset(sub.vertices, fw, 300)
6580
- aire = OpenStudio.getArea(vec)
6581
-
6582
- if aire.empty?
6583
- log(ERR, "Skipping '#{id}': invalid Frame&Divider (#{mth})")
6584
- else
6585
- xm2 = aire.get
6586
- end
6587
- end
6588
- end
6589
-
6590
- m2 += xm2 * sub.multiplier * m
6591
- end
6592
- end
6593
- end
6594
-
6595
- # Required skylight area to add.
6596
- sm2 = rm2 * srr - m2
7084
+ return empty("sets", mth, WRN, rm2) if sets.empty?
6597
7085
 
6598
- # Skip if existing skylights exceed or ~roughly match requested %SRR.
6599
- if sm2.round(2) < w02.round(2)
6600
- log(INF, "Skipping: existing srr > requested srr (#{mth})")
6601
- return 0
6602
- end
7086
+ # Sort sets, from largest to smallest bounded box area.
7087
+ sets = sets.sort_by { |st| st[:bm2] * st[:m] }.reverse
6603
7088
 
6604
- # Any sidelit/sloped roofs being targeted?
6605
- #
6606
- # TODO: enable double-ridged, sloped roofs have double-sloped
6607
- # skylights/wells (patterns "strip"/"strips").
7089
+ # Any sidelit and/or sloped roofs being targeted?
7090
+ # @todo: enable double-ridged, sloped roofs have double-sloped
7091
+ # skylights/wells (patterns "strip"/"strips").
6608
7092
  sidelit = sets.any? { |set| set[:sidelit] }
6609
7093
  sloped = sets.any? { |set| set[:sloped ] }
6610
7094
 
7095
+ # Average sandbox area + revised 'working' SRR%.
7096
+ sbm2 = sets.map { |set| set[:bm2] }.reduce(:+)
7097
+ avm2 = sbm2 / sets.size
7098
+ srr2 = sm2 / sets.size / avm2
7099
+
6611
7100
  # Precalculate skylight rows + cols, for each selected pattern. In the case
6612
7101
  # of 'cols x rows' arrays of skylights, the method initially overshoots
6613
- # with regards to ideal skylight placement, e.g.:
7102
+ # with regards to 'ideal' skylight placement, e.g.:
6614
7103
  #
6615
7104
  # aceee.org/files/proceedings/2004/data/papers/SS04_Panel3_Paper18.pdf
6616
7105
  #
6617
- # ... yet skylight areas are subsequently contracted to strictly meet SRR%.
7106
+ # Skylight areas are subsequently contracted to strictly meet the target.
6618
7107
  sets.each_with_index do |set, i|
6619
- id = "set #{i+1}"
6620
- well = set.key?(:clng)
6621
- space = set[:space]
7108
+ thin = set[:thin ]
6622
7109
  tight = set[:tight]
6623
7110
  factor = tight ? 1.75 : 1.25
7111
+ well = set.key?(:clng)
7112
+ space = set[:space]
6624
7113
  room = rooms[space]
6625
7114
  h = room[:h]
6626
- t = OpenStudio::Transformation.alignFace(set[:box])
6627
- abox = poly(set[:box], false, false, false, t, :ulc)
6628
- obox = getRealignedFace(abox)
6629
- next unless obox[:set]
6630
-
6631
- width = width(obox[:set])
6632
- depth = height(obox[:set])
6633
- area = width * depth
6634
- skym2 = srr * area
7115
+ width = alignedWidth( set[:box], true)
7116
+ depth = alignedHeight(set[:box], true)
7117
+ barea = set.key?(:om2) ? set[:om2] : set[:bm2]
7118
+ rtio = barea / avm2
7119
+ skym2 = srr2 * barea * rtio
6635
7120
 
6636
- # Flag sets if too narrow/shallow to hold a single skylight.
7121
+ # Flag set if too narrow/shallow to hold a single skylight.
6637
7122
  if well
6638
7123
  if width.round(2) < wl.round(2)
6639
- log(ERR, "#{id}: Too narrow")
7124
+ log(WRN, "set #{i+1} well: Too narrow (#{mth})")
6640
7125
  set[:void] = true
6641
7126
  next
6642
7127
  end
6643
7128
 
6644
7129
  if depth.round(2) < wl.round(2)
6645
- log(ERR, "#{id}: Too shallow")
7130
+ log(WRN, "set #{i+1} well: Too shallow (#{mth})")
6646
7131
  set[:void] = true
6647
7132
  next
6648
7133
  end
6649
7134
  else
6650
7135
  if width.round(2) < w0.round(2)
6651
- log(ERR, "#{id}: Too narrow")
7136
+ log(WRN, "set #{i+1}: Too narrow (#{mth})")
6652
7137
  set[:void] = true
6653
7138
  next
6654
7139
  end
6655
7140
 
6656
7141
  if depth.round(2) < w0.round(2)
6657
- log(ERR, "#{id}: Too shallow")
7142
+ log(WRN, "set #{i+1}: Too shallow (#{mth})")
6658
7143
  set[:void] = true
6659
7144
  next
6660
7145
  end
@@ -6667,8 +7152,8 @@ module OSut
6667
7152
  rows = 1
6668
7153
  wx = w0
6669
7154
  wy = w0
6670
- wxl = wl
6671
- wyl = wl
7155
+ wxl = well ? wl : nil
7156
+ wyl = well ? wl : nil
6672
7157
  dX = nil
6673
7158
  dY = nil
6674
7159
 
@@ -6676,31 +7161,26 @@ module OSut
6676
7161
  when "array" # min 2x cols x min 2x rows
6677
7162
  cols = 2
6678
7163
  rows = 2
7164
+ next if thin
6679
7165
 
6680
7166
  if tight
6681
7167
  sp = 1.4 * h / 2
6682
- lx = well ? width - cols * wxl : width - cols * wx
6683
- ly = well ? depth - rows * wyl : depth - rows * wy
7168
+ lx = width - cols * wx
7169
+ ly = depth - rows * wy
6684
7170
  next if lx.round(2) < sp.round(2)
6685
7171
  next if ly.round(2) < sp.round(2)
6686
7172
 
6687
- if well
6688
- cols = ((width - wxl) / (wxl + sp)).round(2).to_i + 1
6689
- rows = ((depth - wyl) / (wyl + sp)).round(2).to_i + 1
6690
- else
6691
- cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
6692
- rows = ((depth - wy) / (wy + sp)).round(2).to_i + 1
6693
- end
6694
-
7173
+ cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
7174
+ rows = ((depth - wy) / (wy + sp)).round(2).to_i + 1
6695
7175
  next if cols < 2
6696
7176
  next if rows < 2
6697
7177
 
6698
- dX = well ? 0.0 : bfr + f
6699
- dY = well ? 0.0 : bfr + f
7178
+ dX = bfr + f
7179
+ dY = bfr + f
6700
7180
  else
6701
7181
  sp = 1.4 * h
6702
7182
  lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols
6703
- ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols
7183
+ ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / rows
6704
7184
  next if lx.round(2) < sp.round(2)
6705
7185
  next if ly.round(2) < sp.round(2)
6706
7186
 
@@ -6715,246 +7195,238 @@ module OSut
6715
7195
  next if cols < 2
6716
7196
  next if rows < 2
6717
7197
 
6718
- ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols
7198
+ ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / rows
6719
7199
  dY = ly / 2
6720
7200
  end
6721
7201
 
6722
- # Current skylight area. If undershooting, adjust skylight width/depth
6723
- # as well as reduce spacing. For geometrical constrained cases,
6724
- # undershooting means not reaching 1.75x the required SRR%. Otherwise,
6725
- # undershooting means not reaching 1.25x the required SRR%. Any
6726
- # consequent overshooting is later corrected.
6727
- tm2 = wx * cols * wy * rows
6728
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
7202
+ # Default allocated skylight area. If undershooting, inflate skylight
7203
+ # width/depth (with reduced spacing). For geometrically-constrained
7204
+ # cases, undershooting means not reaching 1.75x the required target.
7205
+ # Otherwise, undershooting means not reaching 1.25x the required
7206
+ # target. Any consequent overshooting is later corrected.
7207
+ tm2 = wx * cols * wy * rows
6729
7208
 
6730
- # Inflate skylight width/depth (and reduce spacing) to reach SRR%.
6731
- if undershot
7209
+ # Inflate skylight width/depth (and reduce spacing) to reach target.
7210
+ if tm2.round(2) < factor * skym2.round(2)
6732
7211
  ratio2 = 1 + (factor * skym2 - tm2) / tm2
6733
7212
  ratio = Math.sqrt(ratio2)
6734
7213
 
6735
- sp = w
7214
+ sp = wl
6736
7215
  wx *= ratio
6737
7216
  wy *= ratio
6738
- wxl = wx + gap
6739
- wyl = wy + gap
7217
+ wxl = wx + gap if well
7218
+ wyl = wy + gap if well
6740
7219
 
6741
7220
  if tight
6742
- if well
6743
- lx = (width - cols * wxl) / (cols - 1)
6744
- ly = (depth - rows * wyl) / (rows - 1)
6745
- else
6746
- lx = (width - cols * wx) / (cols - 1)
6747
- ly = (depth - rows * wy) / (rows - 1)
6748
- end
6749
-
7221
+ lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1)
7222
+ ly = (depth - 2 * (bfr + f) - rows * wy) / (rows - 1)
6750
7223
  lx = lx.round(2) < sp.round(2) ? sp : lx
6751
7224
  ly = ly.round(2) < sp.round(2) ? sp : ly
6752
-
6753
- if well
6754
- wxl = (width - (cols - 1) * lx) / cols
6755
- wyl = (depth - (rows - 1) * ly) / rows
6756
- wx = wxl - gap
6757
- wy = wyl - gap
6758
- else
6759
- wx = (width - (cols - 1) * lx) / cols
6760
- wy = (depth - (rows - 1) * ly) / rows
6761
- wxl = wx + gap
6762
- wyl = wy + gap
6763
- end
7225
+ wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols
7226
+ wy = (depth - 2 * (bfr + f) - (rows - 1) * ly) / rows
6764
7227
  else
6765
7228
  if well
6766
- lx = (width - cols * wxl) / cols
6767
- ly = (depth - rows * wyl) / rows
6768
- else
6769
- lx = (width - cols * wx) / cols
6770
- ly = (depth - rows * wy) / rows
6771
- end
6772
-
6773
- lx = lx.round(2) < sp.round(2) ? sp : lx
6774
- ly = ly.round(2) < sp.round(2) ? sp : ly
6775
-
6776
- if well
7229
+ lx = (width - cols * wxl) / cols
7230
+ ly = (depth - rows * wyl) / rows
7231
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7232
+ ly = ly.round(2) < sp.round(2) ? sp : ly
6777
7233
  wxl = (width - cols * lx) / cols
6778
7234
  wyl = (depth - rows * ly) / rows
6779
7235
  wx = wxl - gap
6780
7236
  wy = wyl - gap
6781
- lx = (width - cols * wxl) / cols
6782
7237
  ly = (depth - rows * wyl) / rows
6783
7238
  else
7239
+ lx = (width - cols * wx) / cols
7240
+ ly = (depth - rows * wy) / rows
7241
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7242
+ ly = ly.round(2) < sp.round(2) ? sp : ly
6784
7243
  wx = (width - cols * lx) / cols
6785
7244
  wy = (depth - rows * ly) / rows
6786
- wxl = wx + gap
6787
- wyl = wy + gap
6788
- lx = (width - cols * wx) / cols
6789
7245
  ly = (depth - rows * wy) / rows
6790
7246
  end
6791
- end
6792
7247
 
6793
- dY = ly / 2
7248
+ dY = ly / 2
7249
+ end
6794
7250
  end
6795
7251
  when "strips" # min 2x cols x 1x row
6796
7252
  cols = 2
6797
7253
 
6798
7254
  if tight
6799
7255
  sp = h / 2
6800
- lx = well ? width - cols * wxl : width - cols * wx
6801
- ly = well ? depth - wyl : depth - wy
7256
+ dX = bfr + f
7257
+ lx = width - cols * wx
6802
7258
  next if lx.round(2) < sp.round(2)
6803
- next if ly.round(2) < sp.round(2)
6804
-
6805
- if well
6806
- cols = ((width - wxl) / (wxl + sp)).round(2).to_i + 1
6807
- else
6808
- cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
6809
- end
6810
7259
 
7260
+ cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
6811
7261
  next if cols < 2
6812
7262
 
6813
- if well
6814
- wyl = depth - ly
6815
- wy = wyl - gap
7263
+ if thin
7264
+ dY = bfr + f
7265
+ wy = depth - 2 * dY
7266
+ next if wy.round(2) < gap4
6816
7267
  else
6817
- wy = depth - ly
6818
- wyl = wy + gap
6819
- end
7268
+ ly = depth - wy
7269
+ next if ly.round(2) < wl.round(2)
6820
7270
 
6821
- dX = well ? 0 : bfr + f
6822
- dY = ly / 2
7271
+ dY = ly / 2
7272
+ end
6823
7273
  else
6824
7274
  sp = h
6825
- lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols
6826
- ly = well ? depth - wyl : depth - wy
6827
- next if lx.round(2) < sp.round(2)
6828
- next if ly.round(2) < w.round(2)
6829
7275
 
6830
7276
  if well
7277
+ lx = (width - cols * wxl) / cols
7278
+ next if lx.round(2) < sp.round(2)
7279
+
6831
7280
  cols = (width / (wxl + sp)).round(2).to_i
7281
+ next if cols < 2
7282
+
7283
+ ly = depth - wyl
7284
+ dY = ly / 2
7285
+ next if ly.round(2) < wl.round(2)
6832
7286
  else
7287
+ lx = (width - cols * wx) / cols
7288
+ next if lx.round(2) < sp.round(2)
7289
+
6833
7290
  cols = (width / (wx + sp)).round(2).to_i
6834
- end
7291
+ next if cols < 2
6835
7292
 
6836
- next if cols < 2
7293
+ if thin
7294
+ dY = bfr + f
7295
+ wy = depth - 2 * dY
7296
+ next if wy.round(2) < gap4
7297
+ else
7298
+ ly = depth - wy
7299
+ next if ly.round(2) < wl.round(2)
6837
7300
 
6838
- if well
6839
- wyl = depth - ly
6840
- wy = wyl - gap
6841
- else
6842
- wy = depth - ly
6843
- wyl = wy + gap
7301
+ dY = ly / 2
7302
+ end
6844
7303
  end
6845
-
6846
- dY = ly / 2
6847
7304
  end
6848
7305
 
6849
- tm2 = wx * cols * wy
6850
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
7306
+ tm2 = wx * cols * wy
6851
7307
 
6852
- # Inflate skylight width (and reduce spacing) to reach SRR%.
6853
- if undershot
6854
- ratio2 = 1 + (factor * skym2 - tm2) / tm2
7308
+ # Inflate skylight depth to reach target.
7309
+ if tm2.round(2) < factor * skym2.round(2)
7310
+ sp = wl
6855
7311
 
6856
- sp = w
6857
- wx *= ratio2
6858
- wxl = wx + gap
7312
+ # Skip if already thin.
7313
+ unless thin
7314
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
7315
+
7316
+ wy *= ratio2
6859
7317
 
6860
- if tight
6861
7318
  if well
6862
- lx = (width - cols * wxl) / (cols - 1)
7319
+ wyl = wy + gap
7320
+ ly = depth - wyl
7321
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7322
+ wyl = depth - ly
7323
+ wy = wyl - gap
6863
7324
  else
6864
- lx = (width - cols * wx) / (cols - 1)
7325
+ ly = depth - wy
7326
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7327
+ wy = depth - ly
6865
7328
  end
6866
7329
 
6867
- lx = lx.round(2) < sp.round(2) ? sp : lx
7330
+ dY = ly / 2
7331
+ end
7332
+ end
6868
7333
 
6869
- if well
6870
- wxl = (width - (cols - 1) * lx) / cols
6871
- wx = wxl - gap
6872
- else
6873
- wx = (width - (cols - 1) * lx) / cols
6874
- wxl = wx + gap
6875
- end
6876
- else
6877
- if well
6878
- lx = (width - cols * wxl) / cols
6879
- else
6880
- lx = (width - cols * wx) / cols
6881
- end
7334
+ tm2 = wx * cols * wy
7335
+
7336
+ # Inflate skylight width (and reduce spacing) to reach target.
7337
+ if tm2.round(2) < factor * skym2.round(2)
7338
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
6882
7339
 
6883
- lx = lx.round(2) < sp.round(2) ? sp : lx
7340
+ wx *= ratio2
7341
+ wxl = wx + gap if well
6884
7342
 
7343
+ if tight
7344
+ lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1)
7345
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7346
+ wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols
7347
+ else
6885
7348
  if well
7349
+ lx = (width - cols * wxl) / cols
7350
+ lx = lx.round(2) < sp.round(2) ? sp : lx
6886
7351
  wxl = (width - cols * lx) / cols
6887
7352
  wx = wxl - gap
6888
- lx = (width - cols * wxl) / cols
6889
7353
  else
6890
- wx = (width - cols * lx) / cols
6891
- wxl = wx + gap
6892
7354
  lx = (width - cols * wx) / cols
7355
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7356
+ wx = (width - cols * lx) / cols
6893
7357
  end
6894
7358
  end
6895
7359
  end
6896
7360
  else # "strip" 1 (long?) row x 1 column
6897
- sp = w
6898
- lx = well ? width - wxl : width - wx
6899
- ly = well ? depth - wyl : depth - wy
6900
-
6901
7361
  if tight
6902
- next if lx.round(2) < sp.round(2)
6903
- next if ly.round(2) < sp.round(2)
6904
-
6905
- if well
6906
- wxl = width - lx
6907
- wyl = depth - ly
6908
- wx = wxl - gap
6909
- wy = wyl - gap
7362
+ sp = gap4
7363
+ dX = bfr + f
7364
+ wx = width - 2 * dX
7365
+ next if wx.round(2) < sp.round(2)
7366
+
7367
+ if thin
7368
+ dY = bfr + f
7369
+ wy = depth - 2 * dY
7370
+ next if wy.round(2) < sp.round(2)
6910
7371
  else
6911
- wx = width - lx
6912
- wy = depth - ly
6913
- wxl = wx + gap
6914
- wyl = wy + gap
7372
+ ly = depth - wy
7373
+ dY = ly / 2
7374
+ next if ly.round(2) < sp.round(2)
6915
7375
  end
6916
-
6917
- dX = well ? 0.0 : bfr + f
6918
- dY = ly / 2
6919
7376
  else
7377
+ sp = wl
7378
+ lx = well ? width - wxl : width - wx
7379
+ ly = well ? depth - wyl : depth - wy
7380
+ dY = ly / 2
6920
7381
  next if lx.round(2) < sp.round(2)
6921
7382
  next if ly.round(2) < sp.round(2)
7383
+ end
6922
7384
 
6923
- if well
6924
- wxl = width - lx
6925
- wyl = depth - ly
6926
- wx = wxl - gap
6927
- wy = wyl - gap
6928
- else
6929
- wx = width - lx
6930
- wy = depth - ly
6931
- wxl = wx + gap
6932
- wyl = wy + gap
6933
- end
7385
+ tm2 = wx * wy
6934
7386
 
6935
- dY = ly / 2
7387
+ # Inflate skylight width (and reduce spacing) to reach target.
7388
+ if tm2.round(2) < factor * skym2.round(2)
7389
+ unless tight
7390
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
7391
+
7392
+ wx *= ratio2
7393
+
7394
+ if well
7395
+ wxl = wx + gap
7396
+ lx = width - wxl
7397
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7398
+ wxl = width - lx
7399
+ wx = wxl - gap
7400
+ else
7401
+ lx = width - wx
7402
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7403
+ wx = width - lx
7404
+ end
7405
+ end
6936
7406
  end
6937
7407
 
6938
- tm2 = wx * wy
6939
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
7408
+ tm2 = wx * wy
6940
7409
 
6941
- # Inflate skylight depth to reach SRR%.
6942
- if undershot
6943
- ratio2 = 1 + (factor * skym2 - tm2) / tm2
7410
+ # Inflate skylight depth to reach target. Skip if already tight thin.
7411
+ if tm2.round(2) < factor * skym2.round(2)
7412
+ unless thin
7413
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
6944
7414
 
6945
- sp = w
6946
- wy *= ratio2
6947
- wyl = wy + gap
7415
+ wy *= ratio2
6948
7416
 
6949
- ly = well ? depth - wy : depth - wyl
6950
- ly = ly.round(2) < sp.round(2) ? sp : lx
7417
+ if well
7418
+ wyl = wy + gap
7419
+ ly = depth - wyl
7420
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7421
+ wyl = depth - ly
7422
+ wy = wyl - gap
7423
+ else
7424
+ ly = depth - wy
7425
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7426
+ wy = depth - ly
7427
+ end
6951
7428
 
6952
- if well
6953
- wyl = depth - ly
6954
- wy = wyl - gap
6955
- else
6956
- wy = depth - ly
6957
- wyl = wy + gap
7429
+ dY = ly / 2
6958
7430
  end
6959
7431
  end
6960
7432
  end
@@ -6972,10 +7444,13 @@ module OSut
6972
7444
 
6973
7445
  set[pattern] = st
6974
7446
  end
7447
+
7448
+ set[:void] = true unless patterns.any? { |k| set.key?(k) }
6975
7449
  end
6976
7450
 
6977
7451
  # Delete voided sets.
6978
7452
  sets.reject! { |set| set.key?(:void) }
7453
+ return empty("sets (2)", mth, WRN, rm2) if sets.empty?
6979
7454
 
6980
7455
  # Final reset of filters.
6981
7456
  filters.map! { |f| f.include?("b") ? f.delete("b") : f } unless sidelit
@@ -6986,15 +7461,15 @@ module OSut
6986
7461
  filters.reject! { |f| f.empty? }
6987
7462
  filters.uniq!
6988
7463
 
6989
- # Initialize skylight area tally.
7464
+ # Initialize skylight area tally (to increment).
6990
7465
  skm2 = 0
6991
7466
 
6992
7467
  # Assign skylight pattern.
6993
- filters.each_with_index do |filter, i|
7468
+ filters.each do |filter|
6994
7469
  next if skm2.round(2) >= sm2.round(2)
6995
7470
 
7471
+ dm2 = sm2 - skm2 # differential (remaining skylight area to meet).
6996
7472
  sts = sets
6997
- sts = sts.sort_by { |st| st[:bm2] }.reverse!
6998
7473
  sts = sts.reject { |st| st.key?(:pattern) }
6999
7474
 
7000
7475
  if filter.include?("a")
@@ -7029,33 +7504,49 @@ module OSut
7029
7504
 
7030
7505
  fpm2[pattern] = {m2: 0, tight: false} unless fpm2.key?(pattern)
7031
7506
 
7032
- fpm2[pattern][:m2 ] += wx * wy * cols * rows
7033
- fpm2[pattern][:tight] = st[:tight] ? true : false
7507
+ fpm2[pattern][:m2 ] += st[:m] * wx * wy * cols * rows
7508
+ fpm2[pattern][:tight] = true if st[:tight]
7034
7509
  end
7035
7510
  end
7036
7511
 
7037
7512
  pattern = nil
7038
7513
  next if fpm2.empty?
7039
7514
 
7040
- fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h
7041
-
7042
- # Select suitable pattern, often overshooting. Favour array unless
7043
- # geometrically constrainted.
7515
+ # Favour (large) arrays if meeting residual target, unless constrained.
7044
7516
  if fpm2.keys.include?("array")
7045
- if (fpm2["array"][:m2]).round(2) >= sm2.round(2)
7517
+ if fpm2["array"][:m2].round(2) >= dm2.round(2)
7046
7518
  pattern = "array" unless fpm2[:tight]
7047
7519
  end
7048
7520
  end
7049
7521
 
7050
7522
  unless pattern
7051
- if fpm2.values.first[:m2].round(2) >= sm2.round(2)
7052
- pattern = fpm2.keys.first
7053
- elsif fpm2.values.last[:m2].round(2) <= sm2.round(2)
7054
- pattern = fpm2.keys.last
7523
+ fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h
7524
+ min_m2 = fpm2.values.first[:m2]
7525
+ max_m2 = fpm2.values.last[:m2]
7526
+
7527
+ if min_m2.round(2) >= dm2.round(2)
7528
+ # If not large array, then retain pattern generating smallest skylight
7529
+ # area if ALL patterns >= residual target (deterministic sorting).
7530
+ fpm2.keep_if { |_, fm2| fm2[:m2].round(2) == min_m2.round(2) }
7531
+
7532
+ if fpm2.keys.include?("array")
7533
+ pattern = "array"
7534
+ elsif fpm2.keys.include?("strips")
7535
+ pattern = "strips"
7536
+ else # fpm2.keys.include?("strip")
7537
+ pattern = "strip"
7538
+ end
7055
7539
  else
7056
- fpm2.keep_if { |_, fm2| fm2[:m2].round(2) >= sm2.round(2) }
7057
-
7058
- pattern = fpm2.keys.first
7540
+ # Pick pattern offering greatest skylight area (deterministic sorting).
7541
+ fpm2.keep_if { |_, fm2| fm2[:m2].round(2) == max_m2.round(2) }
7542
+
7543
+ if fpm2.keys.include?("strip")
7544
+ pattern = "strip"
7545
+ elsif fpm2.keys.include?("strips")
7546
+ pattern = "strips"
7547
+ else # fpm2.keys.include?("array")
7548
+ pattern = "array"
7549
+ end
7059
7550
  end
7060
7551
  end
7061
7552
 
@@ -7086,55 +7577,162 @@ module OSut
7086
7577
  end
7087
7578
  end
7088
7579
 
7089
- # Skylight size contraction if overshot (e.g. -13.2% if overshot by +13.2%).
7090
- # This is applied on a surface/pattern basis; individual skylight sizes may
7091
- # vary from one surface to the next, depending on respective patterns.
7580
+ # Delete incomplete sets (same as rejected if 'voided').
7581
+ sets.reject! { |set| set.key?(:void) }
7582
+ sets.select! { |set| set.key?(:pattern) }
7583
+ return empty("sets (3)", mth, WRN, rm2) if sets.empty?
7584
+
7585
+ # Skylight size contraction if overshot (e.g. scale down by -13% if > +13%).
7586
+ # Applied on a surface/pattern basis: individual skylight sizes may vary
7587
+ # from one surface to the next, depending on respective patterns.
7588
+
7589
+ # First, skip whole sets altogether if their total m2 < (skm2 - sm2). Only
7590
+ # considered if significant discrepancies vs average set skylight m2.
7591
+ sbm2 = 0
7592
+
7593
+ sets.each do |set|
7594
+ sbm2 += set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7595
+ end
7596
+
7597
+ avm2 = sbm2 / sets.size
7598
+
7599
+ if skm2.round(2) > sm2.round(2)
7600
+ sets.reverse.each do |set|
7601
+ break unless skm2.round(2) > sm2.round(2)
7602
+
7603
+ stm2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7604
+ next unless stm2 < 0.75 * avm2
7605
+ next unless stm2.round(2) < (skm2 - sm2).round(2)
7606
+
7607
+ skm2 -= stm2
7608
+ set[:void] = true
7609
+ end
7610
+ end
7611
+
7612
+ sets.reject! { |set| set.key?(:void) }
7613
+ return empty("sets (4)", mth, WRN, rm2) if sets.empty?
7614
+
7615
+ # Size contraction: round 1: low-hanging fruit.
7092
7616
  if skm2.round(2) > sm2.round(2)
7093
7617
  ratio2 = 1 - (skm2 - sm2) / skm2
7094
7618
  ratio = Math.sqrt(ratio2)
7095
- skm2 *= ratio2
7096
7619
 
7097
7620
  sets.each do |set|
7098
- next if set.key?(:void)
7099
- next unless set.key?(:pattern)
7621
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7622
+ xr = set[:w]
7623
+ yr = set[:d]
7100
7624
 
7101
- pattern = set[:pattern]
7102
- next unless set.key?(pattern)
7625
+ if xr > w0
7626
+ xr = xr * ratio < w0 ? w0 : xr * ratio
7627
+ end
7103
7628
 
7104
- case pattern
7105
- when "array" # equally adjust both width and depth
7106
- xr = set[:w] * ratio
7107
- yr = set[:d] * ratio
7108
- dyr = set[:d] - yr
7109
-
7110
- set[:w ] = xr
7111
- set[:d ] = yr
7112
- set[:w0] = set[:w] + gap
7113
- set[:d0] = set[:d] + gap
7114
- set[:dY] += dyr / 2
7115
- when "strips" # adjust depth
7116
- xr2 = set[:w] * ratio2
7117
-
7118
- set[:w ] = xr2
7119
- set[:w0] = set[:w] + gap
7120
- else # "strip", adjust width
7121
- yr2 = set[:d] * ratio2
7122
- dyr = set[:d] - yr2
7123
-
7124
- set[:d ] = yr2
7125
- set[:d0] = set[:w] + gap
7126
- set[:dY] += dyr / 2
7629
+ if yr > w0
7630
+ yr = yr * ratio < w0 ? w0 : yr * ratio
7127
7631
  end
7632
+
7633
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
7634
+ next if xm2.round(2) == am2.round(2)
7635
+
7636
+ set[:dY] += (set[:d] - yr) / 2
7637
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
7638
+ set[:w ] = xr
7639
+ set[:d ] = yr
7640
+ set[:w0] = set[:w] + gap
7641
+ set[:d0] = set[:d] + gap
7642
+
7643
+ skm2 -= (am2 - xm2)
7128
7644
  end
7129
7645
  end
7130
7646
 
7131
- # Generate skylight well roofs for attics & plenums.
7647
+ # Size contraction: round 2: prioritize larger sets.
7648
+ adm2 = 0
7649
+
7650
+ sets.each_with_index do |set|
7651
+ next if set[:w].round(2) <= w0
7652
+ next if set[:d].round(2) <= w0
7653
+
7654
+ adm2 += set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7655
+ end
7656
+
7657
+ if skm2.round(2) > sm2.round(2) && adm2.round(2) > sm2.round(2)
7658
+ ratio2 = 1 - (adm2 - sm2) / adm2
7659
+ ratio = Math.sqrt(ratio2)
7660
+
7661
+ sets.each do |set|
7662
+ next if set[:w].round(2) <= w0
7663
+ next if set[:d].round(2) <= w0
7664
+
7665
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7666
+ xr = set[:w]
7667
+ yr = set[:d]
7668
+
7669
+ if xr > w0
7670
+ xr = xr * ratio < w0 ? w0 : xr * ratio
7671
+ end
7672
+
7673
+ if yr > w0
7674
+ yr = yr * ratio < w0 ? w0 : yr * ratio
7675
+ end
7676
+
7677
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
7678
+ next if xm2.round(2) == am2.round(2)
7679
+
7680
+ set[:dY] += (set[:d] - yr) / 2
7681
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
7682
+ set[:w ] = xr
7683
+ set[:d ] = yr
7684
+ set[:w0] = set[:w] + gap
7685
+ set[:d0] = set[:d] + gap
7686
+
7687
+ skm2 -= (am2 - xm2)
7688
+ adm2 -= (am2 - xm2)
7689
+ end
7690
+ end
7691
+
7692
+ # Size contraction: round 3: Resort to sizes < requested w0.
7693
+ if skm2.round(2) > sm2.round(2)
7694
+ ratio2 = 1 - (skm2 - sm2) / skm2
7695
+ ratio = Math.sqrt(ratio2)
7696
+
7697
+ sets.each do |set|
7698
+ break unless skm2.round(2) > sm2.round(2)
7699
+
7700
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7701
+ xr = set[:w]
7702
+ yr = set[:d]
7703
+
7704
+ if xr > gap4
7705
+ xr = xr * ratio < gap4 ? gap4 : xr * ratio
7706
+ end
7707
+
7708
+ if yr > gap4
7709
+ yr = yr * ratio < gap4 ? gap4 : yr * ratio
7710
+ end
7711
+
7712
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
7713
+ next if xm2.round(2) == am2.round(2)
7714
+
7715
+ set[:dY] += (set[:d] - yr) / 2
7716
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
7717
+ set[:w ] = xr
7718
+ set[:d ] = yr
7719
+ set[:w0] = set[:w] + gap
7720
+ set[:d0] = set[:d] + gap
7721
+
7722
+ skm2 -= (am2 - xm2)
7723
+ end
7724
+ end
7725
+
7726
+ # Log warning if unable to entirely contract skylight dimensions.
7727
+ if skm2.round(2) > sm2.round(2)
7728
+ log(WRN, "Skylights slightly oversized (#{mth})")
7729
+ end
7730
+
7731
+ # Generate skylight well vertices for roofs, attics & plenums.
7132
7732
  [attics, plenums].each do |greniers|
7133
7733
  k = greniers == attics ? :attic : :plenum
7134
7734
 
7135
7735
  greniers.each do |spce, grenier|
7136
- ti = grenier[:t]
7137
-
7138
7736
  grenier[:roofs].each do |roof|
7139
7737
  sts = sets
7140
7738
  sts = sts.select { |st| st.key?(k) }
@@ -7150,15 +7748,15 @@ module OSut
7150
7748
  sts = sts.select { |st| st[:ld].key?(roof) }
7151
7749
  next if sts.empty?
7152
7750
 
7153
- # If successful, 'genInserts' returns extended roof surface vertices,
7154
- # including leader lines to support cutouts. The final selection is
7751
+ # If successful, 'genInserts' returns extended ROOF surface vertices,
7752
+ # including leader lines to support cutouts. The method also generates
7753
+ # new roof inserts. See key:value pair :vts. The FINAL go/no-go is
7155
7754
  # contingent to successfully inserting corresponding room ceiling
7156
- # inserts (vis-à-vis attic/plenum floor below). The method also
7157
- # generates new roof inserts. See key:value pair :vts.
7755
+ # inserts (vis-à-vis attic/plenum floor below).
7158
7756
  vz = genInserts(roof, sts)
7159
- next if vz.empty? # TODO log error if empty
7757
+ next if vz.empty?
7160
7758
 
7161
- roof.setVertices(ti.inverse * vz)
7759
+ roof.setVertices(vz)
7162
7760
  end
7163
7761
  end
7164
7762
  end
@@ -7178,14 +7776,15 @@ module OSut
7178
7776
 
7179
7777
  room = rooms[space]
7180
7778
  grenier = greniers[spce]
7181
- ti = grenier[:t]
7182
- t0 = room[:t]
7779
+ ti = grenier[:ti]
7780
+ t0 = room[:t0]
7183
7781
  stz = []
7184
7782
 
7185
7783
  ceiling[:roofs].each do |roof|
7186
7784
  sts = sets
7187
7785
 
7188
7786
  sts = sts.select { |st| st.key?(k) }
7787
+ sts = sts.select { |st| st.key?(:pattern) }
7189
7788
  sts = sts.select { |st| st.key?(:clng) }
7190
7789
  sts = sts.select { |st| st.key?(:cm2) }
7191
7790
  sts = sts.select { |st| st.key?(:roof) }
@@ -7206,104 +7805,109 @@ module OSut
7206
7805
 
7207
7806
  next if stz.empty?
7208
7807
 
7209
- # Vertically-cast set roof :vtx onto ceiling.
7210
- stz.each do |st|
7211
- cvtx = cast(ti * st[:vtx], t0 * tile.vertices, ray)
7212
- st[:cvtx] = t0.inverse * cvtx
7213
- end
7214
-
7215
- # Extended ceiling vertices.
7216
- vertices = genExtendedVertices(tile, stz, :cvtx)
7217
- next if vertices.empty?
7218
-
7219
- # Reset ceiling and adjacent floor vertices.
7220
- tile.setVertices(t0.inverse * vertices)
7221
- floor.setVertices(ti.inverse * vertices.to_a.reverse)
7222
-
7223
7808
  # Add new roof inserts & skylights for the (now) toplit space.
7224
7809
  stz.each_with_index do |st, i|
7225
- sub = {}
7226
- sub[:type ] = "Skylight"
7227
- sub[:width ] = st[:w] - f2
7228
- sub[:height] = st[:d] - f2
7229
- sub[:sill ] = gap / 2
7230
- sub[:frame ] = frame if frame
7810
+ sub = {}
7811
+ sub[:type ] = "Skylight"
7812
+ sub[:frame] = frame if frame
7813
+ sub[:sill ] = gap / 2
7231
7814
 
7232
7815
  st[:vts].each do |id, vt|
7233
- roof = OpenStudio::Model::Surface.new(t0.inverse * vt, mdl)
7816
+ roof = OpenStudio::Model::Surface.new(t0.inverse * (ti * vt), mdl)
7234
7817
  roof.setSpace(space)
7235
- roof.setName("#{i}:#{id}:#{space.nameString}")
7818
+ roof.setName("#{id}:#{space.nameString}")
7236
7819
 
7237
7820
  # Generate well walls.
7238
- v0 = roof.vertices
7239
7821
  vX = cast(roof, tile, ray)
7240
- s0 = getSegments(v0)
7241
- sX = getSegments(vX)
7822
+ s0 = segments(t0 * roof.vertices)
7823
+ sX = segments(t0 * vX)
7242
7824
 
7243
7825
  s0.each_with_index do |sg, j|
7244
- sg0 = sg.to_a
7245
- sgX = sX[j].to_a
7246
- vec = OpenStudio::Point3dVector.new
7826
+ sg0 = sg
7827
+ sgX = sX[j]
7828
+ vec = OpenStudio::Point3dVector.new
7247
7829
  vec << sg0.first
7248
7830
  vec << sg0.last
7249
7831
  vec << sgX.last
7250
7832
  vec << sgX.first
7251
7833
 
7252
- grenier_wall = OpenStudio::Model::Surface.new(vec, mdl)
7834
+ v_grenier = ti.inverse * vec
7835
+ v_room = (t0.inverse * vec).to_a.reverse
7836
+
7837
+ grenier_wall = OpenStudio::Model::Surface.new(v_grenier, mdl)
7253
7838
  grenier_wall.setSpace(spce)
7254
- grenier_wall.setName("#{id}:#{j}:#{spce.nameString}")
7839
+ grenier_wall.setName("#{id}:#{i}:#{j}:#{spce.nameString}")
7255
7840
 
7256
- room_wall = OpenStudio::Model::Surface.new(vec.to_a.reverse, mdl)
7841
+ room_wall = OpenStudio::Model::Surface.new(v_room, mdl)
7257
7842
  room_wall.setSpace(space)
7258
- room_wall.setName("#{id}:#{j}:#{space.nameString}")
7843
+ room_wall.setName("#{id}:#{i}:#{j}:#{space.nameString}")
7259
7844
 
7260
7845
  grenier_wall.setAdjacentSurface(room_wall)
7261
7846
  room_wall.setAdjacentSurface(grenier_wall)
7262
7847
  end
7263
7848
 
7264
- # Add individual skylights.
7265
- addSubs(roof, [sub])
7849
+ # Add individual skylights. Independently of the set layout (rows x
7850
+ # cols), individual roof inserts may be deeper than wider (or
7851
+ # vice-versa). Adapt skylight width vs depth accordingly.
7852
+ if st[:d].round(2) > st[:w].round(2)
7853
+ sub[:width ] = st[:d] - f2
7854
+ sub[:height] = st[:w] - f2
7855
+ else
7856
+ sub[:width ] = st[:w] - f2
7857
+ sub[:height] = st[:d] - f2
7858
+ end
7859
+
7860
+ sub[:id] = roof.nameString
7861
+ addSubs(roof, sub, false, true, true)
7266
7862
  end
7267
7863
  end
7864
+
7865
+ # Vertically-cast set roof :vtx onto ceiling.
7866
+ stz.each do |st|
7867
+ st[:cvtx] = t0.inverse * cast(ti * st[:vtx], t0 * tile.vertices, ray)
7868
+ end
7869
+
7870
+ # Extended ceiling vertices.
7871
+ vertices = genExtendedVertices(tile, stz, :cvtx)
7872
+ next if vertices.empty?
7873
+
7874
+ # Reset ceiling and adjacent floor vertices.
7875
+ tile.setVertices(vertices)
7876
+ floor.setVertices(ti.inverse * (t0 * vertices).to_a.reverse)
7268
7877
  end
7269
7878
 
7270
- # New direct roof loop. No overlaps, so no need for relative space
7271
- # coordinate adjustments.
7879
+ # Loop through 'direct' roof surfaces of rooms to toplit (no attics or
7880
+ # plenums). No overlaps, so no relative space coordinate adjustments.
7272
7881
  rooms.each do |space, room|
7273
7882
  room[:roofs].each do |roof|
7274
- sets.each_with_index do |set, i|
7275
- next if set.key?(:clng)
7276
- next unless set.key?(:box)
7277
- next unless set.key?(:roof)
7278
- next unless set.key?(:cols)
7279
- next unless set.key?(:rows)
7280
- next unless set.key?(:d)
7281
- next unless set.key?(:w)
7282
- next unless set.key?(:tight)
7283
- next unless set[:roof] == roof
7284
-
7285
- tight = set[:tight]
7286
-
7287
- d1 = set[:d] - f2
7288
- w1 = set[:w] - f2
7289
-
7290
- # Y-axis 'height' of the roof, once re/aligned.
7291
- # TODO: retrieve st[:out], +efficient
7292
- y = alignedHeight(set[:box])
7293
- dY = set[:dY] if set[:dY]
7294
-
7295
- set[:rows].times.each do |j|
7883
+ sets.each_with_index do |st, i|
7884
+ next unless st.key?(:roof)
7885
+ next unless st[:roof] == roof
7886
+ next if st.key?(:clng)
7887
+ next unless st.key?(:box)
7888
+ next unless st.key?(:cols)
7889
+ next unless st.key?(:rows)
7890
+ next unless st.key?(:d)
7891
+ next unless st.key?(:w)
7892
+ next unless st.key?(:dY)
7893
+
7894
+ w1 = st[:w ] - f2
7895
+ d1 = st[:d ] - f2
7896
+ dY = st[:dY]
7897
+
7898
+ st[:rows].times.each do |j|
7296
7899
  sub = {}
7297
7900
  sub[:type ] = "Skylight"
7298
- sub[:count ] = set[:cols]
7901
+ sub[:count ] = st[:cols]
7299
7902
  sub[:width ] = w1
7300
7903
  sub[:height ] = d1
7301
7904
  sub[:frame ] = frame if frame
7302
- sub[:id ] = "set #{i+1}:#{j+1}"
7905
+ sub[:id ] = "#{roof.nameString}:#{i}:#{j}"
7303
7906
  sub[:sill ] = dY + j * (2 * dY + d1)
7304
- sub[:r_buffer] = set[:dX] if set[:dX]
7305
- sub[:l_buffer] = set[:dX] if set[:dX]
7306
- addSubs(roof, [sub])
7907
+ sub[:r_buffer] = st[:dX] if st[:dX]
7908
+ sub[:l_buffer] = st[:dX] if st[:dX]
7909
+
7910
+ addSubs(roof, sub, false, true, true)
7307
7911
  end
7308
7912
  end
7309
7913
  end