tbd 3.4.1 → 3.4.3

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.
@@ -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
@@ -1924,7 +1924,7 @@ module OSut
1924
1924
  # isolation) to determine whether an UNOCCUPIED space should have its
1925
1925
  # envelope insulated ("plenum") or not ("attic").
1926
1926
  #
1927
- # In contrast to OpenStudio-Standards' "space_plenum?", this method
1927
+ # In contrast to OpenStudio-Standards' "space_plenum?", the method below
1928
1928
  # strictly returns FALSE if a space is indeed "partofTotalFloorArea". It
1929
1929
  # also returns FALSE if the space is a vestibule. Otherwise, it needs more
1930
1930
  # information to determine if such an UNOCCUPIED space is indeed a
@@ -1933,7 +1933,7 @@ module OSut
1933
1933
  # CASE A: it includes the substring "plenum" (case insensitive) in its
1934
1934
  # spaceType's name, or in the latter's standardsSpaceType string;
1935
1935
  #
1936
- # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops: OR
1936
+ # CASE B: "isPlenum" == TRUE in an OpenStudio model WITH HVAC airloops; OR
1937
1937
  #
1938
1938
  # CASE C: its zone holds an 'inactive' thermostat (i.e. can't extract valid
1939
1939
  # setpoints) in an OpenStudio model with setpoint temperatures.
@@ -2054,7 +2054,7 @@ module OSut
2054
2054
  res[:heating] = nil
2055
2055
  res[:cooling] = nil
2056
2056
  elsif cnd.downcase == "semiheated"
2057
- res[:heating] = 15.0 if res[:heating].nil?
2057
+ res[:heating] = 14.0 if res[:heating].nil?
2058
2058
  res[:cooling] = nil
2059
2059
  elsif cnd.downcase.include?("conditioned")
2060
2060
  # "nonresconditioned", "resconditioned" or "indirectlyconditioned"
@@ -2090,6 +2090,60 @@ module OSut
2090
2090
  ok
2091
2091
  end
2092
2092
 
2093
+ ##
2094
+ # Validates whether a space can be considered as REFRIGERATED.
2095
+ #
2096
+ # @param space [OpenStudio::Model::Space] a space
2097
+ #
2098
+ # @return [Bool] whether space is considered REFRIGERATED
2099
+ # @return [false] if invalid input (see logs)
2100
+ def refrigerated?(space = nil)
2101
+ mth = "OSut::#{__callee__}"
2102
+ cl = OpenStudio::Model::Space
2103
+ tg0 = "refrigerated"
2104
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
2105
+
2106
+ # 1. First check OSut's REFRIGERATED status.
2107
+ status = space.additionalProperties.getFeatureAsString(tg0)
2108
+
2109
+ unless status.empty?
2110
+ status = status.get
2111
+ return status if [true, false].include?(status)
2112
+
2113
+ log(ERR, "Unknown #{space.nameString} REFRIGERATED #{status} (#{mth})")
2114
+ end
2115
+
2116
+ # 2. Else, compare design heating/cooling setpoints.
2117
+ stps = setpoints(space)
2118
+ return false unless stps[:heating].nil?
2119
+ return false if stps[:cooling].nil?
2120
+ return true if stps[:cooling] < 15
2121
+
2122
+ false
2123
+ end
2124
+
2125
+ ##
2126
+ # Validates whether a space can be considered as SEMIHEATED as per NECB 2020
2127
+ # 1.2.1.2. 2): design heating setpoint < 15°C (and non-REFRIGERATED).
2128
+ #
2129
+ # @param space [OpenStudio::Model::Space] a space
2130
+ #
2131
+ # @return [Bool] whether space is considered SEMIHEATED
2132
+ # @return [false] if invalid input (see logs)
2133
+ def semiheated?(space = nil)
2134
+ mth = "OSut::#{__callee__}"
2135
+ cl = OpenStudio::Model::Space
2136
+ return mismatch("space", space, cl, mth, DBG, false) unless space.is_a?(cl)
2137
+ return false if refrigerated?(space)
2138
+
2139
+ stps = setpoints(space)
2140
+ return false unless stps[:cooling].nil?
2141
+ return false if stps[:heating].nil?
2142
+ return true if stps[:heating] < 15
2143
+
2144
+ false
2145
+ end
2146
+
2093
2147
  ##
2094
2148
  # Generates an HVAC availability schedule.
2095
2149
  #
@@ -2256,9 +2310,9 @@ module OSut
2256
2310
  # ---- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---- #
2257
2311
  # This final set of utilities targets OpenStudio geometry. Many of the
2258
2312
  # following geometry methods rely on Boost as an OpenStudio dependency.
2259
- # As per Boost requirements, points (e.g. polygons) must first be 'aligned':
2313
+ # As per Boost requirements, points (e.g. vertical polygon) must be 'aligned':
2260
2314
  # - first rotated/tilted as to lay flat along XY plane (Z-axis ~= 0)
2261
- # - initial Z-axis values are represented as Y-axis values
2315
+ # - initial Z-axis values now become Y-axis values
2262
2316
  # - points with the lowest X-axis values are 'aligned' along X-axis (0)
2263
2317
  # - points with the lowest Z-axis values are 'aligned' along Y-axis (0)
2264
2318
  # - for several Boost methods, points must be clockwise in sequence
@@ -2945,9 +2999,16 @@ module OSut
2945
2999
  a1b2 = b2 - a1
2946
3000
  xa1b1 = a.cross(a1b1)
2947
3001
  xa1b2 = a.cross(a1b2)
3002
+ xa1b1.normalize
3003
+ xa1b2.normalize
3004
+ xab.normalize
2948
3005
  return nil unless xab.cross(xa1b1).length.round(4) < TOL2
2949
3006
  return nil unless xab.cross(xa1b2).length.round(4) < TOL2
2950
3007
 
3008
+ # Reset.
3009
+ xa1b1 = a.cross(a1b1)
3010
+ xa1b2 = a.cross(a1b2)
3011
+
2951
3012
  # Both segment endpoints can't be 'behind' point.
2952
3013
  return nil if a.dot(a1b1) < 0 && a.dot(a1b2) < 0
2953
3014
 
@@ -3001,20 +3062,23 @@ module OSut
3001
3062
  end
3002
3063
 
3003
3064
  ##
3004
- # Determines if pre-'aligned' OpenStudio 3D points are listed clockwise.
3065
+ # Validates whether OpenStudio 3D points are listed clockwise, assuming points
3066
+ # have been pre-'aligned' - not just flattened along XY (i.e. Z = 0).
3005
3067
  #
3006
- # @param pts [OpenStudio::Point3dVector] 3D points
3068
+ # @param pts [OpenStudio::Point3dVector] pre-aligned 3D points
3007
3069
  #
3008
3070
  # @return [Bool] whether sequence is clockwise
3009
3071
  # @return [false] if invalid input (see logs)
3010
3072
  def clockwise?(pts = nil)
3011
3073
  mth = "OSut::#{__callee__}"
3012
3074
  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)
3075
+ return invalid("3+ points" , mth, 1, DBG, false) if pts.size < 3
3076
+ return invalid("flat points", mth, 1, DBG, false) unless xyz?(pts, :z)
3016
3077
 
3017
- OpenStudio.pointInPolygon(pts.first, pts, TOL)
3078
+ n = OpenStudio.getOutwardNormal(pts)
3079
+ return invalid("polygon", mth, 1, DBG, false) if n.empty?
3080
+
3081
+ n.get.z > 0 ? false : true
3018
3082
  end
3019
3083
 
3020
3084
  ##
@@ -3144,8 +3208,8 @@ module OSut
3144
3208
  # polygon. In addition to basic OpenStudio polygon tests (e.g. all points
3145
3209
  # sharing the same 3D plane, non-self-intersecting), the method can
3146
3210
  # 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.
3211
+ # Returned vector can also be 'aligned', as well as in UpperLeftCorner (ULC),
3212
+ # BottomLeftCorner (BLC), in clockwise (or counterclockwise) sequence.
3149
3213
  #
3150
3214
  # @param pts [Set<OpenStudio::Point3d>] 3D points
3151
3215
  # @param vx [Bool] whether to check for convexity
@@ -3162,7 +3226,7 @@ module OSut
3162
3226
  v = OpenStudio::Point3dVector.new
3163
3227
  vx = false unless [true, false].include?(vx)
3164
3228
  uq = false unless [true, false].include?(uq)
3165
- co = true unless [true, false].include?(co)
3229
+ co = false unless [true, false].include?(co)
3166
3230
 
3167
3231
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3168
3232
  # Exit if mismatched/invalid arguments.
@@ -3174,7 +3238,7 @@ module OSut
3174
3238
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
3175
3239
  # Minimum 3 points?
3176
3240
  p3 = getNonCollinears(pts, 3)
3177
- return empty("polygon", mth, ERR, v) if p3.size < 3
3241
+ return empty("polygon (non-collinears < 3)", mth, ERR, v) if p3.size < 3
3178
3242
 
3179
3243
  # Coplanar?
3180
3244
  pln = OpenStudio::Plane.new(p3)
@@ -3273,7 +3337,7 @@ module OSut
3273
3337
  return mismatch("point", p0, cl, mth, DBG, false) unless p0.is_a?(cl)
3274
3338
 
3275
3339
  n = OpenStudio.getOutwardNormal(s)
3276
- return false if n.empty?
3340
+ return invalid("plane/normal", mth, 2, DBG, false) if n.empty?
3277
3341
 
3278
3342
  n = n.get
3279
3343
  pl = OpenStudio::Plane.new(s.first, n)
@@ -3299,23 +3363,21 @@ module OSut
3299
3363
  mpV = scalar(mid - p0, 1000)
3300
3364
  p1 = p0 + mpV
3301
3365
  ctr = 0
3302
- pts = []
3303
3366
 
3304
3367
  # Skip if ~collinear.
3305
- next if (mpV.cross(segment.last - segment.first).length).round(4) < TOL2
3368
+ next if mpV.cross(segment.last - segment.first).length.round(4) < TOL2
3306
3369
 
3307
3370
  segments.each do |sg|
3308
3371
  intersect = getLineIntersection([p0, p1], sg)
3309
3372
  next unless intersect
3310
3373
 
3311
- # One of the polygon vertices?
3374
+ # Skip test altogether if one of the polygon vertices.
3312
3375
  if holds?(s, intersect)
3313
- next if holds?(pts, intersect)
3314
-
3315
- pts << intersect
3376
+ ctr = 0
3377
+ break
3378
+ else
3379
+ ctr += 1
3316
3380
  end
3317
-
3318
- ctr += 1
3319
3381
  end
3320
3382
 
3321
3383
  next if ctr.zero?
@@ -3334,56 +3396,88 @@ module OSut
3334
3396
  # @return [Bool] whether 2 polygons are parallel
3335
3397
  # @return [false] if invalid input (see logs)
3336
3398
  def parallel?(p1 = nil, p2 = nil)
3337
- p1 = poly(p1, false, true, false)
3338
- p2 = poly(p2, false, true, false)
3399
+ p1 = poly(p1, false, true)
3400
+ p2 = poly(p2, false, true)
3339
3401
  return false if p1.empty?
3340
3402
  return false if p2.empty?
3341
3403
 
3342
- p1 = getNonCollinears(p1, 3)
3343
- p2 = getNonCollinears(p2, 3)
3344
- return false if p1.empty?
3345
- return false if p2.empty?
3404
+ n1 = OpenStudio.getOutwardNormal(p1)
3405
+ n2 = OpenStudio.getOutwardNormal(p2)
3406
+ return false if n1.empty?
3407
+ return false if n2.empty?
3408
+
3409
+ n1.get.dot(n2.get).abs > 0.99
3410
+ end
3411
+
3412
+ ##
3413
+ # Validates whether a polygon can be considered a valid 'roof' surface, as per
3414
+ # ASHRAE 90.1 & Canadian NECBs, i.e. outward normal within 60° from vertical
3415
+ #
3416
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3417
+ #
3418
+ # @return [Bool] if considered a roof surface
3419
+ # @return [false] if invalid input (see logs)
3420
+ def roof?(pts = nil)
3421
+ ray = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
3422
+ dut = Math.cos(60 * Math::PI / 180)
3423
+ pts = poly(pts, false, true, true)
3424
+ return false if pts.empty?
3346
3425
 
3347
- pl1 = OpenStudio::Plane.new(p1)
3348
- pl2 = OpenStudio::Plane.new(p2)
3426
+ dot = ray.dot(OpenStudio.getOutwardNormal(pts).get)
3427
+ return false if dot.round(2) <= 0
3428
+ return true if dot.round(2) == 1
3349
3429
 
3350
- pl1.outwardNormal.dot(pl2.outwardNormal).abs > 0.99
3430
+ dot.round(4) >= dut.round(4)
3351
3431
  end
3352
3432
 
3353
3433
  ##
3354
- # Validates whether a polygon faces upwards.
3434
+ # Validates whether a polygon faces upwards, harmonized with OpenStudio
3435
+ # Utilities' "alignZPrime" function.
3355
3436
  #
3356
3437
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3357
3438
  #
3358
3439
  # @return [Bool] if facing upwards
3359
3440
  # @return [false] if invalid input (see logs)
3360
3441
  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)
3442
+ ray = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
3443
+ pts = poly(pts, false, true, true)
3366
3444
  return false if pts.empty?
3367
3445
 
3368
- OpenStudio::Plane.new(pts).outwardNormal.dot(up) > 0.99
3446
+ OpenStudio.getOutwardNormal(pts).get.dot(ray) > 0.99
3369
3447
  end
3370
3448
 
3371
3449
  ##
3372
- # Validates whether a polygon faces downwards.
3450
+ # Validates whether a polygon faces downwards, harmonized with OpenStudio
3451
+ # Utilities' "alignZPrime" function.
3373
3452
  #
3374
3453
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3375
3454
  #
3376
3455
  # @return [Bool] if facing downwards
3377
3456
  # @return [false] if invalid input (see logs)
3378
3457
  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)
3458
+ ray = OpenStudio::Point3d.new(0,0,-1) - OpenStudio::Point3d.new(0,0,0)
3459
+ pts = poly(pts, false, true, true)
3381
3460
  return false if pts.empty?
3382
3461
 
3383
- pts = getNonCollinears(pts, 3)
3462
+ OpenStudio.getOutwardNormal(pts).get.dot(ray) > 0.99
3463
+ end
3464
+
3465
+ ##
3466
+ # Validates whether surface can be considered 'sloped' (i.e. not ~flat, as per
3467
+ # OpenStudio Utilities' "alignZPrime"). A vertical polygon returns true.
3468
+ #
3469
+ # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
3470
+ #
3471
+ # @return [Bool] whether surface is sloped
3472
+ # @return [false] if invalid input (see logs)
3473
+ def sloped?(pts = nil)
3474
+ mth = "OSut::#{__callee__}"
3475
+ pts = poly(pts, false, true, true)
3384
3476
  return false if pts.empty?
3477
+ return false if facingUp?(pts)
3478
+ return false if facingDown?(pts)
3385
3479
 
3386
- OpenStudio::Plane.new(pts).outwardNormal.dot(lo) > 0.99
3480
+ true
3387
3481
  end
3388
3482
 
3389
3483
  ##
@@ -3454,6 +3548,15 @@ module OSut
3454
3548
 
3455
3549
  p1.each { |p0| return false unless pointWithinPolygon?(p0, p2) }
3456
3550
 
3551
+ # Although p2 points may lie ALONG p1, none may lie entirely WITHIN p1.
3552
+ p2.each { |p0| return false if pointWithinPolygon?(p0, p1, true) }
3553
+
3554
+ # p1 segment mid-points must not lie OUTSIDE of p2.
3555
+ getSegments(p1).each do |sg|
3556
+ mp = midpoint(sg.first, sg.last)
3557
+ return false unless pointWithinPolygon?(mp, p2)
3558
+ end
3559
+
3457
3560
  entirely = false unless [true, false].include?(entirely)
3458
3561
  return true unless entirely
3459
3562
 
@@ -3600,7 +3703,7 @@ module OSut
3600
3703
  # The following +/- replicates the same solution, based on:
3601
3704
  # https://stackoverflow.com/a/65832417
3602
3705
  p0 = p2.first
3603
- pl = OpenStudio::Plane.new(getNonCollinears(p2, 3))
3706
+ pl = OpenStudio::Plane.new(p2)
3604
3707
  n = pl.outwardNormal
3605
3708
  return face if n.dot(ray).abs < TOL
3606
3709
 
@@ -3962,6 +4065,8 @@ module OSut
3962
4065
  box << OpenStudio::Point3d.new(p1.x, p1.y, p1.z)
3963
4066
  box << OpenStudio::Point3d.new(p2.x, p2.y, p2.z)
3964
4067
  box << OpenStudio::Point3d.new(p3.x, p3.y, p3.z)
4068
+ box = getNonCollinears(box, 4)
4069
+ return bkp unless box.size == 4
3965
4070
 
3966
4071
  box = blc(box)
3967
4072
  return bkp unless rectangular?(box)
@@ -4007,6 +4112,9 @@ module OSut
4007
4112
  box << mpoints.first
4008
4113
  box << mpoints.last
4009
4114
  box << plane.project(mpoints.last)
4115
+ box = getNonCollinears(box).to_a
4116
+ return bkp unless box.size == 4
4117
+
4010
4118
  box = clockwise?(box) ? blc(box.reverse) : blc(box)
4011
4119
  return bkp unless rectangular?(box)
4012
4120
  return bkp unless fits?(box, pts)
@@ -4071,6 +4179,7 @@ module OSut
4071
4179
  out = triadBox(OpenStudio::Point3dVector.new([m0, p1, p2]))
4072
4180
  next if out.empty?
4073
4181
  next unless fits?(out, pts)
4182
+ next if fits?(pts, out)
4074
4183
 
4075
4184
  area = OpenStudio.getArea(out)
4076
4185
  next if area.empty?
@@ -4096,6 +4205,7 @@ module OSut
4096
4205
  out = triadBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4097
4206
  next if out.empty?
4098
4207
  next unless fits?(out, pts)
4208
+ next if fits?(pts, out)
4099
4209
 
4100
4210
  area = OpenStudio.getArea(out)
4101
4211
  next if area.empty?
@@ -4128,6 +4238,7 @@ module OSut
4128
4238
  out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4129
4239
  next if out.empty?
4130
4240
  next unless fits?(out, pts)
4241
+ next if fits?(pts, out)
4131
4242
 
4132
4243
  area = OpenStudio.getArea(box)
4133
4244
  next if area.empty?
@@ -4157,6 +4268,7 @@ module OSut
4157
4268
  out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4158
4269
  next if out.empty?
4159
4270
  next unless fits?(out, pts)
4271
+ next if fits?(pts, out)
4160
4272
 
4161
4273
  area = OpenStudio.getArea(box)
4162
4274
  next if area.empty?
@@ -4191,6 +4303,7 @@ module OSut
4191
4303
  out = medialBox(OpenStudio::Point3dVector.new([p0, p1, p2]))
4192
4304
  next if out.empty?
4193
4305
  next unless fits?(out, pts)
4306
+ next if fits?(pts, out)
4194
4307
 
