osut 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/osut/utils.rb CHANGED
@@ -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