4195
4308
  area = OpenStudio.getArea(out)
4196
4309
  next if area.empty?
@@ -4214,25 +4327,29 @@ module OSut
4214
4327
 
4215
4328
  ##
4216
4329
  # 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 ...
4330
+ # largest bounded box. Input polygon vertex Z-axis values must equal 0, and be
4331
+ # counterclockwise. A Hash is returned with 6x key:value pairs ...
4218
4332
  # set: realigned (cloned) polygon vertices, box: its bounded box (wrt to :set),
4219
4333
  # bbox: its bounding box, t: its translation transformation, r: its rotation
4220
4334
  # transformation, and o: the origin coordinates of its axis of rotation. First,
4221
4335
  # cloned polygon vertices are rotated so the longest axis of symmetry of its
4222
4336
  # 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,
4337
+ # side (of the bounded box) nearest to grid origin (0,0,0). If the axis of
4338
+ # symmetry of the bounded box is already parallel to the X-axis, then the
4339
+ # rotation step is skipped (unless force == true). Whether rotated or not,
4224
4340
  # polygon vertices are then translated as to ensure one or more vertices are
4225
4341
  # 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
4342
+ # Y-axis (no vertices with negative X or Y coordinate values). To unalign
4343
+ # the returned set of vertices (or its bounded box, or its bounding box),
4344
+ # first inverse the translation transformation, then inverse the rotation
4229
4345
  # transformation.
4230
4346
  #
4231
4347
  # @param pts [Set<OpenStudio::Point3d>] OpenStudio 3D points
4348
+ # @param force [Bool] whether to force rotation for aligned yet narrow boxes
4232
4349
  #
4233
4350
  # @return [Hash] :set, :box, :bbox, :t, :r & :o
4234
4351
  # @return [Hash] :set, :box, :bbox, :t, :r & :o (nil) if invalid (see logs)
4235
- def getRealignedFace(pts = nil)
4352
+ def getRealignedFace(pts = nil, force = false)
4236
4353
  mth = "OSut::#{__callee__}"
4237
4354
  out = { set: nil, box: nil, bbox: nil, t: nil, r: nil, o: nil }
4238
4355
  pts = poly(pts, false, true)
@@ -4240,6 +4357,13 @@ module OSut
4240
4357
  return invalid("aligned plane", mth, 1, DBG, out) unless xyz?(pts, :z)
4241
4358
  return invalid("clockwise pts", mth, 1, DBG, out) if clockwise?(pts)
4242
4359
 
4360
+ # Optionally force rotation so bounded box ends up wider than taller.
4361
+ # Strongly suggested for flat surfaces like roofs (see 'sloped?').
4362
+ unless [true, false].include?(force)
4363
+ log(DBG, "Ignoring force input (#{mth})")
4364
+ force = false
4365
+ end
4366
+
4243
4367
  o = OpenStudio::Point3d.new(0, 0, 0)
4244
4368
  w = width(pts)
4245
4369
  h = height(pts)
@@ -4263,7 +4387,6 @@ module OSut
4263
4387
  sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h if square?(box)
4264
4388
  sgs = sgs.sort_by { |sg, s| s[:l ] }.first(2).to_h unless square?(box)
4265
4389
  sgs = sgs.sort_by { |sg, s| s[:mo] }.first(2).to_h unless square?(box)
4266
-
4267
4390
  sg0 = sgs.values[0]
4268
4391
  sg1 = sgs.values[1]
4269
4392
 
@@ -4283,6 +4406,13 @@ module OSut
4283
4406
  axis = OpenStudio::Point3d.new(origin.x, origin.y , d) - origin
4284
4407
  angle = OpenStudio::getAngle(right, seg)
4285
4408
  angle = -angle if north.dot(seg) < 0
4409
+
4410
+ # Skip rotation if bounded box is already aligned along XY grid (albeit
4411
+ # 'narrow'), i.e. if the angle is 90°.
4412
+ if angle.round(3) == (Math::PI/2).round(3)
4413
+ angle = 0 unless force
4414
+ end
4415
+
4286
4416
  r = OpenStudio.createRotation(origin, axis, angle)
4287
4417
  pts = to_p3Dv(r.inverse * pts)
4288
4418
  box = to_p3Dv(r.inverse * box)
@@ -4291,13 +4421,13 @@ module OSut
4291
4421
  xy = OpenStudio::Point3d.new(origin.x + dX, origin.y + dY, 0)
4292
4422
  origin2 = xy - origin
4293
4423
  t = OpenStudio.createTranslation(origin2)
4294
- set = t.inverse * pts
4295
- box = t.inverse * box
4424
+ set = to_p3Dv(t.inverse * pts)
4425
+ box = to_p3Dv(t.inverse * box)
4296
4426
  bbox = outline([set])
4297
4427
 
4298
- out[:set ] = set
4299
- out[:box ] = box
4300
- out[:bbox] = bbox
4428
+ out[:set ] = blc(set)
4429
+ out[:box ] = blc(box)
4430
+ out[:bbox] = blc(bbox)
4301
4431
  out[:t ] = t
4302
4432
  out[:r ] = r
4303
4433
  out[:o ] = origin
@@ -4309,14 +4439,21 @@ module OSut
4309
4439
  # Returns 'width' of a set of OpenStudio 3D points, once re/aligned.
4310
4440
  #
4311
4441
  # @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned
4442
+ # @param force [Bool] whether to force rotation of (narrow) bounded box
4312
4443
  #
4313
- # @return [Float] width along X-axis, once re/aligned
4444
+ # @return [Float] width al©ong X-axis, once re/aligned
4314
4445
  # @return [0.0] if invalid inputs
4315
- def alignedWidth(pts = nil)
4446
+ def alignedWidth(pts = nil, force = false)
4447
+ mth = "OSut::#{__callee__}"
4316
4448
  pts = poly(pts, false, true, true, true)
4317
4449
  return 0 if pts.size < 2
4318
4450
 
4319
- pts = getRealignedFace(pts)[:set]
4451
+ unless [true, false].include?(force)
4452
+ log(DBG, "Ignoring force input (#{mth})")
4453
+ force = false
4454
+ end
4455
+
4456
+ pts = getRealignedFace(pts, force)[:set]
4320
4457
  return 0 if pts.size < 2
4321
4458
 
4322
4459
  pts.max_by(&:x).x - pts.min_by(&:x).x
@@ -4326,57 +4463,77 @@ module OSut
4326
4463
  # Returns 'height' of a set of OpenStudio 3D points, once re/aligned.
4327
4464
  #
4328
4465
  # @param pts [Set<OpenStudio::Point3d>] 3D points, once re/aligned
4466
+ # @param force [Bool] whether to force rotation of (narrow) bounded box
4329
4467
  #
4330
4468
  # @return [Float] height along Y-axis, once re/aligned
4331
4469
  # @return [0.0] if invalid inputs
4332
- def alignedHeight(pts = nil)
4333
- pts = pts = poly(pts, false, true, true, true)
4470
+ def alignedHeight(pts = nil, force = false)
4471
+ mth = "OSut::#{__callee__}"
4472
+ pts = poly(pts, false, true, true, true)
4334
4473
  return 0 if pts.size < 2
4335
4474
 
4336
- pts = getRealignedFace(pts)[:set]
4475
+ unless [true, false].include?(force)
4476
+ log(DBG, "Ignoring force input (#{mth})")
4477
+ force = false
4478
+ end
4479
+
4480
+ pts = getRealignedFace(pts, force)[:set]
4337
4481
  return 0 if pts.size < 2
4338
4482
 
4339
4483
  pts.max_by(&:y).y - pts.min_by(&:y).y
4340
4484
  end
4341
4485
 
4342
4486
  ##
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.
4354
- #
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
4358
- #
4359
- # @return [Integer] number of successfully-generated anchors (check logs)
4360
- def genAnchors(s = nil, set = [], tag = :vtx)
4487
+ # Identifies 'leader line anchors', i.e. specific 3D points of a (larger) set
4488
+ # (e.g. delineating a larger, parent polygon), each anchor linking the BLC
4489
+ # corner of one or more (smaller) subsets (free-floating within the parent)
4490
+ # - see follow-up 'genInserts'. Subsets may hold several 'tagged' vertices
4491
+ # (e.g. :box, :cbox). By default, the solution seeks to anchor subset :box
4492
+ # vertices. Users can select other tags, e.g. tag == :cbox. The solution
4493
+ # minimally validates individual subsets (e.g. no self-intersecting polygons,
4494
+ # coplanarity, no inter-subset conflicts, must fit within larger set).
4495
+ # Potential leader lines cannot intersect each other, similarly tagged subsets
4496
+ # or (parent) polygon edges. For highly-articulated cases (e.g. a narrow
4497
+ # parent polygon with multiple concavities, holding multiple subsets), such
4498
+ # leader line conflicts are likely unavoidable. It is recommended to first
4499
+ # sort subsets (e.g. areas), given the solution's 'first-come-first-served'
4500
+ # policy. Subsets without valid leader lines are ultimately ignored (check
4501
+ # for new set :void keys, see error logs). The larger set of points is
4502
+ # expected to be in space coordinates - not building or site coordinates,
4503
+ # while subset points are expected to 'fit?' in the larger set.
4504
+ #
4505
+ # @param s [Set<OpenStudio::Point3d>] a (larger) parent set of points
4506
+ # @param [Array<Hash>] set a collection of (smaller) sequenced points
4507
+ # @option [Symbol] tag sequence of subset vertices to target
4508
+ #
4509
+ # @return [Integer] number of successfully anchored subsets (see logs)
4510
+ def genAnchors(s = nil, set = [], tag = :box)
4361
4511
  mth = "OSut::#{__callee__}"
4362
- dZ = nil
4363
- t = nil
4512
+ n = 0
4364
4513
  id = s.respond_to?(:nameString) ? "#{s.nameString}: " : ""
4365
4514
  pts = poly(s)
4366
- n = 0
4367
- return n if pts.empty?
4515
+ return invalid("#{id} polygon", mth, 1, DBG, n) if pts.empty?
4368
4516
  return mismatch("set", set, Array, mth, DBG, n) unless set.respond_to?(:to_a)
4369
4517
 
4370
- set = set.to_a
4518
+ origin = OpenStudio::Point3d.new(0,0,0)
4519
+ zenith = OpenStudio::Point3d.new(0,0,1)
4520
+ ray = zenith - origin
4521
+ set = set.to_a
4371
4522
 
4372
- # Validate individual sets. Purge surface-specific leader line anchors.
4523
+ # Validate individual subsets. Purge surface-specific leader line anchors.
4373
4524
  set.each_with_index do |st, i|
4374
- str1 = id + "set ##{i+1}"
4525
+ str1 = id + "subset ##{i+1}"
4375
4526
  str2 = str1 + " #{tag.to_s}"
4376
4527
  return mismatch(str1, st, Hash, mth, DBG, n) unless st.respond_to?(:key?)
4377
4528
  return hashkey( str1, st, tag, mth, DBG, n) unless st.key?(tag)
4378
4529
  return empty("#{str2} vertices", mth, DBG, n) if st[tag].empty?
4379
4530
 
4531
+ if st.key?(:out)
4532
+ return hashkey( str1, st, :t, mth, DBG, n) unless st.key?(:t)
4533
+ return hashkey( str1, st, :ti, mth, DBG, n) unless st.key?(:ti)
4534
+ return hashkey( str1, st, :t0, mth, DBG, n) unless st.key?(:t0)
4535
+ end
4536
+
4380
4537
  stt = poly(st[tag])
4381
4538
  return invalid("#{str2} polygon", mth, 0, DBG, n) if stt.empty?
4382
4539
  return invalid("#{str2} gap", mth, 0, DBG, n) unless fits?(stt, pts, true)
@@ -4391,32 +4548,52 @@ module OSut
4391
4548
  end
4392
4549
  end
4393
4550
 
4394
- if facingUp?(pts)
4395
- if xyz?(pts, :z)
4396
- dZ = 0
4551
+ set.each_with_index do |st, i|
4552
+ # When a subset already holds a leader line anchor (from an initial call
4553
+ # to 'genAnchors'), it inherits key :out - a Hash holding (among others) a
4554
+ # 'realigned' set of points (by default a 'realigned' :box). The latter is
4555
+ # typically generated from an outdoor-facing roof (e.g. when called from
4556
+ # 'lights'). Subsequent calls to 'genAnchors' may send (as first
4557
+ # argument) a corresponding ceiling tile below (also from 'addSkylights').
4558
+ # Roof vs ceiling may neither share alignment transformation nor space
4559
+ # site transformation identities. All subsequent calls to 'genAnchors'
4560
+ # shall recover the :out points, apply a succession of de/alignments and
4561
+ # transformations in sync , and overwrite tagged points.
4562
+ #
4563
+ # Although 'genAnchors' and 'genInserts' have both been developed to
4564
+ # support anchor insertions in other cases (e.g. bay window in a wall),
4565
+ # variables and terminology here continue pertain to roofs, ceilings,
4566
+ # skylights and wells - less abstract, simpler to follow.
4567
+ if st.key?(:out)
4568
+ ti = st[:ti ] # unoccupied attic/plenum space site transformation
4569
+ t0 = st[:t0 ] # occupied space site transformation
4570
+ t = st[:t ] # initial alignment transformation of roof surface
4571
+ o = st[:out]
4572
+ tpts = t0.inverse * (ti * (t * (o[:r] * (o[:t] * o[:set]))))
4573
+ tpts = cast(tpts, pts, ray)
4574
+
4575
+ st[tag] = tpts
4397
4576
  else
4398
- dZ = pts.first.z
4399
- pts = flatten(pts).to_a
4577
+ st[:t] = OpenStudio::Transformation.alignFace(pts) unless st.key?(:t)
4578
+ tpts = st[:t].inverse * st[tag]
4579
+ o = getRealignedFace(tpts, true)
4580
+ tpts = st[:t] * (o[:r] * (o[:t] * o[:set]))
4581
+
4582
+ st[:out] = o
4583
+ st[tag ] = tpts
4400
4584
  end
4401
- else
4402
- t = OpenStudio::Transformation.alignFace(pts)
4403
- pts = t.inverse * pts
4404
4585
  end
4405
4586
 
4406
- # Set leader lines anchors. Gather candidate leader line anchors; select
4407
- # anchor with shortest distance to first vertex of 'tagged' set.
4587
+ # Identify candidate leader line anchors for each subset.
4408
4588
  set.each_with_index do |st, i|
4409
4589
  candidates = []
4410
- break if st[:ld].key?(s)
4411
-
4412
- stt = dZ ? flatten(st[tag]).to_a : t.inverse * st[tag]
4413
- p1 = stt.first
4590
+ tpts = st[tag]
4414
4591
 
4415
- pts.each_with_index do |pt, k|
4416
- ld = [pt, p1]
4417
- nb = 0
4592
+ pts.each do |pt|
4593
+ ld = [pt, tpts.first]
4594
+ nb = 0
4418
4595
 
4419
- # Check for intersections between leader line and polygon edges.
4596
+ # Check for intersections between leader line and larger polygon edges.
4420
4597
  getSegments(pts).each do |sg|
4421
4598
  break unless nb.zero?
4422
4599
  next if holds?(sg, pt)
@@ -4424,43 +4601,34 @@ module OSut
4424
4601
  nb += 1 if lineIntersects?(sg, ld)
4425
4602
  end
4426
4603
 
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|
4604
+ # Check for intersections between candidate leader line vs other subsets.
4605
+ set.each do |other|
4431
4606
  break unless nb.zero?
4432
- next if i == j
4607
+ next if st == other
4433
4608
 
4434
- ost = dZ ? flatten(other[tag]).to_a : t.inverse * other[tag]
4435
- sgj = getSegments(ost)
4609
+ ost = other[tag]
4436
4610
 
4437
- sgj.each { |sg| nb += 1 if lineIntersects?(ld, sg) }
4611
+ getSegments(ost).each { |sg| nb += 1 if lineIntersects?(ld, sg) }
4438
4612
  end
4439
4613
 
4440
- next unless nb.zero?
4441
-
4442
4614
  # ... and previous leader lines (first come, first serve basis).
4443
- set.each_with_index do |other, j|
4615
+ set.each do |other|
4444
4616
  break unless nb.zero?
4445
- next if i == j
4617
+ next if st == other
4618
+ next unless other.key?(:ld)
4446
4619
  next unless other[:ld].key?(s)
4447
4620
 
4448
4621
  ost = other[tag]
4449
- pj = ost.first
4450
- old = other[:ld][s]
4451
- ldj = dZ ? flatten([ old, pj ]) : t.inverse * [ old, pj ]
4622
+ pld = other[:ld][s]
4623
+ next if same?(pld, pt)
4452
4624
 
4453
- unless same?(old, pt)
4454
- nb += 1 if lineIntersects?(ld, ldj)
4455
- end
4625
+ nb += 1 if lineIntersects?(ld, [pld, ost.first])
4456
4626
  end
4457
4627
 
4458
- next unless nb.zero?
4459
-
4460
4628
  # Finally, check for self-intersections.
4461
- getSegments(stt).each do |sg|
4629
+ getSegments(tpts).each do |sg|
4462
4630
  break unless nb.zero?
4463
- next if holds?(sg, p1)
4631
+ next if holds?(sg, tpts.first)
4464
4632
 
4465
4633
  nb += 1 if lineIntersects?(sg, ld)
4466
4634
  nb += 1 if (sg.first - sg.last).cross(ld.first - ld.last).length < TOL
@@ -4471,18 +4639,13 @@ module OSut
4471
4639
 
4472
4640
  if candidates.empty?
4473
4641
  str = id + "set ##{i+1}"
4474
- log(ERR, "#{str}: unable to anchor #{tag} leader line (#{mth})")
4642
+ log(WRN, "#{str}: unable to anchor #{tag} leader line (#{mth})")
4475
4643
  st[:void] = true
4476
4644
  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
-
4645
+ p0 = candidates.sort_by { |pt| (pt - tpts.first).length }.first
4485
4646
  n += 1
4647
+
4648
+ st[:ld][s] = p0
4486
4649
  end
4487
4650
  end
4488
4651
 
@@ -4490,17 +4653,19 @@ module OSut
4490
4653
  end
4491
4654
 
4492
4655
  ##
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).
4656
+ # Extends (larger) polygon vertices to circumscribe one or more (smaller)
4657
+ # subsets of vertices, based on previously-generated 'leader line' anchors.
4658
+ # The solution minimally validates individual subsets (e.g. no
4659
+ # self-intersecting polygons, coplanarity, no inter-subset conflicts, must fit
4660
+ # within larger set). Valid leader line anchors (set key :ld) need to be
4661
+ # generated prior to calling the method - see 'genAnchors'. Subsets may hold
4662
+ # several 'tag'ged vertices (e.g. :box, :vtx). By default, the solution
4663
+ # seeks to anchor subset :vtx vertices. Users can select other tags, e.g.
4664
+ # tag == :box).
4500
4665
  #
4501
4666
  # @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
4667
+ # @param [Array<Hash>] set a collection of (smaller) sequenced vertices
4668
+ # @option set [Hash] :ld a polygon-specific leader line anchors
4504
4669
  # @option [Symbol] tag sequence of set vertices to target
4505
4670
  #
4506
4671
  # @return [OpenStudio::Point3dVector] extended vertices (see logs if empty)
@@ -4519,8 +4684,9 @@ module OSut
4519
4684
 
4520
4685
  # Validate individual sets.
4521
4686
  set.each_with_index do |st, i|
4522
- str1 = id + "set ##{i+1}"
4687
+ str1 = id + "subset ##{i+1}"
4523
4688
  str2 = str1 + " #{tag.to_s}"
4689
+ next if st.key?(:void) && st[:void]
4524
4690
  return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?)
4525
4691
  return hashkey( str1, st, tag, mth, DBG, a) unless st.key?(tag)
4526
4692
  return empty("#{str2} vertices", mth, DBG, a) if st[tag].empty?
@@ -4540,7 +4706,8 @@ module OSut
4540
4706
  v << pt
4541
4707
 
4542
4708
  # Loop through each valid set; concatenate circumscribing vertices.
4543
- set.each_with_index do |st, i|
4709
+ set.each do |st|
4710
+ next if st.key?(:void) && st[:void]
4544
4711
  next unless same?(st[:ld][s], pt)
4545
4712
  next unless st.key?(tag)
4546
4713
 
@@ -4553,21 +4720,22 @@ module OSut
4553
4720
  end
4554
4721
 
4555
4722
  ##
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
4723
+ # Generates (1D or 2D) arrays of (smaller) rectangular collection of points,
4724
+ # (e.g. arrays of polygon inserts) from subset parameters, within a (larger)
4725
+ # set (e.g. parent polygon). If successful, each subset inherits additional
4726
+ # key:value pairs: namely :vtx (collection of circumscribing vertices), and
4727
+ # :vts (collection of individual insert vertices). Valid leader line anchors
4728
+ # (set key :ld) need to be generated prior to calling the solution
4729
+ # - see 'genAnchors'.
4730
+ #
4731
+ # @param s [Set<OpenStudio::Point3d>] a larger (parent) set of points
4732
+ # @param [Array<Hash>] set a collection of (smaller) sequenced vertices
4733
+ # @option set [Set<OpenStudio::Point3d>] :box bounding box of each subset
4734
+ # @option set [Hash] :ld a collection of leader line anchors
4567
4735
  # @option set [Integer] :rows (1) number of rows of inserts
4568
4736
  # @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
4737
+ # @option set [Numeric] :w0 width of individual inserts (wrt cols) min 0.4
4738
+ # @option set [Numeric] :d0 depth of individual inserts (wrt rows) min 0.4
4571
4739
  # @option set [Numeric] :dX (0) optional left/right X-axis buffer
4572
4740
  # @option set [Numeric] :dY (0) optional top/bottom Y-axis buffer
4573
4741
  #
@@ -4587,10 +4755,12 @@ module OSut
4587
4755
 
4588
4756
  # Validate/reset individual set collections.
4589
4757
  set.each_with_index do |st, i|
4590
- str1 = id + "set ##{i+1}"
4758
+ str1 = id + "subset ##{i+1}"
4759
+ next if st.key?(:void) && st[:void]
4591
4760
  return mismatch(str1, st, Hash, mth, DBG, a) unless st.respond_to?(:key?)
4592
4761
  return hashkey( str1, st, :box, mth, DBG, a) unless st.key?(:box)
4593
4762
  return hashkey( str1, st, :ld, mth, DBG, a) unless st.key?(:ld)
4763
+ return hashkey( str1, st, :out, mth, DBG, a) unless st.key?(:out)
4594
4764
 
4595
4765
  str2 = str1 + " anchor"
4596
4766
  ld = st[:ld]
@@ -4599,7 +4769,7 @@ module OSut
4599
4769
  return mismatch(str2, ld[s], cl, mth, DBG, a) unless ld[s].is_a?(cl)
4600
4770
 
4601
4771
  # Ensure each set bounding box is safely within larger polygon boundaries.
4602
- # TO DO: In line with related addSkylights "TO DO", expand method to
4772
+ # @todo: In line with related addSkylights' @todo, expand solution to
4603
4773
  # safely handle 'side' cutouts (i.e. no need for leader lines). In
4604
4774
  # so doing, boxes could eventually align along surface edges.
4605
4775
  str3 = str1 + " box"
@@ -4659,9 +4829,10 @@ module OSut
4659
4829
  end
4660
4830
  end
4661
4831
 
4662
- # Flag conflicts between set bounding boxes. TO DO: ease up for ridges.
4832
+ # Flag conflicts between set bounding boxes. @todo: ease up for ridges.
4663
4833
  set.each_with_index do |st, i|
4664
4834
  bx = st[:box]
4835
+ next if st.key?(:void) && st[:void]
4665
4836
 
4666
4837
  set.each_with_index do |other, j|
4667
4838
  next if i == j
@@ -4673,37 +4844,18 @@ module OSut
4673
4844
  end
4674
4845
  end
4675
4846
 
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]))
4847
+ t = OpenStudio::Transformation.alignFace(pts)
4848
+ rpts = t.inverse * pts
4702
4849
 
4850
+ # Loop through each 'valid' subset (i.e. linking a valid leader line anchor),
4851
+ # generate subset vertex array based on user-provided specs.
4852
+ set.each_with_index do |st, i|
4853
+ str = id + "subset ##{i+1}"
4854
+ next if st.key?(:void) && st[:void]
4703
4855
 
4856
+ o = st[:out]
4704
4857
  vts = {} # collection of individual (named) polygon insert vertices
4705
4858
  vtx = [] # sequence of circumscribing polygon vertices
4706
-
4707
4859
  bx = o[:set]
4708
4860
  w = width(bx) # overall sandbox width
4709
4861
  d = height(bx) # overall sandbox depth
@@ -4712,7 +4864,7 @@ module OSut
4712
4864
  cols = st[:cols] # number of array columns
4713
4865
  rows = st[:rows] # number of array rows
4714
4866
  x = st[:w0 ] # width of individual insert
4715
- y = st[:d0 ] # depth of indivual insert
4867
+ y = st[:d0 ] # depth of individual insert
4716
4868
  gX = 0 # gap between insert columns
4717
4869
  gY = 0 # gap between insert rows
4718
4870
 
@@ -4790,15 +4942,7 @@ module OSut
4790
4942
  vec << OpenStudio::Point3d.new(xC + x, yC , 0)
4791
4943
 
4792
4944
  # 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
4945
+ vts[nom] = to_p3Dv(t * ulc(o[:r] * (o[:t] * vec)))
4802
4946
 
4803
4947
  # Add reverse vertices, circumscribing each insert.
4804
4948
  vec.reverse!
@@ -4814,18 +4958,8 @@ module OSut
4814
4958
  end
4815
4959
  end
4816
4960
 
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
4961
  st[:vts] = vts
4828
- st[:vtx] = vtx
4962
+ st[:vtx] = to_p3Dv(t * (o[:r] * (o[:t] * vtx)))
4829
4963
  end
4830
4964
 
4831
4965
  # Extended vertex sequence of the larger polygon.
@@ -4835,9 +4969,11 @@ module OSut
4835
4969
  ##
4836
4970
  # Returns an array of OpenStudio space surfaces or subsurfaces that match
4837
4971
  # criteria, e.g. exterior, north-east facing walls in hotel "lobby". Note that
4838
- # 'sides' rely on space coordinates (not absolute model coordinates). Also,
4972
+ # 'sides' rely on space coordinates (not building or site coordinates). Also,
4839
4973
  # 'sides' are exclusive (not inclusive), e.g. walls strictly north-facing or
4840
4974
  # strictly east-facing would not be returned if 'sides' holds [:north, :east].
4975
+ # No outside boundary condition filters if 'boundary' argument == "all". No
4976
+ # surface type filters if 'type' argument == "all".
4841
4977
  #
4842
4978
  # @param spaces [Set<OpenStudio::Model::Space>] target spaces
4843
4979
  # @param boundary [#to_s] OpenStudio outside boundary condition
@@ -4845,7 +4981,7 @@ module OSut
4845
4981
  # @param sides [Set<Symbols>] direction keys, e.g. :north (see OSut::SIDZ)
4846
4982
  #
4847
4983
  # @return [Array<OpenStudio::Model::Surface>] surfaces (may be empty, no logs)
4848
- def facets(spaces = [], boundary = "Outdoors", type = "Wall", sides = [])
4984
+ def facets(spaces = [], boundary = "all", type = "all", sides = [])
4849
4985
  spaces = spaces.is_a?(OpenStudio::Model::Space) ? [spaces] : spaces
4850
4986
  spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
4851
4987
  return [] if spaces.empty?
@@ -4859,8 +4995,8 @@ module OSut
4859
4995
  return [] if boundary.empty?
4860
4996
  return [] if type.empty?
4861
4997
 
4862
- # Filter sides. If sides is initially empty, return all surfaces of matching
4863
- # type and outside boundary condition.
4998
+ # Filter sides. If 'sides' is initially empty, return all surfaces of
4999
+ # matching type and outside boundary condition.
4864
5000
  unless sides.empty?
4865
5001
  sides = sides.select { |side| SIDZ.include?(side) }
4866
5002
  return [] if sides.empty?
@@ -4870,8 +5006,13 @@ module OSut
4870
5006
  return [] unless space.respond_to?(:setSpaceType)
4871
5007
 
4872
5008
  space.surfaces.each do |s|
4873
- next unless s.outsideBoundaryCondition.downcase == boundary
4874
- next unless s.surfaceType.downcase == type
5009
+ unless boundary == "all"
5010
+ next unless s.outsideBoundaryCondition.downcase == boundary
5011
+ end
5012
+
5013
+ unless type == "all"
5014
+ next unless s.surfaceType.downcase == type
5015
+ end
4875
5016
 
4876
5017
  if sides.empty?
4877
5018
  faces << s
@@ -4891,13 +5032,15 @@ module OSut
4891
5032
 
4892
5033
  # SubSurfaces?
4893
5034
  spaces.each do |space|
4894
- break unless faces.empty?
4895
-
4896
5035
  space.surfaces.each do |s|
4897
- next unless s.outsideBoundaryCondition.downcase == boundary
5036
+ unless boundary == "all"
5037
+ next unless s.outsideBoundaryCondition.downcase == boundary
5038
+ end
4898
5039
 
4899
5040
  s.subSurfaces.each do |sub|
4900
- next unless sub.subSurfaceType.downcase == type
5041
+ unless type == "all"
5042
+ next unless sub.subSurfaceType.downcase == type
5043
+ end
4901
5044
 
4902
5045
  if sides.empty?
4903
5046
  faces << sub
@@ -5012,7 +5155,9 @@ module OSut
5012
5155
  # Returns outdoor-facing, space-related roof surfaces. These include
5013
5156
  # outdoor-facing roofs of each space per se, as well as any outdoor-facing
5014
5157
  # roof surface of unoccupied spaces immediately above (e.g. plenums, attics)
5015
- # overlapping any of the ceiling surfaces of each space.
5158
+ # overlapping any of the ceiling surfaces of each space. It does not include
5159
+ # surfaces labelled as 'RoofCeiling', which do not comply with ASHRAE 90.1 or
5160
+ # NECB tilt criteria - see 'roof?'.
5016
5161
  #
5017
5162
  # @param spaces [Set<OpenStudio::Model::Space>] target spaces
5018
5163
  #
@@ -5028,12 +5173,12 @@ module OSut
5028
5173
 
5029
5174
  # Space-specific outdoor-facing roof surfaces.
5030
5175
  roofs = facets(spaces, "Outdoors", "RoofCeiling")
5176
+ roofs = roofs.select { |roof| roof?(roof) }
5031
5177
 
5032
- # Outdoor-facing roof surfaces of unoccupied plenums or attics above?
5033
5178
  spaces.each do |space|
5034
- # When multiple spaces are involved (e.g. plenums, attics), the target
5179
+ # When unoccupied spaces are involved (e.g. plenums, attics), the target
5035
5180
  # space may not share the same local transformation as the space(s) above.
5036
- # Fetching local transformation.
5181
+ # Fetching site transformation.
5037
5182
  t0 = transforms(space)
5038
5183
  next unless t0[:t]
5039
5184
 
@@ -5056,8 +5201,10 @@ module OSut
5056
5201
 
5057
5202
  ti = ti[:t]
5058
5203
 
5059
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
5204
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
5060
5205
  facets(other, "Outdoors", "RoofCeiling").each do |ruf|
5206
+ next unless roof?(ruf)
5207
+
5061
5208
  rvi = ti * ruf.vertices
5062
5209
  cst = cast(cv0, rvi, up)
5063
5210
  next unless overlaps?(cst, rvi, false)
@@ -5128,11 +5275,13 @@ module OSut
5128
5275
  # @option subs [#to_f] :r_buffer gap between sub/array and right corner
5129
5276
  # @option subs [#to_f] :l_buffer gap between sub/array and left corner
5130
5277
  # @param clear [Bool] whether to remove current sub surfaces
5278
+ # @param bound [Bool] whether to add subs wrt surface's bounded box
5279
+ # @param realign [Bool] whether to first realign bounded box
5131
5280
  # @param bfr [#to_f] safety buffer, to maintain near other edges
5132
5281
  #
5133
5282
  # @return [Bool] whether addition is successful
5134
5283
  # @return [false] if invalid input (see logs)
5135
- def addSubs(s = nil, subs = [], clear = false, bfr = 0.005)
5284
+ def addSubs(s = nil, subs = [], clear = false, bound = false, realign = false, bfr = 0.005)
5136
5285
  mth = "OSut::#{__callee__}"
5137
5286
  v = OpenStudio.openStudioVersion.split(".").join.to_i
5138
5287
  cl1 = OpenStudio::Model::Surface
@@ -5144,15 +5293,18 @@ module OSut
5144
5293
 
5145
5294
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5146
5295
  # 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)
5296
+ sbs = subs.is_a?(cl3) ? [subs] : subs
5297
+ sbs = sbs.respond_to?(:to_a) ? sbs.to_a : []
5298
+ return mismatch("surface", s, cl1, mth, DBG, no) unless s.is_a?(cl1)
5299
+ return mismatch("subs", subs, cl2, mth, DBG, no) if sbs.empty?
5149
5300
  return empty("surface points", mth, DBG, no) if poly(s).empty?
5150
5301
 
5151
- # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5152
- # Clear existing sub surfaces if requested.
5153
- nom = s.nameString
5154
- mdl = s.model
5302
+ subs = sbs
5303
+ nom = s.nameString
5304
+ mdl = s.model
5155
5305
 
5306
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5307
+ # Purge existing sub surfaces?
5156
5308
  unless [true, false].include?(clear)
5157
5309
  log(WRN, "#{nom}: Keeping existing sub surfaces (#{mth})")
5158
5310
  clear = false
@@ -5160,6 +5312,27 @@ module OSut
5160
5312
 
5161
5313
  s.subSurfaces.map(&:remove) if clear
5162
5314
 
5315
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5316
+ # Add sub surfaces with respect to base surface's bounded box? This is
5317
+ # often useful (in some cases necessary) with irregular or concave surfaces.
5318
+ # If true, sub surface parameters (e.g. height, offset, centreline) no
5319
+ # longer apply to the original surface 'bounding' box, but instead to its
5320
+ # largest 'bounded' box. This can be combined with the 'realign' parameter.
5321
+ unless [true, false].include?(bound)
5322
+ log(WRN, "#{nom}: Ignoring bounded box (#{mth})")
5323
+ bound = false
5324
+ end
5325
+
5326
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5327
+ # Force re-alignment of base surface (or its 'bounded' box)? False by
5328
+ # default (ideal for vertical/tilted walls & sloped roofs). If set to true
5329
+ # for a narrow wall for instance, an array of sub surfaces will be added
5330
+ # from bottom to top (rather from left to right).
5331
+ unless [true, false].include?(realign)
5332
+ log(WRN, "#{nom}: Ignoring realignment (#{mth})")
5333
+ realign = false
5334
+ end
5335
+
5163
5336
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5164
5337
  # Ensure minimum safety buffer.
5165
5338
  if bfr.respond_to?(:to_f)
@@ -5192,15 +5365,21 @@ module OSut
5192
5365
  s0 = poly(s, false, false, false, t, :ulc)
5193
5366
  s00 = nil
5194
5367
 
5195
- if facingUp?(s) || facingDown?(s) # TODO: redundant check?
5196
- s00 = getRealignedFace(s0)
5197
- return false unless s00[:set]
5368
+ # Adapt sandbox if user selects to 'bound' and/or 'realign'.
5369
+ if bound
5370
+ box = boundedBox(s0)
5198
5371
 
5199
- s0 = s00[:set]
5372
+ if realign
5373
+ s00 = getRealignedFace(box, true)
5374
+ return invalid("bound realignment", mth, 0, DBG, false) unless s00[:set]
5375
+ end
5376
+ elsif realign
5377
+ s00 = getRealignedFace(s0, false)
5378
+ return invalid("unbound realignment", mth, 0, DBG, false) unless s00[:set]
5200
5379
  end
5201
5380
 
5202
- max_x = width(s0)
5203
- max_y = height(s0)
5381
+ max_x = s00 ? width( s00[:set]) : width(s0)
5382
+ max_y = s00 ? height(s00[:set]) : height(s0)
5204
5383
  mid_x = max_x / 2
5205
5384
  mid_y = max_y / 2
5206
5385
 
@@ -5219,7 +5398,7 @@ module OSut
5219
5398
  sub[:type ] = trim(sub[:type])
5220
5399
  sub[:id ] = trim(sub[:id])
5221
5400
  sub[:type ] = type if sub[:type].empty?
5222
- sub[:id ] = "OSut|#{nom}|#{index}" if sub[:id ].empty?
5401
+ sub[:id ] = "OSut:#{nom}:#{index}" if sub[:id ].empty?
5223
5402
  sub[:count ] = 1 unless sub[:count ].respond_to?(:to_i)
5224
5403
  sub[:multiplier] = 1 unless sub[:multiplier].respond_to?(:to_i)
5225
5404
  sub[:count ] = sub[:count ].to_i
@@ -5271,8 +5450,10 @@ module OSut
5271
5450
  next if key == :frame
5272
5451
  next if key == :assembly
5273
5452
 
5274
- ok = value.respond_to?(:to_f)
5275
- return mismatch(key, value, Float, mth, DBG, no) unless ok
5453
+ unless value.respond_to?(:to_f)
5454
+ return mismatch(key, value, Float, mth, DBG, no)
5455
+ end
5456
+
5276
5457
  next if key == :centreline
5277
5458
 
5278
5459
  negative(key, mth, WRN) if value < 0
@@ -5329,27 +5510,27 @@ module OSut
5329
5510
  # Log/reset "height" if beyond min/max.
5330
5511
  if sub.key?(:height)
5331
5512
  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})")
5513
+ log(WRN, "Reset '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5514
+ sub[:height] = sub[:height].clamp(glass, max_height)
5515
+ log(WRN, "Height '#{id}' reset to #{sub[:height].round(3)}m (#{mth})")
5335
5516
  end
5336
5517
  end
5337
5518
 
5338
5519
  # Log/reset "head" height if beyond min/max.
5339
5520
  if sub.key?(:head)
5340
5521
  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})")
5522
+ log(WRN, "Reset '#{id}' head #{sub[:head].round(3)}m (#{mth})")
5523
+ sub[:head] = sub[:head].clamp(min_head, max_head)
5524
+ log(WRN, "Head '#{id}' reset to #{sub[:head].round(3)}m (#{mth})")
5344
5525
  end
5345
5526
  end
5346
5527
 
5347
5528
  # Log/reset "sill" height if beyond min/max.
5348
5529
  if sub.key?(:sill)
5349
5530
  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})")
5531
+ log(WRN, "Reset '#{id}' sill #{sub[:sill].round(3)}m (#{mth})")
5532
+ sub[:sill] = sub[:sill].clamp(min_sill, max_sill)
5533
+ log(WRN, "Sill '#{id}' reset to #{sub[:sill].round(3)}m (#{mth})")
5353
5534
  end
5354
5535
  end
5355
5536
 
@@ -5368,8 +5549,9 @@ module OSut
5368
5549
  log(ERR, "Skip: invalid '#{id}' head/sill combo (#{mth})")
5369
5550
  next
5370
5551
  else
5552
+ log(WRN, "(Re)set '#{id}' sill #{sub[:sill].round(3)}m (#{mth})")
5371
5553
  sub[:sill] = sill
5372
- log(WRN, "(Re)set '#{id}' sill height to #{sub[:sill]} m (#{mth})")
5554
+ log(WRN, "Sill '#{id}' (re)set to #{sub[:sill].round(3)}m (#{mth})")
5373
5555
  end
5374
5556
  end
5375
5557
 
@@ -5379,7 +5561,8 @@ module OSut
5379
5561
  height = sub[:head] - sub[:sill]
5380
5562
 
5381
5563
  if sub.key?(:height) && (sub[:height] - height).abs > TOL2
5382
- log(WRN, "(Re)set '#{id}' height to #{height} m (#{mth})")
5564
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5565
+ log(WRN, "Height '#{id}' (re)set to #{height.round(3)}m (#{mth})")
5383
5566
  end
5384
5567
 
5385
5568
  sub[:height] = height
@@ -5400,9 +5583,10 @@ module OSut
5400
5583
  log(ERR, "Skip: invalid '#{id}' head/height combo (#{mth})")
5401
5584
  next
5402
5585
  else
5586
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5403
5587
  sub[:sill ] = sill
5404
5588
  sub[:height] = height
5405
- log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
5589
+ log(WRN, "Height '#{id}' re(set) #{sub[:height].round(3)}m (#{mth})")
5406
5590
  end
5407
5591
  else
5408
5592
  sub[:sill] = sill
@@ -5428,9 +5612,10 @@ module OSut
5428
5612
  log(ERR, "Skip: invalid '#{id}' sill/height combo (#{mth})")
5429
5613
  next
5430
5614
  else
5615
+ log(WRN, "(Re)set '#{id}' height #{sub[:height].round(3)}m (#{mth})")
5431
5616
  sub[:head ] = head
5432
5617
  sub[:height] = height
5433
- log(WRN, "(Re)set '#{id}' height to #{sub[:height]} m (#{mth})")
5618
+ log(WRN, "Height '#{id}' reset to #{sub[:height].round(3)}m (#{mth})")
5434
5619
  end
5435
5620
  else
5436
5621
  sub[:head] = head
@@ -5459,9 +5644,9 @@ module OSut
5459
5644
  # Log/reset "width" if beyond min/max.
5460
5645
  if sub.key?(:width)
5461
5646
  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})")
5647
+ log(WRN, "Reset '#{id}' width #{sub[:width].round(3)}m (#{mth})")
5648
+ sub[:width] = sub[:width].clamp(glass, max_width)
5649
+ log(WRN, "Width '#{id}' reset to #{sub[:width].round(3)}m (#{mth})")
5465
5650
  end
5466
5651
  end
5467
5652
 
@@ -5471,7 +5656,7 @@ module OSut
5471
5656
 
5472
5657
  if sub[:count] < 1
5473
5658
  sub[:count] = 1
5474
- log(WRN, "Reset '#{id}' count to #{sub[:count]} (#{mth})")
5659
+ log(WRN, "Reset '#{id}' count to min 1 (#{mth})")
5475
5660
  end
5476
5661
  else
5477
5662
  sub[:count] = 1
@@ -5482,16 +5667,18 @@ module OSut
5482
5667
  # Log/reset if left-sided buffer under min jamb position.
5483
5668
  if sub.key?(:l_buffer)
5484
5669
  if sub[:l_buffer] < min_ljamb - TOL
5670
+ log(WRN, "Reset '#{id}' left buffer #{sub[:l_buffer].round(3)}m (#{mth})")
5485
5671
  sub[:l_buffer] = min_ljamb
5486
- log(WRN, "Reset '#{id}' left buffer to #{sub[:l_buffer]} m (#{mth})")
5672
+ log(WRN, "Left buffer '#{id}' reset to #{sub[:l_buffer].round(3)}m (#{mth})")
5487
5673
  end
5488
5674
  end
5489
5675
 
5490
5676
  # Log/reset if right-sided buffer beyond max jamb position.
5491
5677
  if sub.key?(:r_buffer)
5492
5678
  if sub[:r_buffer] > max_rjamb - TOL
5679
+ log(WRN, "Reset '#{id}' right buffer #{sub[:r_buffer].round(3)}m (#{mth})")
5493
5680
  sub[:r_buffer] = min_rjamb
5494
- log(WRN, "Reset '#{id}' right buffer to #{sub[:r_buffer]} m (#{mth})")
5681
+ log(WRN, "Right buffer '#{id}' reset to #{sub[:r_buffer].round(3)}m (#{mth})")
5495
5682
  end
5496
5683
  end
5497
5684
 
@@ -5511,15 +5698,15 @@ module OSut
5511
5698
  sub[:multiplier] = 0
5512
5699
  sub[:height ] = 0 if sub.key?(:height)
5513
5700
  sub[:width ] = 0 if sub.key?(:width)
5514
- log(ERR, "Skip: '#{id}' ratio ~0 (#{mth})")
5701
+ log(ERR, "Skip: ratio ~0 (#{mth})")
5515
5702
  next
5516
5703
  end
5517
5704
 
5518
5705
  # Log/reset if "ratio" beyond min/max?
5519
5706
  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})")
5707
+ log(WRN, "Reset ratio #{sub[:ratio].round(3)} (#{mth})")
5708
+ sub[:ratio] = sub[:ratio].clamp(min, max)
5709
+ log(WRN, "Ratio reset to #{sub[:ratio].round(3)} (#{mth})")
5523
5710
  end
5524
5711
 
5525
5712
  # Log/reset "count" unless 1.
@@ -5536,7 +5723,7 @@ module OSut
5536
5723
 
5537
5724
  if sub.key?(:l_buffer)
5538
5725
  if sub.key?(:centreline)
5539
- log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
5726
+ log(WRN, "Skip '#{id}' left buffer (vs centreline) (#{mth})")
5540
5727
  else
5541
5728
  x0 = sub[:l_buffer] - frame
5542
5729
  xf = x0 + w
@@ -5544,7 +5731,7 @@ module OSut
5544
5731
  end
5545
5732
  elsif sub.key?(:r_buffer)
5546
5733
  if sub.key?(:centreline)
5547
- log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
5734
+ log(WRN, "Skip '#{id}' right buffer (vs centreline) (#{mth})")
5548
5735
  else
5549
5736
  xf = max_x - sub[:r_buffer] + frame
5550
5737
  x0 = xf - w
@@ -5559,13 +5746,14 @@ module OSut
5559
5746
  sub[:multiplier] = 0
5560
5747
  sub[:height ] = 0 if sub.key?(:height)
5561
5748
  sub[:width ] = 0 if sub.key?(:width)
5562
- log(ERR, "Skip: invalid (ratio) width/centreline (#{mth})")
5749
+ log(ERR, "Skip '#{id}': invalid (ratio) width/centreline (#{mth})")
5563
5750
  next
5564
5751
  end
5565
5752
 
5566
5753
  if sub.key?(:width) && (sub[:width] - width).abs > TOL
5754
+ log(WRN, "Reset '#{id}' width (ratio) #{sub[:width].round(2)}m (#{mth})")
5567
5755
  sub[:width] = width
5568
- log(WRN, "Reset width (ratio) to #{sub[:width]} (#{mth})")
5756
+ log(WRN, "Width (ratio) '#{id}' reset to #{sub[:width].round(2)}m (#{mth})")
5569
5757
  end
5570
5758
 
5571
5759
  sub[:width] = width unless sub.key?(:width)
@@ -5583,12 +5771,13 @@ module OSut
5583
5771
  width = sub[:width] + frames
5584
5772
  gap = (max_x - n * width) / (n + 1)
5585
5773
  gap = sub[:offset] - width if sub.key?(:offset)
5586
- gap = 0 if gap < bfr
5774
+ gap = 0 if gap < buffer
5587
5775
  offset = gap + width
5588
5776
 
5589
5777
  if sub.key?(:offset) && (offset - sub[:offset]).abs > TOL
5778
+ log(WRN, "Reset '#{id}' sub offset #{sub[:offset].round(2)}m (#{mth})")
5590
5779
  sub[:offset] = offset
5591
- log(WRN, "Reset sub offset to #{sub[:offset]} m (#{mth})")
5780
+ log(WRN, "Sub offset (#{id}) reset to #{sub[:offset].round(2)}m (#{mth})")
5592
5781
  end
5593
5782
 
5594
5783
  sub[:offset] = offset unless sub.key?(:offset)
@@ -5600,7 +5789,7 @@ module OSut
5600
5789
 
5601
5790
  if sub.key?(:l_buffer)
5602
5791
  if sub.key?(:centreline)
5603
- log(WRN, "Skip #{id} left buffer (vs centreline) (#{mth})")
5792
+ log(WRN, "Skip '#{id}' left buffer (vs centreline) (#{mth})")
5604
5793
  else
5605
5794
  x0 = sub[:l_buffer] - frame
5606
5795
  xf = x0 + w
@@ -5608,7 +5797,7 @@ module OSut
5608
5797
  end
5609
5798
  elsif sub.key?(:r_buffer)
5610
5799
  if sub.key?(:centreline)
5611
- log(WRN, "Skip #{id} right buffer (vs centreline) (#{mth})")
5800
+ log(WRN, "Skip '#{id}' right buffer (vs centreline) (#{mth})")
5612
5801
  else
5613
5802
  xf = max_x - sub[:r_buffer] + frame
5614
5803
  x0 = xf - w
@@ -5617,7 +5806,7 @@ module OSut
5617
5806
  end
5618
5807
 
5619
5808
  # Too wide?
5620
- if x0 < bfr - TOL2 || xf > max_x - bfr - TOL2
5809
+ if x0 < buffer - TOL2 || xf > max_x - buffer - TOL2
5621
5810
  sub[:ratio ] = 0 if sub.key?(:ratio)
5622
5811
  sub[:count ] = 0
5623
5812
  sub[:multiplier] = 0
@@ -5633,10 +5822,9 @@ module OSut
5633
5822
 
5634
5823
  # Generate sub(s).
5635
5824
  sub[:count].times do |i|
5636
- name = "#{id}|#{i}"
5825
+ name = "#{id}:#{i}"
5637
5826
  fr = 0
5638
5827
  fr = sub[:frame].frameWidth if sub[:frame]
5639
-
5640
5828
  vec = OpenStudio::Point3dVector.new
5641
5829
  vec << OpenStudio::Point3d.new(pos, sub[:head], 0)
5642
5830
  vec << OpenStudio::Point3d.new(pos, sub[:sill], 0)
@@ -5647,34 +5835,41 @@ module OSut
5647
5835
  # Log/skip if conflict between individual sub and base surface.
5648
5836
  vc = vec
5649
5837
  vc = offset(vc, fr, 300) if fr > 0
5650
- ok = fits?(vc, s)
5651
5838
 
5652
- log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})") unless ok
5653
- break unless ok
5839
+ unless fits?(vc, s)
5840
+ log(ERR, "Skip '#{name}': won't fit in '#{nom}' (#{mth})")
5841
+ break
5842
+ end
5654
5843
 
5655
5844
  # Log/skip if conflicts with existing subs (even if same array).
5845
+ conflict = false
5846
+
5656
5847
  s.subSurfaces.each do |sb|
5657
5848
  nome = sb.nameString
5658
5849
  fd = sb.windowPropertyFrameAndDivider
5659
- fr = 0 if fd.empty?
5660
- fr = fd.get.frameWidth unless fd.empty?
5850
+ fr = fd.empty? ? 0 : fd.get.frameWidth
5661
5851
  vk = sb.vertices
5662
5852
  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
5853
+
5854
+ if overlaps?(vc, vk)
5855
+ log(ERR, "Skip '#{name}': overlaps '#{nome}' (#{mth})")
5856
+ conflict = true
5857
+ break
5858
+ end
5667
5859
  end
5668
5860
 
5669
- break unless ok
5861
+ break if conflict
5670
5862
 
5671
5863
  sb = OpenStudio::Model::SubSurface.new(vec, mdl)
5672
5864
  sb.setName(name)
5673
5865
  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
5866
+ sb.setConstruction(sub[:assembly]) if sub[:assembly]
5867
+ sb.setMultiplier(sub[:multiplier]) if sub[:multiplier] > 1
5868
+
5869
+ if sub[:frame] && sb.allowWindowPropertyFrameAndDivider
5870
+ sb.setWindowPropertyFrameAndDivider(sub[:frame])
5871
+ end
5872
+
5678
5873
  sb.setSurface(s)
5679
5874
 
5680
5875
  # Reset "pos" if array.
@@ -5685,32 +5880,15 @@ module OSut
5685
5880
  true
5686
5881
  end
5687
5882
 
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
5883
  ##
5708
5884
  # Returns the "gross roof area" above selected conditioned, occupied spaces.
5709
5885
  # This includes all roof surfaces of indirectly-conditioned, unoccupied spaces
5710
5886
  # like plenums (if located above any of the selected spaces). This also
5711
5887
  # includes roof surfaces of unconditioned or unenclosed spaces like attics, if
5712
5888
  # vertically-overlapping any ceiling of occupied spaces below; attic roof
5713
- # sections above uninsulated soffits are excluded, for instance.
5889
+ # sections above uninsulated soffits are excluded, for instance. It does not
5890
+ # include surfaces labelled as 'RoofCeiling', which do not comply with ASHRAE
5891
+ # 90.1 or NECB tilt criteria - see 'roof?'.
5714
5892
  def grossRoofArea(spaces = [])
5715
5893
  mth = "OSut::#{__callee__}"
5716
5894
  up = OpenStudio::Point3d.new(0,0,1) - OpenStudio::Point3d.new(0,0,0)
@@ -5721,6 +5899,7 @@ module OSut
5721
5899
  spaces = spaces.respond_to?(:to_a) ? spaces.to_a : []
5722
5900
  spaces = spaces.select { |space| space.is_a?(OpenStudio::Model::Space) }
5723
5901
  spaces = spaces.select { |space| space.partofTotalFloorArea }
5902
+ spaces = spaces.reject { |space| unconditioned?(space) }
5724
5903
  return invalid("spaces", mth, 1, DBG, 0) if spaces.empty?
5725
5904
 
5726
5905
  # The method is very similar to OpenStudio-Standards' :
@@ -5732,17 +5911,18 @@ module OSut
5732
5911
  #
5733
5912
  # ... yet differs with regards to attics with overhangs/soffits.
5734
5913
 
5735
- # Start with roof surfaces of occupied spaces.
5914
+ # Start with roof surfaces of occupied, conditioned spaces.
5736
5915
  spaces.each do |space|
5737
5916
  facets(space, "Outdoors", "RoofCeiling").each do |roof|
5738
5917
  next if rfs.key?(roof)
5918
+ next unless roof?(roof)
5739
5919
 
5740
5920
  rfs[roof] = {m2: roof.grossArea, m: space.multiplier}
5741
5921
  end
5742
5922
  end
5743
5923
 
5744
5924
  # Roof surfaces of unoccupied, conditioned spaces above (e.g. plenums)?
5745
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
5925
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
5746
5926
  spaces.each do |space|
5747
5927
  facets(space, "Surface", "RoofCeiling").each do |ceiling|
5748
5928
  floor = ceiling.adjacentSurface
@@ -5757,6 +5937,7 @@ module OSut
5757
5937
 
5758
5938
  facets(other, "Outdoors", "RoofCeiling").each do |roof|
5759
5939
  next if rfs.key?(roof)
5940
+ next unless roof?(roof)
5760
5941
 
5761
5942
  rfs[roof] = {m2: roof.grossArea, m: other.multiplier}
5762
5943
  end
@@ -5764,7 +5945,7 @@ module OSut
5764
5945
  end
5765
5946
 
5766
5947
  # Roof surfaces of unoccupied, unconditioned spaces above (e.g. attics)?
5767
- # TO DO: recursive call for stacked spaces as atria (via AirBoundaries).
5948
+ # @todo: recursive call for stacked spaces as atria (via AirBoundaries).
5768
5949
  spaces.each do |space|
5769
5950
  # When taking overlaps into account, the target space may not share the
5770
5951
  # same local transformation as the space(s) above.
@@ -5792,6 +5973,8 @@ module OSut
5792
5973
  ti = ti[:t]
5793
5974
 
5794
5975
  facets(other, "Outdoors", "RoofCeiling").each do |roof|
5976
+ next unless roof?(roof)
5977
+
5795
5978
  rvi = ti * roof.vertices
5796
5979
  cst = cast(cv0, rvi, up)
5797
5980
  next if cst.empty?
@@ -5799,7 +5982,7 @@ module OSut
5799
5982
  # The overlap calculation fails for roof and ceiling surfaces with
5800
5983
  # previously-added leader lines.
5801
5984
  #
5802
- # TODO: revise approach for attics ONCE skylight wells have been added.
5985
+ # @todo: revise approach for attics ONCE skylight wells have been added.
5803
5986
  olap = nil
5804
5987
  olap = overlap(cst, rvi, false)
5805
5988
  next if olap.empty?
@@ -5823,13 +6006,14 @@ module OSut
5823
6006
  end
5824
6007
 
5825
6008
  ##
5826
- # Identifies horizontal ridges between 2x sloped roof surfaces (same space).
5827
- # If successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an
6009
+ # Identifies horizontal ridges along 2x sloped (roof?) surfaces (same space).
6010
+ # The concept of 'sloped' is harmonized with OpenStudio's "alignZPrime". If
6011
+ # successful, the returned Array holds 'ridge' Hashes. Each Hash holds: an
5828
6012
  # :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.
6013
+ # (Array of 2x linked surfaces). Each surface may be linked to more than one
6014
+ # horizontal ridge.
5831
6015
  #
5832
- # @param roofs [Array<OpenStudio::Model::Surface>] target roof surfaces
6016
+ # @param roofs [Array<OpenStudio::Model::Surface>] target surfaces
5833
6017
  #
5834
6018
  # @return [Array] horizontal ridges (see logs if empty)
5835
6019
  def getHorizontalRidges(roofs = [])
@@ -5838,7 +6022,7 @@ module OSut
5838
6022
  return ridges unless roofs.is_a?(Array)
5839
6023
 
5840
6024
  roofs = roofs.select { |s| s.is_a?(OpenStudio::Model::Surface) }
5841
- roofs = roofs.select { |s| slopedRoof?(s) }
6025
+ roofs = roofs.select { |s| sloped?(s) }
5842
6026
 
5843
6027
  roofs.each do |roof|
5844
6028
  maxZ = roof.vertices.max_by(&:z).z
@@ -5873,7 +6057,7 @@ module OSut
5873
6057
  next unless ruf.space.get == space
5874
6058
 
5875
6059
  getSegments(ruf).each do |edg|
5876
- break if match
6060
+ break if match
5877
6061
  next unless same?(edge, edg) || same?(edge, edg.reverse)
5878
6062
 
5879
6063
  ridge[:roofs] << ruf
@@ -5887,50 +6071,180 @@ module OSut
5887
6071
  ridges
5888
6072
  end
5889
6073
 
6074
+ ##
6075
+ # Preselects ideal spaces to toplight, based on 'addSkylights' options and key
6076
+ # building model geometry attributes. Can be called from within 'addSkylights'
6077
+ # by setting :ration (opts key:value argument) to 'true' ('false' by default).
6078
+ # Alternatively, the method can be called prior to 'addSkylights'. The set of
6079
+ # filters stems from previous rounds of 'addSkylights' stress testing. It is
6080
+ # intended as an option to prune away less ideal candidate spaces (irregular,
6081
+ # smaller) in favour of (larger) candidates (notably with more suitable
6082
+ # roof geometries). This is key when dealing with attic and plenums, where
6083
+ # 'addSkylights' seeks to add skylight wells (relying on roof cut-outs and
6084
+ # leader lines). Another check/outcome is whether to prioritize skylight
6085
+ # allocation in already sidelit spaces - opts[:sidelit] may be reset to 'true'.
6086
+ #
6087
+ # @param spaces [Array<OpenStudio::Model::Space>] candidate(s) to toplight
6088
+ # @param [Hash] opts requested skylight attributes (same as 'addSkylights')
6089
+ # @option opts [#to_f] :size (1.22m) template skylight width/depth (min 0.4m)
6090
+ #
6091
+ # @return [Array<OpenStudio::Model::Space>] candidates (see logs if empty)
6092
+ def toToplit(spaces = [], opts = {})
6093
+ mth = "OSut::#{__callee__}"
6094
+ gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width)
6095
+ w = 1.22 # default 48" x 48" skylight base
6096
+ w2 = w * w
6097
+
6098
+ # Validate skylight size, if provided.
6099
+ if opts.key?(:size)
6100
+ if opts[:size].respond_to?(:to_f)
6101
+ w = opts[:size].to_f
6102
+ w2 = w * w
6103
+ return invalid(size, mth, 0, ERR, []) if w.round(2) < gap4
6104
+ else
6105
+ return mismatch("size", opts[:size], Numeric, mth, DBG, [])
6106
+ end
6107
+ end
6108
+
6109
+ # Accept single 'OpenStudio::Model::Space' (vs an array of spaces). Filter.
6110
+ #
6111
+ # Whether individual spaces are UNCONDITIONED (e.g. attics, unheated areas)
6112
+ # or flagged as NOT being part of the total floor area (e.g. unoccupied
6113
+ # plenums), should of course reflect actual design intentions. It's up to
6114
+ # modellers to correctly flag such cases - can't safely guess in lieu of
6115
+ # design/modelling team.
6116
+ #
6117
+ # A friendly reminder: 'addSkylights' should be called separately for
6118
+ # strictly SEMIHEATED spaces vs REGRIGERATED spaces vs all other CONDITIONED
6119
+ # spaces, as per 90.1 and NECB requirements.
6120
+ if spaces.respond_to?(:spaceType) || spaces.respond_to?(:to_a)
6121
+ spaces = spaces.respond_to?(:to_a) ? spaces.to_a : [spaces]
6122
+ spaces = spaces.select { |sp| sp.respond_to?(:spaceType) }
6123
+ spaces = spaces.select { |sp| sp.partofTotalFloorArea }
6124
+ spaces = spaces.reject { |sp| unconditioned?(sp) }
6125
+ spaces = spaces.reject { |sp| vestibule?(sp) }
6126
+ spaces = spaces.reject { |sp| getRoofs(sp).empty? }
6127
+ spaces = spaces.reject { |sp| sp.floorArea < 4 * w2 }
6128
+ spaces = spaces.sort_by(&:floorArea).reverse
6129
+ return empty("spaces", mth, WRN, 0) if spaces.empty?
6130
+ else
6131
+ return mismatch("spaces", spaces, Array, mth, DBG, 0)
6132
+ end
6133
+
6134
+ # Unfenestrated spaces have no windows, glazed doors or skylights. By
6135
+ # default, 'addSkylights' will prioritize unfenestrated spaces (over all
6136
+ # existing sidelit ones) and maximize skylight sizes towards achieving the
6137
+ # required skylight area target. This concentrates skylights for instance in
6138
+ # typical (large) core spaces, vs (narrower) perimeter spaces. However, for
6139
+ # less conventional spatial layouts, this default approach can produce less
6140
+ # optimal skylight distributions. A balance is needed to prioritize large
6141
+ # unfenestrated spaces when appropriate on one hand, while excluding smaller
6142
+ # unfenestrated ones on the other. Here, exclusion is based on the average
6143
+ # floor area of spaces to toplight.
6144
+ fm2 = spaces.sum(&:floorArea)
6145
+ afm2 = fm2 / spaces.size
6146
+
6147
+ unfen = spaces.reject { |sp| daylit?(sp) }.sort_by(&:floorArea).reverse
6148
+
6149
+ # Target larger unfenestrated spaces, if sufficient in area.
6150
+ if unfen.empty?
6151
+ opts[:sidelit] = true
6152
+ else
6153
+ if spaces.size > unfen.size
6154
+ ufm2 = unfen.sum(&:floorArea)
6155
+ u0fm2 = unfen.first.floorArea
6156
+
6157
+ if ufm2 > 0.33 * fm2 && u0fm2 > 3 * afm2
6158
+ unfen = unfen.reject { |sp| sp.floorArea > 0.25 * afm2 }
6159
+ spaces = spaces.reject { |sp| unfen.include?(sp) }
6160
+ else
6161
+ opts[:sidelit] = true
6162
+ end
6163
+ end
6164
+ end
6165
+
6166
+ espaces = {}
6167
+ rooms = []
6168
+ toits = []
6169
+
6170
+ # Gather roof surfaces - possibly those of attics or plenums above.
6171
+ spaces.each do |sp|
6172
+ getRoofs(sp).each do |rf|
6173
+ espaces[sp] = {roofs: []} unless espaces.key?(sp)
6174
+ espaces[sp][:roofs] << rf unless espaces[sp][:roofs].include?(rf)
6175
+ end
6176
+ end
6177
+
6178
+ # Priortize larger spaces.
6179
+ espaces = espaces.sort_by { |espace, _| espace.floorArea }.reverse
6180
+
6181
+ # Prioritize larger roof surfaces.
6182
+ espaces.each do |_, datum|
6183
+ datum[:roofs] = datum[:roofs].sort_by(&:grossArea).reverse
6184
+ end
6185
+
6186
+ # Single out largest roof in largest space, key when dealing with shared
6187
+ # attics or plenum roofs.
6188
+ espaces.each do |espace, datum|
6189
+ rfs = datum[:roofs].reject { |ruf| toits.include?(ruf) }
6190
+ next if rfs.empty?
6191
+
6192
+ toits << rfs.sort { |ruf| ruf.grossArea }.reverse.first
6193
+ rooms << espace
6194
+ end
6195
+
6196
+ log(INF, "No ideal toplit candidates (#{mth})") if rooms.empty?
6197
+
6198
+ rooms
6199
+ end
6200
+
5890
6201
  ##
5891
6202
  # 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).
6203
+ # spaces, based on requested skylight area, or a skylight-to-roof ratio (SRR%).
6204
+ # If the user selects 0m2 as the requested :area (or 0 as the requested :srr),
6205
+ # while setting the option :clear as true, the method simply purges all
6206
+ # pre-existing roof fenestrated subsurfaces of selected spaces, and exits while
6207
+ # returning 0 (without logging an error or warning). Pre-existing skylight
6208
+ # wells are not cleared however. Pre-toplit spaces are otherwise ignored.
6209
+ # Boolean options :attic, :plenum, :sloped and :sidelit further restrict
6210
+ # candidate spaces to toplight. If applicable, options :attic and :plenum add
6211
+ # skylight wells. Option :patterns restricts preset skylight allocation
6212
+ # layouts in order of preference; if left empty, all preset patterns are
6213
+ # considered, also in order of preference (see examples).
5902
6214
  #
5903
6215
  # @param spaces [Array<OpenStudio::Model::Space>] space(s) to toplight
5904
6216
  # @param [Hash] opts requested skylight attributes
5905
- # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.10]
6217
+ # @option opts [#to_f] :area overall skylight area
6218
+ # @option opts [#to_f] :srr skylight-to-roof ratio (0.00, 0.90]
5906
6219
  # @option opts [#to_f] :size (1.22) template skylight width/depth (min 0.4m)
5907
6220
  # @option opts [#frameWidth] :frame (nil) OpenStudio Frame & Divider (optional)
5908
6221
  # @option opts [Bool] :clear (true) whether to first purge existing skylights
6222
+ # @option opts [Bool] :ration (true) finer selection of candidates to toplight
5909
6223
  # @option opts [Bool] :sidelit (true) whether to consider sidelit spaces
5910
6224
  # @option opts [Bool] :sloped (true) whether to consider sloped roof surfaces
5911
6225
  # @option opts [Bool] :plenum (true) whether to consider plenum wells
5912
6226
  # @option opts [Bool] :attic (true) whether to consider attic wells
5913
6227
  # @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)
6228
+ # @example (a) consider 2D array of individual skylights, e.g. n(1.22m x 1.22m)
5915
6229
  # opts[:patterns] = ["array"]
5916
6230
  # @example (b) consider 'a', then array of 1x(size) x n(size) skylight strips
5917
6231
  # opts[:patterns] = ["array", "strips"]
5918
6232
  #
5919
- # @return [Float] returns gross roof area if successful (see logs if 0 m2)
6233
+ # @return [Float] returns gross roof area if successful (see logs if 0m2)
5920
6234
  def addSkyLights(spaces = [], opts = {})
5921
6235
  mth = "OSut::#{__callee__}"
5922
6236
  clear = true
5923
- srr = 0.0
6237
+ srr = nil
6238
+ area = nil
5924
6239
  frame = nil # FrameAndDivider object
5925
6240
  f = 0.0 # FrameAndDivider frame width
5926
- gap = 0.1 # min 2" around well (2x), as well as max frame width
6241
+ gap = 0.1 # min 2" around well (2x == 4"), as well as max frame width
5927
6242
  gap2 = 0.2 # 2x gap
5928
6243
  gap4 = 0.4 # minimum skylight 16" width/depth (excluding frame width)
5929
6244
  bfr = 0.005 # minimum array perimeter buffer (no wells)
5930
6245
  w = 1.22 # default 48" x 48" skylight base
5931
6246
  w2 = w * w # m2
5932
6247
 
5933
-
5934
6248
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
5935
6249
  # Excerpts of ASHRAE 90.1 2022 definitions:
5936
6250
  #
@@ -5994,7 +6308,7 @@ module OSut
5994
6308
  # to exclude portions of any roof surface: all plenum roof surfaces (in
5995
6309
  # addition to soffit surfaces) would need to be insulated). The method takes
5996
6310
  # such circumstances into account, which requires vertically casting of
5997
- # surfaces ontoothers, as well as overlap calculations. If successful, the
6311
+ # surfaces onto others, as well as overlap calculations. If successful, the
5998
6312
  # method returns the "GROSS ROOF AREA" (in m2), based on the above rationale.
5999
6313
  #
6000
6314
  # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
@@ -6014,7 +6328,7 @@ module OSut
6014
6328
  # instance, if the GROSS ROOF AREA were based on insulated ceiling surfaces,
6015
6329
  # there would be a topological disconnect between flat ceiling and sloped
6016
6330
  # skylights above. Should NECB users first 'project' (sloped) skylight rough
6017
- # openings onto flat ceilings when calculating %SRR? Without much needed
6331
+ # openings onto flat ceilings when calculating SRR%? Without much needed
6018
6332
  # clarification, the (clearer) 90.1 rules equally apply here to NECB cases.
6019
6333
 
6020
6334
  # If skylight wells are indeed required, well wall edges are always vertical
@@ -6037,19 +6351,8 @@ module OSut
6037
6351
 
6038
6352
  mdl = spaces.first.model
6039
6353
 
6040
- # Exit if mismatched or invalid argument classes/keys.
6354
+ # Exit if mismatched or invalid options.
6041
6355
  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
6356
 
6054
6357
  # Validate Frame & Divider object, if provided.
6055
6358
  if opts.key?(:frame)
@@ -6085,6 +6388,27 @@ module OSut
6085
6388
  wl = w0 + gap
6086
6389
  wl2 = wl * wl
6087
6390
 
6391
+ # Validate requested skylight-to-roof ratio (or overall area).
6392
+ if opts.key?(:area)
6393
+ if opts[:area].respond_to?(:to_f)
6394
+ area = opts[:area].to_f
6395
+ log(WRN, "Area reset to 0.0m2 (#{mth})") if area < 0
6396
+ else
6397
+ return mismatch("area", opts[:area], Numeric, mth, DBG, 0)
6398
+ end
6399
+ elsif opts.key?(:srr)
6400
+ if opts[:srr].respond_to?(:to_f)
6401
+ srr = opts[:srr].to_f
6402
+ log(WRN, "SRR (#{srr.round(2)}) reset to 0% (#{mth})") if srr < 0
6403
+ log(WRN, "SRR (#{srr.round(2)}) reset to 90% (#{mth})") if srr > 0.90
6404
+ srr = srr.clamp(0.00, 0.10)
6405
+ else
6406
+ return mismatch("srr", opts[:srr], Numeric, mth, DBG, 0)
6407
+ end
6408
+ else
6409
+ return hashkey("area", opts, :area, mth, ERR, 0)
6410
+ end
6411
+
6088
6412
  # Validate purge request, if provided.
6089
6413
  if opts.key?(:clear)
6090
6414
  clear = opts[:clear]
@@ -6095,64 +6419,126 @@ module OSut
6095
6419
  end
6096
6420
  end
6097
6421
 
6422
+ # Purge if requested.
6098
6423
  getRoofs(spaces).each { |s| s.subSurfaces.map(&:remove) } if clear
6099
6424
 
6100
6425
  # Safely exit, e.g. if strictly called to purge existing roof subsurfaces.
6101
- return 0 if srr < TOL
6426
+ return 0 if area && area.round(2) == 0
6427
+ return 0 if srr && srr.round(2) == 0
6102
6428
 
6103
- # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
6104
- # The method seeks to insert a skylight array within the largest rectangular
6105
- # 'bounded box' that neatly 'fits' within a given roof surface. This equally
6106
- # applies to any vertically-cast overlap between roof and plenum (or attic)
6107
- # 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').
6110
- #
6111
- # Depending on geometric complexity (e.g. building/roof concavity,
6112
- # triangulation), the total area of bounded boxes may be significantly less
6113
- # 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.
6117
- #
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').
6120
- #
6121
- # Preset skylight allocation patterns (in order of precedence):
6122
- # 1. "array"
6123
- # _____________________
6124
- # | _ _ _ | - ?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)
6127
- # | _ _ _ | - +suitable for wide spaces (storage, retail)
6128
- # | |_| |_| |_| | - ~1.4x height + skylight width 'ideal' rule
6129
- # |_____________________| - better daylight distribution, many wells
6130
- #
6131
- # 2. "strips"
6132
- # _____________________
6133
- # | _ _ _ | - ?x columns (min 2), 1x row
6134
- # | | | | | | | | - ~doubles %SRR ...
6135
- # | | | | | | | | - SRR ~10% (1.2m x ?1.2m), as illustrated
6136
- # | | | | | | | | - SRR ~19% (2.4m x ?1.2m)
6137
- # | |_| |_| |_| | - ~roof monitor layout
6138
- # |_____________________| - fewer wells
6139
- #
6140
- # 3. "strip"
6141
- # ____________________
6142
- # | | - 1x column, 1x row (min 1x)
6143
- # | ______________ | - SRR ~11% (1.2m x ?1.2m)
6144
- # | | ............ | | - SRR ~22% (2.4m x ?1.2m), as illustrated
6145
- # | |______________| | - +suitable for elongated bounded boxes
6146
- # | | - 1x well
6147
- # |____________________|
6148
- #
6149
- # TO-DO: Support strips/strip patterns along ridge of paired roof surfaces.
6150
- layouts = ["array", "strips", "strip"]
6151
- patterns = []
6429
+ m2 = 0 # total existing skylight rough opening area
6430
+ rm2 = grossRoofArea(spaces) # excludes e.g. overhangs
6152
6431
 
6153
- # Validate skylight placement patterns, if provided.
6154
- if opts.key?(:patterns)
6155
- if opts[:patterns].is_a?(Array)
6432
+ # Tally existing skylight rough opening areas.
6433
+ spaces.each do |space|
6434
+ m = space.multiplier
6435
+
6436
+ facets(space, "Outdoors", "RoofCeiling").each do |roof|
6437
+ roof.subSurfaces.each do |sub|
6438
+ next unless fenestration?(sub)
6439
+
6440
+ id = sub.nameString
6441
+ xm2 = sub.grossArea
6442
+
6443
+ if sub.allowWindowPropertyFrameAndDivider
6444
+ unless sub.windowPropertyFrameAndDivider.empty?
6445
+ fw = sub.windowPropertyFrameAndDivider.get.frameWidth
6446
+ vec = offset(sub.vertices, fw, 300)
6447
+ aire = OpenStudio.getArea(vec)
6448
+
6449
+ if aire.empty?
6450
+ log(ERR, "Skipping '#{id}': invalid Frame&Divider (#{mth})")
6451
+ else
6452
+ xm2 = aire.get
6453
+ end
6454
+ end
6455
+ end
6456
+
6457
+ m2 += xm2 * sub.multiplier * m
6458
+ end
6459
+ end
6460
+ end
6461
+
6462
+ # Required skylight area to add.
6463
+ sm2 = area ? area : rm2 * srr - m2
6464
+
6465
+ # Warn/skip if existing skylights exceed or ~roughly match targets.
6466
+ if sm2.round(2) < w02.round(2)
6467
+ if m2 > 0
6468
+ log(INF, "Skipping: existing skylight area > request (#{mth})")
6469
+ return rm2
6470
+ else
6471
+ log(INF, "Requested skylight area < min size (#{mth})")
6472
+ end
6473
+ elsif 0.9 * rm2.round(2) < sm2.round(2)
6474
+ log(INF, "Skipping: requested skylight area > 90% of GRA (#{mth})")
6475
+ return rm2
6476
+ end
6477
+
6478
+ opts[:ration] = true unless opts.key?(:ration)
6479
+
6480
+ # By default, seek ideal candidate spaces/roofs. Bail out if unsuccessful.
6481
+ unless opts[:ration] == false
6482
+ spaces = toToplit(spaces, opts)
6483
+ return rm2 if spaces.empty?
6484
+ end
6485
+
6486
+ # --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- #
6487
+ # The method seeks to insert a skylight array within the largest rectangular
6488
+ # 'bounded box' that neatly 'fits' within a given roof surface. This equally
6489
+ # applies to any vertically-cast overlap between roof and plenum (or attic)
6490
+ # floor, which in turn generates skylight wells. Skylight arrays are
6491
+ # inserted from left-to-right & top-to-bottom (as illustrated below), once a
6492
+ # roof (or cast 3D overlap) is 'aligned' in 2D.
6493
+ #
6494
+ # Depending on geometric complexity (e.g. building/roof concavity,
6495
+ # triangulation), the total area of bounded boxes may be significantly less
6496
+ # than the calculated "GROSS ROOF AREA", which can make it challenging to
6497
+ # attain the requested skylight area. If :patterns are left unaltered, the
6498
+ # method will select those that maximize the likelihood of attaining the
6499
+ # requested target, to the detriment of spatial daylighting distribution.
6500
+ #
6501
+ # The default skylight module size is 1.22m x 1.22m (4' x 4'), which can be
6502
+ # overridden by the user, e.g. 2.44m x 2.44m (8' x 8'). However, skylight
6503
+ # sizes usually end up either contracted or inflated to exactly meet a
6504
+ # request skylight area or SRR%,
6505
+ #
6506
+ # Preset skylight allocation patterns (in order of precedence):
6507
+ #
6508
+ # 1. "array"
6509
+ # _____________________
6510
+ # | _ _ _ | - ?x columns ("cols") >= ?x rows (min 2x2)
6511
+ # | |_| |_| |_| | - SRR ~5% (1.22m x 1.22m), as illustrated
6512
+ # | | - SRR ~19% (2.44m x 2.44m)
6513
+ # | _ _ _ | - +suitable for wide spaces (storage, retail)
6514
+ # | |_| |_| |_| | - ~1.4x height + skylight width 'ideal' rule
6515
+ # |_____________________| - better daylight distribution, many wells
6516
+ #
6517
+ # 2. "strips"
6518
+ # _____________________
6519
+ # | _ _ _ | - ?x columns (min 2), 1x row
6520
+ # | | | | | | | | - ~doubles %SRR ...
6521
+ # | | | | | | | | - SRR ~10% (1.22m x ?1.22m), as illustrated
6522
+ # | | | | | | | | - SRR ~19% (2.44m x ?1.22m)
6523
+ # | |_| |_| |_| | - ~roof monitor layout
6524
+ # |_____________________| - fewer wells
6525
+ #
6526
+ # 3. "strip"
6527
+ # ____________________
6528
+ # | | - 1x column, 1x row (min 1x)
6529
+ # | ______________ | - SRR ~11% (1.22m x ?1.22m)
6530
+ # | | ............ | | - SRR ~22% (2.44m x ?1.22m), as illustrated
6531
+ # | |______________| | - +suitable for elongated bounded boxes
6532
+ # | | - 1x well
6533
+ # |____________________|
6534
+ #
6535
+ # @todo: Support strips/strip patterns along ridge of paired roof surfaces.
6536
+ layouts = ["array", "strips", "strip"]
6537
+ patterns = []
6538
+
6539
+ # Validate skylight placement patterns, if provided.
6540
+ if opts.key?(:patterns)
6541
+ if opts[:patterns].is_a?(Array)
6156
6542
  opts[:patterns].each_with_index do |pattern, i|
6157
6543
  pattern = trim(pattern).downcase
6158
6544
 
@@ -6174,27 +6560,26 @@ module OSut
6174
6560
  # - large roof surface areas (e.g. retail, classrooms ... not corridors)
6175
6561
  # - not sidelit (favours core spaces)
6176
6562
  # - having flat roofs (avoids sloped roofs)
6177
- # - not under plenums, nor attics (avoids wells)
6563
+ # - neither under plenums, nor attics (avoids wells)
6178
6564
  #
6179
6565
  # This ideal (albeit stringent) set of conditions is "combo a".
6180
6566
  #
6181
- # If required %SRR has not yet been achieved, the method decrementally drops
6182
- # selection criteria and starts over, e.g.:
6567
+ # If the requested skylight area has not yet been achieved (after initially
6568
+ # applying "combo a"), the method decrementally drops selection criteria and
6569
+ # starts over, e.g.:
6183
6570
  # - then considers sidelit spaces
6184
6571
  # - then considers sloped roofs
6185
6572
  # - then considers skylight wells
6186
6573
  #
6187
6574
  # 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.
6575
+ # given combo, all the while giving priority to larger roof areas. An error
6576
+ # message is logged if the target isn't ultimately achieved.
6192
6577
  #
6193
- # Through filters, users may restrict candidate roof surfaces:
6578
+ # Through filters, users may in advance restrict candidate roof surfaces:
6194
6579
  # b. above occupied sidelit spaces ('false' restricts to core spaces)
6195
6580
  # 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)
6581
+ # d. above INDIRECTLY CONDITIONED spaces (e.g. plenums, uninsulated wells)
6582
+ # e. above UNCONDITIONED spaces (e.g. attics, insulated wells)
6198
6583
  filters = ["a", "b", "bc", "bcd", "bcde"]
6199
6584
 
6200
6585
  # Prune filters, based on user-selected options.
@@ -6213,8 +6598,8 @@ module OSut
6213
6598
  filters.reject! { |f| f.empty? }
6214
6599
  filters.uniq!
6215
6600
 
6216
- # Remaining filters may be further reduced (after space/roof processing),
6217
- # depending on geometry, e.g.:
6601
+ # Remaining filters may be further pruned automatically after space/roof
6602
+ # processing, depending on geometry, e.g.:
6218
6603
  # - if there are no sidelit spaces: filter "b" will be pruned away
6219
6604
  # - if there are no sloped roofs : filter "c" will be pruned away
6220
6605
  # - if no plenums are identified : filter "d" will be pruned away
@@ -6228,12 +6613,10 @@ module OSut
6228
6613
  attics = {} # unoccupied UNCONDITIONED spaces above rooms
6229
6614
  ceilings = {} # of occupied CONDITIONED space (if plenums/attics)
6230
6615
 
6231
- # Select candidate 'rooms' to toplit - excludes plenums/attics.
6616
+ # Candidate 'rooms' to toplit - excludes plenums/attics.
6232
6617
  spaces.each do |space|
6233
- next if unconditioned?(space) # e.g. attic
6234
- next unless space.partofTotalFloorArea # occupied (not plenum)
6618
+ id = space.nameString
6235
6619
 
6236
- # Already toplit?
6237
6620
  if daylit?(space, false, true, false)
6238
6621
  log(WRN, "#{id} is already toplit, skipping (#{mth})")
6239
6622
  next
@@ -6241,30 +6624,40 @@ module OSut
6241
6624
 
6242
6625
  # When unoccupied spaces are involved (e.g. plenums, attics), the occupied
6243
6626
  # space (to toplight) may not share the same local transformation as its
6244
- # unoccupied space(s) above. Fetching local transformation.
6245
- h = 0
6627
+ # unoccupied space(s) above. Fetching site transformation.
6246
6628
  t0 = transforms(space)
6247
6629
  next unless t0[:t]
6248
6630
 
6249
- toitures = facets(space, "Outdoors", "RoofCeiling")
6250
- plafonds = facets(space, "Surface", "RoofCeiling")
6631
+ # Calculate space height.
6632
+ hMIN = 10000
6633
+ hMAX = 0
6634
+ surfs = facets(space)
6635
+
6636
+ surfs.each { |surf| hMAX = [hMAX, surf.vertices.max_by(&:z).z].max }
6637
+ surfs.each { |surf| hMIN = [hMIN, surf.vertices.min_by(&:z).z].min }
6251
6638
 
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 }
6639
+ h = hMAX - hMIN
6640
+
6641
+ unless h > 0
6642
+ log(ERR, "#{id} height? #{hMIN.round(2)} vs #{hMAX.round(2)} (#{mth})")
6643
+ next
6644
+ end
6254
6645
 
6255
6646
  rooms[space] = {}
6256
- rooms[space][:t ] = t0[:t]
6647
+ rooms[space][:t0 ] = t0[:t]
6257
6648
  rooms[space][:m ] = space.multiplier
6258
6649
  rooms[space][:h ] = h
6259
- rooms[space][:roofs ] = toitures
6650
+ rooms[space][:roofs ] = facets(space, "Outdoors", "RoofCeiling")
6260
6651
  rooms[space][:sidelit] = daylit?(space, true, false, false)
6261
6652
 
6262
6653
  # Fetch and process room-specific outdoor-facing roof surfaces, the most
6263
- # basic 'set' to track:
6264
- # - no skylight wells
6654
+ # basic 'set' to track, e.g.:
6655
+ # - no skylight wells (i.e. no leader lines)
6265
6656
  # - 1x skylight array per roof surface
6266
- # - no need to preprocess space transformation
6657
+ # - no need to consider site transformation
6267
6658
  rooms[space][:roofs].each do |roof|
6659
+ next unless roof?(roof)
6660
+
6268
6661
  box = boundedBox(roof)
6269
6662
  next if box.empty?
6270
6663
 
@@ -6274,133 +6667,112 @@ module OSut
6274
6667
  bm2 = bm2.get
6275
6668
  next if bm2.round(2) < w02.round(2)
6276
6669
 
6277
- # Track if bounded box is significantly smaller than roof.
6670
+ width = alignedWidth(box, true)
6671
+ depth = alignedHeight(box, true)
6672
+ next if width < wl * 3
6673
+ next if depth < wl
6674
+
6675
+ # A set is 'tight' if the area of its bounded box is significantly
6676
+ # smaller than that of its roof. A set is 'thin' if the depth of its
6677
+ # bounded box is (too) narrow. If either is true, some geometry rules
6678
+ # may be relaxed to maximize allocated skylight area. Neither apply to
6679
+ # cases with skylight wells.
6278
6680
  tight = bm2 < roof.grossArea / 2 ? true : false
6681
+ thin = depth.round(2) < (1.5 * wl).round(2) ? true : false
6279
6682
 
6280
6683
  set = {}
6281
6684
  set[:box ] = box
6282
6685
  set[:bm2 ] = bm2
6283
6686
  set[:tight ] = tight
6687
+ set[:thin ] = thin
6284
6688
  set[:roof ] = roof
6285
6689
  set[:space ] = space
6690
+ set[:m ] = space.multiplier
6286
6691
  set[:sidelit] = rooms[space][:sidelit]
6287
- set[:t ] = rooms[space][:t ]
6288
- set[:sloped ] = slopedRoof?(roof)
6692
+ set[:t0 ] = rooms[space][:t0]
6693
+ set[:t ] = OpenStudio::Transformation.alignFace(roof.vertices)
6289
6694
  sets << set
6290
6695
  end
6291
6696
  end
6292
6697
 
6293
6698
  # Process outdoor-facing roof surfaces of plenums and attics above.
6294
6699
  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?
6700
+ t0 = room[:t0]
6701
+ rufs = getRoofs(space) - room[:roofs]
6299
6702
 
6300
- # Process room ceilings, as 1x or more are overlapping roofs above. Fetch
6301
- # vertically-cast overlaps.
6302
6703
  rufs.each do |ruf|
6704
+ next unless roof?(ruf)
6705
+
6303
6706
  espace = ruf.space
6304
6707
  next if espace.empty?
6305
6708
 
6306
6709
  espace = espace.get
6307
6710
  next if espace.partofTotalFloorArea
6308
6711
 
6309
- m = espace.multiplier
6310
- ti = transforms(espace)
6311
- next unless ti[:t]
6312
-
6313
- ti = ti[:t]
6314
- vtx = ruf.vertices
6712
+ m = espace.multiplier
6315
6713
 
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)
6714
+ if m != space.multiplier
6715
+ log(ERR, "Skipping #{ruf.nameString} (multiplier mismatch) (#{mth})")
6716
+ next
6335
6717
  end
6336
6718
 
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
6719
+ ti = transforms(espace)
6720
+ next unless ti[:t]
6362
6721
 
6363
- tile.setVertices(vtx)
6722
+ ti = ti[:t]
6723
+ rpts = ti * ruf.vertices
6364
6724
 
6365
- ci0 = cast(t0 * tile.vertices, ri, ray)
6725
+ # Process occupied room ceilings, as 1x or more are overlapping roof
6726
+ # surfaces above. Vertically cast, then fetch overlap.
6727
+ facets(space, "Surface", "RoofCeiling").each do |tile|
6728
+ tpts = t0 * tile.vertices
6729
+ ci0 = cast(tpts, rpts, ray)
6366
6730
  next if ci0.empty?
6367
6731
 
6368
- olap = overlap(ri, ci0, false)
6732
+ olap = overlap(rpts, ci0)
6369
6733
  next if olap.empty?
6370
6734
 
6735
+ om2 = OpenStudio.getArea(olap)
6736
+ next if om2.empty?
6737
+
6738
+ om2 = om2.get
6739
+ next if om2.round(2) < w02.round(2)
6740
+
6371
6741
  box = boundedBox(olap)
6372
6742
  next if box.empty?
6373
6743
 
6374
6744
  # 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.
6745
+ # linking new base roof 'inserts' (as well as new ceiling ones)
6746
+ # through 'leader lines'. This requires an offset to ensure no
6747
+ # conflicts with roof or (ceiling) tile edges.
6377
6748
  #
6378
- # TO DO: expand the method to factor in cases where simple 'side'
6749
+ # @todo: Expand the method to factor in cases where simple 'side'
6379
6750
  # cutouts can be supported (no need for leader lines), e.g.
6380
6751
  # skylight strips along roof ridges.
6381
6752
  box = offset(box, -gap, 300)
6382
- box = poly(box, false, false, false, false, :blc)
6383
6753
  next if box.empty?
6384
6754
 
6385
6755
  bm2 = OpenStudio.getArea(box)
6386
6756
  next if bm2.empty?
6387
6757
 
6388
6758
  bm2 = bm2.get
6389
- next if bm2.round(2) < w02.round(2)
6759
+ next if bm2.round(2) < wl2.round(2)
6760
+
6761
+ width = alignedWidth(box, true)
6762
+ depth = alignedHeight(box, true)
6763
+ next if width < wl * 3
6764
+ next if depth < wl * 2
6390
6765
 
6391
- # Vertically-cast box onto ceiling below.
6392
- cbox = cast(box, t0 * tile.vertices, ray)
6766
+ # Vertically cast box onto tile below.
6767
+ cbox = cast(box, tpts, ray)
6393
6768
  next if cbox.empty?
6394
6769
 
6395
6770
  cm2 = OpenStudio.getArea(cbox)
6396
6771
  next if cm2.empty?
6397
6772
 
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
6773
+ cm2 = cm2.get
6774
+ box = ti.inverse * box
6775
+ cbox = t0.inverse * cbox
6404
6776
 
6405
6777
  unless ceilings.key?(tile)
6406
6778
  floor = tile.adjacentSurface
@@ -6412,10 +6784,6 @@ module OSut
6412
6784
 
6413
6785
  floor = floor.get
6414
6786
 
6415
- # Ensure BLC vertex sequence.
6416
- vtx = t0 * vtx
6417
- floor.setVertices(ti.inverse * vtx.reverse)
6418
-
6419
6787
  if floor.space.empty?
6420
6788
  log(ERR, "#{floor.nameString} space? (#{mth})")
6421
6789
  next
@@ -6428,32 +6796,40 @@ module OSut
6428
6796
  next
6429
6797
  end
6430
6798
 
6431
- ceilings[tile] = {}
6432
- ceilings[tile][:roofs] = []
6433
- ceilings[tile][:space] = space
6434
- ceilings[tile][:floor] = floor
6799
+ ceilings[tile] = {}
6800
+ ceilings[tile][:roofs ] = []
6801
+ ceilings[tile][:space ] = space
6802
+ ceilings[tile][:floor ] = floor
6435
6803
  end
6436
6804
 
6437
6805
  ceilings[tile][:roofs] << ruf
6438
6806
 
6439
- # More detailed skylight set entries with suspended ceilings.
6807
+ # Skylight set key:values are more detailed with suspended ceilings.
6808
+ # The overlap (:olap) remains in 'transformed' site coordinates (with
6809
+ # regards to the roof). The :box polygon reverts to attic/plenum space
6810
+ # coordinates, while the :cbox polygon is reset with regards to the
6811
+ # occupied space coordinates.
6440
6812
  set = {}
6441
6813
  set[:olap ] = olap
6442
6814
  set[:box ] = box
6443
6815
  set[:cbox ] = cbox
6816
+ set[:om2 ] = om2
6444
6817
  set[:bm2 ] = bm2
6445
6818
  set[:cm2 ] = cm2
6446
- set[:tight ] = tight
6819
+ set[:tight ] = false
6820
+ set[:thin ] = false
6447
6821
  set[:roof ] = ruf
6448
6822
  set[:space ] = space
6823
+ set[:m ] = space.multiplier
6449
6824
  set[:clng ] = tile
6450
- set[:t ] = t0
6825
+ set[:t0 ] = t0
6826
+ set[:ti ] = ti
6827
+ set[:t ] = OpenStudio::Transformation.alignFace(ruf.vertices)
6451
6828
  set[:sidelit] = room[:sidelit]
6452
- set[:sloped ] = slopedRoof?(ruf)
6453
6829
 
6454
6830
  if unconditioned?(espace) # e.g. attic
6455
6831
  unless attics.key?(espace)
6456
- attics[espace] = {t: ti, m: m, bm2: 0, roofs: []}
6832
+ attics[espace] = {ti: ti, m: m, bm2: 0, roofs: []}
6457
6833
  end
6458
6834
 
6459
6835
  attics[espace][:bm2 ] += bm2
@@ -6464,7 +6840,7 @@ module OSut
6464
6840
  ceilings[tile][:attic] = espace
6465
6841
  else # e.g. plenum
6466
6842
  unless plenums.key?(espace)
6467
- plenums[espace] = {t: ti, m: m, bm2: 0, roofs: []}
6843
+ plenums[espace] = {ti: ti, m: m, bm2: 0, roofs: []}
6468
6844
  end
6469
6845
 
6470
6846
  plenums[espace][:bm2 ] += bm2
@@ -6481,21 +6857,20 @@ module OSut
6481
6857
  end
6482
6858
  end
6483
6859
 
6484
- # Ensure uniqueness of plenum roofs, and set GROSS ROOF AREA.
6860
+ # Ensure uniqueness of plenum roofs.
6485
6861
  attics.values.each do |attic|
6486
6862
  attic[:roofs ].uniq!
6487
- attic[:ridges] = getHorizontalRidges(attic[:roofs]) # TO-DO
6863
+ attic[:ridges] = getHorizontalRidges(attic[:roofs]) # @todo
6488
6864
  end
6489
6865
 
6490
6866
  plenums.values.each do |plenum|
6491
6867
  plenum[:roofs ].uniq!
6492
- # plenum[:m2 ] = plenum[:roofs].sum(&:grossArea)
6493
- plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # TO-DO
6868
+ plenum[:ridges] = getHorizontalRidges(plenum[:roofs]) # @todo
6494
6869
  end
6495
6870
 
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.
6871
+ # Regardless of the selected skylight arrangement pattern, the solution only
6872
+ # considers attic/plenum sets that can be successfully linked to leader line
6873
+ # anchors, for both roof and ceiling surfaces. First, attic/plenum roofs.
6499
6874
  [attics, plenums].each do |greniers|
6500
6875
  k = greniers == attics ? :attic : :plenum
6501
6876
 
@@ -6512,7 +6887,8 @@ module OSut
6512
6887
  sts = sts.select { |st| st[:roof] == roof }
6513
6888
  next if sts.empty?
6514
6889
 
6515
- sts = sts.sort_by { |st| st[:bm2] }
6890
+ sts = sts.sort_by { |st| st[:bm2] }.reverse
6891
+
6516
6892
  genAnchors(roof, sts, :box)
6517
6893
  end
6518
6894
  end
@@ -6527,7 +6903,7 @@ module OSut
6527
6903
  next unless ceiling.key?(k)
6528
6904
 
6529
6905
  space = ceiling[:space]
6530
- spce = ceiling[k ]
6906
+ spce = ceiling[k]
6531
6907
  next unless ceiling.key?(:roofs)
6532
6908
  next unless rooms.key?(space)
6533
6909
 
@@ -6553,108 +6929,72 @@ module OSut
6553
6929
 
6554
6930
  next if stz.empty?
6555
6931
 
6932
+ stz = stz.sort_by { |st| st[:cm2] }.reverse
6556
6933
  genAnchors(tile, stz, :cbox)
6557
6934
  end
6558
6935
 
6559
6936
  # Delete voided sets.
6560
6937
  sets.reject! { |set| set.key?(:void) }
6561
6938
 
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)
6939
+ return empty("sets", mth, WRN, rm2) if sets.empty?
6581
6940
 
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
6941
+ # Sort sets, from largest to smallest bounded box area.
6942
+ sets = sets.sort_by { |st| st[:bm2] * st[:m] }.reverse
6589
6943
 
6590
- m2 += xm2 * sub.multiplier * m
6591
- end
6592
- end
6593
- end
6594
-
6595
- # Required skylight area to add.
6596
- sm2 = rm2 * srr - m2
6597
-
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
6603
-
6604
- # Any sidelit/sloped roofs being targeted?
6605
- #
6606
- # TODO: enable double-ridged, sloped roofs have double-sloped
6607
- # skylights/wells (patterns "strip"/"strips").
6944
+ # Any sidelit and/or sloped roofs being targeted?
6945
+ # @todo: enable double-ridged, sloped roofs have double-sloped
6946
+ # skylights/wells (patterns "strip"/"strips").
6608
6947
  sidelit = sets.any? { |set| set[:sidelit] }
6609
6948
  sloped = sets.any? { |set| set[:sloped ] }
6610
6949
 
6950
+ # Average sandbox area + revised 'working' SRR%.
6951
+ sbm2 = sets.map { |set| set[:bm2] }.reduce(:+)
6952
+ avm2 = sbm2 / sets.size
6953
+ srr2 = sm2 / sets.size / avm2
6954
+
6611
6955
  # Precalculate skylight rows + cols, for each selected pattern. In the case
6612
6956
  # of 'cols x rows' arrays of skylights, the method initially overshoots
6613
- # with regards to ideal skylight placement, e.g.:
6957
+ # with regards to 'ideal' skylight placement, e.g.:
6614
6958
  #
6615
6959
  # aceee.org/files/proceedings/2004/data/papers/SS04_Panel3_Paper18.pdf
6616
6960
  #
6617
- # ... yet skylight areas are subsequently contracted to strictly meet SRR%.
6961
+ # Skylight areas are subsequently contracted to strictly meet the target.
6618
6962
  sets.each_with_index do |set, i|
6619
- id = "set #{i+1}"
6620
- well = set.key?(:clng)
6621
- space = set[:space]
6963
+ thin = set[:thin ]
6622
6964
  tight = set[:tight]
6623
6965
  factor = tight ? 1.75 : 1.25
6966
+ well = set.key?(:clng)
6967
+ space = set[:space]
6624
6968
  room = rooms[space]
6625
6969
  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
6970
+ width = alignedWidth( set[:box], true)
6971
+ depth = alignedHeight(set[:box], true)
6972
+ barea = set.key?(:om2) ? set[:om2] : set[:bm2]
6973
+ rtio = barea / avm2
6974
+ skym2 = srr2 * barea * rtio
6635
6975
 
6636
- # Flag sets if too narrow/shallow to hold a single skylight.
6976
+ # Flag set if too narrow/shallow to hold a single skylight.
6637
6977
  if well
6638
6978
  if width.round(2) < wl.round(2)
6639
- log(ERR, "#{id}: Too narrow")
6979
+ log(WRN, "set #{i+1} well: Too narrow (#{mth})")
6640
6980
  set[:void] = true
6641
6981
  next
6642
6982
  end
6643
6983
 
6644
6984
  if depth.round(2) < wl.round(2)
6645
- log(ERR, "#{id}: Too shallow")
6985
+ log(WRN, "set #{i+1} well: Too shallow (#{mth})")
6646
6986
  set[:void] = true
6647
6987
  next
6648
6988
  end
6649
6989
  else
6650
6990
  if width.round(2) < w0.round(2)
6651
- log(ERR, "#{id}: Too narrow")
6991
+ log(WRN, "set #{i+1}: Too narrow (#{mth})")
6652
6992
  set[:void] = true
6653
6993
  next
6654
6994
  end
6655
6995
 
6656
6996
  if depth.round(2) < w0.round(2)
6657
- log(ERR, "#{id}: Too shallow")
6997
+ log(WRN, "set #{i+1}: Too shallow (#{mth})")
6658
6998
  set[:void] = true
6659
6999
  next
6660
7000
  end
@@ -6667,8 +7007,8 @@ module OSut
6667
7007
  rows = 1
6668
7008
  wx = w0
6669
7009
  wy = w0
6670
- wxl = wl
6671
- wyl = wl
7010
+ wxl = well ? wl : nil
7011
+ wyl = well ? wl : nil
6672
7012
  dX = nil
6673
7013
  dY = nil
6674
7014
 
@@ -6676,31 +7016,26 @@ module OSut
6676
7016
  when "array" # min 2x cols x min 2x rows
6677
7017
  cols = 2
6678
7018
  rows = 2
7019
+ next if thin
6679
7020
 
6680
7021
  if tight
6681
7022
  sp = 1.4 * h / 2
6682
- lx = well ? width - cols * wxl : width - cols * wx
6683
- ly = well ? depth - rows * wyl : depth - rows * wy
7023
+ lx = width - cols * wx
7024
+ ly = depth - rows * wy
6684
7025
  next if lx.round(2) < sp.round(2)
6685
7026
  next if ly.round(2) < sp.round(2)
6686
7027
 
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
-
7028
+ cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
7029
+ rows = ((depth - wy) / (wy + sp)).round(2).to_i + 1
6695
7030
  next if cols < 2
6696
7031
  next if rows < 2
6697
7032
 
6698
- dX = well ? 0.0 : bfr + f
6699
- dY = well ? 0.0 : bfr + f
7033
+ dX = bfr + f
7034
+ dY = bfr + f
6700
7035
  else
6701
7036
  sp = 1.4 * h
6702
7037
  lx = well ? (width - cols * wxl) / cols : (width - cols * wx) / cols
6703
- ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols
7038
+ ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / rows
6704
7039
  next if lx.round(2) < sp.round(2)
6705
7040
  next if ly.round(2) < sp.round(2)
6706
7041
 
@@ -6715,246 +7050,238 @@ module OSut
6715
7050
  next if cols < 2
6716
7051
  next if rows < 2
6717
7052
 
6718
- ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / cols
7053
+ ly = well ? (depth - rows * wyl) / rows : (depth - rows * wy) / rows
6719
7054
  dY = ly / 2
6720
7055
  end
6721
7056
 
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
7057
+ # Default allocated skylight area. If undershooting, inflate skylight
7058
+ # width/depth (with reduced spacing). For geometrically-constrained
7059
+ # cases, undershooting means not reaching 1.75x the required target.
7060
+ # Otherwise, undershooting means not reaching 1.25x the required
7061
+ # target. Any consequent overshooting is later corrected.
7062
+ tm2 = wx * cols * wy * rows
6729
7063
 
6730
- # Inflate skylight width/depth (and reduce spacing) to reach SRR%.
6731
- if undershot
7064
+ # Inflate skylight width/depth (and reduce spacing) to reach target.
7065
+ if tm2.round(2) < factor * skym2.round(2)
6732
7066
  ratio2 = 1 + (factor * skym2 - tm2) / tm2
6733
7067
  ratio = Math.sqrt(ratio2)
6734
7068
 
6735
- sp = w
7069
+ sp = wl
6736
7070
  wx *= ratio
6737
7071
  wy *= ratio
6738
- wxl = wx + gap
6739
- wyl = wy + gap
7072
+ wxl = wx + gap if well
7073
+ wyl = wy + gap if well
6740
7074
 
6741
7075
  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
-
7076
+ lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1)
7077
+ ly = (depth - 2 * (bfr + f) - rows * wy) / (rows - 1)
6750
7078
  lx = lx.round(2) < sp.round(2) ? sp : lx
6751
7079
  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
7080
+ wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols
7081
+ wy = (depth - 2 * (bfr + f) - (rows - 1) * ly) / rows
6764
7082
  else
6765
7083
  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
7084
+ lx = (width - cols * wxl) / cols
7085
+ ly = (depth - rows * wyl) / rows
7086
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7087
+ ly = ly.round(2) < sp.round(2) ? sp : ly
6777
7088
  wxl = (width - cols * lx) / cols
6778
7089
  wyl = (depth - rows * ly) / rows
6779
7090
  wx = wxl - gap
6780
7091
  wy = wyl - gap
6781
- lx = (width - cols * wxl) / cols
6782
7092
  ly = (depth - rows * wyl) / rows
6783
7093
  else
7094
+ lx = (width - cols * wx) / cols
7095
+ ly = (depth - rows * wy) / rows
7096
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7097
+ ly = ly.round(2) < sp.round(2) ? sp : ly
6784
7098
  wx = (width - cols * lx) / cols
6785
7099
  wy = (depth - rows * ly) / rows
6786
- wxl = wx + gap
6787
- wyl = wy + gap
6788
- lx = (width - cols * wx) / cols
6789
7100
  ly = (depth - rows * wy) / rows
6790
7101
  end
6791
- end
6792
7102
 
6793
- dY = ly / 2
7103
+ dY = ly / 2
7104
+ end
6794
7105
  end
6795
7106
  when "strips" # min 2x cols x 1x row
6796
7107
  cols = 2
6797
7108
 
6798
7109
  if tight
6799
7110
  sp = h / 2
6800
- lx = well ? width - cols * wxl : width - cols * wx
6801
- ly = well ? depth - wyl : depth - wy
7111
+ dX = bfr + f
7112
+ lx = width - cols * wx
6802
7113
  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
7114
 
7115
+ cols = ((width - wx) / (wx + sp)).round(2).to_i + 1
6811
7116
  next if cols < 2
6812
7117
 
6813
- if well
6814
- wyl = depth - ly
6815
- wy = wyl - gap
7118
+ if thin
7119
+ dY = bfr + f
7120
+ wy = depth - 2 * dY
7121
+ next if wy.round(2) < gap4
6816
7122
  else
6817
- wy = depth - ly
6818
- wyl = wy + gap
6819
- end
7123
+ ly = depth - wy
7124
+ next if ly.round(2) < wl.round(2)
6820
7125
 
6821
- dX = well ? 0 : bfr + f
6822
- dY = ly / 2
7126
+ dY = ly / 2
7127
+ end
6823
7128
  else
6824
7129
  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
7130
 
6830
7131
  if well
7132
+ lx = (width - cols * wxl) / cols
7133
+ next if lx.round(2) < sp.round(2)
7134
+
6831
7135
  cols = (width / (wxl + sp)).round(2).to_i
7136
+ next if cols < 2
7137
+
7138
+ ly = depth - wyl
7139
+ dY = ly / 2
7140
+ next if ly.round(2) < wl.round(2)
6832
7141
  else
7142
+ lx = (width - cols * wx) / cols
7143
+ next if lx.round(2) < sp.round(2)
7144
+
6833
7145
  cols = (width / (wx + sp)).round(2).to_i
6834
- end
7146
+ next if cols < 2
6835
7147
 
6836
- next if cols < 2
7148
+ if thin
7149
+ dY = bfr + f
7150
+ wy = depth - 2 * dY
7151
+ next if wy.round(2) < gap4
7152
+ else
7153
+ ly = depth - wy
7154
+ next if ly.round(2) < wl.round(2)
6837
7155
 
6838
- if well
6839
- wyl = depth - ly
6840
- wy = wyl - gap
6841
- else
6842
- wy = depth - ly
6843
- wyl = wy + gap
7156
+ dY = ly / 2
7157
+ end
6844
7158
  end
6845
-
6846
- dY = ly / 2
6847
7159
  end
6848
7160
 
6849
- tm2 = wx * cols * wy
6850
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
7161
+ tm2 = wx * cols * wy
6851
7162
 
6852
- # Inflate skylight width (and reduce spacing) to reach SRR%.
6853
- if undershot
6854
- ratio2 = 1 + (factor * skym2 - tm2) / tm2
7163
+ # Inflate skylight depth to reach target.
7164
+ if tm2.round(2) < factor * skym2.round(2)
7165
+ sp = wl
6855
7166
 
6856
- sp = w
6857
- wx *= ratio2
6858
- wxl = wx + gap
7167
+ # Skip if already thin.
7168
+ unless thin
7169
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
7170
+
7171
+ wy *= ratio2
6859
7172
 
6860
- if tight
6861
7173
  if well
6862
- lx = (width - cols * wxl) / (cols - 1)
7174
+ wyl = wy + gap
7175
+ ly = depth - wyl
7176
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7177
+ wyl = depth - ly
7178
+ wy = wyl - gap
6863
7179
  else
6864
- lx = (width - cols * wx) / (cols - 1)
7180
+ ly = depth - wy
7181
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7182
+ wy = depth - ly
6865
7183
  end
6866
7184
 
6867
- lx = lx.round(2) < sp.round(2) ? sp : lx
7185
+ dY = ly / 2
7186
+ end
7187
+ end
6868
7188
 
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
7189
+ tm2 = wx * cols * wy
7190
+
7191
+ # Inflate skylight width (and reduce spacing) to reach target.
7192
+ if tm2.round(2) < factor * skym2.round(2)
7193
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
6882
7194
 
6883
- lx = lx.round(2) < sp.round(2) ? sp : lx
7195
+ wx *= ratio2
7196
+ wxl = wx + gap if well
6884
7197
 
7198
+ if tight
7199
+ lx = (width - 2 * (bfr + f) - cols * wx) / (cols - 1)
7200
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7201
+ wx = (width - 2 * (bfr + f) - (cols - 1) * lx) / cols
7202
+ else
6885
7203
  if well
7204
+ lx = (width - cols * wxl) / cols
7205
+ lx = lx.round(2) < sp.round(2) ? sp : lx
6886
7206
  wxl = (width - cols * lx) / cols
6887
7207
  wx = wxl - gap
6888
- lx = (width - cols * wxl) / cols
6889
7208
  else
6890
- wx = (width - cols * lx) / cols
6891
- wxl = wx + gap
6892
7209
  lx = (width - cols * wx) / cols
7210
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7211
+ wx = (width - cols * lx) / cols
6893
7212
  end
6894
7213
  end
6895
7214
  end
6896
7215
  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
7216
  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
7217
+ sp = gap4
7218
+ dX = bfr + f
7219
+ wx = width - 2 * dX
7220
+ next if wx.round(2) < sp.round(2)
7221
+
7222
+ if thin
7223
+ dY = bfr + f
7224
+ wy = depth - 2 * dY
7225
+ next if wy.round(2) < sp.round(2)
6910
7226
  else
6911
- wx = width - lx
6912
- wy = depth - ly
6913
- wxl = wx + gap
6914
- wyl = wy + gap
7227
+ ly = depth - wy
7228
+ dY = ly / 2
7229
+ next if ly.round(2) < sp.round(2)
6915
7230
  end
6916
-
6917
- dX = well ? 0.0 : bfr + f
6918
- dY = ly / 2
6919
7231
  else
7232
+ sp = wl
7233
+ lx = well ? width - wxl : width - wx
7234
+ ly = well ? depth - wyl : depth - wy
7235
+ dY = ly / 2
6920
7236
  next if lx.round(2) < sp.round(2)
6921
7237
  next if ly.round(2) < sp.round(2)
7238
+ end
6922
7239
 
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
7240
+ tm2 = wx * wy
6934
7241
 
6935
- dY = ly / 2
7242
+ # Inflate skylight width (and reduce spacing) to reach target.
7243
+ if tm2.round(2) < factor * skym2.round(2)
7244
+ unless tight
7245
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
7246
+
7247
+ wx *= ratio2
7248
+
7249
+ if well
7250
+ wxl = wx + gap
7251
+ lx = width - wxl
7252
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7253
+ wxl = width - lx
7254
+ wx = wxl - gap
7255
+ else
7256
+ lx = width - wx
7257
+ lx = lx.round(2) < sp.round(2) ? sp : lx
7258
+ wx = width - lx
7259
+ end
7260
+ end
6936
7261
  end
6937
7262
 
6938
- tm2 = wx * wy
6939
- undershot = tm2.round(2) < factor * skym2.round(2) ? true : false
7263
+ tm2 = wx * wy
6940
7264
 
6941
- # Inflate skylight depth to reach SRR%.
6942
- if undershot
6943
- ratio2 = 1 + (factor * skym2 - tm2) / tm2
7265
+ # Inflate skylight depth to reach target. Skip if already tight thin.
7266
+ if tm2.round(2) < factor * skym2.round(2)
7267
+ unless thin
7268
+ ratio2 = 1 + (factor * skym2 - tm2) / tm2
6944
7269
 
6945
- sp = w
6946
- wy *= ratio2
6947
- wyl = wy + gap
7270
+ wy *= ratio2
6948
7271
 
6949
- ly = well ? depth - wy : depth - wyl
6950
- ly = ly.round(2) < sp.round(2) ? sp : lx
7272
+ if well
7273
+ wyl = wy + gap
7274
+ ly = depth - wyl
7275
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7276
+ wyl = depth - ly
7277
+ wy = wyl - gap
7278
+ else
7279
+ ly = depth - wy
7280
+ ly = ly.round(2) < sp.round(2) ? sp : ly
7281
+ wy = depth - ly
7282
+ end
6951
7283
 
6952
- if well
6953
- wyl = depth - ly
6954
- wy = wyl - gap
6955
- else
6956
- wy = depth - ly
6957
- wyl = wy + gap
7284
+ dY = ly / 2
6958
7285
  end
6959
7286
  end
6960
7287
  end
@@ -6972,10 +7299,13 @@ module OSut
6972
7299
 
6973
7300
  set[pattern] = st
6974
7301
  end
7302
+
7303
+ set[:void] = true unless patterns.any? { |k| set.key?(k) }
6975
7304
  end
6976
7305
 
6977
7306
  # Delete voided sets.
6978
7307
  sets.reject! { |set| set.key?(:void) }
7308
+ return empty("sets (2)", mth, WRN, rm2) if sets.empty?
6979
7309
 
6980
7310
  # Final reset of filters.
6981
7311
  filters.map! { |f| f.include?("b") ? f.delete("b") : f } unless sidelit
@@ -6986,15 +7316,15 @@ module OSut
6986
7316
  filters.reject! { |f| f.empty? }
6987
7317
  filters.uniq!
6988
7318
 
6989
- # Initialize skylight area tally.
7319
+ # Initialize skylight area tally (to increment).
6990
7320
  skm2 = 0
6991
7321
 
6992
7322
  # Assign skylight pattern.
6993
- filters.each_with_index do |filter, i|
7323
+ filters.each do |filter|
6994
7324
  next if skm2.round(2) >= sm2.round(2)
6995
7325
 
7326
+ dm2 = sm2 - skm2 # differential (remaining skylight area to meet).
6996
7327
  sts = sets
6997
- sts = sts.sort_by { |st| st[:bm2] }.reverse!
6998
7328
  sts = sts.reject { |st| st.key?(:pattern) }
6999
7329
 
7000
7330
  if filter.include?("a")
@@ -7029,33 +7359,49 @@ module OSut
7029
7359
 
7030
7360
  fpm2[pattern] = {m2: 0, tight: false} unless fpm2.key?(pattern)
7031
7361
 
7032
- fpm2[pattern][:m2 ] += wx * wy * cols * rows
7033
- fpm2[pattern][:tight] = st[:tight] ? true : false
7362
+ fpm2[pattern][:m2 ] += st[:m] * wx * wy * cols * rows
7363
+ fpm2[pattern][:tight] = true if st[:tight]
7034
7364
  end
7035
7365
  end
7036
7366
 
7037
7367
  pattern = nil
7038
7368
  next if fpm2.empty?
7039
7369
 
7040
- fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h
7041
-
7042
- # Select suitable pattern, often overshooting. Favour array unless
7043
- # geometrically constrainted.
7370
+ # Favour (large) arrays if meeting residual target, unless constrained.
7044
7371
  if fpm2.keys.include?("array")
7045
- if (fpm2["array"][:m2]).round(2) >= sm2.round(2)
7372
+ if fpm2["array"][:m2].round(2) >= dm2.round(2)
7046
7373
  pattern = "array" unless fpm2[:tight]
7047
7374
  end
7048
7375
  end
7049
7376
 
7050
7377
  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
7378
+ fpm2 = fpm2.sort_by { |_, fm2| fm2[:m2] }.to_h
7379
+ min_m2 = fpm2.values.first[:m2]
7380
+ max_m2 = fpm2.values.last[:m2]
7381
+
7382
+ if min_m2.round(2) >= dm2.round(2)
7383
+ # If not large array, then retain pattern generating smallest skylight
7384
+ # area if ALL patterns >= residual target (deterministic sorting).
7385
+ fpm2.keep_if { |_, fm2| fm2[:m2].round(2) == min_m2.round(2) }
7386
+
7387
+ if fpm2.keys.include?("array")
7388
+ pattern = "array"
7389
+ elsif fpm2.keys.include?("strips")
7390
+ pattern = "strips"
7391
+ else fpm2.keys.include?("strip")
7392
+ pattern = "strip"
7393
+ end
7055
7394
  else
7056
- fpm2.keep_if { |_, fm2| fm2[:m2].round(2) >= sm2.round(2) }
7057
-
7058
- pattern = fpm2.keys.first
7395
+ # Pick pattern offering greatest skylight area (deterministic sorting).
7396
+ fpm2.keep_if { |_, fm2| fm2[:m2].round(2) == max_m2.round(2) }
7397
+
7398
+ if fpm2.keys.include?("strip")
7399
+ pattern = "strip"
7400
+ elsif fpm2.keys.include?("strips")
7401
+ pattern = "strips"
7402
+ else fpm2.keys.include?("array")
7403
+ pattern = "array"
7404
+ end
7059
7405
  end
7060
7406
  end
7061
7407
 
@@ -7086,55 +7432,162 @@ module OSut
7086
7432
  end
7087
7433
  end
7088
7434
 
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.
7435
+ # Delete incomplete sets (same as rejected if 'voided').
7436
+ sets.reject! { |set| set.key?(:void) }
7437
+ sets.select! { |set| set.key?(:pattern) }
7438
+ return empty("sets (3)", mth, WRN, rm2) if sets.empty?
7439
+
7440
+ # Skylight size contraction if overshot (e.g. scale down by -13% if > +13%).
7441
+ # Applied on a surface/pattern basis: individual skylight sizes may vary
7442
+ # from one surface to the next, depending on respective patterns.
7443
+
7444
+ # First, skip whole sets altogether if their total m2 < (skm2 - sm2). Only
7445
+ # considered if significant discrepancies vs average set skylight m2.
7446
+ sbm2 = 0
7447
+
7448
+ sets.each do |set|
7449
+ sbm2 += set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7450
+ end
7451
+
7452
+ avm2 = sbm2 / sets.size
7453
+
7454
+ if skm2.round(2) > sm2.round(2)
7455
+ sets.reverse.each do |set|
7456
+ break unless skm2.round(2) > sm2.round(2)
7457
+
7458
+ stm2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7459
+ next unless stm2 < 0.75 * avm2
7460
+ next unless stm2.round(2) < (skm2 - sm2).round(2)
7461
+
7462
+ skm2 -= stm2
7463
+ set[:void] = true
7464
+ end
7465
+ end
7466
+
7467
+ sets.reject! { |set| set.key?(:void) }
7468
+ return empty("sets (4)", mth, WRN, rm2) if sets.empty?
7469
+
7470
+ # Size contraction: round 1: low-hanging fruit.
7092
7471
  if skm2.round(2) > sm2.round(2)
7093
7472
  ratio2 = 1 - (skm2 - sm2) / skm2
7094
7473
  ratio = Math.sqrt(ratio2)
7095
- skm2 *= ratio2
7096
7474
 
7097
7475
  sets.each do |set|
7098
- next if set.key?(:void)
7099
- next unless set.key?(:pattern)
7476
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7477
+ xr = set[:w]
7478
+ yr = set[:d]
7100
7479
 
7101
- pattern = set[:pattern]
7102
- next unless set.key?(pattern)
7480
+ if xr > w0
7481
+ xr = xr * ratio < w0 ? w0 : xr * ratio
7482
+ end
7103
7483
 
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
7484
+ if yr > w0
7485
+ yr = yr * ratio < w0 ? w0 : yr * ratio
7127
7486
  end
7487
+
7488
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
7489
+ next if xm2.round(2) == am2.round(2)
7490
+
7491
+ set[:dY] += (set[:d] - yr) / 2
7492
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
7493
+ set[:w ] = xr
7494
+ set[:d ] = yr
7495
+ set[:w0] = set[:w] + gap
7496
+ set[:d0] = set[:d] + gap
7497
+
7498
+ skm2 -= (am2 - xm2)
7499
+ end
7500
+ end
7501
+
7502
+ # Size contraction: round 2: prioritize larger sets.
7503
+ adm2 = 0
7504
+
7505
+ sets.each_with_index do |set, i|
7506
+ next if set[:w].round(2) <= w0
7507
+ next if set[:d].round(2) <= w0
7508
+
7509
+ adm2 += set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7510
+ end
7511
+
7512
+ if skm2.round(2) > sm2.round(2) && adm2.round(2) > sm2.round(2)
7513
+ ratio2 = 1 - (adm2 - sm2) / adm2
7514
+ ratio = Math.sqrt(ratio2)
7515
+
7516
+ sets.each do |set|
7517
+ next if set[:w].round(2) <= w0
7518
+ next if set[:d].round(2) <= w0
7519
+
7520
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7521
+ xr = set[:w]
7522
+ yr = set[:d]
7523
+
7524
+ if xr > w0
7525
+ xr = xr * ratio < w0 ? w0 : xr * ratio
7526
+ end
7527
+
7528
+ if yr > w0
7529
+ yr = yr * ratio < w0 ? w0 : yr * ratio
7530
+ end
7531
+
7532
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
7533
+ next if xm2.round(2) == am2.round(2)
7534
+
7535
+ set[:dY] += (set[:d] - yr) / 2
7536
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
7537
+ set[:w ] = xr
7538
+ set[:d ] = yr
7539
+ set[:w0] = set[:w] + gap
7540
+ set[:d0] = set[:d] + gap
7541
+
7542
+ skm2 -= (am2 - xm2)
7543
+ adm2 -= (am2 - xm2)
7128
7544
  end
7129
7545
  end
7130
7546
 
7131
- # Generate skylight well roofs for attics & plenums.
7547
+ # Size contraction: round 3: Resort to sizes < requested w0.
7548
+ if skm2.round(2) > sm2.round(2)
7549
+ ratio2 = 1 - (skm2 - sm2) / skm2
7550
+ ratio = Math.sqrt(ratio2)
7551
+
7552
+ sets.each do |set|
7553
+ break unless skm2.round(2) > sm2.round(2)
7554
+
7555
+ am2 = set[:cols] * set[:w] * set[:rows] * set[:d] * set[:m]
7556
+ xr = set[:w]
7557
+ yr = set[:d]
7558
+
7559
+ if xr > gap4
7560
+ xr = xr * ratio < gap4 ? gap4 : xr * ratio
7561
+ end
7562
+
7563
+ if yr > gap4
7564
+ yr = yr * ratio < gap4 ? gap4 : yr * ratio
7565
+ end
7566
+
7567
+ xm2 = set[:cols] * xr * set[:rows] * yr * set[:m]
7568
+ next if xm2.round(2) == am2.round(2)
7569
+
7570
+ set[:dY] += (set[:d] - yr) / 2
7571
+ set[:dX] += (set[:w] - xr) / 2 if set.key?(:dX)
7572
+ set[:w ] = xr
7573
+ set[:d ] = yr
7574
+ set[:w0] = set[:w] + gap
7575
+ set[:d0] = set[:d] + gap
7576
+
7577
+ skm2 -= (am2 - xm2)
7578
+ end
7579
+ end
7580
+
7581
+ # Log warning if unable to entirely contract skylight dimensions.
7582
+ if skm2.round(2) > sm2.round(2)
7583
+ log(WRN, "Skylights slightly oversized (#{mth})")
7584
+ end
7585
+
7586
+ # Generate skylight well vertices for roofs, attics & plenums.
7132
7587
  [attics, plenums].each do |greniers|
7133
7588
  k = greniers == attics ? :attic : :plenum
7134
7589
 
7135
7590
  greniers.each do |spce, grenier|
7136
- ti = grenier[:t]
7137
-
7138
7591
  grenier[:roofs].each do |roof|
7139
7592
  sts = sets
7140
7593
  sts = sts.select { |st| st.key?(k) }
@@ -7150,15 +7603,15 @@ module OSut
7150
7603
  sts = sts.select { |st| st[:ld].key?(roof) }
7151
7604
  next if sts.empty?
7152
7605
 
7153
- # If successful, 'genInserts' returns extended roof surface vertices,
7154
- # including leader lines to support cutouts. The final selection is
7606
+ # If successful, 'genInserts' returns extended ROOF surface vertices,
7607
+ # including leader lines to support cutouts. The method also generates
7608
+ # new roof inserts. See key:value pair :vts. The FINAL go/no-go is
7155
7609
  # 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.
7610
+ # inserts (vis-à-vis attic/plenum floor below).
7158
7611
  vz = genInserts(roof, sts)
7159
- next if vz.empty? # TODO log error if empty
7612
+ next if vz.empty?
7160
7613
 
7161
- roof.setVertices(ti.inverse * vz)
7614
+ roof.setVertices(vz)
7162
7615
  end
7163
7616
  end
7164
7617
  end
@@ -7178,14 +7631,15 @@ module OSut
7178
7631
 
7179
7632
  room = rooms[space]
7180
7633
  grenier = greniers[spce]
7181
- ti = grenier[:t]
7182
- t0 = room[:t]
7634
+ ti = grenier[:ti]
7635
+ t0 = room[:t0]
7183
7636
  stz = []
7184
7637
 
7185
7638
  ceiling[:roofs].each do |roof|
7186
7639
  sts = sets
7187
7640
 
7188
7641
  sts = sts.select { |st| st.key?(k) }
7642
+ sts = sts.select { |st| st.key?(:pattern) }
7189
7643
  sts = sts.select { |st| st.key?(:clng) }
7190
7644
  sts = sts.select { |st| st.key?(:cm2) }
7191
7645
  sts = sts.select { |st| st.key?(:roof) }
@@ -7206,104 +7660,109 @@ module OSut
7206
7660
 
7207
7661
  next if stz.empty?
7208
7662
 
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
7663
  # Add new roof inserts & skylights for the (now) toplit space.
7224
7664
  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
7665
+ sub = {}
7666
+ sub[:type ] = "Skylight"
7667
+ sub[:frame] = frame if frame
7668
+ sub[:sill ] = gap / 2
7231
7669
 
7232
7670
  st[:vts].each do |id, vt|
7233
- roof = OpenStudio::Model::Surface.new(t0.inverse * vt, mdl)
7671
+ roof = OpenStudio::Model::Surface.new(t0.inverse * (ti * vt), mdl)
7234
7672
  roof.setSpace(space)
7235
- roof.setName("#{i}:#{id}:#{space.nameString}")
7673
+ roof.setName("#{id}:#{space.nameString}")
7236
7674
 
7237
7675
  # Generate well walls.
7238
- v0 = roof.vertices
7239
7676
  vX = cast(roof, tile, ray)
7240
- s0 = getSegments(v0)
7241
- sX = getSegments(vX)
7677
+ s0 = getSegments(t0 * roof.vertices)
7678
+ sX = getSegments(t0 * vX)
7242
7679
 
7243
7680
  s0.each_with_index do |sg, j|
7244
7681
  sg0 = sg.to_a
7245
7682
  sgX = sX[j].to_a
7246
- vec = OpenStudio::Point3dVector.new
7683
+ vec = OpenStudio::Point3dVector.new
7247
7684
  vec << sg0.first
7248
7685
  vec << sg0.last
7249
7686
  vec << sgX.last
7250
7687
  vec << sgX.first
7251
7688
 
7252
- grenier_wall = OpenStudio::Model::Surface.new(vec, mdl)
7689
+ v_grenier = ti.inverse * vec
7690
+ v_room = (t0.inverse * vec).to_a.reverse
7691
+
7692
+ grenier_wall = OpenStudio::Model::Surface.new(v_grenier, mdl)
7253
7693
  grenier_wall.setSpace(spce)
7254
- grenier_wall.setName("#{id}:#{j}:#{spce.nameString}")
7694
+ grenier_wall.setName("#{id}:#{i}:#{j}:#{spce.nameString}")
7255
7695
 
7256
- room_wall = OpenStudio::Model::Surface.new(vec.to_a.reverse, mdl)
7696
+ room_wall = OpenStudio::Model::Surface.new(v_room, mdl)
7257
7697
  room_wall.setSpace(space)
7258
- room_wall.setName("#{id}:#{j}:#{space.nameString}")
7698
+ room_wall.setName("#{id}:#{i}:#{j}:#{space.nameString}")
7259
7699
 
7260
7700
  grenier_wall.setAdjacentSurface(room_wall)
7261
7701
  room_wall.setAdjacentSurface(grenier_wall)
7262
7702
  end
7263
7703
 
7264
- # Add individual skylights.
7265
- addSubs(roof, [sub])
7704
+ # Add individual skylights. Independently of the set layout (rows x
7705
+ # cols), individual roof inserts may be deeper than wider (or
7706
+ # vice-versa). Adapt skylight width vs depth accordingly.
7707
+ if st[:d].round(2) > st[:w].round(2)
7708
+ sub[:width ] = st[:d] - f2
7709
+ sub[:height] = st[:w] - f2
7710
+ else
7711
+ sub[:width ] = st[:w] - f2
7712
+ sub[:height] = st[:d] - f2
7713
+ end
7714
+
7715
+ sub[:id] = roof.nameString
7716
+ addSubs(roof, sub, false, true, true)
7266
7717
  end
7267
7718
  end
7719
+
7720
+ # Vertically-cast set roof :vtx onto ceiling.
7721
+ stz.each do |st|
7722
+ st[:cvtx] = t0.inverse * cast(ti * st[:vtx], t0 * tile.vertices, ray)
7723
+ end
7724
+
7725
+ # Extended ceiling vertices.
7726
+ vertices = genExtendedVertices(tile, stz, :cvtx)
7727
+ next if vertices.empty?
7728
+
7729
+ # Reset ceiling and adjacent floor vertices.
7730
+ tile.setVertices(vertices)
7731
+ floor.setVertices(ti.inverse * (t0 * vertices).to_a.reverse)
7268
7732
  end
7269
7733
 
7270
- # New direct roof loop. No overlaps, so no need for relative space
7271
- # coordinate adjustments.
7734
+ # Loop through 'direct' roof surfaces of rooms to toplit (no attics or
7735
+ # plenums). No overlaps, so no relative space coordinate adjustments.
7272
7736
  rooms.each do |space, room|
7273
7737
  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|
7738
+ sets.each_with_index do |st, i|
7739
+ next unless st.key?(:roof)
7740
+ next unless st[:roof] == roof
7741
+ next if st.key?(:clng)
7742
+ next unless st.key?(:box)
7743
+ next unless st.key?(:cols)
7744
+ next unless st.key?(:rows)
7745
+ next unless st.key?(:d)
7746
+ next unless st.key?(:w)
7747
+ next unless st.key?(:dY)
7748
+
7749
+ w1 = st[:w ] - f2
7750
+ d1 = st[:d ] - f2
7751
+ dY = st[:dY]
7752
+
7753
+ st[:rows].times.each do |j|
7296
7754
  sub = {}
7297
7755
  sub[:type ] = "Skylight"
7298
- sub[:count ] = set[:cols]
7756
+ sub[:count ] = st[:cols]
7299
7757
  sub[:width ] = w1
7300
7758
  sub[:height ] = d1
7301
7759
  sub[:frame ] = frame if frame
7302
- sub[:id ] = "set #{i+1}:#{j+1}"
7760
+ sub[:id ] = "#{roof.nameString}:#{i}:#{j}"
7303
7761
  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])
7762
+ sub[:r_buffer] = st[:dX] if st[:dX]
7763
+ sub[:l_buffer] = st[:dX] if st[:dX]
7764
+
7765
+ addSubs(roof, sub, false, true, true)
7307
7766
  end
7308
7767
  end
7309
7768
  end