diff --git a/README.md b/README.md index 303a884..0dfd9f5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The picture is what `toSVG` writes for page with a color for each layer. Its dimensions, leaders and stipple are small functions in the example that draw lines and text, and every size comes from a few parameters at the top of the file. -[`examples`](examples) has both drawings and how to run them. +[`examples`](examples) has the drawings and how to run them. A smaller drawing, start to finish: @@ -317,7 +317,7 @@ directly, so tests and examples need no build step. ```sh pnpm install pnpm verify # format check, lint, type check, tests, package smoke test -pnpm example # writes both examples to examples/out/ as DXF and SVG +pnpm example # writes the examples to examples/out/ as DXF and SVG pnpm readme:drawing # redraws the pictures in docs/assets/ from the examples pnpm bench # times the command from a save to the redrawn picture ``` diff --git a/docs/assets/plan-set-A-101-dark.svg b/docs/assets/plan-set-A-101-dark.svg new file mode 100644 index 0000000..117674d --- /dev/null +++ b/docs/assets/plan-set-A-101-dark.svg @@ -0,0 +1,1004 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GARAGE + 498 SF | 10'-0" CLG. + + + FOYER + + + + STUDY + 296 SF | 10'-0" CLG. + + + GREAT ROOM + 521 SF | 10'-0" CLG. + + + KITCHEN + 384 SF | 10'-0" CLG. + + + LAUNDRY + 96 SF | 9'-0" CLG. + + + PWDR. + + + + PANTRY + + + + ENTRY PORCH + 52 SF + + + COVERED LANAI + 232 SF + + + + 101 + + + + 102 + + + + 103 + + + + 104 + + + + 105 + + + + 106 + + + + 107 + + + + 108 + + + + 109 + + + + A + + 1-01 + + + A + + 1-02 + + + A + + 1-03 + + + E + + 1-04 + + + A + + 1-05 + + + B + + 1-06 + + + D + + 1-07 + + + C + + 1-08 + + + D + + 1-09 + + + + + + + 10'-0" + + + + + + + + 12'-0" + + + + + + + + 6'-9" + + + + + + + + 2'-7" + + + + + + + + 7'-2" + + + + + + + + 7'-0" + + + + + + + + 6'-6" + + + + + + + + 22'-0" + + + + + + + + 9'-4" + + + + + + + + 20'-8" + + + + + + + + 52'-0" + + + + + + + + 21'-0" + + + + + + + + 21'-0" + + + + + + + + 10'-0" + + + + + + + + 52'-0" + + + + + + + + 8'-0" + + + + + + + + 20'-0" + + + + + + + + 9'-6" + + + + + + + + 6'-6" + + + + + + + + 44'-0" + + + + + + + + 4'-10" + + + + + + + + 6'-8" + + + + + + + + 17'-6" + + + + + + + + 9'-8" + + + + + + + + 5'-4" + + + + + + + + 44'-0" + + + + 16'-0" X 8'-0" IMPACT RATED + OVERHEAD DOOR, SEE A-601 + + + RAISED LANAI SLAB EL. 10.67' NAVD, + ON STEM WALLS PER S-101, 36" GUARD + + + ENTRY PORCH SLAB EL. 10.67' NAVD, + 4" STEP DOWN FROM DOOR 101, + 36" GUARD AT OPEN EDGE + + + 5R DN TO WALK AT 8.00', + HANDRAILS BOTH SIDES + + + 4R DN TO GARAGE SLAB, + HANDRAIL, 36" LANDING + + + 4R DN TO POOL DECK + AT 8.40' NAVD + + + FLOOD VENT, TYP. OF 4, + SEE S-101 + + + + + + + + + + + + + + + + + + + + + + + 1'-3" + + + + + + + + 1'-8" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + UP + GARAGE SLAB EL. 8.50' NAVD + F.F.E. 11.00' NAVD + + + + + + 1 + A-301 + + + + + + 1 + A-301 + + + + + + 2 + A-301 + + + + + + + + + + + + + + + + + + + HALYARD & MOSS + ENGINEERING - DEMO + 100 EXAMPLE WAY, SUITE 0 + CORAL HERON KEY, FL 34999 + CA NO. 00000 (FICTIONAL) + PROJECT + CALLOWAY RESIDENCE + NEW TWO-STORY SINGLE-FAMILY RESIDENCE + 218 SANDPIPER COVE DRIVE + CORAL HERON KEY, FLORIDA 34999 + OWNER + JUNIPER AND THEO CALLOWAY + LOT 14, BLOCK C, HERON SOUND ESTATES + PARCEL ID 00-00-00-00000-000-0140 + MANGROVE COUNTY (FICTIONAL) + REVISIONS + ENGINEER'S SEAL + DEMONSTRATION SET + NOT FOR CONSTRUCTION + NO SEAL. NO ENGINEER OF RECORD. + ENGINEER OF RECORD + J. R. HALYARD, P.E. (FICTIONAL) + FLORIDA P.E. NO. 00000 + DATE + JOB NO. + DRAWN BY + CHECKED BY + SCALE + SHEET TITLE + SHEET NUMBER + + + 2026-11-13 + 26-0418 + AGENT + -- + AS NOTED + FIRST FLOOR PLAN + + ISSUED FOR PERMIT + A-101 + SHEET 3 OF 15 + + + + + + + + + + + + + + NO. + DATE + DESCRIPTION + 0 + 2026-11-13 + ISSUED FOR PERMIT + 1 + 2026-11-13 + PLAN REVIEW RESPONSES + + + + 1 + A-101 + FIRST FLOOR PLAN + SCALE: 1/4" = 1'-0" + + + + + + + N + + PLAN NOTES + + 1. + DIMENSIONS ARE TO THE FACE OF MASONRY OR THE FACE OF STUD UNLESS NOTED. DO + NOT SCALE THE DRAWINGS. + 2. + EXTERIOR WALLS OF THE FIRST FLOOR ARE 8" CMU WITH A REINFORCED TIE BEAM. SEE + S-101 AND S-501. + 3. + ALL EXTERIOR GLAZED OPENINGS ARE IMPACT RATED FOR THE DESIGN PRESSURES ON + A-601, WITH FLORIDA PRODUCT APPROVAL. + 4. + GARAGE SEPARATION (FBC-R R302.6): 5/8" TYPE X GYPSUM BOARD ON THE GARAGE CEILING + BELOW BEDROOM 2 AND THE HALL, 1/2" GYPSUM BOARD ON THE GARAGE WALLS AND ON + STRUCTURE SUPPORTING THE CEILING. DOOR 103: SOLID CORE 1 3/8" MIN., SELF-CLOSING + AND SELF-LATCHING. + 5. + FLOOD VENTS: 4 ENGINEERED VENTS, EACH CERTIFIED (ICC-ES) TO COVER 200 SQ. FT. OF + ENCLOSED AREA: 4 X 200 = 800 SQ. FT. >= 528 SQ. FT. GROSS GARAGE AREA. BOTTOM + WITHIN 12" OF GRADE, ON TWO WALLS (FBC-R R322.2.2). LOCATIONS PER S-101. + 6. + SMOKE AND CARBON MONOXIDE ALARMS PER E-101, INTERCONNECTED. + 7. + MAIN STAIR: 18 RISERS AT 7.68" (7 3/4" MAX.), 17 TREADS AT 10", 36" LANDINGS TOP AND + BOTTOM, HANDRAIL 34"-38" ABOVE THE NOSINGS, GUARD 36" MIN. (FBC-R R311.7). + 8. + GARAGE STEPS AT DOOR 103: 36" LANDING, 4 RISERS AT 7 1/2", TREADS 10", HANDRAIL ONE + SIDE. ENTRY STAIR: 5 RISERS AT 6 3/8", TREADS 11", HANDRAILS BOTH SIDES. LANAI STEPS: + 4 RISERS AT 6 13/16", TREADS 11", HANDRAILS BOTH SIDES. + 9. + GUARDS 36" HIGH AT THE PORCH AND LANAI EDGES, WHICH ARE 32" ABOVE FINISHED + GRADE (8.00' NAVD), WITH NO OPENING A 4" SPHERE PASSES (FBC-R R312.1). + 10. + ROOM AREAS ON THE TAGS ARE NET, TO THE INSIDE FACE OF THE WALLS. G-001 + TABULATES GROSS AREAS. + LEGEND + + + + 101 + + DOOR NUMBER, SEE A-601 + + + A + + WINDOW TYPE, SEE A-601 + 1-01 + WINDOW MARK, SEE A-601 + + 8" CMU WALL + + 2X4 STUD WALL + + 36" GUARD OR HALF WALL + + OVERHEAD OR HIDDEN + + + + 1 + A-301 + + SECTION CUT: NO. / SHEET + + + + + FLOOD VENT + + diff --git a/docs/assets/plan-set-A-101-light.svg b/docs/assets/plan-set-A-101-light.svg new file mode 100644 index 0000000..14e93ae --- /dev/null +++ b/docs/assets/plan-set-A-101-light.svg @@ -0,0 +1,1004 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GARAGE + 498 SF | 10'-0" CLG. + + + FOYER + + + + STUDY + 296 SF | 10'-0" CLG. + + + GREAT ROOM + 521 SF | 10'-0" CLG. + + + KITCHEN + 384 SF | 10'-0" CLG. + + + LAUNDRY + 96 SF | 9'-0" CLG. + + + PWDR. + + + + PANTRY + + + + ENTRY PORCH + 52 SF + + + COVERED LANAI + 232 SF + + + + 101 + + + + 102 + + + + 103 + + + + 104 + + + + 105 + + + + 106 + + + + 107 + + + + 108 + + + + 109 + + + + A + + 1-01 + + + A + + 1-02 + + + A + + 1-03 + + + E + + 1-04 + + + A + + 1-05 + + + B + + 1-06 + + + D + + 1-07 + + + C + + 1-08 + + + D + + 1-09 + + + + + + + 10'-0" + + + + + + + + 12'-0" + + + + + + + + 6'-9" + + + + + + + + 2'-7" + + + + + + + + 7'-2" + + + + + + + + 7'-0" + + + + + + + + 6'-6" + + + + + + + + 22'-0" + + + + + + + + 9'-4" + + + + + + + + 20'-8" + + + + + + + + 52'-0" + + + + + + + + 21'-0" + + + + + + + + 21'-0" + + + + + + + + 10'-0" + + + + + + + + 52'-0" + + + + + + + + 8'-0" + + + + + + + + 20'-0" + + + + + + + + 9'-6" + + + + + + + + 6'-6" + + + + + + + + 44'-0" + + + + + + + + 4'-10" + + + + + + + + 6'-8" + + + + + + + + 17'-6" + + + + + + + + 9'-8" + + + + + + + + 5'-4" + + + + + + + + 44'-0" + + + + 16'-0" X 8'-0" IMPACT RATED + OVERHEAD DOOR, SEE A-601 + + + RAISED LANAI SLAB EL. 10.67' NAVD, + ON STEM WALLS PER S-101, 36" GUARD + + + ENTRY PORCH SLAB EL. 10.67' NAVD, + 4" STEP DOWN FROM DOOR 101, + 36" GUARD AT OPEN EDGE + + + 5R DN TO WALK AT 8.00', + HANDRAILS BOTH SIDES + + + 4R DN TO GARAGE SLAB, + HANDRAIL, 36" LANDING + + + 4R DN TO POOL DECK + AT 8.40' NAVD + + + FLOOD VENT, TYP. OF 4, + SEE S-101 + + + + + + + + + + + + + + + + + + + + + + + 1'-3" + + + + + + + + 1'-8" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + UP + GARAGE SLAB EL. 8.50' NAVD + F.F.E. 11.00' NAVD + + + + + + 1 + A-301 + + + + + + 1 + A-301 + + + + + + 2 + A-301 + + + + + + + + + + + + + + + + + + + HALYARD & MOSS + ENGINEERING - DEMO + 100 EXAMPLE WAY, SUITE 0 + CORAL HERON KEY, FL 34999 + CA NO. 00000 (FICTIONAL) + PROJECT + CALLOWAY RESIDENCE + NEW TWO-STORY SINGLE-FAMILY RESIDENCE + 218 SANDPIPER COVE DRIVE + CORAL HERON KEY, FLORIDA 34999 + OWNER + JUNIPER AND THEO CALLOWAY + LOT 14, BLOCK C, HERON SOUND ESTATES + PARCEL ID 00-00-00-00000-000-0140 + MANGROVE COUNTY (FICTIONAL) + REVISIONS + ENGINEER'S SEAL + DEMONSTRATION SET + NOT FOR CONSTRUCTION + NO SEAL. NO ENGINEER OF RECORD. + ENGINEER OF RECORD + J. R. HALYARD, P.E. (FICTIONAL) + FLORIDA P.E. NO. 00000 + DATE + JOB NO. + DRAWN BY + CHECKED BY + SCALE + SHEET TITLE + SHEET NUMBER + + + 2026-11-13 + 26-0418 + AGENT + -- + AS NOTED + FIRST FLOOR PLAN + + ISSUED FOR PERMIT + A-101 + SHEET 3 OF 15 + + + + + + + + + + + + + + NO. + DATE + DESCRIPTION + 0 + 2026-11-13 + ISSUED FOR PERMIT + 1 + 2026-11-13 + PLAN REVIEW RESPONSES + + + + 1 + A-101 + FIRST FLOOR PLAN + SCALE: 1/4" = 1'-0" + + + + + + + N + + PLAN NOTES + + 1. + DIMENSIONS ARE TO THE FACE OF MASONRY OR THE FACE OF STUD UNLESS NOTED. DO + NOT SCALE THE DRAWINGS. + 2. + EXTERIOR WALLS OF THE FIRST FLOOR ARE 8" CMU WITH A REINFORCED TIE BEAM. SEE + S-101 AND S-501. + 3. + ALL EXTERIOR GLAZED OPENINGS ARE IMPACT RATED FOR THE DESIGN PRESSURES ON + A-601, WITH FLORIDA PRODUCT APPROVAL. + 4. + GARAGE SEPARATION (FBC-R R302.6): 5/8" TYPE X GYPSUM BOARD ON THE GARAGE CEILING + BELOW BEDROOM 2 AND THE HALL, 1/2" GYPSUM BOARD ON THE GARAGE WALLS AND ON + STRUCTURE SUPPORTING THE CEILING. DOOR 103: SOLID CORE 1 3/8" MIN., SELF-CLOSING + AND SELF-LATCHING. + 5. + FLOOD VENTS: 4 ENGINEERED VENTS, EACH CERTIFIED (ICC-ES) TO COVER 200 SQ. FT. OF + ENCLOSED AREA: 4 X 200 = 800 SQ. FT. >= 528 SQ. FT. GROSS GARAGE AREA. BOTTOM + WITHIN 12" OF GRADE, ON TWO WALLS (FBC-R R322.2.2). LOCATIONS PER S-101. + 6. + SMOKE AND CARBON MONOXIDE ALARMS PER E-101, INTERCONNECTED. + 7. + MAIN STAIR: 18 RISERS AT 7.68" (7 3/4" MAX.), 17 TREADS AT 10", 36" LANDINGS TOP AND + BOTTOM, HANDRAIL 34"-38" ABOVE THE NOSINGS, GUARD 36" MIN. (FBC-R R311.7). + 8. + GARAGE STEPS AT DOOR 103: 36" LANDING, 4 RISERS AT 7 1/2", TREADS 10", HANDRAIL ONE + SIDE. ENTRY STAIR: 5 RISERS AT 6 3/8", TREADS 11", HANDRAILS BOTH SIDES. LANAI STEPS: + 4 RISERS AT 6 13/16", TREADS 11", HANDRAILS BOTH SIDES. + 9. + GUARDS 36" HIGH AT THE PORCH AND LANAI EDGES, WHICH ARE 32" ABOVE FINISHED + GRADE (8.00' NAVD), WITH NO OPENING A 4" SPHERE PASSES (FBC-R R312.1). + 10. + ROOM AREAS ON THE TAGS ARE NET, TO THE INSIDE FACE OF THE WALLS. G-001 + TABULATES GROSS AREAS. + LEGEND + + + + 101 + + DOOR NUMBER, SEE A-601 + + + A + + WINDOW TYPE, SEE A-601 + 1-01 + WINDOW MARK, SEE A-601 + + 8" CMU WALL + + 2X4 STUD WALL + + 36" GUARD OR HALF WALL + + OVERHEAD OR HIDDEN + + + + 1 + A-301 + + SECTION CUT: NO. / SHEET + + + + + FLOOD VENT + + diff --git a/docs/assets/plan-set-A-201-dark.svg b/docs/assets/plan-set-A-201-dark.svg new file mode 100644 index 0000000..09b6d07 --- /dev/null +++ b/docs/assets/plan-set-A-201-dark.svg @@ -0,0 +1,896 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 101 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 102 + + + + + + + + + A + + + + + + + + + A + + + + + + + + + A + + + + + + + + + A + + + + + + + + + B + + + + + + + + + + E + + + + + B.F.E. 9.00' NAVD (ZONE AE) + + D.F.E. 10.00' NAVD (BFE + 1'-0") + + + + + + RIDGE + EL. 43.11' NAVD + + + + + + + MEAN ROOF HT. + EL. 37.11' NAVD + + + + + + + TOP OF PLATE + EL. 31.61' NAVD + + + + + + + SECOND FLOOR + EL. 22.52' NAVD + + + + + + + TOP OF TIE BEAM + EL. 21.00' NAVD + + + + + + + FIRST FLOOR (F.F.E.) + EL. 11.00' NAVD + + + + + + + GARAGE SLAB + EL. 8.50' NAVD + + + + + + + FINISH GRADE + EL. 8.00' NAVD + + + + + + + + 3'-0" + + + + + + + + 10'-0" + + + + + + + + 1'-6 1/4" + + + + + + + + 9'-1 1/8" + + + + + + + + 27'-1 3/8" BLDG. HT., D.F.E. TO MEAN ROOF (35'-0" MAX.) + + + + STANDING SEAM METAL ROOFING, 24 GA., + 6:12, ON SELF-ADHERED UNDERLAYMENT + + + ALUMINUM FASCIA AND + VENTED ALUMINUM SOFFIT + + + STUCCO ON LATH OVER 7/16" OSB + ON 2X6 STUDS AT 16" O.C. + + + STUCCO BAND AT FLOOR LINE, + CONTROL JOINT TOP AND BOTTOM + + + IMPACT RATED WINDOW, + SEE A-601 + + + 5/8" STUCCO ON 8" CMU + + + 36" GUARD AT PORCH EDGE, + 4" SPHERE MAX. OPENING (R312.1) + + + STUCCO ON CMU STEM WALL, + COMPACTED FILL BELOW SLAB + + + 16'-0" X 8'-0" IMPACT + OVERHEAD DOOR, SEE A-601 + + + ENTRY STAIR: 5 RISERS AT 6.4", 4 TREADS + AT 11", HANDRAILS BOTH SIDES AT 34"-38" + + + FINISH GRADE EL. 8.00' NAVD, SLOPE + AWAY 6" IN 10'-0" (FBC-R R401.3) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 108 + + + + + + + + + + + + + + + + + + B + + + + + + + + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + + E + + + + + + + + + + + + + + + + + + + E + + + + + B.F.E. 9.00' NAVD (ZONE AE) + + D.F.E. 10.00' NAVD (BFE + 1'-0") + + + + + + RIDGE + EL. 43.11' NAVD + + + + + + + MEAN ROOF HT. + EL. 37.11' NAVD + + + + + + + TOP OF PLATE + EL. 31.61' NAVD + + + + + + + SECOND FLOOR + EL. 22.52' NAVD + + + + + + + TOP OF TIE BEAM + EL. 21.00' NAVD + + + + + + + FIRST FLOOR (F.F.E.) + EL. 11.00' NAVD + + + + + + + FINISH GRADE + EL. 8.00' NAVD + + + + + + + + 3'-0" + + + + + + + + 10'-0" + + + + + + + + 1'-6 1/4" + + + + + + + + 9'-1 1/8" + + + + + + + + 27'-1 3/8" BLDG. HT., D.F.E. TO MEAN ROOF (35'-0" MAX.) + + + + STANDING SEAM METAL ROOFING, 24 GA., + 6:12, ON SELF-ADHERED UNDERLAYMENT + + + ALUMINUM FASCIA AND + VENTED ALUMINUM SOFFIT + + + STUCCO ON LATH OVER 7/16" OSB + ON 2X6 STUDS AT 16" O.C. + + + LANAI ROOF, STANDING SEAM + METAL AT 3:12, SEE S-102 + + + STUCCO BAND AT FLOOR LINE, + CONTROL JOINT TOP AND BOTTOM + + + IMPACT RATED WINDOW, + SEE A-601 + + + 5/8" STUCCO ON 8" CMU + + + 36" GUARD AT LANAI, 3 SIDES, + 4" SPHERE MAX. OPENING (R312.1) + + + STUCCO ON CMU STEM WALL, + COMPACTED FILL BELOW SLAB + + + LANAI STEPS: 4 RISERS AT 6.8", 11" TREADS, + HANDRAILS BOTH SIDES, TO POOL DECK EL. 8.40' + + + FINISH GRADE EL. 8.00' NAVD, SLOPE + AWAY 6" IN 10'-0" (FBC-R R401.3) + + + + + + + + + + + + + + + + HALYARD & MOSS + ENGINEERING - DEMO + 100 EXAMPLE WAY, SUITE 0 + CORAL HERON KEY, FL 34999 + CA NO. 00000 (FICTIONAL) + PROJECT + CALLOWAY RESIDENCE + NEW TWO-STORY SINGLE-FAMILY RESIDENCE + 218 SANDPIPER COVE DRIVE + CORAL HERON KEY, FLORIDA 34999 + OWNER + JUNIPER AND THEO CALLOWAY + LOT 14, BLOCK C, HERON SOUND ESTATES + PARCEL ID 00-00-00-00000-000-0140 + MANGROVE COUNTY (FICTIONAL) + REVISIONS + ENGINEER'S SEAL + DEMONSTRATION SET + NOT FOR CONSTRUCTION + NO SEAL. NO ENGINEER OF RECORD. + ENGINEER OF RECORD + J. R. HALYARD, P.E. (FICTIONAL) + FLORIDA P.E. NO. 00000 + DATE + JOB NO. + DRAWN BY + CHECKED BY + SCALE + SHEET TITLE + SHEET NUMBER + + + 2026-11-13 + 26-0418 + AGENT + -- + AS NOTED + EXTERIOR ELEVATIONS, + SOUTH AND NORTH + ISSUED FOR PERMIT + A-201 + SHEET 6 OF 15 + + + + + + + + + + + + + + NO. + DATE + DESCRIPTION + 0 + 2026-11-13 + ISSUED FOR PERMIT + 1 + 2026-11-13 + PLAN REVIEW RESPONSES + + + + 1 + A-201 + SOUTH (FRONT) ELEVATION + SCALE: 1/4" = 1'-0" + + + + + + 2 + A-201 + NORTH (REAR) ELEVATION + SCALE: 1/4" = 1'-0" + + + ELEVATION NOTES + + 1. + ELEVATIONS IN FEET ARE IN NAVD 88. HEIGHTS ARE TO THE TOP OF CONCRETE OR THE TOP OF FRAMING. + 2. + FLOOD ZONE AE, PANEL 12000C0000J (DEMO). B.F.E. 9.00', D.F.E. 10.00', F.F.E. 11.00' NAVD. THE LOWEST + FLOOR IS 2'-0" ABOVE THE B.F.E. (FBC-R R322.2.1). + 3. + THE GARAGE IS BELOW THE D.F.E.: PARKING, ACCESS AND STORAGE ONLY. FLOOD DAMAGE-RESISTANT + MATERIALS BELOW THE D.F.E. FLOOD VENTS PER S-101. + 4. + STUCCO: 3 COATS, 7/8" ON LATH OVER FRAMING, 5/8" ON MASONRY, WITH CONTROL JOINTS AT 144 SQ. FT. + MAX. AND AT EVERY CHANGE OF SUBSTRATE (ASTM C926, C1063). + 5. + ALL WINDOWS AND DOORS IMPACT RATED, WITH FLORIDA PRODUCT APPROVAL, FOR THE DESIGN + PRESSURES ON A-601. WINDOW HEADS: FIRST FLOOR 8'-0", SECOND FLOOR 7'-2" A.F.F.; GARAGE WINDOW + 8'-0" ABOVE THE GARAGE SLAB. + 6. + ROOFING: STANDING SEAM METAL WITH FLORIDA PRODUCT APPROVAL, ON SELF-ADHERED + UNDERLAYMENT OVER 19/32" PLYWOOD DECK (FBC-R R905.10). + 7. + BUILDING HEIGHT IS MEASURED FROM THE D.F.E. (10.00' NAVD) TO THE MEAN ROOF HEIGHT: 27'-1 3/8", 35'-0" + MAX. IN RSF-6 RESIDENTIAL SINGLE-FAMILY (DEMO). + 8. + FINISH GRADE 8.00' NAVD AT THE FOUNDATION (EXISTING 7.50'), SLOPING AWAY 6" IN THE FIRST 10'-0" TO + THE SWALES (FBC-R R401.3). SEE C-101. + 9. + ENTRY STAIR: 5 RISERS OF 6.4" AND 4 TREADS OF 11" FROM THE PORCH (10.67') TO THE WALK. LANAI STEPS: + 4 RISERS OF 6.8" AND 3 TREADS OF 11" TO THE POOL DECK (8.40'). HANDRAILS BOTH SIDES OF EACH, 34" TO + 38" ABOVE THE NOSINGS (FBC-R R311.7.8). + 10. + GUARDS: 36" HIGH AT THE ENTRY PORCH'S OPEN EDGE AND THE LANAI'S WEST, NORTH AND EAST SIDES, + WHERE THE SLAB IS MORE THAN 30" ABOVE GRADE. NO OPENING PASSES A 4" SPHERE (FBC-R R312.1). + LEVELS + LEVEL + ABOVE F.F. + NAVD 88 + + RIDGE + 32'-1 3/8" + 43.11' + + MEAN ROOF HEIGHT + 26'-1 3/8" + 37.11' + + TOP OF PLATE, 2ND FLOOR + 20'-7 3/8" + 31.61' + + SECOND FLOOR + 11'-6 1/4" + 22.52' + + TOP OF TIE BEAM + 10'-0" + 21.00' + + FIRST FLOOR (F.F.E.) + 0" + 11.00' + + ENTRY PORCH AND LANAI SLAB + -4" + 10.67' + + D.F.E. + -1'-0" + 10.00' + + B.F.E. + -2'-0" + 9.00' + + GARAGE SLAB + -2'-6" + 8.50' + + POOL DECK + -2'-7 1/4" + 8.40' + + FINISH GRADE AT FOUNDATION + -3'-0" + 8.00' + + EXISTING GRADE + -3'-6" + 7.50' + + + + + + diff --git a/docs/assets/plan-set-A-201-light.svg b/docs/assets/plan-set-A-201-light.svg new file mode 100644 index 0000000..36f1588 --- /dev/null +++ b/docs/assets/plan-set-A-201-light.svg @@ -0,0 +1,896 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 101 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 102 + + + + + + + + + A + + + + + + + + + A + + + + + + + + + A + + + + + + + + + A + + + + + + + + + B + + + + + + + + + + E + + + + + B.F.E. 9.00' NAVD (ZONE AE) + + D.F.E. 10.00' NAVD (BFE + 1'-0") + + + + + + RIDGE + EL. 43.11' NAVD + + + + + + + MEAN ROOF HT. + EL. 37.11' NAVD + + + + + + + TOP OF PLATE + EL. 31.61' NAVD + + + + + + + SECOND FLOOR + EL. 22.52' NAVD + + + + + + + TOP OF TIE BEAM + EL. 21.00' NAVD + + + + + + + FIRST FLOOR (F.F.E.) + EL. 11.00' NAVD + + + + + + + GARAGE SLAB + EL. 8.50' NAVD + + + + + + + FINISH GRADE + EL. 8.00' NAVD + + + + + + + + 3'-0" + + + + + + + + 10'-0" + + + + + + + + 1'-6 1/4" + + + + + + + + 9'-1 1/8" + + + + + + + + 27'-1 3/8" BLDG. HT., D.F.E. TO MEAN ROOF (35'-0" MAX.) + + + + STANDING SEAM METAL ROOFING, 24 GA., + 6:12, ON SELF-ADHERED UNDERLAYMENT + + + ALUMINUM FASCIA AND + VENTED ALUMINUM SOFFIT + + + STUCCO ON LATH OVER 7/16" OSB + ON 2X6 STUDS AT 16" O.C. + + + STUCCO BAND AT FLOOR LINE, + CONTROL JOINT TOP AND BOTTOM + + + IMPACT RATED WINDOW, + SEE A-601 + + + 5/8" STUCCO ON 8" CMU + + + 36" GUARD AT PORCH EDGE, + 4" SPHERE MAX. OPENING (R312.1) + + + STUCCO ON CMU STEM WALL, + COMPACTED FILL BELOW SLAB + + + 16'-0" X 8'-0" IMPACT + OVERHEAD DOOR, SEE A-601 + + + ENTRY STAIR: 5 RISERS AT 6.4", 4 TREADS + AT 11", HANDRAILS BOTH SIDES AT 34"-38" + + + FINISH GRADE EL. 8.00' NAVD, SLOPE + AWAY 6" IN 10'-0" (FBC-R R401.3) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 108 + + + + + + + + + + + + + + + + + + B + + + + + + + + + + + + + + + + + + A + + + + + + + + + + + + + + + + + + + E + + + + + + + + + + + + + + + + + + + E + + + + + B.F.E. 9.00' NAVD (ZONE AE) + + D.F.E. 10.00' NAVD (BFE + 1'-0") + + + + + + RIDGE + EL. 43.11' NAVD + + + + + + + MEAN ROOF HT. + EL. 37.11' NAVD + + + + + + + TOP OF PLATE + EL. 31.61' NAVD + + + + + + + SECOND FLOOR + EL. 22.52' NAVD + + + + + + + TOP OF TIE BEAM + EL. 21.00' NAVD + + + + + + + FIRST FLOOR (F.F.E.) + EL. 11.00' NAVD + + + + + + + FINISH GRADE + EL. 8.00' NAVD + + + + + + + + 3'-0" + + + + + + + + 10'-0" + + + + + + + + 1'-6 1/4" + + + + + + + + 9'-1 1/8" + + + + + + + + 27'-1 3/8" BLDG. HT., D.F.E. TO MEAN ROOF (35'-0" MAX.) + + + + STANDING SEAM METAL ROOFING, 24 GA., + 6:12, ON SELF-ADHERED UNDERLAYMENT + + + ALUMINUM FASCIA AND + VENTED ALUMINUM SOFFIT + + + STUCCO ON LATH OVER 7/16" OSB + ON 2X6 STUDS AT 16" O.C. + + + LANAI ROOF, STANDING SEAM + METAL AT 3:12, SEE S-102 + + + STUCCO BAND AT FLOOR LINE, + CONTROL JOINT TOP AND BOTTOM + + + IMPACT RATED WINDOW, + SEE A-601 + + + 5/8" STUCCO ON 8" CMU + + + 36" GUARD AT LANAI, 3 SIDES, + 4" SPHERE MAX. OPENING (R312.1) + + + STUCCO ON CMU STEM WALL, + COMPACTED FILL BELOW SLAB + + + LANAI STEPS: 4 RISERS AT 6.8", 11" TREADS, + HANDRAILS BOTH SIDES, TO POOL DECK EL. 8.40' + + + FINISH GRADE EL. 8.00' NAVD, SLOPE + AWAY 6" IN 10'-0" (FBC-R R401.3) + + + + + + + + + + + + + + + + HALYARD & MOSS + ENGINEERING - DEMO + 100 EXAMPLE WAY, SUITE 0 + CORAL HERON KEY, FL 34999 + CA NO. 00000 (FICTIONAL) + PROJECT + CALLOWAY RESIDENCE + NEW TWO-STORY SINGLE-FAMILY RESIDENCE + 218 SANDPIPER COVE DRIVE + CORAL HERON KEY, FLORIDA 34999 + OWNER + JUNIPER AND THEO CALLOWAY + LOT 14, BLOCK C, HERON SOUND ESTATES + PARCEL ID 00-00-00-00000-000-0140 + MANGROVE COUNTY (FICTIONAL) + REVISIONS + ENGINEER'S SEAL + DEMONSTRATION SET + NOT FOR CONSTRUCTION + NO SEAL. NO ENGINEER OF RECORD. + ENGINEER OF RECORD + J. R. HALYARD, P.E. (FICTIONAL) + FLORIDA P.E. NO. 00000 + DATE + JOB NO. + DRAWN BY + CHECKED BY + SCALE + SHEET TITLE + SHEET NUMBER + + + 2026-11-13 + 26-0418 + AGENT + -- + AS NOTED + EXTERIOR ELEVATIONS, + SOUTH AND NORTH + ISSUED FOR PERMIT + A-201 + SHEET 6 OF 15 + + + + + + + + + + + + + + NO. + DATE + DESCRIPTION + 0 + 2026-11-13 + ISSUED FOR PERMIT + 1 + 2026-11-13 + PLAN REVIEW RESPONSES + + + + 1 + A-201 + SOUTH (FRONT) ELEVATION + SCALE: 1/4" = 1'-0" + + + + + + 2 + A-201 + NORTH (REAR) ELEVATION + SCALE: 1/4" = 1'-0" + + + ELEVATION NOTES + + 1. + ELEVATIONS IN FEET ARE IN NAVD 88. HEIGHTS ARE TO THE TOP OF CONCRETE OR THE TOP OF FRAMING. + 2. + FLOOD ZONE AE, PANEL 12000C0000J (DEMO). B.F.E. 9.00', D.F.E. 10.00', F.F.E. 11.00' NAVD. THE LOWEST + FLOOR IS 2'-0" ABOVE THE B.F.E. (FBC-R R322.2.1). + 3. + THE GARAGE IS BELOW THE D.F.E.: PARKING, ACCESS AND STORAGE ONLY. FLOOD DAMAGE-RESISTANT + MATERIALS BELOW THE D.F.E. FLOOD VENTS PER S-101. + 4. + STUCCO: 3 COATS, 7/8" ON LATH OVER FRAMING, 5/8" ON MASONRY, WITH CONTROL JOINTS AT 144 SQ. FT. + MAX. AND AT EVERY CHANGE OF SUBSTRATE (ASTM C926, C1063). + 5. + ALL WINDOWS AND DOORS IMPACT RATED, WITH FLORIDA PRODUCT APPROVAL, FOR THE DESIGN + PRESSURES ON A-601. WINDOW HEADS: FIRST FLOOR 8'-0", SECOND FLOOR 7'-2" A.F.F.; GARAGE WINDOW + 8'-0" ABOVE THE GARAGE SLAB. + 6. + ROOFING: STANDING SEAM METAL WITH FLORIDA PRODUCT APPROVAL, ON SELF-ADHERED + UNDERLAYMENT OVER 19/32" PLYWOOD DECK (FBC-R R905.10). + 7. + BUILDING HEIGHT IS MEASURED FROM THE D.F.E. (10.00' NAVD) TO THE MEAN ROOF HEIGHT: 27'-1 3/8", 35'-0" + MAX. IN RSF-6 RESIDENTIAL SINGLE-FAMILY (DEMO). + 8. + FINISH GRADE 8.00' NAVD AT THE FOUNDATION (EXISTING 7.50'), SLOPING AWAY 6" IN THE FIRST 10'-0" TO + THE SWALES (FBC-R R401.3). SEE C-101. + 9. + ENTRY STAIR: 5 RISERS OF 6.4" AND 4 TREADS OF 11" FROM THE PORCH (10.67') TO THE WALK. LANAI STEPS: + 4 RISERS OF 6.8" AND 3 TREADS OF 11" TO THE POOL DECK (8.40'). HANDRAILS BOTH SIDES OF EACH, 34" TO + 38" ABOVE THE NOSINGS (FBC-R R311.7.8). + 10. + GUARDS: 36" HIGH AT THE ENTRY PORCH'S OPEN EDGE AND THE LANAI'S WEST, NORTH AND EAST SIDES, + WHERE THE SLAB IS MORE THAN 30" ABOVE GRADE. NO OPENING PASSES A 4" SPHERE (FBC-R R312.1). + LEVELS + LEVEL + ABOVE F.F. + NAVD 88 + + RIDGE + 32'-1 3/8" + 43.11' + + MEAN ROOF HEIGHT + 26'-1 3/8" + 37.11' + + TOP OF PLATE, 2ND FLOOR + 20'-7 3/8" + 31.61' + + SECOND FLOOR + 11'-6 1/4" + 22.52' + + TOP OF TIE BEAM + 10'-0" + 21.00' + + FIRST FLOOR (F.F.E.) + 0" + 11.00' + + ENTRY PORCH AND LANAI SLAB + -4" + 10.67' + + D.F.E. + -1'-0" + 10.00' + + B.F.E. + -2'-0" + 9.00' + + GARAGE SLAB + -2'-6" + 8.50' + + POOL DECK + -2'-7 1/4" + 8.40' + + FINISH GRADE AT FOUNDATION + -3'-0" + 8.00' + + EXISTING GRADE + -3'-6" + 7.50' + + + + + + diff --git a/examples/README.md b/examples/README.md index 3e99614..74d9c03 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,9 @@ # Examples Each example builds a drawing and writes it to `examples/out/` as an AutoCAD -2018 DXF and an SVG. `pnpm example` runs both. The pictures here are the SVGs -restyled by `pnpm readme:drawing`, which gives each layer a color and keeps the -lineweights the drawing sets. +2018 DXF and an SVG. `pnpm example` runs them all. The pictures here are the +SVGs restyled by `pnpm readme:drawing`, which gives each layer a color and keeps +the lineweights the drawing sets. ## Outdoor kitchen @@ -129,3 +129,85 @@ sides in the order `rect` draws them. The lot and the residence have the ids Move the residence toward the street and the command warns that it is in the front setback, with the distance it keeps. [Checks of intent](../docs/intent.md) has more. + +## Permit set + + + + Sheet A-101 of the permit set, the first floor plan at 1/4 inch to 1 foot: walls, doors, windows, the stair and the fixtures, with door and window tags, room names and areas, dimension strings, revision clouds, plan notes and a legend, inside a border with the firm, the project and the sheet number in a strip down the right side + + + + + Sheet A-201 of the permit set, the south and north elevations of a two-story house with a hip roof at 1/4 inch to 1 foot, with level markers in feet above the datum, flood elevations, window tags, notes and a table of levels, inside the same border + + +[`plan-set/`](plan-set) is a permit set for a two-story house on a canal lot in +Florida, in 15 sheets on ARCH D: a cover with the sheet index and the code data, +the site plan, the floor plans and the roof plan, the elevations, a building +section with a wall section, the door and window schedules, the foundation plan, +the framing plans and the structural details, and the mechanical, electrical and +plumbing sheets. It is one drawing in inches, with a layout for each sheet. + +The project, the owner, the address, the engineer and the firm are made up. The +set shows how a whole set is drawn with tsdraft. It is not a design to build +from: no engineer designed it, and its seal reads "DEMONSTRATION SET, NOT FOR +CONSTRUCTION". + +```sh +node examples/plan-set/set.ts +``` + +It writes the set to `examples/out/plan-set/plan-set.dxf`, the border to +`Set up/TB-ARCHD.dxf` beside it and an SVG of each sheet with its border. +The file's default export is the drawing, so `tsdraft render` and `tsdraft dev` +take it too. It also exports the border's +drawing as `xrefs`, which the pictures draw on each sheet, and `intent`, the +zoning setbacks, which `tsdraft render` and `tsdraft dev` check on each build. + +How it is put together: + +- [`model/house.ts`](plan-set/model/house.ts) is the house, once: its walls, + openings, rooms, plate heights, floor depth and roof, in inches from the + southwest corner of the main block. Every sheet reads it. The floor plans, the + foundation and framing plans and the mechanical and electrical plans draw the + same walls, and the elevations, the section and the plumbing risers take their + heights from it. +- The schedules and tables are worked out in code. The door and window schedules + list the openings of the model, with each window's design pressure. The cover + adds up the areas of the house. The panel schedule and the service load count + the devices the power plan places. The plumbing fixture schedule adds up the + fixture units of its fixtures. [`project.ts`](plan-set/project.ts) holds the + project, the firm and the design criteria that the cover, the notes and the + title block read. +- [`lib/kit.ts`](plan-set/lib/kit.ts) defines the layers, the text styles, the + symbols as blocks and the helpers the sheets share, and + [`lib/plan.ts`](plan-set/lib/plan.ts) draws a floor plan of a level at any + origin. Each sheet draws its views in model space at an origin of its own, so + no viewport shows another sheet's views, and sizes their annotation for the + view's scale with `fromPaper`. +- The border is a drawing of its own, `borderDrawing()` in + [`lib/titleblock.ts`](plan-set/lib/titleblock.ts). Each layout places it as the + xref `TB-ARCHD`, from the path `Set up/TB-ARCHD.dwg`, the way a firm keeps its + border in a file of its own. The sheet's fields, such as its title, number, + date and scale, are the attributes of a block, `SHEET FIELDS`, inserted over + the border. To load the border in CAD, save its DXF as that DWG, in a + `Set up` folder beside the set's file. + +What the set does not do yet, because tsdraft cannot: + +- Hatches and fills are loose lines: earth, insulation and stipple from + [`lib/drafting.ts`](lib/drafting.ts), and concrete, batt insulation and fill + from [`sheets/elevations-lib.ts`](plan-set/sheets/elevations-lib.ts). +- Leaders are a polyline, an arrowhead and text, not leader objects. +- A dimension writes only a decimal number, so the example writes the feet and + inches of each architectural dimension as its text. +- A note is single-line texts, one for each line, broken with `measureText`. +- The title block's fields are attributes the code sets, not fields that CAD + fills from the file and the layout. + +The set is also drawn without a standard. Its layers, text styles and text +heights are its own, in `lib/kit.ts`, and its border is an xref and a block of +its own rather than a standard's title block. To draw a sheet of your own, start +from the [site plan](#site-plan), which takes its sheet format, title block, +layers and text roles from a standard. diff --git a/examples/plan-set/lib/frame.ts b/examples/plan-set/lib/frame.ts new file mode 100644 index 0000000..25195fb --- /dev/null +++ b/examples/plan-set/lib/frame.ts @@ -0,0 +1,55 @@ +// The frame every sheet shares: the layout on ARCH D, the border attached as an xref, the +// sheet's fields and its revision table. +import type { Point, Sheet } from "../../../src/index.ts"; +import { PROJECT, SHEETS, sheetInfo } from "../project.ts"; +import { LAYERS, type D } from "./kit.ts"; +import { revisionTable } from "./titleblock.ts"; + +export const BORDER = "TB-ARCHD"; +/** The border's path from the set's file, as the firm keeps it: in a `Set up` folder. */ +export const BORDER_PATH = "Set up/TB-ARCHD.dwg"; + +export const REVISIONS = [ + { no: "0", date: PROJECT.date, what: PROJECT.issue }, + { no: "1", date: PROJECT.date, what: "PLAN REVIEW RESPONSES" }, +]; + +export type SheetFn = (d: D, sheet: Sheet) => void; + +/** Adds the layout of sheet `number`, framed, and calls `draw` on it. */ +export function frame(d: D, number: string, draw: SheetFn): void { + const info = sheetInfo(number); + const index = SHEETS.indexOf(info) + 1; + d.layout( + number, + { paper: "ARCH D", plotStyle: info.mep ? "acad.ctb" : "monochrome.ctb" }, + (sheet) => { + d.layer(LAYERS.ttlb, {}, () => { + d.insert(BORDER, [0, 0], { id: `border-${number}` }); + d.insert("SHEET FIELDS", [0, 0], { + id: `fields-${number}`, + attributes: { + DATE: PROJECT.date, + JOB: PROJECT.job, + DRAWN: PROJECT.drawn, + CHECKED: PROJECT.checked, + SCALE: "AS NOTED", + TITLE1: info.title, + TITLE2: info.title2 ?? "", + ISSUE: PROJECT.issue, + SHEETNO: number, + SHEETOF: `SHEET ${index} OF ${SHEETS.length}`, + }, + }); + }); + revisionTable( + d, + number === "M-101" ? [REVISIONS[0]!, { ...REVISIONS[1]!, what: "SHEET ADDED" }] : REVISIONS, + ); + draw(d, sheet); + }, + ); +} + +/** A point on the sheet. */ +export type P = Point; diff --git a/examples/plan-set/lib/kit.ts b/examples/plan-set/lib/kit.ts new file mode 100644 index 0000000..b8d22ba --- /dev/null +++ b/examples/plan-set/lib/kit.ts @@ -0,0 +1,829 @@ +// The set's drafting kit: text styles, layers, symbols as blocks, the sheet frame and the +// helpers every sheet uses. The drawing is in inches, as the firm's model files are. Sizes of +// annotation are paper sizes, turned into model inches for a view's ratio with `fromPaper`. +import { + CENTER, + DASHED, + fromPaper, + HIDDEN, + measureText, + PHANTOM, + type DrawingBuilder, + type Point, + type TextAlign, + type TextVerticalAlign, +} from "../../../src/index.ts"; +import { arrowhead, feetInches } from "../../lib/drafting.ts"; + +export type D = DrawingBuilder; +export const FONT = "Arial" as const; +export const NOTES = "NOTES"; +export const TITLE = "TITLE"; + +/** Text heights on paper, in inches. */ +export const H = { + small: 1 / 16, + note: 3 / 32, + label: 1 / 8, + sub: 5 / 32, + head: 3 / 16, + title: 1 / 4, +}; + +/** A view: its ratio and the paper-to-model helper for it. */ +export interface View { + readonly ratio: number; + readonly label: string; + /** Paper inches as model inches. */ + readonly p: (inches: number) => number; + /** The dimension style for the view. */ + readonly dim: string; +} + +const LABELS: Record = { + 12: `1" = 1'-0"`, + 16: `3/4" = 1'-0"`, + 24: `1/2" = 1'-0"`, + 32: `3/8" = 1'-0"`, + 48: `1/4" = 1'-0"`, + 64: `3/16" = 1'-0"`, + 96: `1/8" = 1'-0"`, + 192: `1/16" = 1'-0"`, + 120: `1" = 10'-0"`, + 240: `1" = 20'-0"`, +}; + +const views = new Map(); + +/** The view at `ratio`, with its dimension style, defined once per drawing. */ +export function view(d: D, ratio: number): View { + const known = views.get(ratio); + if (known) return known; + const p = (inches: number) => fromPaper(inches, "in", { ratio, units: "in" }); + const dim = `DIM-${ratio}`; + const civil = ratio >= 120; + d.dimensionStyle({ + name: dim, + textStyle: NOTES, + textHeight: H.note, + arrowhead: "architectural-tick", + arrowSize: 1 / 16, + extensionOffset: 1 / 16, + extensionBeyond: 1 / 16, + textGap: 1 / 32, + scale: p(1), + // A civil view measures in decimal feet. An architectural one gets feet and inches as a + // text override, since a dimension writes only decimals. + precision: civil ? 1 : 0, + measurementScale: civil ? 1 / 12 : 1, + suffix: civil ? "'" : '"', + }); + const v: View = { ratio, label: LABELS[ratio] ?? `1:${ratio}`, p, dim }; + views.set(ratio, v); + return v; +} + +/** Resets the per-drawing caches, for a second drawing in one process. */ +export function resetKit(): void { + views.clear(); +} + +export const ftIn = (inches: number): string => feetInches(Math.abs(inches)); + +/** How long a text plots, in paper inches at `height`. */ +export const width = (value: string, height: number): number => + measureText({ value, height, font: FONT }).length; + +/** A text in the set's style. */ +export function put( + d: D, + at: Point, + value: string, + height: number, + o: { align?: TextAlign; valign?: TextVerticalAlign; rotation?: number; style?: string } = {}, +): void { + if (!value.trim()) return; + d.text(at, value, { height, ...o, style: o.style ?? NOTES }); +} + +/** Breaks a sentence into lines no longer than `max`, by how long each plots. */ +export function wrap(sentence: string, height: number, max: number): string[] { + const lines: string[] = []; + let line = ""; + for (const word of sentence.split(/\s+/)) { + const longer = line ? `${line} ${word}` : word; + if (line && width(longer, height) > max) { + lines.push(line); + line = word; + } else line = longer; + } + return line ? [...lines, line] : lines; +} + +export const LAYERS = { + // The firm's sheet layers. + ttlb: "G-TTLB", + text: "G-ANNO-TEXT", + vport: "G-ANNO-NPLT", + revText: "Rev_Text", + revs: "G-ANNO-REVS", + // Plans. + wall: "A-WALL", + wallLow: "A-WALL-PRHT", + door: "A-DOOR", + glaz: "A-GLAZ", + flor: "A-FLOR", + stair: "A-FLOR-STRS", + case: "A-FLOR-CASE", + fixt: "P-FIXT", + ovhd: "A-FLOR-OVHD", + roof: "A-ROOF", + roofOutl: "A-ROOF-OUTL", + anno: "A-ANNO-TEXT", + dims: "A-ANNO-DIMS", + symb: "A-ANNO-SYMB", + tags: "A-ANNO-TAGS", + elev: "A-ELEV", + elevFine: "A-ELEV-FINE", + sect: "A-SECT", + sectFine: "A-SECT-FINE", + patt: "A-PATT", + grade: "A-ELEV-GRAD", + // Structure. + fndn: "S-FNDN", + ftng: "S-FNDN-HIDD", + slab: "S-SLAB", + fram: "S-FRAM", + beam: "S-BEAM", + strAnno: "S-ANNO-TEXT", + strDims: "S-ANNO-DIMS", + // Site. + prop: "C-PROP", + setb: "C-SETB", + bldg: "C-BLDG", + pave: "C-PAVE", + road: "C-ROAD-CNTR", + wall2: "C-SEAW", + water: "C-WATR", + topo: "C-TOPO", + util: "C-UTIL", + tree: "L-PLNT", + civAnno: "C-ANNO-TEXT", + civDims: "C-ANNO-DIMS", + // MEP. The base plan under them plots gray. + base: "E-BASE", + lite: "E-LITE", + powr: "E-POWR", + circ: "E-POWR-CIRC", + eAnno: "E-ANNO-TEXT", + plmb: "P-DOMW", + sanr: "P-SANR", + pAnno: "P-ANNO-TEXT", +} as const; + +/** Defines every layer of the set, with its color, linetype and pen. */ +export function defineLayers(d: D): void { + const L = LAYERS; + const layer = (name: string, color: number, lineweight: number | "Default", linetype?: string) => + d.layer(name, { color, lineweight, ...(linetype ? { linetype } : {}) }); + d.linetype(DASHED); + d.linetype(HIDDEN); + d.linetype(CENTER); + d.linetype(PHANTOM); + layer(L.ttlb, 7, 0.35); + layer(L.text, 7, 0.25); + d.layer(L.vport, { color: 8, plot: false }); + layer(L.revText, 7, 0.18); + layer(L.revs, 7, 0.35); + for (let i = 1; i <= 8; i++) layer(`Rev_${i}`, 7, 0.18); + layer(L.wall, 7, 0.5); + layer(L.wallLow, 7, 0.35); + layer(L.door, 7, 0.25); + layer(L.glaz, 7, 0.18); + layer(L.flor, 7, 0.18); + layer(L.stair, 7, 0.25); + layer(L.case, 7, 0.18); + layer(L.fixt, 7, 0.18); + layer(L.ovhd, 7, 0.18, "HIDDEN"); + layer(L.roof, 7, 0.35); + layer(L.roofOutl, 7, 0.5); + layer(L.anno, 7, 0.25); + layer(L.dims, 7, 0.18); + layer(L.symb, 7, 0.25); + layer(L.tags, 7, 0.25); + layer(L.elev, 7, 0.5); + layer(L.elevFine, 7, 0.18); + layer(L.sect, 7, 0.6); + layer(L.sectFine, 7, 0.25); + layer(L.patt, 7, 0.09); + layer(L.grade, 7, 0.7); + layer(L.fndn, 7, 0.5); + layer(L.ftng, 7, 0.25, "HIDDEN"); + layer(L.slab, 7, 0.18); + layer(L.fram, 7, 0.25); + layer(L.beam, 7, 0.5, "DASHED"); + layer(L.strAnno, 7, 0.25); + layer(L.strDims, 7, 0.18); + layer(L.prop, 7, 0.7, "PHANTOM"); + layer(L.setb, 7, 0.25, "DASHED"); + layer(L.bldg, 7, 0.6); + layer(L.pave, 7, 0.35); + layer(L.road, 7, 0.18, "CENTER"); + layer(L.wall2, 7, 0.5); + layer(L.water, 7, 0.18); + layer(L.topo, 7, 0.18, "DASHED"); + layer(L.util, 7, 0.25, "DASHED"); + layer(L.tree, 7, 0.18); + layer(L.civAnno, 7, 0.25); + layer(L.civDims, 7, 0.18); + // Color 252 is a light gray. The MEP sheets plot with acad.ctb, so it screens the base plan. + layer(L.base, 252, 0.18); + layer(L.lite, 7, 0.35); + layer(L.powr, 7, 0.35); + layer(L.circ, 7, 0.25); + layer(L.eAnno, 7, 0.25); + layer(L.plmb, 7, 0.35); + layer(L.sanr, 7, 0.5); + layer(L.pAnno, 7, 0.25); +} + +/** Defines the text styles and the set's symbols. Call once, before any sheet. */ +export function defineSymbols(d: D): void { + d.textStyle({ name: NOTES, font: FONT }); + d.textStyle({ name: TITLE, font: FONT, widthFactor: 0.9 }); + + // Symbols are drawn in paper inches. In model space they are inserted at `view.p(1)`. + d.block("NORTH ARROW", {}, () => { + const r = 0.375; + d.circle([0, 0], r); + d.polyline( + [ + [0, r * 1.25], + [r * 0.38, -r * 0.7], + [0, -r * 0.35], + [-r * 0.38, -r * 0.7], + ], + true, + ); + d.line([0, -r * 0.35], [0, r * 1.25]); + d.text([0, r * 1.35], "N", { height: H.head, style: NOTES, align: "center" }); + }); + // A door tag: a number in a circle. + d.block("DOOR TAG", {}, () => { + d.circle([0, 0], 0.14); + d.attribute("DOOR", [0, 0], { + value: "000", + height: 0.08, + style: NOTES, + align: "center", + valign: "middle", + }); + }); + // A window tag: a letter in a hexagon. + d.block("WINDOW TAG", {}, () => { + const r = 0.13; + const pts: Point[] = []; + for (let i = 0; i < 6; i++) + pts.push([r * Math.cos((Math.PI / 3) * i), r * Math.sin((Math.PI / 3) * i)]); + d.polyline(pts, true); + d.attribute("WINDOW", [0, 0], { + value: "X", + height: 0.09, + style: NOTES, + align: "center", + valign: "middle", + }); + }); + // A room tag: the name over the area and the ceiling. + d.block("ROOM TAG", {}, () => { + d.attribute("ROOM", [0, 0.04], { + value: "ROOM", + height: H.label, + style: NOTES, + align: "center", + }); + d.attribute("AREA", [0, -0.04], { + value: "", + height: H.note, + style: NOTES, + align: "center", + valign: "top", + }); + }); + // A section mark: a bubble with the section over the sheet, and an arrow the way it looks. + // Its base point is the bubble's middle. Rotate it to turn the arrow; +X is the default. + d.block("SECTION MARK", {}, () => { + const r = 0.22; + d.circle([0, 0], r); + d.line([-r, 0], [r, 0]); + d.polyline( + [ + [r * 0.7, r * 0.7], + [r * 1.9, 0], + [r * 0.7, -r * 0.7], + ], + false, + ); + d.attribute("SECT", [0, 0.04], { value: "1", height: 0.1, style: NOTES, align: "center" }); + d.attribute("SHEET", [0, -0.04], { + value: "A-000", + height: 0.07, + style: NOTES, + align: "center", + valign: "top", + }); + }); + // A section bubble with no arrow: `sectionCut` draws the arrow the way the section looks, so + // the bubble's text stays upright whatever the direction. + d.block("SECTION BUBBLE", {}, () => { + const r = 0.22; + d.circle([0, 0], r); + d.line([-r, 0], [r, 0]); + d.attribute("SECT", [0, 0.04], { value: "1", height: 0.1, style: NOTES, align: "center" }); + d.attribute("SHEET", [0, -0.04], { + value: "A-000", + height: 0.07, + style: NOTES, + align: "center", + valign: "top", + }); + }); + // A level marker for elevations and sections: a target with the level's name and elevation. + d.block("LEVEL", {}, () => { + const r = 0.08; + d.circle([0, 0], r); + d.line([-r, 0], [r, 0]); + d.line([0, -r], [0, r]); + d.attribute("NAME", [r + 0.06, 0.03], { value: "LEVEL", height: H.note, style: NOTES }); + d.attribute("EL", [r + 0.06, -0.03], { + value: "EL. 0.00'", + height: H.note, + style: NOTES, + valign: "top", + }); + }); + // A spot grade for the site plan. + d.block("SPOT", {}, () => { + d.line([-0.05, -0.05], [0.05, 0.05]); + d.line([-0.05, 0.05], [0.05, -0.05]); + d.attribute("EL", [0.08, 0.02], { value: "0.00", height: H.note, style: NOTES }); + }); + // A palm for the site plan, its fronds one unit in radius: insert it at the canopy's radius. + d.block("PALM", {}, () => { + d.circle([0, 0], 0.12); + for (let i = 0; i < 8; i++) { + const a = (Math.PI / 4) * i; + d.line([Math.cos(a) * 0.12, Math.sin(a) * 0.12], [Math.cos(a + 0.25), Math.sin(a + 0.25)]); + } + }); + // The view title: a bubble with the view's number, the title and the scale. The underline + // is drawn per view, as long as its title. + d.block("VIEW TITLE", {}, () => { + const r = 0.25; + d.circle([r, 0], r); + d.line([0, 0], [2 * r, 0]); + d.attribute("VIEW_NO", [r, 0.05], { + value: "1", + height: H.label, + style: NOTES, + align: "center", + }); + d.attribute("VIEW_SHEET", [r, -0.05], { + value: "A-000", + height: 0.08, + style: NOTES, + align: "center", + valign: "top", + }); + d.attribute("VIEW_TITLE", [2 * r + 0.12, 0.06], { + value: "VIEW", + height: H.title, + style: TITLE, + }); + d.attribute("VIEW_SCALE", [2 * r + 0.12, -0.06], { + value: "SCALE", + height: H.label, + style: NOTES, + valign: "top", + }); + }); + // Electrical symbols, in paper inches. + d.block("E-DUPLEX", {}, () => { + d.circle([0, 0], 0.07); + d.line([-0.03, -0.1], [-0.03, 0.1]); + d.line([0.03, -0.1], [0.03, 0.1]); + }); + d.block("E-GFCI", {}, () => { + d.circle([0, 0], 0.07); + d.line([-0.03, -0.1], [-0.03, 0.1]); + d.line([0.03, -0.1], [0.03, 0.1]); + d.text([0.12, -0.03], "GFI", { height: 0.06, style: NOTES }); + }); + d.block("E-SWITCH", {}, () => { + d.text([0, 0], "S", { height: 0.12, style: NOTES, align: "center", valign: "middle" }); + }); + d.block("E-SWITCH3", {}, () => { + d.text([0, 0], "S", { height: 0.12, style: NOTES, align: "center", valign: "middle" }); + d.text([0.07, -0.07], "3", { height: 0.06, style: NOTES }); + }); + d.block("E-CEILING", {}, () => { + const r = 0.1; + d.circle([0, 0], r); + d.line([-r * 0.7, -r * 0.7], [r * 0.7, r * 0.7]); + d.line([-r * 0.7, r * 0.7], [r * 0.7, -r * 0.7]); + }); + d.block("E-RECESSED", {}, () => { + d.circle([0, 0], 0.07); + d.circle([0, 0], 0.035); + }); + d.block("E-FAN", {}, () => { + d.circle([0, 0], 0.08); + for (const a of [0, 90, 180, 270]) { + const t = (a * Math.PI) / 180; + d.polyline( + [ + [Math.cos(t) * 0.08, Math.sin(t) * 0.08], + [Math.cos(t + 0.35) * 0.3, Math.sin(t + 0.35) * 0.3], + [Math.cos(t - 0.15) * 0.3, Math.sin(t - 0.15) * 0.3], + ], + true, + ); + } + }); + d.block("E-WALLLIGHT", {}, () => { + d.circle([0, 0.06], 0.06); + d.line([-0.1, 0], [0.1, 0]); + d.line([-0.04, 0.02], [0.04, 0.1]); + d.line([-0.04, 0.1], [0.04, 0.02]); + }); + d.block("E-SMOKE", {}, () => { + d.circle([0, 0], 0.09); + d.text([0, 0], "S/C", { height: 0.05, style: NOTES, align: "center", valign: "middle" }); + }); + d.block("E-EXHAUST", {}, () => { + d.rect([-0.09, -0.09], 0.18, 0.18); + d.line([-0.09, -0.09], [0.09, 0.09]); + d.line([-0.09, 0.09], [0.09, -0.09]); + }); + d.block("E-PANEL", {}, () => { + d.rect([-0.2, -0.06], 0.4, 0.12); + d.line([-0.2, -0.06], [0.2, 0.06]); + }); + d.block("E-220", {}, () => { + d.circle([0, 0], 0.08); + d.line([-0.06, -0.05], [0.06, 0.05]); + d.line([-0.06, 0.05], [0.06, -0.05]); + d.line([0, -0.08], [0, 0.08]); + }); + d.block("E-DISC", {}, () => { + d.rect([-0.08, -0.08], 0.16, 0.16); + d.text([0, 0], "D", { height: 0.08, style: NOTES, align: "center", valign: "middle" }); + }); + // A flood vent in a wall, for plans. + d.block("FLOOD VENT", {}, () => { + d.rect([-0.12, -0.05], 0.24, 0.1); + d.line([-0.12, -0.05], [0.12, 0.05]); + }); +} + +// --------------------------------------------------------------------------------------------- +// Dimensions. + +/** + * A chained string of linear dimensions through `points`, along `axis`, with its line at `at` + * on the other axis. An architectural view writes feet and inches as each dimension's text, as + * a dimension writes only decimals. + */ +export function dimString( + d: D, + v: View, + points: readonly Point[], + axis: "x" | "y", + at: number, +): void { + const i = axis === "x" ? 0 : 1; + const sorted = [...points].sort((a, b) => a[i] - b[i]); + const unique = sorted.filter((p, k) => k === 0 || Math.abs(p[i] - sorted[k - 1]![i]) > 0.01); + for (let k = 1; k < unique.length; k++) { + const a = unique[k - 1]!; + const b = unique[k]!; + const line: Point = axis === "x" ? [0, at] : [at, 0]; + const civil = v.ratio >= 120; + d.linearDimension(a, b, line, { + style: v.dim, + ...(axis === "y" ? { along: "y" as const } : {}), + ...(civil ? {} : { text: ftIn(b[i] - a[i]) }), + }); + } +} + +// --------------------------------------------------------------------------------------------- +// Notes in model space: a leader to a point, with lines of text. + +/** A note with a leader, sized for view `v`. `lines` are wrapped by the caller. */ +export function leaderNote( + d: D, + v: View, + target: Point, + elbow: Point, + lines: readonly string[], +): void { + const h = v.p(H.note); + const side = elbow[0] >= target[0] ? 1 : -1; + const end: Point = [elbow[0] + side * v.p(1 / 8), elbow[1]]; + d.polyline([target, elbow, end]); + arrowhead(d, target, elbow, v.p(1 / 8)); + const x = end[0] + side * v.p(1 / 16); + lines.forEach((line, k) => { + put(d, [x, elbow[1] - k * h * 1.6], line, h, { + align: side > 0 ? "left" : "right", + valign: "middle", + }); + }); +} + +/** A note of several lines in model space, wrapped to `maxPaper` inches. */ +export function modelNote( + d: D, + v: View, + at: Point, + text: string, + maxPaper = 2.5, + align: TextAlign = "left", +): number { + const h = v.p(H.note); + const lines = wrap(text, H.note, maxPaper); + lines.forEach((line, k) => put(d, [at[0], at[1] - k * h * 1.6], line, h, { align })); + return lines.length * h * 1.6; +} + +// --------------------------------------------------------------------------------------------- +// The sheet. + +/** The drawing area of every sheet, inside the border and left of the title strip. */ +export const AREA = { left: 2.0, bottom: 0.75, right: 31.75, top: 23.25 } as const; +/** The title strip on the right of the sheet. */ +export const STRIP = { left: 32.0, right: 35.5, bottom: 0.5, top: 23.5 } as const; + +/** A view on a sheet: a viewport at a ratio, with its title under it. */ +export function viewport( + d: D, + sheet: { + viewport: ( + c: Point, + w: number, + h: number, + o: { center: Point; ratio: number; id?: string }, + ) => unknown; + }, + o: { + corner: Point; + size: [number, number]; + center: Point; + v: View; + id: string; + number: string; + sheetNo: string; + title: string; + scale?: string; + titleAt?: Point; + }, +): void { + d.layer(LAYERS.vport, {}, () => { + sheet.viewport(o.corner, o.size[0], o.size[1], { + center: o.center, + ratio: o.v.ratio, + id: o.id, + }); + }); + viewTitle( + d, + o.titleAt ?? [o.corner[0] + 0.1, o.corner[1] - 0.35], + o.number, + o.sheetNo, + o.title, + o.scale ?? `SCALE: ${o.v.label}`, + ); +} + +/** A view title on the sheet: the bubble, the title over a line as long as it, the scale. */ +export function viewTitle( + d: D, + at: Point, + number: string, + sheetNo: string, + title: string, + scale: string, +): void { + d.layer(LAYERS.text, {}, () => { + d.insert("VIEW TITLE", at, { + attributes: { VIEW_NO: number, VIEW_SHEET: sheetNo, VIEW_TITLE: title, VIEW_SCALE: scale }, + }); + const length = Math.max( + measureText({ value: title, height: H.title, font: FONT, widthFactor: 0.9 }).length, + width(scale, H.label), + ); + d.line([at[0] + 0.5, at[1]], [at[0] + 0.62 + length + 0.15, at[1]], { lineweight: 0.5 }); + }); +} + +/** A graphic scale in paper space for a view, `length` inches long, numbered in feet. */ +export function graphicScale(d: D, at: Point, v: View, length = 2): void { + const feetPerInch = v.ratio / 12; + // Divide the bar where the numbers come out round. + const total = length * feetPerInch; + const step = [1, 2, 4, 5, 8, 10, 20, 25, 40, 50, 100].find((s) => total / s <= 4) ?? 100; + const stops: number[] = []; + for (let f = 0; f <= total + 1e-9; f += step) stops.push(f); + const bar = stops.at(-1)! / feetPerInch; + const h = 0.08; + d.layer(LAYERS.text, {}, () => { + d.rect(at, bar, h); + stops.forEach((f, k) => { + const x = at[0] + f / feetPerInch; + if (k > 0 && k < stops.length - 1) d.line([x, at[1]], [x, at[1] + h]); + put(d, [x, at[1] + h + 0.05], `${f}'`, H.small, { align: "center" }); + }); + for (let k = 0; k + 1 < stops.length; k += 2) { + const x0 = at[0] + stops[k]! / feetPerInch; + const x1 = at[0] + stops[k + 1]! / feetPerInch; + d.line([x0, at[1] + h / 2], [x1, at[1] + h / 2]); + } + }); +} + +/** Notes in a column on the sheet. Returns the y below the last line. */ +export function noteColumn( + d: D, + at: Point, + colWidth: number, + heading: string, + notes: readonly string[], + o: { numbered?: boolean; height?: number } = {}, +): number { + const h = o.height ?? H.note; + const lead = h * 1.65; + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], heading, H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + colWidth, y], { lineweight: 0.35 }); + y -= 0.08 + h; + const indent = o.numbered === false ? 0 : width("00. ", h); + notes.forEach((note, k) => { + if (o.numbered !== false) put(d, [at[0], y], `${k + 1}.`, h); + for (const line of wrap(note, h, colWidth - indent)) { + put(d, [at[0] + indent, y], line, h); + y -= lead; + } + y -= h * 0.5; + }); + }); + return y; +} + +/** + * A table on the sheet: a heading, a header row and rows, in columns of the given widths. Each + * cell is checked to fit; a value too long for its cell throws, so the table is fixed in code. + */ +export function table( + d: D, + at: Point, + heading: string, + columns: readonly { name: string; width: number; align?: TextAlign }[], + rows: readonly (readonly string[])[], + o: { height?: number; rowHeight?: number } = {}, +): number { + const h = o.height ?? H.note; + const rh = o.rowHeight ?? h * 2.4; + const total = columns.reduce((s, c) => s + c.width, 0); + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + if (heading) { + put(d, [at[0], y], heading, H.label, { valign: "top" }); + y -= H.label + 0.1; + } + const top = y; + const all = [columns.map((c) => c.name), ...rows]; + all.forEach((cells, r) => { + let x = at[0]; + cells.forEach((cell, c) => { + const col = columns[c]!; + const pad = 0.05; + const textWidth = width(cell, h); + if (textWidth > col.width - 2 * pad) { + throw new Error( + `Table "${heading}": "${cell}" is ${textWidth.toFixed(2)} in long for a cell ${col.width} in wide`, + ); + } + const align = col.align ?? "left"; + const tx = + align === "center" + ? x + col.width / 2 + : align === "right" + ? x + col.width - pad + : x + pad; + put(d, [tx, y - rh / 2], cell, h, { align, valign: "middle" }); + x += col.width; + }); + y -= rh; + d.line([at[0], y], [at[0] + total, y], { lineweight: r === 0 ? 0.35 : 0.13 }); + }); + d.rect([at[0], y], total, top - y, { lineweight: 0.35 }); + let x = at[0]; + for (const col of columns.slice(0, -1)) { + x += col.width; + d.line([x, y], [x, top]); + } + }); + return y; +} + +/** The arrow and slope of a roof, in model space for view `v`. */ +export function slopeArrow(d: D, v: View, at: Point, angle: number, text: string): void { + const len = v.p(0.6); + const t = (angle * Math.PI) / 180; + const tip: Point = [at[0] + (Math.cos(t) * len) / 2, at[1] + (Math.sin(t) * len) / 2]; + const tail: Point = [at[0] - (Math.cos(t) * len) / 2, at[1] - (Math.sin(t) * len) / 2]; + d.line(tail, tip); + arrowhead(d, tip, tail, v.p(0.1)); + let rot = angle; + if (rot > 90) rot -= 180; + if (rot <= -90) rot += 180; + // The text stands on the side its own "up" points to, clear of the arrow. + const r = (rot * Math.PI) / 180; + const lift = v.p(0.07); + put(d, [at[0] - Math.sin(r) * lift, at[1] + Math.cos(r) * lift], text, v.p(H.note), { + align: "center", + rotation: rot, + }); +} + +/** Draws one sheet: its model space and its layout. */ +export type SheetDraw = (d: D) => void; + +/** + * A section cut mark in model space for view `v`: the bubble upright at `at`, with its arrow + * pointing the way the section looks. + */ +export function sectionCut( + d: D, + v: View, + at: Point, + look: "N" | "S" | "E" | "W", + sect: string, + sheet: string, +): void { + d.insert("SECTION BUBBLE", at, { scale: v.p(1), attributes: { SECT: sect, SHEET: sheet } }); + const r = v.p(0.22); + const dir: Point = + look === "N" ? [0, 1] : look === "S" ? [0, -1] : look === "E" ? [1, 0] : [-1, 0]; + const across: Point = [-dir[1], dir[0]]; + const base = (k: number): Point => [ + at[0] + dir[0] * r * 0.7 + across[0] * r * 0.7 * k, + at[1] + dir[1] * r * 0.7 + across[1] * r * 0.7 * k, + ]; + d.polyline([base(1), [at[0] + dir[0] * r * 1.9, at[1] + dir[1] * r * 1.9], base(-1)], false); +} + +/** + * A revision cloud around a rectangle, as arcs bulging outward, with a delta tag holding the + * revision number at its upper right. Sizes are paper inches through `p`. + */ +export function revisionCloud( + d: D, + p: (inches: number) => number, + rect: readonly [number, number, number, number], + rev: string, +): void { + const [x0, y0, x1, y1] = rect; + const step = p(0.3); + d.layer(LAYERS.revs, {}, () => { + const side = (a: Point, b: Point, outward: number) => { + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + const n = Math.max(1, Math.round(len / step)); + const ux = (b[0] - a[0]) / len; + const uy = (b[1] - a[1]) / len; + const base = (Math.atan2(uy, ux) * 180) / Math.PI; + for (let k = 0; k < n; k++) { + const c: Point = [a[0] + ux * (len / n) * (k + 0.5), a[1] + uy * (len / n) * (k + 0.5)]; + const r = len / n / 2; + // A half circle on the outside of the side. + d.arc(c, r, base + (outward > 0 ? 0 : 180), base + (outward > 0 ? 180 : 360)); + } + }; + side([x0, y0], [x1, y0], -1); + side([x1, y0], [x1, y1], -1); + side([x1, y1], [x0, y1], -1); + side([x0, y1], [x0, y0], -1); + const h = p(0.28); + const at: Point = [x1 + p(0.12), y1 + p(0.12)]; + d.polyline([at, [at[0] + h, at[1]], [at[0] + h / 2, at[1] + h * 0.87]], true); + d.text([at[0] + h / 2, at[1] + h * 0.3], rev, { + height: p(0.1), + style: NOTES, + align: "center", + valign: "middle", + }); + }); +} diff --git a/examples/plan-set/lib/plan.ts b/examples/plan-set/lib/plan.ts new file mode 100644 index 0000000..a45c71b --- /dev/null +++ b/examples/plan-set/lib/plan.ts @@ -0,0 +1,580 @@ +// Draws a floor plan of a level at an origin in model space: the walls as their clean outline, +// openings, doors, windows, stairs and fixtures, and, for an architectural sheet, the tags and +// the dimension strings. +import type { Point } from "../../../src/index.ts"; +import { + ATTIC_ACCESS, + ENTRY, + GARAGE_STEPS, + HEIGHTS, + LANAI, + LANAI_SLAB, + POWDER, + STAIR, + WINDOW_MARKS, + type Level, + type Opening, + type Rect, +} from "../model/house.ts"; +import { dimString, ftIn, H, LAYERS, put, type D, type View } from "./kit.ts"; + +export interface PlanLayers { + wall: string; + low: string; + door: string; + glaz: string; + fixt: string; + stair: string; + ovhd: string; +} + +export const ARCH_LAYERS: PlanLayers = { + wall: LAYERS.wall, + low: LAYERS.wallLow, + door: LAYERS.door, + glaz: LAYERS.glaz, + fixt: LAYERS.fixt, + stair: LAYERS.stair, + ovhd: LAYERS.ovhd, +}; + +/** Every layer the base plan under the MEP and framing sheets: one gray layer. */ +export const BASE_LAYERS = (layer: string): PlanLayers => ({ + wall: layer, + low: layer, + door: layer, + glaz: layer, + fixt: layer, + stair: layer, + ovhd: layer, +}); + +const inside = (r: Rect, x: number, y: number) => x > r[0] && x < r[2] && y > r[1] && y < r[3]; + +/** + * The outline of the union of wall rectangles, less the cuts, as lines merged along each run. + * The walls are axis-aligned, so a grid of every x and y they hold decides each cell. + */ +export function wallOutline(walls: readonly Rect[], cuts: readonly Rect[]): [Point, Point][] { + const xs = [...new Set([...walls, ...cuts].flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b); + const ys = [...new Set([...walls, ...cuts].flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b); + const nx = xs.length - 1; + const ny = ys.length - 1; + const filled: boolean[][] = []; + for (let i = 0; i < nx; i++) { + filled.push([]); + for (let j = 0; j < ny; j++) { + const cx = (xs[i]! + xs[i + 1]!) / 2; + const cy = (ys[j]! + ys[j + 1]!) / 2; + filled[i]!.push(walls.some((r) => inside(r, cx, cy)) && !cuts.some((r) => inside(r, cx, cy))); + } + } + const at = (i: number, j: number) => i >= 0 && j >= 0 && i < nx && j < ny && filled[i]![j]!; + const lines: [Point, Point][] = []; + // Horizontal edges. + for (let j = 0; j <= ny; j++) { + let start: number | undefined; + for (let i = 0; i <= nx; i++) { + const edge = i < nx && at(i, j - 1) !== at(i, j); + if (edge && start === undefined) start = i; + if (!edge && start !== undefined) { + lines.push([ + [xs[start]!, ys[j]!], + [xs[i]!, ys[j]!], + ]); + start = undefined; + } + } + } + for (let i = 0; i <= nx; i++) { + let start: number | undefined; + for (let j = 0; j <= ny; j++) { + const edge = j < ny && at(i - 1, j) !== at(i, j); + if (edge && start === undefined) start = j; + if (!edge && start !== undefined) { + lines.push([ + [xs[i]!, ys[start]!], + [xs[i]!, ys[j]!], + ]); + start = undefined; + } + } + } + return lines; +} + +/** Whether an opening's wall runs along x. */ +export const alongX = (o: Opening) => o.cut[2] - o.cut[0] > o.cut[3] - o.cut[1]; +export const openingWidth = (o: Opening) => (alongX(o) ? o.cut[2] - o.cut[0] : o.cut[3] - o.cut[1]); +export const openingCenter = (o: Opening): Point => [ + (o.cut[0] + o.cut[2]) / 2, + (o.cut[1] + o.cut[3]) / 2, +]; + +/** Draws one door leaf and its swing. */ +function leaf(d: D, o: Opening, hinge: "start" | "end", w: number, from: number): void { + const [x0, y0, x1, y1] = o.cut; + const s = o.swing ?? 1; + if (alongX(o)) { + const y = s > 0 ? y1 : y0; + const hx = hinge === "start" ? from : from; + const dir = hinge === "start" ? 1 : -1; + d.line([hx, y], [hx, y + s * w]); + const a0 = s > 0 ? (dir > 0 ? 0 : 90) : dir > 0 ? 270 : 180; + d.arc([hx, y], w, a0, a0 + 90); + void x0; + void x1; + } else { + const x = s > 0 ? x1 : x0; + const hy = from; + const dir = hinge === "start" ? 1 : -1; + d.line([x, hy], [x + s * w, hy]); + const a0 = s > 0 ? (dir > 0 ? 0 : 270) : dir > 0 ? 90 : 180; + d.arc([x, hy], w, a0, a0 + 90); + } +} + +/** Draws an opening's door or window in its cut. */ +function opening(d: D, o: Opening, L: PlanLayers): void { + const [x0, y0, x1, y1] = o.cut; + const ax = alongX(o); + const w = openingWidth(o); + const start = ax ? x0 : y0; + const end = ax ? x1 : y1; + const mid = ax ? (y0 + y1) / 2 : (x0 + x1) / 2; + const seg = (a: number, c: number, b: number, c2 = c): [Point, Point] => + ax + ? [ + [a, c], + [b, c2], + ] + : [ + [c, a], + [c2, b], + ]; + switch (o.kind) { + case "window": { + d.layer(L.glaz, {}, () => { + const lo = ax ? y0 : x0; + const hi = ax ? y1 : x1; + for (const c of [lo, hi, mid - 0.75, mid + 0.75]) d.line(...seg(start, c, end)); + }); + return; + } + case "door": + d.layer(L.door, {}, () => leaf(d, o, o.hinge ?? "start", w, o.hinge === "end" ? end : start)); + return; + case "double": + d.layer(L.door, {}, () => { + leaf(d, o, "start", w / 2, start); + leaf(d, o, "end", w / 2, end); + }); + return; + case "slider": + d.layer(L.door, {}, () => { + // Four panels, two fixed outside and two that slide inside them. + const q = w / 4; + for (let k = 0; k < 4; k++) { + const c = mid + (k === 1 || k === 2 ? -1.25 : 1.25); + d.line(...seg(start + k * q, c, start + (k + 1) * q + (k < 3 ? 2 : 0))); + } + }); + d.layer(L.glaz, {}, () => { + d.line(...seg(start, ax ? y0 : x0, end)); + d.line(...seg(start, ax ? y1 : x1, end)); + }); + return; + case "overhead": + d.layer(L.door, {}, () => { + d.line(...seg(start, ax ? y1 - 1 : x1 - 1, end)); + d.line(...seg(start, ax ? y1 - 2.5 : x1 - 2.5, end)); + }); + d.layer(L.ovhd, {}, () => { + // The door's tracks overhead, back to its opener. + d.line(...seg(start, ax ? y1 : x1, start, ax ? y1 + 108 : x1 + 108)); + d.line(...seg(end, ax ? y1 : x1, end, ax ? y1 + 108 : x1 + 108)); + }); + return; + case "bifold": + d.layer(L.door, {}, () => { + const n = 4; + const q = w / n; + const pts: Point[] = []; + for (let k = 0; k <= n; k++) { + const a = start + k * q; + const c = (ax ? y1 : x1) + (k % 2 === 1 ? 6 : 0); + pts.push(ax ? [a, c] : [c, a]); + } + d.polyline(pts); + }); + return; + case "cased": + d.layer(L.ovhd, {}, () => { + d.line(...seg(start, mid, end)); + }); + return; + } +} + +type Xf = (p: Point) => Point; +const xf = (at: Point, rot: number): Xf => { + const t = (rot * Math.PI) / 180; + const c = Math.cos(t); + const s = Math.sin(t); + return ([x, y]) => [at[0] + x * c - y * s, at[1] + x * s + y * c]; +}; +const ring = (f: Xf, cx: number, cy: number, rx: number, ry: number, n = 20): Point[] => { + const pts: Point[] = []; + for (let k = 0; k < n; k++) { + const a = (2 * Math.PI * k) / n; + pts.push(f([cx + rx * Math.cos(a), cy + ry * Math.sin(a)])); + } + return pts; +}; +const box = (d: D, f: Xf, x0: number, y0: number, x1: number, y1: number) => + d.polyline([f([x0, y0]), f([x1, y0]), f([x1, y1]), f([x0, y1])], true); + +/** Fixtures, each drawn from its back edge at `at`, facing the room at `rot` degrees. */ +export const FIXTURES = { + toilet(d: D, at: Point, rot: number) { + const f = xf(at, rot); + box(d, f, -10, 0, 10, 8); + d.polyline(ring(f, 0, 18, 7.5, 10), true); + }, + lav(d: D, at: Point, rot: number, width = 36) { + const f = xf(at, rot); + box(d, f, -width / 2, 0, width / 2, 22); + d.polyline(ring(f, 0, 11, 8, 6), true); + }, + doubleLav(d: D, at: Point, rot: number, width = 72) { + const f = xf(at, rot); + box(d, f, -width / 2, 0, width / 2, 22); + d.polyline(ring(f, -width / 4, 11, 8, 6), true); + d.polyline(ring(f, width / 4, 11, 8, 6), true); + }, + tub(d: D, at: Point, rot: number) { + const f = xf(at, rot); + box(d, f, -30, 0, 30, 32); + d.polyline(ring(f, 0, 16, 26, 12, 28), true); + }, + shower(d: D, at: Point, rot: number, w = 48, depth = 42) { + const f = xf(at, rot); + box(d, f, -w / 2, 0, w / 2, depth); + d.line(f([-w / 2, 0]), f([w / 2, depth])); + d.line(f([w / 2, 0]), f([-w / 2, depth])); + d.circle(f([0, depth / 2]), 2); + }, + counter(d: D, x0: number, y0: number, x1: number, y1: number) { + d.rect([x0, y0], x1 - x0, y1 - y0); + }, + sink(d: D, at: Point, rot: number) { + const f = xf(at, rot); + box(d, f, -16, 3, -1, 19); + box(d, f, 1, 3, 16, 19); + }, + range(d: D, at: Point, rot: number) { + const f = xf(at, rot); + box(d, f, -15, 0, 15, 24); + for (const [x, y] of [ + [-7, 7], + [7, 7], + [-7, 17], + [7, 17], + ] as const) + d.circle(f([x, y]), 4); + }, + fridge(d: D, at: Point, rot: number) { + const f = xf(at, rot); + box(d, f, -18, 0, 18, 30); + d.line(f([-18, 30]), f([18, 0])); + }, + washerDryer(d: D, at: Point, rot: number) { + const f = xf(at, rot); + box(d, f, -28, 0, -1, 27); + box(d, f, 1, 0, 28, 27); + d.circle(f([-14.5, 13.5]), 10); + }, + waterHeater(d: D, at: Point) { + d.circle(at, 11); + }, +}; + +/** The stairs: 16 treads of 10" from `y0` going north, with the cut and the arrow. */ +function stairs(d: D, level: "L1" | "L2", L: PlanLayers): void { + const { x0, x1, y0, treads } = STAIR; + const run = STAIR.tread; + d.layer(L.stair, {}, () => { + for (let k = 0; k <= treads; k++) { + const y = y0 + k * run; + if (level === "L1" && k > 9) continue; + d.line([x0, y], [x1, y]); + } + // The handrail and the arrow up the run. + d.line([x0 + 3, y0], [x0 + 3, y0 + treads * run]); + const mid = (x0 + x1) / 2; + if (level === "L1") { + d.line([x0, y0 + 9.6 * run], [x1, y0 + 8.4 * run]); + d.polyline([ + [mid, y0 + 6], + [mid, y0 + 8.4 * run], + ]); + } else { + d.polyline([ + [mid, y0 + treads * run - 6], + [mid, y0 + 6], + ]); + } + }); + if (level === "L1") { + d.layer(L.ovhd, {}, () => { + for (let k = 10; k <= treads; k++) d.line([x0, y0 + k * run], [x1, y0 + k * run]); + }); + } +} + +/** The fixtures of a level. */ +function fixtures(d: D, level: "L1" | "L2", L: PlanLayers): void { + d.layer(L.fixt, {}, () => { + if (level === "L1") { + // Kitchen: counters on the north and west walls, the island, the sink under the window. + // The west counter stops short of doors 105 and 107; the fridge stands past door 107. + FIXTURES.counter(d, 172.5, 496, 379.5, 520); + FIXTURES.counter(d, 136.5, 354, 160.5, 434); + FIXTURES.counter(d, 204, 384, 312, 426); + FIXTURES.sink(d, [252, 520], 180); + FIXTURES.range(d, [136.5, 394], -90); + FIXTURES.fridge(d, [136.5, 494], -90); + // Laundry, powder and the water heater in the garage. + FIXTURES.washerDryer(d, [70, 408], 180); + // Powder room: the water closet's centerline 20" from the north wall and 15" from the + // vanity south of it, with 36" clear in front (FBC-R P2705.1). + FIXTURES.toilet(d, [8, POWDER.wc], -90); + FIXTURES.lav(d, [8, POWDER.vanity], -90, 30); + FIXTURES.waterHeater(d, [240, 262]); + } else { + // Bath 2. + FIXTURES.tub(d, [141, 312], 180); + FIXTURES.toilet(d, [80, 240], -90); + FIXTURES.lav(d, [141, 208.5], 0, 48); + // Owner's bath. + FIXTURES.doubleLav(d, [501, 168], 180, 96); + FIXTURES.shower(d, [570, 6], 0, 84, 48); + FIXTURES.tub(d, [430, 6], 0); + FIXTURES.toilet(d, [618, 120], 90); + } + }); +} + +/** Draws a level's plan at `origin`. */ +export function drawPlan( + d: D, + origin: Point, + level: Level, + L: PlanLayers, + which: "L1" | "L2", +): void { + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + const shift = (r: Rect): Rect => [ + r[0] + origin[0], + r[1] + origin[1], + r[2] + origin[0], + r[3] + origin[1], + ]; + const walls = level.walls.map(shift); + const cuts = level.openings.map((x) => shift(x.cut)); + d.layer(L.wall, {}, () => { + for (const [a, b] of wallOutline(walls, cuts)) d.line(a, b); + }); + d.layer(L.low, {}, () => { + for (const [a, b] of wallOutline(level.low.map(shift), [])) d.line(a, b); + }); + for (const op of level.openings) opening(d, { ...op, cut: shift(op.cut) }, L); + // Draw the fixtures and stairs at the origin by shifting through a temporary builder view. + const shifted = shiftBuilder(d, origin); + stairs(shifted, which, L); + fixtures(shifted, which, L); + if (which === "L1") extras(d, origin, L); + if (which === "L2") { + d.layer(L.ovhd, {}, () => { + const [a0, b0, a1, b1] = shift(ATTIC_ACCESS); + d.rect([a0, b0], a1 - a0, b1 - b0); + d.line([a0, b0], [a1, b1]); + d.line([a0, b1], [a1, b0]); + }); + } + if (which === "L1") { + d.layer(L.wall, {}, () => { + // The lanai's slab edge and its columns. + const [x0, y0, x1, y1] = shift(LANAI); + d.polyline([ + [x0, y0], + [x0, y1], + [x1, y1], + [x1, y0], + ]); + for (const cx of [x0, x1 - 12]) d.rect([cx, y1 - 12], 12, 12); + }); + d.layer(L.ovhd, {}, () => { + // The beam over the porch, and the lanai's beam. + d.line(o([272, 4]), o([376, 4])); + const [x0, , x1, y1] = shift(LANAI); + d.line([x0, y1 - 6], [x1, y1 - 6]); + }); + } +} + +/** The first floor's steps, guards and flood vents, all from the model. */ +function extras(d: D, origin: Point, L: PlanLayers): void { + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + d.layer(L.stair, {}, () => { + // Garage steps at door 103: the landing's edge, the treads and a handrail on the west. + const g = GARAGE_STEPS; + const edge = g.top - g.landing; + for (let k = 0; k < g.risers; k++) + d.line(o([g.x0, edge - k * g.tread]), o([g.x1, edge - k * g.tread])); + d.line(o([g.x0, g.top]), o([g.x0, edge - (g.risers - 1) * g.tread])); + d.line(o([g.x1, g.top]), o([g.x1, edge - (g.risers - 1) * g.tread])); + d.line(o([g.x1 - 2, g.top - 6]), o([g.x1 - 2, edge - (g.risers - 1) * g.tread])); + // The front entry stair, south of the porch, with a handrail each side. + const e = ENTRY; + const treads = e.risers - 1; + for (let k = 0; k <= treads; k++) d.line(o([e.x0, -k * e.tread]), o([e.x1, -k * e.tread])); + for (const x of [e.x0, e.x1]) d.line(o([x, 0]), o([x, -treads * e.tread])); + for (const x of [e.x0 + 2, e.x1 - 2]) d.line(o([x, 6]), o([x, -treads * e.tread - 12])); + // Lanai steps down to the pool deck. + const ls = LANAI_SLAB.steps; + const top = LANAI[3]; + const lt = ls.risers - 1; + for (let k = 0; k <= lt; k++) + d.line(o([ls.x0, top + k * ls.tread]), o([ls.x1, top + k * ls.tread])); + for (const x of [ls.x0, ls.x1]) d.line(o([x, top]), o([x, top + lt * ls.tread])); + for (const x of [ls.x0 + 2, ls.x1 - 2]) + d.line(o([x, top - 6]), o([x, top + lt * ls.tread + 12])); + }); + d.layer(L.low, {}, () => { + // 36" guards at the porch's open edge, each side of the stair, and on the lanai's open sides. + // Each guard is drawn as the legend shows it, two lines 2" apart, and returns to the + // stair's handrails. + for (const [a, b] of [ + [272, ENTRY.x0 + 2], + [ENTRY.x1 - 2, 376], + ] as const) + d.rect(o([a, 0]), b - a, 2); + const [x0, y0, x1, y1] = LANAI; + d.rect(o([x0, y0]), 2, y1 - y0); + d.rect(o([x1 - 2, y0]), 2, y1 - y0); + d.rect(o([x0, y1 - 2]), LANAI_SLAB.steps.x0 + 2 - x0, 2); + d.rect(o([LANAI_SLAB.steps.x1 - 2, y1 - 2]), x1 - LANAI_SLAB.steps.x1 + 2, 2); + }); +} + +/** + * A builder whose drawing calls are shifted by `origin`. Only the calls the fixtures and the + * stairs use are wrapped. + */ +function shiftBuilder(d: D, origin: Point): D { + const s = (p: Point): Point => [p[0] + origin[0], p[1] + origin[1]]; + const proxy = Object.create(d) as D; + Object.assign(proxy, { + line: (a: Point, b: Point, opts?: object) => d.line(s(a), s(b), opts as never), + polyline: (pts: Point[], closed?: boolean, opts?: object) => + d.polyline(pts.map(s), closed, opts as never), + rect: (at: Point, w: number, h: number, opts?: object) => d.rect(s(at), w, h, opts as never), + circle: (c: Point, r: number, opts?: object) => d.circle(s(c), r, opts as never), + arc: (c: Point, r: number, a0: number, a1: number, opts?: object) => + d.arc(s(c), r, a0, a1, opts as never), + layer: (name: string, opts: object, draw?: () => void) => + d.layer(name as never, opts as never, draw), + }); + return proxy; +} + +/** Tags of rooms, doors and windows, sized for view `v`. */ +export function planTags(d: D, origin: Point, level: Level, v: View): void { + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + d.layer(LAYERS.tags, {}, () => { + for (const room of level.rooms) { + const [x0, y0, x1, y1] = room.rect; + const at = room.tagAt ?? [(x0 + x1) / 2, (y0 + y1) / 2]; + const area = Math.round(room.area ?? ((x1 - x0) * (y1 - y0)) / 144); + const small = x1 - x0 < 80 || y1 - y0 < 60; + d.insert("ROOM TAG", o(at), { + scale: v.p(1), + attributes: { + ROOM: room.name, + AREA: small ? "" : room.ceiling ? `${area} SF | ${room.ceiling}` : `${area} SF`, + }, + }); + } + for (const op of level.openings) { + if (!op.tag) continue; + const [cx, cy] = openingCenter(op); + const ax = alongX(op); + // A tag sits off the wall on the inside of the opening's swing, or on the room side. + const off = v.p(0.3); + const sideX = cx > 312 ? -1 : 1; + const sideY = cy > 264 ? -1 : 1; + const at: Point = ax + ? [cx, cy + (op.tagSide ?? sideY) * off] + : [cx + (op.tagSide ?? sideX) * off, cy]; + if (op.kind === "window") { + d.insert("WINDOW TAG", o(at), { scale: v.p(1), attributes: { WINDOW: op.tag } }); + const mark = WINDOW_MARKS.get(op); + if (mark) + put(d, [o(at)[0], o(at)[1] - v.p(0.17)], mark, v.p(H.small), { + align: "center", + valign: "top", + }); + } else d.insert("DOOR TAG", o(at), { scale: v.p(1), attributes: { DOOR: op.tag } }); + } + }); +} + +/** The exterior dimension strings of a level: openings, jogs and overall, on all four sides. */ +export function planDims( + d: D, + origin: Point, + level: Level, + v: View, + sides: readonly ("S" | "N" | "E" | "W")[] = ["S", "N", "E", "W"], +): void { + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + const ext = level.openings.filter((x) => x.exterior); + const gap = v.p(0.375); + const first = v.p(0.75); + const xMax = 624; + const yMax = level.name === "FIRST FLOOR" ? 528 : 528; + d.layer(LAYERS.dims, {}, () => { + for (const side of sides) { + const horizontal = side === "S" || side === "N"; + const faceY = side === "S" ? 0 : yMax; + const faceX = side === "W" ? 0 : xMax; + const onFace = ext.filter((x) => + horizontal + ? alongX(x) && (side === "S" ? x.cut[1] < 100 : x.cut[3] > yMax - 20) + : !alongX(x) && (side === "W" ? x.cut[0] < 20 : x.cut[2] > xMax - 20), + ); + const centers = onFace.map((x) => (horizontal ? openingCenter(x)[0] : openingCenter(x)[1])); + const ends = horizontal ? [0, xMax] : [0, yMax]; + const jogs = horizontal && side === "S" && level.name === "FIRST FLOOR" ? [264, 376] : []; + const dir = side === "S" || side === "W" ? -1 : 1; + const line = (k: number) => (horizontal ? faceY : faceX) + dir * (first + gap * k); + const pts = (values: number[]): Point[] => + values.map((a) => o(horizontal ? [a, faceY] : [faceX, a])); + const strings = [ + [...ends, ...jogs, ...centers].sort((a, b) => a - b), + ...(jogs.length ? [[...ends, ...jogs].sort((a, b) => a - b)] : []), + ends, + ]; + strings.forEach((values, k) => { + const at = horizontal ? origin[1] + line(k) : origin[0] + line(k); + dimString(d, v, pts(values), horizontal ? "x" : "y", at); + }); + } + }); +} + +/** Writes an elevation label for a plan note, such as "F.F.E. 11.00' NAVD". */ +export const navd = (feet: number) => `${feet.toFixed(2)}' NAVD`; +export { ftIn, H, put }; +export const plateNote = () => `PLATE HT. ${ftIn(HEIGHTS.l1Plate)}`; diff --git a/examples/plan-set/lib/titleblock.ts b/examples/plan-set/lib/titleblock.ts new file mode 100644 index 0000000..d19cbcf --- /dev/null +++ b/examples/plan-set/lib/titleblock.ts @@ -0,0 +1,192 @@ +// The title block, in two parts, as the firm keeps it: +// +// - The border: a drawing of its own, `Set up/TB-ARCHD.dwg`, which every sheet attaches on +// its layout as an xref. It holds the lines, the labels, the firm and the project, in model +// space, in paper inches, with its lower-left corner at 0,0. +// - The sheet's fields: a block of attributes, `SHEET FIELDS`, inserted on each layout over +// the border. The firm fills these from the file's and the layout's names with fields, which +// tsdraft cannot write, so here each is an attribute the code sets. +import { createDrawing, type Drawing, type Point } from "../../../src/index.ts"; +import { FIRM, PROJECT } from "../project.ts"; +import { FONT, H, STRIP, type D } from "./kit.ts"; + +/** Where each part of the strip lies, in paper inches. */ +export const TB = { + border: { left: 1.5, bottom: 0.5, right: 35.5, top: 23.5 }, + firmBottom: 21.2, + projectBottom: 17.9, + revBottom: 14.6, + sealBottom: 11.1, + eorBottom: 9.9, + fieldRows: [9.9, 9.3, 8.7, 8.1], + titleBottom: 4.0, + numberBottom: 0.5, +} as const; + +const L = STRIP.left; +const R = STRIP.right; +const MID = (L + R) / 2; +const PAD = 0.07; + +/** The boxes of the four rows of small fields: two per row. */ +export const FIELD_BOXES: Record = { + DATE: { at: [L + PAD, TB.fieldRows[1]], label: "DATE" }, + JOB: { at: [MID + PAD, TB.fieldRows[1]], label: "JOB NO." }, + DRAWN: { at: [L + PAD, TB.fieldRows[2]], label: "DRAWN BY" }, + CHECKED: { at: [MID + PAD, TB.fieldRows[2]], label: "CHECKED BY" }, + SCALE: { at: [L + PAD, TB.fieldRows[3]], label: "SCALE" }, +}; + +/** The border drawing, which is written to its own DWG and attached by every sheet. */ +export function borderDrawing(): Drawing { + const b = createDrawing({ units: "in" }); + b.textStyle({ name: "TB", font: FONT }); + b.textStyle({ name: "TB-TITLE", font: FONT, widthFactor: 0.9 }); + b.layer("G-TTLB-HEVY", { color: 7, lineweight: 0.7 }); + b.layer("G-TTLB", { color: 7, lineweight: 0.35 }); + b.layer("G-TTLB-FINE", { color: 7, lineweight: 0.13 }); + b.layer("G-TTLB-TEXT", { color: 7, lineweight: 0.25 }); + const t = ( + at: Point, + value: string, + height: number, + o: { + align?: "left" | "center" | "right"; + valign?: "top" | "middle" | "baseline"; + style?: string; + } = {}, + ) => b.text(at, value, { height, ...o, style: o.style ?? "TB" }); + const { border } = TB; + b.layer("G-TTLB-HEVY", {}, () => { + b.rect([border.left, border.bottom], border.right - border.left, border.top - border.bottom); + b.line([L, border.bottom], [L, border.top]); + }); + b.layer("G-TTLB", {}, () => { + for (const y of [ + TB.firmBottom, + TB.projectBottom, + TB.revBottom, + TB.sealBottom, + TB.eorBottom, + TB.fieldRows[3], + TB.titleBottom, + ]) { + b.line([L, y], [R, y]); + } + }); + b.layer("G-TTLB-FINE", {}, () => { + for (const y of TB.fieldRows.slice(1, 3)) b.line([L, y], [R, y]); + b.line([MID, TB.fieldRows[0]], [MID, TB.fieldRows[2]]); + }); + b.layer("G-TTLB-TEXT", {}, () => { + // The firm. + t([MID, 22.95], FIRM.name, 0.3, { align: "center", style: "TB-TITLE" }); + t([MID, 22.55], FIRM.sub, H.label, { align: "center" }); + FIRM.lines.forEach((line, k) => t([MID, 22.2 - k * 0.16], line, H.small, { align: "center" })); + // The project. + const label = (y: number, value: string) => t([L + PAD, y], value, H.small, { valign: "top" }); + label(TB.firmBottom - PAD, "PROJECT"); + t([MID, 20.6], PROJECT.name, H.head, { align: "center", style: "TB-TITLE" }); + t([MID, 20.3], PROJECT.kind, H.small, { align: "center" }); + t([MID, 20.0], PROJECT.address, H.note, { align: "center" }); + t([MID, 19.8], PROJECT.city, H.note, { align: "center" }); + label(19.55, "OWNER"); + t([MID, 19.2], PROJECT.owner, H.note, { align: "center" }); + t([MID, 18.85], PROJECT.parcel, H.small, { align: "center" }); + t([MID, 18.65], `PARCEL ID ${PROJECT.parcelId}`, H.small, { align: "center" }); + t([MID, 18.35], PROJECT.county, H.small, { align: "center" }); + label(TB.projectBottom - PAD, "REVISIONS"); + label(TB.revBottom - PAD, "ENGINEER'S SEAL"); + t([MID, 13.0], "DEMONSTRATION SET", H.label, { align: "center" }); + t([MID, 12.75], "NOT FOR CONSTRUCTION", H.label, { align: "center" }); + t([MID, 12.45], "NO SEAL. NO ENGINEER OF RECORD.", H.small, { align: "center" }); + label(TB.sealBottom - PAD, "ENGINEER OF RECORD"); + t([MID, 10.45], "J. R. HALYARD, P.E. (FICTIONAL)", H.note, { align: "center" }); + t([MID, 10.15], "FLORIDA P.E. NO. 00000", H.small, { align: "center" }); + for (const [, box] of Object.entries(FIELD_BOXES)) { + t([box.at[0], box.at[1] + 0.6 - PAD], box.label, H.small, { valign: "top" }); + } + label(TB.fieldRows[3] - PAD, "SHEET TITLE"); + label(TB.titleBottom - PAD, "SHEET NUMBER"); + }); + return b.build(); +} + +/** Defines the block of a sheet's fields, whose attributes sit in the border's boxes. */ +export function defineSheetFields(d: D): void { + d.block("SHEET FIELDS", {}, () => { + for (const [tag, box] of Object.entries(FIELD_BOXES)) { + d.attribute(tag, [box.at[0], box.at[1] + 0.12], { + value: "-", + height: H.note, + style: "NOTES", + prompt: box.label, + }); + } + d.attribute("TITLE1", [MID, 7.3], { + value: "-", + height: 0.21, + style: "TITLE", + align: "center", + prompt: "Sheet title", + }); + d.attribute("TITLE2", [MID, 6.9], { + value: "", + height: 0.21, + style: "TITLE", + align: "center", + prompt: "Sheet title, second line", + }); + d.attribute("ISSUE", [MID, 4.5], { + value: "", + height: H.note, + style: "NOTES", + align: "center", + prompt: "Issue", + }); + d.attribute("SHEETNO", [MID, 2.1], { + value: "-", + height: 0.7, + style: "TITLE", + align: "center", + valign: "middle", + prompt: "Sheet number", + }); + d.attribute("SHEETOF", [MID, 0.85], { + value: "", + height: H.note, + style: "NOTES", + align: "center", + prompt: "Sheet of", + }); + }); +} + +/** The revision table of a sheet, in paper space on the firm's layers, one per revision. */ +export function revisionTable( + d: D, + revisions: readonly { no: string; date: string; what: string }[], +): void { + const top = TB.projectBottom - 0.22; + const rowH = (top - TB.revBottom - 0.02) / 9; + const cols = [L, L + 0.35, L + 1.15, R]; + d.layer("Rev_Text", {}, () => { + for (let r = 0; r <= 9; r++) d.line([L, top - r * rowH], [R, top - r * rowH]); + for (const x of cols.slice(1, -1)) d.line([x, top], [x, top - 9 * rowH]); + ["NO.", "DATE", "DESCRIPTION"].forEach((name, k) => + d.text([cols[k]! + 0.05, top - rowH / 2], name, { + height: H.small, + style: "NOTES", + valign: "middle", + }), + ); + }); + revisions.slice(0, 8).forEach((rev, k) => { + d.layer(`Rev_${k + 1}`, {}, () => { + const y = top - (k + 1.5) * rowH; + d.text([cols[0]! + 0.05, y], rev.no, { height: H.small, style: "NOTES", valign: "middle" }); + d.text([cols[1]! + 0.05, y], rev.date, { height: H.small, style: "NOTES", valign: "middle" }); + d.text([cols[2]! + 0.05, y], rev.what, { height: H.small, style: "NOTES", valign: "middle" }); + }); + }); +} diff --git a/examples/plan-set/model/geometry.ts b/examples/plan-set/model/geometry.ts new file mode 100644 index 0000000..c4743ed --- /dev/null +++ b/examples/plan-set/model/geometry.ts @@ -0,0 +1,6 @@ +// Small facts about an opening, shared by the model and the sheets. +import type { Opening } from "./house.ts"; + +export const alongXOf = (o: Opening): boolean => o.cut[2] - o.cut[0] > o.cut[3] - o.cut[1]; +export const widthOf = (o: Opening): number => + alongXOf(o) ? o.cut[2] - o.cut[0] : o.cut[3] - o.cut[1]; diff --git a/examples/plan-set/model/house.ts b/examples/plan-set/model/house.ts new file mode 100644 index 0000000..d6fd615 --- /dev/null +++ b/examples/plan-set/model/house.ts @@ -0,0 +1,455 @@ +// The house, once, in building coordinates: inches, from the southwest corner of the main +// block, x to the east and y to the north, toward the canal. Every plan, elevation, section, +// schedule and the site plan read this, so a moved window moves on every sheet. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; + +export type Rect = readonly [x0: number, y0: number, x1: number, y1: number]; + +export const W = 624; // 52'-0" +export const DEPTH = 528; // 44'-0" +export const LANAI: Rect = [384, 528, 616, 672]; + +export interface Opening { + /** The cut through the wall. */ + readonly cut: Rect; + readonly kind: "door" | "double" | "slider" | "overhead" | "bifold" | "cased" | "window"; + /** A door's number, or a window's type letter. */ + readonly tag?: string; + /** For a door: the side it swings to, + or - across the wall, and the hinge end. */ + readonly swing?: 1 | -1; + readonly hinge?: "start" | "end"; + /** The opening's height, in inches, for elevations and schedules. */ + readonly height?: number; + /** Head height above the floor. */ + readonly head?: number; + readonly exterior?: boolean; + /** Which side of the wall its tag goes on, + or - across the wall, when not the room side. */ + readonly tagSide?: 1 | -1; +} + +export interface Room { + readonly name: string; + readonly rect: Rect; + readonly ceiling?: string; + /** Where the tag goes, when not the middle. */ + readonly tagAt?: Point; + /** The area in square feet, where the rectangle holds another room too. */ + readonly area?: number; + readonly conditioned: boolean; +} + +export interface Level { + readonly name: string; + readonly walls: readonly Rect[]; + readonly exterior: readonly Rect[]; + readonly low: readonly Rect[]; + readonly openings: readonly Opening[]; + readonly rooms: readonly Room[]; + /** The wall thickness of the exterior walls. */ + readonly ext: number; +} + +/** Window types, by tag. Width and height in inches, head at 8'-0" unless noted. */ +export const WINDOW_TYPES: Record< + string, + { width: number; height: number; kind: string; note: string } +> = { + A: { width: 36, height: 60, kind: "SINGLE HUNG", note: "IMPACT LAMINATED, LOW-E" }, + B: { width: 48, height: 42, kind: "CASEMENT", note: "IMPACT LAMINATED, LOW-E" }, + C: { width: 24, height: 36, kind: "AWNING", note: "IMPACT, OBSCURE GLASS" }, + D: { width: 36, height: 36, kind: "SINGLE HUNG", note: "IMPACT LAMINATED, LOW-E" }, + E: { width: 72, height: 60, kind: "TWIN SINGLE HUNG", note: "IMPACT LAMINATED, LOW-E, MULLED" }, +}; + +/** A window. `head` is its head height above the level's floor: 8'-0" on the first floor. */ +const win = (tag: keyof typeof WINDOW_TYPES, cut: Rect, head = 96): Opening => ({ + cut, + kind: "window", + tag, + height: WINDOW_TYPES[tag]!.height, + head, + exterior: true, +}); +/** Second-floor heads, 7'-2" so the trim clears the soffit and every sill is 24" or more. */ +const L2_HEAD = 86; +/** Garage window heads: 8'-0" above the garage slab, which is 2'-6" below the first floor. */ +const GARAGE_HEAD = 66; + +/** + * The main stair: 18 risers of 7.68" over the 138.25" from floor to floor, and 17 treads of + * 10", from a 36" landing inside the front door, going north along the garage wall. + */ +export const STAIR = { + x0: 272, + x1: 314, + y0: 116, + risers: 18, + tread: 10, + get treads() { + return this.risers - 1; + }, + get y1() { + return this.y0 + this.treads * this.tread; + }, +}; + +/** The garage steps at door 103: a 36" landing at the door, then 4 risers of 7.5" down to the slab. */ +export const GARAGE_STEPS = { x0: 14, x1: 62, top: 288, landing: 36, risers: 4, tread: 10 }; + +/** The front entry: the porch slab and 5 risers of 6.4" down to the walk, 4 treads of 11". */ +export const ENTRY = { slab: 10.67, x0: 321, x1: 369, risers: 5, tread: 11 }; + +/** The lanai: a raised slab on stem walls, 36" guards on its three open sides, steps to the pool deck. */ +export const LANAI_SLAB = { + slab: 10.67, + guard: 36, + steps: { x0: 476, x1: 524, risers: 4, tread: 11, handrails: "both" as const }, + deck: 8.4, +}; + +/** + * Engineered flood vents in the garage walls, by center. Each is certified (ICC-ES) to cover + * `FLOOD_VENT_RATING` sq. ft. of enclosed area. + */ +export const FLOOD_VENT_RATING = 200; + +/** The powder room's fixtures, by the y of their centerlines on the west wall. */ +export const POWDER = { wc: 500, vanity: 470, vanityWidth: 30 }; +export const FLOOD_VENTS: { at: Point; wall: "W" | "S" }[] = [ + { at: [4, 22], wall: "W" }, + { at: [4, 100], wall: "W" }, + { at: [4, 190], wall: "W" }, + // In the south wall's pier east of the garage door: the porch side of the east wall sits + // against the porch fill, 26" above the garage slab. + { at: [240, 4], wall: "S" }, +]; + +/** Attic vents: rates per foot of ridge and soffit vent, and the off-ridge vents a hip needs. */ +export const ROOF_VENTS = { ridgeRate: 18, soffitRate: 9, offRidge: { count: 8, each: 54 } }; + +/** The attic access: a 30" x 54" pull-down stair in the second-floor hall ceiling. */ +export const ATTIC_ACCESS: Rect = [236, 24, 266, 78]; + +// --- First floor: 8" masonry outside, 4 1/2" studs inside. --------------------------------- +const L1_EXT: Rect[] = [ + [0, 0, 264, 8], + [0, 0, 8, 528], + [0, 520, 624, 528], + [616, 0, 624, 528], + [384, 0, 624, 8], + [264, 0, 272, 296], // garage to porch and foyer, masonry + [264, 72, 384, 80], // the porch's back wall + [376, 0, 384, 80], // the porch's east side + [8, 288, 272, 296], // garage to laundry, masonry +]; +const L1_INT: Rect[] = [ + [379.5, 80, 384, 292.5], + [384, 192, 616, 196.5], + [272, 288, 384, 292.5], + [132, 296, 136.5, 520], + [8, 408, 132, 412.5], + [72, 412.5, 76.5, 520], +]; + +export const L1: Level = { + name: "FIRST FLOOR", + ext: 8, + exterior: L1_EXT, + walls: [...L1_EXT, ...L1_INT], + low: [[314, 116, 318.5, 286]], + openings: [ + { + cut: [324, 72, 366, 80], + kind: "door", + tag: "101", + swing: 1, + hinge: "start", + height: 96, + exterior: true, + }, + { cut: [24, 0, 216, 8], kind: "overhead", tag: "102", height: 96, exterior: true }, + { cut: [24, 288, 60, 296], kind: "door", tag: "103", swing: 1, hinge: "start", height: 80 }, + { cut: [379.5, 100, 384, 160], kind: "double", tag: "104", swing: 1, height: 96 }, + { + cut: [132, 312, 136.5, 348], + kind: "door", + tag: "105", + swing: -1, + hinge: "start", + height: 80, + }, + { + cut: [24, 408, 54, 412.5], + kind: "door", + tag: "106", + swing: 1, + hinge: "start", + height: 80, + tagSide: 1, + }, + { cut: [132, 440, 136.5, 470], kind: "door", tag: "107", swing: -1, hinge: "end", height: 80 }, + { cut: [432, 520, 576, 528], kind: "slider", tag: "108", height: 96, exterior: true }, + { + cut: [0, 120, 8, 156], + kind: "door", + tag: "109", + swing: 1, + hinge: "end", + height: 80, + exterior: true, + }, + { cut: [324, 288, 372, 292.5], kind: "cased" }, + { cut: [379.5, 216, 384, 264], kind: "cased" }, + win("A", [444, 0, 480, 8]), + win("A", [528, 0, 564, 8]), + win("A", [616, 78, 624, 114]), + win("E", [616, 300, 624, 372]), + win("A", [616, 432, 624, 468]), + { ...win("B", [228, 520, 276, 528]), tagSide: 1 }, + win("D", [0, 330, 8, 366]), + { ...win("C", [0, 452, 8, 476]), tagSide: -1 }, + win("D", [0, 40, 8, 76], GARAGE_HEAD), + ], + rooms: [ + { name: "GARAGE", rect: [8, 8, 264, 288], ceiling: "10'-0\" CLG.", conditioned: false }, + { + name: "FOYER", + rect: [318.5, 80, 379.5, 288], + ceiling: "10'-0\" CLG.", + tagAt: [349, 200], + conditioned: true, + }, + { name: "STUDY", rect: [384, 8, 616, 192], ceiling: "10'-0\" CLG.", conditioned: true }, + { + name: "GREAT ROOM", + rect: [384, 196.5, 616, 520], + ceiling: "10'-0\" CLG.", + conditioned: true, + }, + { + name: "KITCHEN", + rect: [136.5, 292.5, 379.5, 520], + ceiling: "10'-0\" CLG.", + tagAt: [258, 340], + conditioned: true, + }, + { name: "LAUNDRY", rect: [8, 296, 132, 408], ceiling: "9'-0\" CLG.", conditioned: true }, + { + name: "PWDR.", + rect: [8, 412.5, 72, 520], + ceiling: "9'-0\" CLG.", + tagAt: [54, 438], + conditioned: true, + }, + { name: "PANTRY", rect: [76.5, 412.5, 132, 520], tagAt: [104, 500], conditioned: true }, + { name: "ENTRY PORCH", rect: [272, 0, 376, 72], conditioned: false }, + { name: "COVERED LANAI", rect: LANAI, conditioned: false }, + ], +}; + +// --- Second floor: 2x6 studs outside, 2x4 inside. ------------------------------------------- +const L2_EXT: Rect[] = [ + [0, 0, 624, 6], + [0, 0, 6, 528], + [0, 522, 624, 528], + [618, 0, 624, 528], +]; +const L2_INT: Rect[] = [ + [216, 6, 220.5, 522], + [6, 204, 216, 208.5], + [6, 312, 216, 316.5], + [61.5, 208.5, 66, 312], + [6, 474, 96, 478.5], + [96, 474, 100.5, 522], + [379.5, 6, 384, 522], + [384, 168, 618, 172.5], + [384, 264, 618, 268.5], +]; + +export const L2: Level = { + name: "SECOND FLOOR", + ext: 6, + exterior: L2_EXT, + walls: [...L2_EXT, ...L2_INT], + low: [ + [268, 112, 272.5, 286], + [268, 112, 318.5, 116.5], + [314, 112, 318.5, 286], + ], + openings: [ + { + cut: [216, 150, 220.5, 186], + kind: "door", + tag: "201", + swing: -1, + hinge: "start", + height: 80, + }, + { cut: [18, 204, 48, 208.5], kind: "door", tag: "202", swing: -1, hinge: "start", height: 80 }, + { cut: [216, 230, 220.5, 260], kind: "door", tag: "203", swing: -1, hinge: "end", height: 80 }, + { cut: [216, 340, 220.5, 376], kind: "door", tag: "204", swing: -1, hinge: "end", height: 80 }, + { cut: [18, 474, 78, 478.5], kind: "bifold", tag: "205", height: 80 }, + { cut: [379.5, 400, 384, 436], kind: "door", tag: "206", swing: 1, hinge: "end", height: 80 }, + { + cut: [420, 264, 450, 268.5], + kind: "door", + tag: "207", + swing: -1, + hinge: "start", + height: 80, + }, + { + cut: [420, 168, 450, 172.5], + kind: "door", + tag: "208", + swing: -1, + hinge: "start", + height: 80, + }, + win("A", [60, 0, 96, 6], L2_HEAD), + win("A", [132, 0, 168, 6], L2_HEAD), + win("A", [0, 96, 6, 132], L2_HEAD), + win("A", [0, 380, 6, 416], L2_HEAD), + win("A", [132, 522, 168, 528], L2_HEAD), + win("E", [264, 522, 336, 528], L2_HEAD), + win("E", [468, 522, 540, 528], L2_HEAD), + win("A", [618, 330, 624, 366], L2_HEAD), + win("A", [618, 430, 624, 466], L2_HEAD), + win("B", [480, 0, 528, 6], L2_HEAD), + win("E", [284, 0, 356, 6], L2_HEAD), + win("C", [618, 60, 624, 84], L2_HEAD), + ], + rooms: [ + { name: "BEDROOM 2", rect: [6, 6, 216, 204], ceiling: "9'-0\" CLG.", conditioned: true }, + { name: "CLO.", rect: [6, 208.5, 61.5, 312], conditioned: true }, + { name: "BATH 2", rect: [66, 208.5, 216, 312], ceiling: "9'-0\" CLG.", conditioned: true }, + { + name: "BEDROOM 3", + rect: [6, 316.5, 216, 522], + area: (210 * 205.5 - 94.5 * 43.5) / 144, + ceiling: "9'-0\" CLG.", + tagAt: [120, 400], + conditioned: true, + }, + { name: "CLO.", rect: [6, 478.5, 96, 522], tagAt: [51, 504], conditioned: true }, + { + name: "LOFT", + rect: [220.5, 270, 379.5, 522], + ceiling: "9'-0\" CLG.", + tagAt: [300, 420], + conditioned: true, + }, + { name: "HALL", rect: [220.5, 6, 379.5, 106], tagAt: [300, 60], conditioned: true }, + { name: "OWNER'S BATH", rect: [384, 6, 618, 168], ceiling: "9'-0\" CLG.", conditioned: true }, + { name: "OWNER'S CLOSET", rect: [384, 172.5, 618, 264], conditioned: true }, + { + name: "OWNER'S BEDROOM", + rect: [384, 268.5, 618, 522], + ceiling: "9'-0\" TRAY CLG.", + conditioned: true, + }, + ], +}; + +/** The footprint of the main block, as the site plan and the setbacks see it. */ +export const FOOTPRINT: Point[] = [ + [0, 0], + [264, 0], + [264, 72], + [376, 72], + [376, 0], + [W, 0], + [W, DEPTH], + [LANAI[2], DEPTH], + [LANAI[2], LANAI[3]], + [LANAI[0], LANAI[3]], + [LANAI[0], DEPTH], + [0, DEPTH], +]; + +/** Heights, in inches, from the first floor. The elevations and sections read these. */ +export const HEIGHTS = { + l1Plate: 120, // 10'-0" to the top of the tie beam + floorDepth: 18.25, // a 1 1/2" P.T. sill on the tie beam, 16" floor trusses and 3/4" sheathing + get l2() { + return this.l1Plate + this.floorDepth; + }, + l2Plate: 109.125, // 9'-1 1/8" studs and plates + get l2Top() { + return this.l2 + this.l2Plate; + }, + pitch: 6, // in 12 + overhang: 24, + heel: 6, + get stem() { + return (CRITERIA.flood.ffe - CRITERIA.flood.finishGrade) * 12; // 3'-0" from finished grade + }, + garageDrop: 30, // the garage slab is 2'-6" below the first floor + lanaiPlate: 108, + lanaiPitch: 3, +}; + +/** The area of a rectangle in square feet. */ +export const sf = (r: Rect): number => ((r[2] - r[0]) * (r[3] - r[1])) / 144; + +/** Gross areas from the outside faces, in square feet. */ +export function areas() { + const garage = (264 * 288) / 144; + const porch = (112 * 72) / 144; + const l1Gross = (W * DEPTH) / 144 - porch - garage; + const l2Gross = (W * DEPTH) / 144; + const lanai = sf(LANAI); + return { + garage, + porch, + l1: l1Gross, + l2: l2Gross, + lanai, + living: l1Gross + l2Gross, + total: l1Gross + l2Gross + garage + porch + lanai, + }; +} + +/** + * The roofs. The main roof is a hip at 6:12 over the main block, with the overhang all round; + * all four planes have one pitch, so the hips run at 45 degrees in plan and the ridge runs + * east-west. The lanai has a shed roof at 3:12 on a ledger against the house's north wall. + * Heights are inches above the first floor. + */ +export const ROOF = (() => { + const o = HEIGHTS.overhang; + const eave: Rect = [-o, -o, W + o, DEPTH + o]; + const half = (eave[3] - eave[1]) / 2; // the run from the eave to the ridge + const ridgeY = (eave[1] + eave[3]) / 2; + const ridge: [Point, Point] = [ + [eave[0] + half, ridgeY], + [eave[2] - half, ridgeY], + ]; + // The roof plane is `heel` above the plate over the outside face of the wall. + const atWall = HEIGHTS.l2Top + HEIGHTS.heel; + const eaveHeight = atWall - (o * HEIGHTS.pitch) / 12; + const ridgeHeight = eaveHeight + (half * HEIGHTS.pitch) / 12; + const lanai = { + rect: [LANAI[0] - 12, DEPTH, LANAI[2] + 12, LANAI[3] + 18] as Rect, + high: 150, // at the ledger on the house's wall + get low() { + return this.high - ((this.rect[3] - this.rect[1]) * HEIGHTS.lanaiPitch) / 12; + }, + }; + return { eave, ridge, ridgeY, eaveHeight, ridgeHeight, lanai }; +})(); + +/** Each window's mark, numbered through the first floor and then the second: 1-01, 2-10. */ +export const WINDOW_MARKS: Map = (() => { + const marks = new Map(); + let n = 0; + for (const [level, prefix] of [ + [L1, "1"], + [L2, "2"], + ] as const) { + for (const o of level.openings) + if (o.kind === "window") marks.set(o, `${prefix}-${String(++n).padStart(2, "0")}`); + } + return marks; +})(); diff --git a/examples/plan-set/model/structure-wind.ts b/examples/plan-set/model/structure-wind.ts new file mode 100644 index 0000000..f4c2b5c --- /dev/null +++ b/examples/plan-set/model/structure-wind.ts @@ -0,0 +1,68 @@ +// Components-and-cladding wind pressures on the main hip roof, ASCE 7-22 chapter 30 part 1, +// worked the same way as the wall pressures in `wind.ts`, so S-501 quotes one set of numbers. +// Allowable stress design: 0.6 times the strength-level pressure. +import { HEIGHTS } from "./house.ts"; +import { endZone, meanRoofHeight, qh } from "./wind.ts"; + +/** The roof slope in degrees. */ +export const roofAngle = (): number => (Math.atan(HEIGHTS.pitch / 12) * 180) / Math.PI; + +/** + * GCp of the hip roof's zones at an effective area of 10 sq. ft. or less (roofing and sheathing), + * figure 30.3-2F (hip, 20 to 27 degrees): -1.4, -2.0 and -2.0, the 7-16 figure 30.3-2G values + * the 7-22 commentary keeps. The positive +0.6 is read off the graph by eye, so the engineer of + * record checks it. + */ +const ROOF_GCP = { 1: -1.4, 2: -2.0, 3: -2.0, pos: 0.6 } as const; +/** The wall's positive GCp at 10 sq. ft., figure 30.3-1, which section 30.7 adds under an overhang. */ +const WALL_POS = 1.0; + +export interface RoofZonePressure { + readonly zone: string; + readonly where: string; + readonly gcp: number; + /** The positive GCp, where the surface takes a positive pressure. */ + readonly gcpPos: number | null; + readonly neg: number; + readonly pos: number | null; +} + +/** ASD pressures by roof zone, psf. Overhangs add the wall's coefficient and drop GCpi (30.7). */ +export function roofPressures(): RoofZonePressure[] { + const q = 0.6 * qh(); + const gcpi = 0.18; + const roof = (zone: 1 | 2 | 3, where: string): RoofZonePressure => ({ + zone: String(zone), + where, + gcp: ROOF_GCP[zone], + gcpPos: ROOF_GCP.pos, + neg: q * (ROOF_GCP[zone] - gcpi), + pos: q * (ROOF_GCP.pos + gcpi), + }); + const overhang = (zone: string, gcp: number, where: string): RoofZonePressure => ({ + zone, + where, + gcp: gcp - WALL_POS, + gcpPos: null, + neg: q * (gcp - WALL_POS), + pos: null, + }); + return [ + roof(1, "FIELD"), + roof(2, "WITHIN a OF RIDGE AND HIPS"), + roof(3, "WITHIN a OF EAVES"), + // The 2'-0" overhang lies wholly within a of an eave, so it takes zone 3 (or 2 at a hip). + overhang("O.H.", ROOF_GCP[3], `${HEIGHTS.overhang / 12}'-0" OVERHANGS (ROOF + WALL, 30.7)`), + // Soffit panels take the adjacent wall's pressure, zone 5 at 10 sq. ft. (figure 30.7-1, note 3). + { + zone: "SOFFIT", + where: "SOFFIT PANELS (= ADJ. WALL, ZONE 5)", + gcp: -1.4, + gcpPos: WALL_POS, + neg: q * (-1.4 - gcpi), + pos: q * (WALL_POS + gcpi), + }, + ]; +} + +export { endZone, meanRoofHeight, qh }; diff --git a/examples/plan-set/model/wind.ts b/examples/plan-set/model/wind.ts new file mode 100644 index 0000000..3d82ce1 --- /dev/null +++ b/examples/plan-set/model/wind.ts @@ -0,0 +1,52 @@ +// Components-and-cladding wind pressures on the walls, ASCE 7-22 chapter 30, part 1 (h <= 60'). +// Worked out from the criteria and the house, so the schedules, the notes and the garage door +// all quote one number. Allowable stress design: 0.6 times the strength-level pressure. +import { CRITERIA } from "../project.ts"; +import { DEPTH, HEIGHTS, ROOF, W, type Opening } from "./house.ts"; +import { alongXOf, widthOf } from "./geometry.ts"; + +/** Mean roof height above grade, feet. */ +export const meanRoofHeight = (): number => + (CRITERIA.flood.ffe * 12 + + (ROOF.eaveHeight + ROOF.ridgeHeight) / 2 - + CRITERIA.flood.finishGrade * 12) / + 12; + +/** Velocity pressure at the mean roof height, psf: 0.00256 Kz Kzt Kd Ke V^2, exposure D. */ +export function qh(): number { + const h = meanRoofHeight(); + // Exposure D, ASCE 7-22 table 26.10-1, interpolated between 25' and 40'. + const kz = + h <= 25 ? 1.12 : h <= 30 ? 1.12 + ((h - 25) / 5) * 0.04 : 1.16 + ((h - 30) / 10) * 0.06; + return 0.00256 * kz * 1.0 * 0.85 * 1.0 * CRITERIA.wind.vult ** 2; +} + +/** The end zone width a, feet: 10% of the least horizontal dimension or 0.4h, not under 3'. */ +export const endZone = (): number => + Math.max(Math.min((0.1 * Math.min(W, DEPTH)) / 12, 0.4 * meanRoofHeight()), 3); + +/** GCp of a wall zone for an effective area in square feet, figure 30.3-1. */ +function gcp(zone: 4 | 5, area: number): { pos: number; neg: number } { + const t = Math.min(Math.max(Math.log10(area / 10) / Math.log10(50), 0), 1); + const pos = 1.0 - 0.3 * t; + const neg = zone === 4 ? -1.1 + 0.3 * t : -1.4 + 0.6 * t; + return { pos, neg }; +} + +/** The ASD design pressures of an exterior opening, psf, with its zone. */ +export function designPressure(o: Opening): { zone: 4 | 5; pos: number; neg: number } { + const a = endZone() * 12; + const [x0, y0, x1, y1] = o.cut; + const ax = alongXOf(o); + const nearCorner = ax ? x0 < a || x1 > W - a : y0 < a || y1 > DEPTH - a; + const zone: 4 | 5 = nearCorner ? 5 : 4; + const area = Math.max((widthOf(o) * (o.height ?? 80)) / 144, 10); + const g = gcp(zone, area); + const q = qh(); + const gcpi = 0.18; + return { zone, pos: 0.6 * q * (g.pos + gcpi), neg: 0.6 * q * (g.neg - gcpi) }; +} + +export const psf = (p: { pos: number; neg: number }) => + `+${p.pos.toFixed(1)} / ${p.neg.toFixed(1)}`; +export { HEIGHTS }; diff --git a/examples/plan-set/project.ts b/examples/plan-set/project.ts new file mode 100644 index 0000000..2d825f8 --- /dev/null +++ b/examples/plan-set/project.ts @@ -0,0 +1,107 @@ +// The project: a made-up two-story residence on a made-up canal lot in a made-up Florida town. +// Every name, address and number here is fictional. +export const PROJECT = { + name: "CALLOWAY RESIDENCE", + kind: "NEW TWO-STORY SINGLE-FAMILY RESIDENCE", + address: "218 SANDPIPER COVE DRIVE", + city: "CORAL HERON KEY, FLORIDA 34999", + owner: "JUNIPER AND THEO CALLOWAY", + parcel: "LOT 14, BLOCK C, HERON SOUND ESTATES", + plat: "PLAT BOOK 00, PAGE 00 (DEMO)", + parcelId: "00-00-00-00000-000-0140", + county: "MANGROVE COUNTY (FICTIONAL)", + job: "26-0418", + date: "2026-11-13", + drawn: "AGENT", + checked: "--", + issue: "ISSUED FOR PERMIT", +} as const; + +export const FIRM = { + name: "HALYARD & MOSS", + sub: "ENGINEERING - DEMO", + lines: ["100 EXAMPLE WAY, SUITE 0", "CORAL HERON KEY, FL 34999", "CA NO. 00000 (FICTIONAL)"], +} as const; + +/** The design criteria, which the cover, the structural sheets and the notes all read. */ +export const CRITERIA = { + code: "2023 FLORIDA BUILDING CODE, RESIDENTIAL, 8TH EDITION", + codes: [ + "2023 FLORIDA BUILDING CODE, RESIDENTIAL, 8TH EDITION (FBC-R)", + "2023 FLORIDA BUILDING CODE, ENERGY CONSERVATION, 8TH EDITION", + "FBC-R CHAPTERS 12-24 (MECHANICAL) AND 25-33 (PLUMBING) GOVERN THIS DWELLING", + "NFPA 70, NATIONAL ELECTRICAL CODE, 2020 EDITION", + "ASCE 7-22, MINIMUM DESIGN LOADS FOR BUILDINGS", + ], + wind: { + vult: 165, + vasd: 128, + risk: "II", + exposure: "D", + enclosure: "ENCLOSED", + gcpi: "+/-0.18", + debris: "YES - ALL OPENINGS IMPACT RATED", + }, + flood: { + zone: "AE", + panel: "12000C0000J (DEMO)", + bfe: 9.0, + dfe: 10.0, + ffe: 11.0, + garage: 8.5, + grade: 7.5, + /** Finished grade at the foundation, after fill. Slopes to 7.40 at the swales. */ + finishGrade: 8.0, + datum: "NAVD 88", + }, + soil: 2000, + concrete: 3000, + rebar: 60, + cmu: 2000, + live: { floor: 40, sleeping: 30, roof: 20 }, + dead: { roof: 15, floor: 12 }, + occupancy: "R-3 (FBC-R SINGLE-FAMILY DWELLING)", + construction: "TYPE V-B", + zoning: { + district: "RSF-6 RESIDENTIAL SINGLE-FAMILY (DEMO)", + front: 25, + side: 7.5, + rear: 20, + poolSide: 7.5, + poolRear: 10, + maxHeight: 35, + maxCoverage: 40, + }, +} as const; + +export interface SheetInfo { + readonly number: string; + readonly title: string; + readonly title2?: string; + readonly mep?: boolean; +} + +/** The set's sheets, in order. The index on the cover and every title block read this. */ +export const SHEETS: readonly SheetInfo[] = [ + { number: "G-001", title: "COVER SHEET,", title2: "INDEX AND CODE DATA" }, + { number: "C-101", title: "SITE PLAN" }, + { number: "A-101", title: "FIRST FLOOR PLAN" }, + { number: "A-102", title: "SECOND FLOOR PLAN" }, + { number: "A-103", title: "ROOF PLAN" }, + { number: "A-201", title: "EXTERIOR ELEVATIONS,", title2: "SOUTH AND NORTH" }, + { number: "A-202", title: "EXTERIOR ELEVATIONS,", title2: "EAST AND WEST" }, + { number: "A-301", title: "BUILDING SECTION", title2: "AND WALL SECTION" }, + { number: "A-601", title: "DOOR AND WINDOW", title2: "SCHEDULES" }, + { number: "S-101", title: "FOUNDATION PLAN" }, + { number: "S-102", title: "FLOOR AND ROOF", title2: "FRAMING PLANS" }, + { number: "S-501", title: "STRUCTURAL DETAILS", title2: "AND NOTES" }, + { number: "M-101", title: "MECHANICAL PLANS", title2: "AND SCHEDULES", mep: true }, + { number: "E-101", title: "POWER AND", title2: "LIGHTING PLANS", mep: true }, + { number: "P-101", title: "PLUMBING RISER", title2: "DIAGRAMS", mep: true }, +]; + +export const sheetInfo = (number: string): SheetInfo => { + const s = SHEETS.find((x) => x.number === number); + if (!s) throw new Error(`No sheet ${number}`); + return s; +}; diff --git a/examples/plan-set/set.ts b/examples/plan-set/set.ts new file mode 100644 index 0000000..9ea28bf --- /dev/null +++ b/examples/plan-set/set.ts @@ -0,0 +1,70 @@ +// The Calloway Residence permit set: one drawing, one layout per sheet, in inches. The border +// is the firm's way: its own DWG, attached on every layout as an xref. +import { mkdirSync, writeFileSync } from "node:fs"; +import { checkIntent, createDrawing, toDXF, toSVG, type Intent } from "../../src/index.ts"; +import { BORDER, BORDER_PATH } from "./lib/frame.ts"; +import { defineLayers, defineSymbols, resetKit, type SheetDraw } from "./lib/kit.ts"; +import { borderDrawing, defineSheetFields } from "./lib/titleblock.ts"; +import { SHEETS } from "./project.ts"; +import { SHEETS as COVER } from "./sheets/cover.ts"; +import { SHEETS as ELEVATIONS } from "./sheets/elevations.ts"; +import { SHEETS as MEP } from "./sheets/mep.ts"; +import { SHEETS as PLANS } from "./sheets/plans.ts"; +import { SHEETS as ROOF } from "./sheets/roof.ts"; +import { SHEETS as SCHEDULES } from "./sheets/schedules.ts"; +import { INTENT, SHEETS as SITE } from "./sheets/site.ts"; +import { SHEETS as STRUCTURE } from "./sheets/structure.ts"; + +export function buildSet() { + resetKit(); + const d = createDrawing({ units: "in", linetypeScale: 0.5 }); + defineLayers(d); + defineSymbols(d); + defineSheetFields(d); + d.xref(BORDER, BORDER_PATH); + // Each module gives the sheets it draws, by number. A sheet draws its model space at an + // origin of its own, so no viewport shows another sheet's views. + const sheets: Record = { + ...COVER, + ...SITE, + ...PLANS, + ...ROOF, + ...ELEVATIONS, + ...SCHEDULES, + ...STRUCTURE, + ...MEP, + }; + for (const s of SHEETS) sheets[s.number]?.(d); + return d.build(); +} + +const drawing = buildSet(); +export default drawing; +export const xrefs = { [BORDER]: borderDrawing() }; +export const intent: Intent = INTENT; + +// When run, it writes the set's DXF, the border's DXF at the path the xref names, and an SVG of each +// sheet, to examples/out/plan-set/. Model space holds every sheet's views side by side, so it +// gets no picture of its own. +if (import.meta.main) { + const outDir = new URL("../out/plan-set/", import.meta.url); + const borderFile = new URL(BORDER_PATH.replace(/\.dwg$/, ".dxf"), outDir); + mkdirSync(new URL(".", borderFile), { recursive: true }); + writeFileSync(new URL("plan-set.dxf", outDir), toDXF(drawing)); + writeFileSync(borderFile, toDXF(xrefs[BORDER])); + const layouts = drawing.layouts ?? []; + for (const { name } of layouts) { + writeFileSync(new URL(`${name}.svg`, outDir), toSVG(drawing, { layout: name, xrefs })); + } + const onSheets = layouts.reduce((sum, layout) => sum + layout.entities.length, 0); + console.log( + `Wrote ${drawing.entities.length} entities in model space and ${onSheets} on ` + + `${layouts.length} sheets, on ${drawing.layers.length} layers, to examples/out/plan-set/`, + ); + const warnings = checkIntent(drawing, intent); + console.log( + warnings.length === 0 + ? "The house keeps out of every setback" + : warnings.map((w) => w.message).join("\n"), + ); +} diff --git a/examples/plan-set/sheets/cover.ts b/examples/plan-set/sheets/cover.ts new file mode 100644 index 0000000..5417d08 --- /dev/null +++ b/examples/plan-set/sheets/cover.ts @@ -0,0 +1,259 @@ +// G-001: the cover. Everything here is on the paper: the project, the index read from the sheet +// list, the code data and design criteria read from `project.ts`, the areas worked out from the +// house, a vicinity map and the legend of the set's symbols. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA, FIRM, PROJECT, SHEETS as SHEET_LIST } from "../project.ts"; +import { areas, ROOF } from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + H, + LAYERS, + noteColumn, + put, + table, + TITLE, + type D, + type SheetDraw, +} from "../lib/kit.ts"; + +function cover(d: D): void { + frame(d, "G-001", (dd) => { + const top = AREA.top - 0.2; + dd.layer(LAYERS.text, {}, () => { + put(dd, [AREA.left + 0.2, top], PROJECT.name, 0.75, { valign: "top", style: TITLE }); + put(dd, [AREA.left + 0.25, top - 1.05], PROJECT.kind, H.head, { valign: "top" }); + put(dd, [AREA.left + 0.25, top - 1.4], `${PROJECT.address}, ${PROJECT.city}`, H.head, { + valign: "top", + }); + put( + dd, + [AREA.left + 0.25, top - 1.75], + `OWNER: ${PROJECT.owner} | ${PROJECT.parcel} | JOB ${PROJECT.job}`, + H.label, + { valign: "top" }, + ); + dd.line([AREA.left + 0.2, top - 2.1], [AREA.right - 0.2, top - 2.1], { lineweight: 0.7 }); + }); + const colTop = top - 2.45; + const a = areas(); + const sf = (n: number) => `${Math.round(n).toLocaleString("en-US")} SF`; + const heightAboveDfe = + CRITERIA.flood.ffe + (ROOF.eaveHeight + ROOF.ridgeHeight) / 2 / 12 - CRITERIA.flood.dfe; + + // Column 1: project data, criteria, areas. + const c1 = AREA.left + 0.2; + let y = table( + dd, + [c1, colTop], + "PROJECT DATA", + [ + { name: "ITEM", width: 2.6 }, + { name: "VALUE", width: 4.6 }, + ], + [ + ["SCOPE", "NEW SINGLE-FAMILY DWELLING"], + ["OCCUPANCY", "R-3 / FBC-R DWELLING"], + ["CONSTRUCTION TYPE", CRITERIA.construction], + ["STORIES", "2"], + ["FIRE SPRINKLERS", "NONE (NOT REQUIRED)"], + ["ZONING", "RSF-6 (DEMO)"], + [ + "FLOOD ZONE / BFE", + `${CRITERIA.flood.zone} / ${CRITERIA.flood.bfe.toFixed(2)}' ${CRITERIA.flood.datum}`, + ], + ["DESIGN FLOOD ELEV.", `${CRITERIA.flood.dfe.toFixed(2)}' NAVD`], + ["FIRST FLOOR", `${CRITERIA.flood.ffe.toFixed(2)}' NAVD`], + ["FINISHED GRADE", `${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD AT HOUSE`], + ["BUILDING HEIGHT", `${heightAboveDfe.toFixed(2)}' D.F.E. TO MEAN ROOF`], + ["HEIGHT LIMIT", `${CRITERIA.zoning.maxHeight}'-0" (D.F.E. TO MEAN ROOF)`], + ["PLATE HEIGHTS", `1ST 10'-0", 2ND 9'-1 1/8"`], + ], + ); + y -= 0.3; + y = table( + dd, + [c1, y], + "AREA TABULATION (GROSS, TO OUTSIDE FACE)", + [ + { name: "AREA", width: 4.2 }, + { name: "SQ. FT.", width: 3.0, align: "right" }, + ], + [ + ["FIRST FLOOR, CONDITIONED", sf(a.l1)], + ["SECOND FLOOR, CONDITIONED", sf(a.l2)], + ["TOTAL CONDITIONED", sf(a.living)], + ["GARAGE", sf(a.garage)], + ["ENTRY PORCH", sf(a.porch)], + ["COVERED LANAI", sf(a.lanai)], + ["TOTAL UNDER ROOF", sf(a.total)], + ], + ); + y -= 0.3; + const w = CRITERIA.wind; + table( + dd, + [c1, y], + "DESIGN CRITERIA (ASCE 7-22)", + [ + { name: "ITEM", width: 3.6 }, + { name: "VALUE", width: 3.6 }, + ], + [ + ["ULTIMATE WIND SPEED, VULT", `${w.vult} MPH`], + ["NOMINAL WIND SPEED, VASD", `${w.vasd} MPH`], + ["RISK CATEGORY", w.risk], + ["EXPOSURE CATEGORY", w.exposure], + ["ENCLOSURE", w.enclosure], + ["INTERNAL PRESSURE COEF.", w.gcpi], + ["WIND-BORNE DEBRIS REGION", "YES, IMPACT OPENINGS"], + ["FLOOR LIVE LOAD", `${CRITERIA.live.floor} / ${CRITERIA.live.sleeping} PSF (SLEEPING)`], + ["ROOF LIVE / DEAD LOAD", `${CRITERIA.live.roof} / ${CRITERIA.dead.roof} PSF`], + ["SOIL BEARING (ASSUMED)", `${CRITERIA.soil.toLocaleString("en-US")} PSF`], + ], + ); + + // Column 2: code data and general notes. + const c2 = AREA.left + 7.9; + y = noteColumn(dd, [c2, colTop], 7.4, "APPLICABLE CODES", [...CRITERIA.codes], { + numbered: false, + }); + y -= 0.25; + noteColumn(dd, [c2, y], 7.4, "GENERAL NOTES", [ + "THE CONTRACTOR SHALL VERIFY ALL DIMENSIONS AND CONDITIONS AT THE SITE AND REPORT ANY DISCREPANCY TO THE ENGINEER BEFORE STARTING WORK.", + "ALL WORK SHALL COMPLY WITH THE CODES LISTED ABOVE AND THE REQUIREMENTS OF THE AUTHORITY HAVING JURISDICTION.", + "THESE DRAWINGS SHOW THE COMPLETED STRUCTURE. MEANS, METHODS, SHORING AND SAFETY DURING CONSTRUCTION ARE THE CONTRACTOR'S.", + "WRITTEN DIMENSIONS GOVERN. DO NOT SCALE THE DRAWINGS.", + "EXTERIOR WINDOWS, DOORS, GARAGE DOOR, ROOFING AND SOFFIT SHALL HAVE FLORIDA PRODUCT APPROVAL FOR THE DESIGN PRESSURES ON A-601 AND S-501, SUBMITTED BEFORE INSTALLATION.", + "PRE-ENGINEERED WOOD TRUSSES ARE A DEFERRED SUBMITTAL, DESIGNED BY A FLORIDA REGISTERED ENGINEER FOR THE LOADS ON S-501. SUBMIT SEALED TRUSS DRAWINGS BEFORE THE FRAMING INSPECTION.", + "ENERGY CODE COMPLIANCE BY FORM R405 (PERFORMANCE), SUBMITTED UNDER SEPARATE COVER.", + "THE LOWEST FLOOR AND ALL MECHANICAL AND ELECTRICAL EQUIPMENT ARE AT OR ABOVE THE DESIGN FLOOD ELEVATION. ENCLOSED AREAS BELOW IT ARE FOR PARKING, ACCESS AND STORAGE ONLY.", + "SUBMIT ELEVATION CERTIFICATES AT THE LOWEST FLOOR FORM INSPECTION AND AT FINAL.", + "TERMITE PROTECTION: SOIL TREATMENT BY A LICENSED APPLICATOR PER FBC-R R318, CERTIFICATE AT FINAL.", + ]); + + // Column 3: the index, read from the sheet list. + const c3 = AREA.left + 15.8; + y = table( + dd, + [c3, colTop], + "SHEET INDEX", + [ + { name: "SHEET", width: 1.0 }, + { name: "TITLE", width: 5.6 }, + ], + SHEET_LIST.map((s) => [ + s.number, + [s.title, s.title2].filter(Boolean).join(" ").replace(", ", " ") + + (s.number === "M-101" ? " (ADDED, REV. 1)" : ""), + ]), + { rowHeight: 0.26 }, + ); + y -= 0.3; + table( + dd, + [c3, y], + "ABBREVIATIONS", + [ + { name: "", width: 1.0 }, + { name: "", width: 2.3 }, + { name: "", width: 1.0 }, + { name: "", width: 2.3 }, + ], + [ + ["A.F.F.", "ABOVE FINISH FLOOR", "GWB", "GYPSUM WALLBOARD"], + ["BFE", "BASE FLOOD ELEVATION", "MAX.", "MAXIMUM"], + ["CLG.", "CEILING", "MIN.", "MINIMUM"], + ["CMU", "CONCRETE MASONRY UNIT", "NAVD", "N. AMER. VERT. DATUM"], + ["CONC.", "CONCRETE", "O.C.", "ON CENTER"], + ["DFE", "DESIGN FLOOD ELEV.", "OSB", "ORIENTED STRAND BD."], + ["EL.", "ELEVATION", "P.T.", "PRESSURE TREATED"], + ["F.F.E.", "FINISH FLOOR ELEV.", "T.O.", "TOP OF"], + ["FTG.", "FOOTING", "TYP.", "TYPICAL"], + ], + { rowHeight: 0.24 }, + ); + + // Column 4: vicinity map, legend and the team. + const c4 = AREA.left + 23.0; + vicinity(dd, [c4, colTop], 6.5, 5.0); + legend(dd, [c4, colTop - 6.0], 6.5); + noteColumn( + dd, + [c4, AREA.bottom + 3.2], + 6.5, + "PROJECT TEAM", + [ + `OWNER: ${PROJECT.owner}`, + `ENGINEER: ${FIRM.name} (FICTIONAL), ${FIRM.lines[0]}, ${FIRM.lines[1]}`, + "CONTRACTOR: TO BE SELECTED", + ], + { numbered: false }, + ); + }); +} + +/** A made-up vicinity map: streets, the canal and the site, not to scale. */ +function vicinity(d: D, at: Point, w: number, h: number): void { + const [x, y0] = at; + const y = y0 - h; + d.layer(LAYERS.text, {}, () => { + put(d, [x, y0], "VICINITY MAP", H.label, { valign: "top" }); + put(d, [x + w, y0], "NOT TO SCALE", H.small, { valign: "top", align: "right" }); + d.rect([x, y], w, h - 0.3, { lineweight: 0.35 }); + const roads: [Point, Point, string, number][] = [ + [[x, y + 1.2], [x + w, y + 1.2], "SANDPIPER COVE DR.", 0], + [[x, y + 3.4], [x + w, y + 3.4], "HERON SOUND BLVD.", 0], + [[x + 1.4, y], [x + 1.4, y + h - 0.3], "OSPREY WAY", 90], + [[x + 5.0, y], [x + 5.0, y + h - 0.3], "COQUINA AVE.", 90], + ]; + for (const [a, b, name, rot] of roads) { + d.line(a, b, { lineweight: 0.5 }); + // Each name sits beside its road, clear of the crossing roads. + const at: Point = rot ? [a[0] - 0.08, y + 0.15] : [x + 2.0, a[1] + 0.08]; + put(d, at, name, H.small, { rotation: rot }); + } + // The canal, between the two streets, and the site on its south bank. + d.line([x + 1.5, y + 2.1], [x + w, y + 2.1], { lineweight: 0.18 }); + d.line([x + 1.5, y + 2.5], [x + w, y + 2.5], { lineweight: 0.18 }); + put(d, [x + 3.2, y + 2.24], "HERON SOUND CANAL", H.small, { align: "center" }); + d.rect([x + 3.0, y + 1.3], 0.35, 0.7, { lineweight: 0.7 }); + put(d, [x + 3.5, y + 1.55], "SITE", H.label); + d.insert("NORTH ARROW", [x + w - 0.6, y + h - 1.0], { scale: 0.7 }); + }); +} + +function legend(d: D, at: Point, w: number): void { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "SYMBOLS", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.35; + }); + const row = (label: string, draw: (c: Point) => void) => { + d.layer(LAYERS.text, {}, () => { + draw([at[0] + 0.45, y + 0.04]); + put(d, [at[0] + 1.2, y], label, H.note, { valign: "middle" }); + }); + y -= 0.5; + }; + row("VIEW TITLE: VIEW NO. / SHEET", (c) => + d.insert("VIEW TITLE", [c[0] - 0.25, c[1]], { + scale: 0.6, + attributes: { VIEW_NO: "1", VIEW_SHEET: "A-301", VIEW_TITLE: "", VIEW_SCALE: "" }, + }), + ); + row("SECTION: NO. / SHEET", (c) => + d.insert("SECTION MARK", c, { attributes: { SECT: "A", SHEET: "A-301" } }), + ); + row("DOOR NUMBER", (c) => d.insert("DOOR TAG", c, { attributes: { DOOR: "101" } })); + row("WINDOW TYPE", (c) => d.insert("WINDOW TAG", c, { attributes: { WINDOW: "A" } })); + row("LEVEL: NAME / ELEVATION", (c) => + d.insert("LEVEL", [c[0] - 0.2, c[1]], { attributes: { NAME: "", EL: "" } }), + ); + row("SPOT GRADE, PROPOSED / (EXISTING)", (c) => d.insert("SPOT", c, { attributes: { EL: "" } })); + row("FLOOD VENT", (c) => d.insert("FLOOD VENT", c)); +} + +export const SHEETS: Record = { "G-001": cover }; diff --git a/examples/plan-set/sheets/elevations-lib.ts b/examples/plan-set/sheets/elevations-lib.ts new file mode 100644 index 0000000..7c6881b --- /dev/null +++ b/examples/plan-set/sheets/elevations-lib.ts @@ -0,0 +1,372 @@ +// Helpers the elevation and section sheets share: a local frame for a view (h along the view, +// z up from the first floor), occluded lines, level markers, patterns tsdraft has no hatch for, +// and the heights in NAVD. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; +import { ENTRY, HEIGHTS, LANAI_SLAB, ROOF } from "../model/house.ts"; +import { H, LAYERS, leaderNote, put, width, type D, type View } from "../lib/kit.ts"; +import { arrowhead, lineOutside, type Box } from "../../lib/drafting.ts"; + +/** Elevation in NAVD 88 of a height above the first floor, in inches. */ +export const navd = (z: number): number => CRITERIA.flood.ffe + z / 12; +export const el = (z: number): string => `EL. ${navd(z).toFixed(2)}' NAVD`; +/** A height in NAVD as a height above the first floor, in inches. */ +export const zOf = (elevation: number): number => (elevation - CRITERIA.flood.ffe) * 12; + +const quarter = (z: number): number => Math.round(z * 4) / 4; + +/** The heights every elevation and section reads, in inches above the first floor. */ +export const Z = (() => { + /** Finished grade at the foundation, after fill: the grade the elevations and sections draw. */ + const grade = zOf(CRITERIA.flood.finishGrade); + const fascia = 6; + return { + grade, + existing: zOf(CRITERIA.flood.grade), + garage: zOf(CRITERIA.flood.garage), + bfe: zOf(CRITERIA.flood.bfe), + dfe: zOf(CRITERIA.flood.dfe), + ff: 0, + tieBeam: HEIGHTS.l1Plate, + tieBeamDepth: 16, + l2: HEIGHTS.l2, + plate: HEIGHTS.l2Top, + eave: ROOF.eaveHeight, + fascia, + soffit: ROOF.eaveHeight - fascia, + ridge: ROOF.ridgeHeight, + /** The mean roof height of a hip roof: halfway from the eave to the ridge. */ + mean: (ROOF.eaveHeight + ROOF.ridgeHeight) / 2, + /** The continuous footing, as S-101 note 3 and 1/S-501 have it: 20" x 12", 18" below grade. */ + footingTop: grade - 6, + footingDepth: 12, + footingWidth: 20, + // The model gives these in NAVD to 0.01'; to the nearest 1/4" the slabs are 4" below the F.F.E. + porch: quarter(zOf(ENTRY.slab)), + lanai: quarter(zOf(LANAI_SLAB.slab)), + deck: quarter(zOf(LANAI_SLAB.deck)), + }; +})(); + +/** A stair's riser height, from the levels it joins and its number of risers. */ +export const riser = (top: number, bottom: number, risers: number): number => + (top - bottom) / risers; + +/** + * A guard seen face-on: a top rail and a bottom rail with pickets at 4" o.c. between, `height` + * above `z`, from h0 to h1. Lines, as aluminum pickets would be drawn. + */ +export function guardElev( + L: Local, + h0: number, + h1: number, + z: number, + height = LANAI_SLAB.guard, +): void { + if (h1 - h0 < 1) return; + L.d.layer(LAYERS.elevFine, {}, () => { + L.rect(h0, z + height - 2, h1, z + height); + L.rect(h0, z + 2, h1, z + 3.5); + const n = Math.max(1, Math.round((h1 - h0) / 4)); + for (let k = 1; k < n; k++) { + const h = h0 + ((h1 - h0) * k) / n; + L.line(h, z + 3.5, h, z + height - 2); + } + L.line(h0, z, h0, z + height); + L.line(h1, z, h1, z + height); + }); +} + +/** + * A stair seen from the front: its sides, a line at each nosing, and a handrail at each side, + * seen end-on as a post from the bottom to 36" above the top. + */ +export function stairFront( + L: Local, + h0: number, + h1: number, + top: number, + bottom: number, + risers: number, + rail = true, +): void { + const r = riser(top, bottom, risers); + L.d.layer(LAYERS.elevFine, {}, () => { + L.line(h0, bottom, h0, top); + L.line(h1, bottom, h1, top); + for (let k = 1; k < risers; k++) L.line(h0, top - k * r, h1, top - k * r); + if (rail) { + L.rect(h0, bottom, h0 + 2, top + 36); + L.rect(h1 - 2, bottom, h1, top + 36); + } + }); +} + +/** + * A stair in profile, from its top nosing at `h` going `dir` (+1 or -1 along h) down to + * `bottom`, with a handrail 36" above the nosings and a post at each end. + */ +export function stairProfile( + L: Local, + h: number, + dir: 1 | -1, + top: number, + bottom: number, + risers: number, + tread: number, +): void { + const r = riser(top, bottom, risers); + const pts: [number, number][] = [[h, top]]; + for (let k = 0; k < risers; k++) { + const at = h + dir * k * tread; + pts.push([at, top - (k + 1) * r]); + if (k < risers - 1) pts.push([at + dir * tread, top - (k + 1) * r]); + } + const last = h + dir * (risers - 1) * tread; + L.d.layer(LAYERS.elevFine, {}, () => { + L.poly(pts); + // The handrail, parallel to the nosings, with a post at each end. + L.poly([ + [last, bottom + r + 36], + [h, top + 36], + ]); + L.line(last - dir * 2, bottom + r, last - dir * 2, bottom + r + 36); + L.line(h - dir * 2, top, h - dir * 2, top + 36); + }); +} + +/** A frame of a view in model space: local h (along) and z (up), from an origin. */ +export interface Local { + readonly d: D; + readonly o: Point; + readonly P: (h: number, z: number) => Point; + /** A line, left out where it passes behind `holes` when it is horizontal or vertical. */ + readonly line: (h0: number, z0: number, h1: number, z1: number) => void; + readonly rect: (h0: number, z0: number, h1: number, z1: number) => void; + readonly poly: (pts: readonly (readonly [number, number])[], closed?: boolean) => void; + /** A box in model coordinates, for the drafting helpers. */ + readonly box: (h0: number, z0: number, h1: number, z1: number) => Box; +} + +export function local(d: D, o: Point, holes: readonly Box[] = []): Local { + const P = (h: number, z: number): Point => [o[0] + h, o[1] + z]; + const line = (h0: number, z0: number, h1: number, z1: number) => { + if (holes.length && (h0 === h1 || z0 === z1)) lineOutside(d, P(h0, z0), P(h1, z1), holes); + else d.line(P(h0, z0), P(h1, z1)); + }; + const rect = (h0: number, z0: number, h1: number, z1: number) => { + if (holes.length) { + line(h0, z0, h1, z0); + line(h1, z0, h1, z1); + line(h1, z1, h0, z1); + line(h0, z1, h0, z0); + } else d.polyline([P(h0, z0), P(h1, z0), P(h1, z1), P(h0, z1)], true); + }; + const poly = (pts: readonly (readonly [number, number])[], closed = false) => + d.polyline( + pts.map(([h, z]) => P(h, z)), + closed, + ); + const box = (h0: number, z0: number, h1: number, z1: number): Box => { + const [a, b] = [P(Math.min(h0, h1), Math.min(z0, z1)), P(Math.max(h0, h1), Math.max(z0, z1))]; + return [a[0], a[1], b[0] - a[0], b[1] - a[1]]; + }; + return { d, o, P, line, rect, poly, box }; +} + +/** A level marker: the LEVEL block at `at`, its line run back to `from`. */ +export function level( + L: Local, + v: View, + h: number, + z: number, + from: number, + name: string, + elev: string, +): void { + const r = v.p(0.08); + L.d.layer(LAYERS.symb, {}, () => { + const end = from < h ? h - r : h + r; + L.line(from, z, end, z); + L.d.insert("LEVEL", L.P(h, z), { scale: v.p(1), attributes: { NAME: name, EL: elev } }); + }); +} + +/** A dashed line across a view with a label ending at `labelAt` (its right end), for the flood levels. */ +export function floodLine( + L: Local, + v: View, + h0: number, + h1: number, + z: number, + label: string, + labelAt = h1, +): void { + L.d.layer(LAYERS.anno, {}, () => { + L.d.line(L.P(h0, z), L.P(h1, z), { linetype: "DASHED", lineweight: 0.35 }); + put(L.d, L.P(labelAt - v.p(0.1), z + v.p(0.04)), label, v.p(H.small), { align: "right" }); + }); +} + +export interface Note { + /** The point the leader points to, local. */ + readonly at: readonly [number, number]; + readonly lines: readonly string[]; + /** Where the leader turns, local, for a note placed by hand. Stacked notes leave it out. */ + readonly elbow?: readonly [number, number]; + /** The side the text goes, when it is not the side away from the target. */ + readonly side?: 1 | -1; +} + +/** The kit's leaderNote with the side of the text given: the kit puts it away from the target. */ +export function sideNote( + d: D, + v: View, + target: Point, + elbow: Point, + lines: readonly string[], + side: 1 | -1, +): void { + const h = v.p(H.note); + const end: Point = [elbow[0] + side * v.p(1 / 8), elbow[1]]; + d.polyline([target, elbow, end]); + arrowhead(d, target, elbow, v.p(1 / 8)); + const x = end[0] + side * v.p(1 / 16); + lines.forEach((line, k) => { + put(d, [x, elbow[1] - k * h * 1.6], line, h, { + align: side > 0 ? "left" : "right", + valign: "middle", + }); + }); +} + +/** + * Leader notes stacked in a column at local `h`, each as near the height of its target as the + * notes above it let it be. Returns the lowest z used. + */ +export function noteStack( + L: Local, + v: View, + h: number, + notes: readonly Note[], + top = Infinity, + avoid: readonly (readonly [number, number])[] = [], +): number { + const lead = v.p(H.note) * 1.6; + const gap = v.p(0.12); + const sorted = [...notes].sort((a, b) => b.at[1] - a.at[1]); + let floor = top; + L.d.layer(LAYERS.anno, {}, () => { + for (const n of sorted) { + if (n.elbow && n.side) { + sideNote(L.d, v, L.P(n.at[0], n.at[1]), L.P(n.elbow[0], n.elbow[1]), n.lines, n.side); + continue; + } + if (n.elbow) { + leaderNote(L.d, v, L.P(n.at[0], n.at[1]), L.P(n.elbow[0], n.elbow[1]), n.lines); + continue; + } + let z = Math.min(n.at[1], floor); + // Keep the elbow out of the bands given, such as the text of level markers. + for (const [lo, hi] of [...avoid].sort((a, b) => b[0] - a[0])) if (z > lo && z < hi) z = lo; + leaderNote(L.d, v, L.P(n.at[0], n.at[1]), L.P(h, z), n.lines); + floor = z - n.lines.length * lead - gap; + } + }); + return floor; +} + +/** The longest line of a note, in paper inches. */ +export const noteWidth = (lines: readonly string[]): number => + Math.max(...lines.map((l) => width(l, H.note))); + +// --------------------------------------------------------------------------------------------- +// Patterns. tsdraft has no hatch entity, so these are lines, as lib/drafting.ts draws its own. + +function seeded(seed: number): () => number { + let s = seed; + return () => { + s = (s * 1664525 + 1013904223) % 4294967296; + return s / 4294967296; + }; +} + +const r2 = (p: Point): Point => [Math.round(p[0] * 100) / 100, Math.round(p[1] * 100) / 100]; + +/** + * Concrete as drafters draw it (AR-CONC): small triangles and dots scattered over `area`, one + * mark per cell of `size`, kept inside the area. + */ +export function concrete(d: D, area: Box, size: number, seed = 7): void { + const rnd = seeded(seed); + const [x0, y0, w, h] = area; + const m = size * 0.25; + for (let y = y0; y < y0 + h - 1e-6; y += size) { + for (let x = x0; x < x0 + w - 1e-6; x += size) { + const c: Point = [x + m + rnd() * (size - 2 * m), y + m + rnd() * (size - 2 * m)]; + const inArea = (p: Point) => + p[0] > x0 + m * 0.5 && + p[0] < x0 + w - m * 0.5 && + p[1] > y0 + m * 0.5 && + p[1] < y0 + h - m * 0.5; + if (rnd() < 0.45) { + const s = size * (0.18 + 0.12 * rnd()); + const a = rnd() * Math.PI * 2; + const pts = [0, 1, 2].map((k): Point => + r2([ + c[0] + s * Math.cos(a + (k * 2 * Math.PI) / 3), + c[1] + s * Math.sin(a + (k * 2 * Math.PI) / 3), + ]), + ); + if (pts.every(inArea)) d.polyline(pts, true); + } else { + const s = size * 0.04; + const p0 = r2([c[0] - s, c[1]]); + const p1 = r2([c[0] + s, c[1]]); + if (inArea(p0) && inArea(p1)) d.line(p0, p1); + } + } + } +} + +/** Compacted fill or sand: a sparse field of dots. */ +export function dots(d: D, area: Box, size: number, seed = 3): void { + const rnd = seeded(seed); + const [x0, y0, w, h] = area; + for (let y = y0; y < y0 + h - 1e-6; y += size) { + for (let x = x0; x < x0 + w - 1e-6; x += size) { + const c: Point = r2([x + size * (0.15 + 0.7 * rnd()), y + size * (0.15 + 0.7 * rnd())]); + const s = size * 0.05; + d.line(c, r2([c[0] + s, c[1]])); + } + } +} + +/** Batt insulation running up a vertical cavity: the looping line, turned on its side. */ +export function battUp(d: D, area: Box): void { + const [x0, y0, w, h] = area; + const radius = w / 2; + const across = w / 4; + const advance = w * 0.35; + const loops = Math.max(1, Math.floor((h - across * 2) / advance)); + const start = y0 + (h - loops * advance) / 2; + const pts: Point[] = []; + for (let i = 0; i <= loops * 24; i++) { + const t = (i / 24) * 2 * Math.PI; + const y = start + (advance * t) / (2 * Math.PI) - across * Math.sin(t); + pts.push(r2([x0 + radius - radius * Math.cos(t), y])); + } + d.polyline(pts); +} + +/** A cut piece of lumber: its outline and the cross drafters draw through a cut member. */ +export function lumber(L: Local, h0: number, z0: number, h1: number, z1: number): void { + L.rect(h0, z0, h1, z1); + L.line(h0, z0, h1, z1); + L.line(h0, z1, h1, z0); +} + +/** A rebar in section, a small circle. */ +export function bar(L: Local, h: number, z: number, dia = 0.625): void { + L.d.circle(L.P(h, z), dia / 2); +} diff --git a/examples/plan-set/sheets/elevations-section.ts b/examples/plan-set/sheets/elevations-section.ts new file mode 100644 index 0000000..c6beb22 --- /dev/null +++ b/examples/plan-set/sheets/elevations-section.ts @@ -0,0 +1,827 @@ +// A-301: the building section, north-south through x = 450 looking west (the study, the great +// room and the lanai, the owner's bath, closet and bedroom above) at 1/4" = 1'-0", and the +// typical wall section through the first-floor CMU wall and the second-floor wood wall, from +// the footing to the roof, at 1/2" = 1'-0". +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; +import { DEPTH, HEIGHTS, L1, L2, LANAI, LANAI_SLAB, ROOF, W } from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + ftIn, + H, + LAYERS, + noteColumn, + put, + sectionCut, + table, + view, + viewport, + width, + type D, + type SheetDraw, + type View, +} from "../lib/kit.ts"; +import { breakLine, earth, hatch, insulation } from "../../lib/drafting.ts"; +import { ARCH_LAYERS, drawPlan } from "../lib/plan.ts"; +import { + bar, + battUp, + concrete, + dots, + floodLine, + guardElev, + local, + lumber, + navd, + noteStack, + noteWidth, + Z, + type Note, +} from "./elevations-lib.ts"; +import { elevSymbols, levelLeft } from "./elevations-symbols.ts"; + +/** Model-space origins of the views, inside this module's range (x 80000 to 199999). */ +export const ORIGIN = { + building: [92000, 0] as Point, + wall: [97000, 0] as Point, + key: [103000, 0] as Point, +}; + +/** Where the building section cuts, in building x. */ +export const CUT_X = 450; + +const ov = HEIGHTS.overhang; +const slope = HEIGHTS.pitch / 12; +const TB = Z.tieBeam - Z.tieBeamDepth; // the bottom of the tie beam +/** The continuous footing of S-101 note 3 and 1/S-501: 20" x 12", centered on the 8" wall. */ +const FOOT = { top: Z.footingTop, bottom: Z.footingTop - Z.footingDepth, w: Z.footingWidth }; +/** LB-1, the lanai beam of S-102: 8" x 20", its top at the lanai plate height (4/S-501). */ +const LB = { top: HEIGHTS.lanaiPlate, depth: 20, width: 8 }; +const SLAB = 4; +const CLG2 = Z.plate; // the second floor's ceiling, under the roof trusses' bottom chords + +/** The roof's top at building (x, y): the hip roof's planes, one pitch all round. */ +const roofTop = (x: number, y: number): number => + Z.eave + slope * Math.min(x - ROOF.eave[0], ROOF.eave[2] - x, y - ROOF.eave[1], ROOF.eave[3] - y); + +interface Extent { + left: number; + right: number; + bottom: number; + top: number; +} + +// --------------------------------------------------------------------------------------------- +// The building section. + +function buildingSection(d: D, v: View): Extent { + const o = ORIGIN.building; + const L = local(d, o); + const x = CUT_X; + const N = DEPTH; + const lan = { y0: DEPTH, y1: LANAI[3], roofEnd: ROOF.lanai.rect[3] }; + const lanaiTop = (y: number) => ROOF.lanai.high - (y - DEPTH) * (HEIGHTS.lanaiPitch / 12); + + // The cut parts, heavy. + const cmuS: [number, number] = [0, 8]; + const cmuN: [number, number] = [N - 8, N]; + const winS = L1.openings.find( + (op) => op.kind === "window" && op.cut[1] === 0 && op.cut[0] <= x && op.cut[2] >= x, + ); + const slider = L1.openings.find((op) => op.cut[3] === DEPTH && op.cut[0] <= x && op.cut[2] >= x); + const sill = winS ? (winS.head ?? 96) - (winS.height ?? 60) : 0; + const head = winS ? (winS.head ?? 96) : 0; + const sliderHead = slider ? (slider.height ?? 96) : 0; + + d.layer(LAYERS.sect, {}, () => { + // Footings, stem walls and the slab. + for (const [a, b] of [cmuS, cmuN]) { + const c = (a + b) / 2; + L.rect(c - FOOT.w / 2, FOOT.bottom, c + FOOT.w / 2, FOOT.top); + L.rect(a, FOOT.top, b, Z.ff - SLAB); + } + L.rect(cmuS[0], Z.ff - SLAB, cmuN[1], Z.ff); + // The thickened slab under the bearing wall between the study and the great room. + L.poly([ + [180, Z.ff - SLAB], + [186, Z.ff - 16], + [203, Z.ff - 16], + [209, Z.ff - SLAB], + ]); + // First floor CMU, the window in the south wall and the slider in the north wall. + if (winS) { + L.rect(cmuS[0], Z.ff, cmuS[1], sill); + L.rect(cmuS[0], head, cmuS[1], TB); + } else L.rect(cmuS[0], Z.ff, cmuS[1], TB); + if (slider) L.rect(cmuN[0], sliderHead, cmuN[1], TB); + else L.rect(cmuN[0], Z.ff, cmuN[1], TB); + // Tie beams. + L.rect(cmuS[0], TB, cmuS[1], Z.tieBeam); + L.rect(cmuN[0], TB, cmuN[1], Z.tieBeam); + // The interior bearing wall, 2x4 studs. + L.rect(192, Z.ff, 196.5, Z.tieBeam); + // Second floor walls: 2x6 outside, 2x4 inside. + L.rect(0, Z.l2, 6, Z.plate); + L.rect(N - 6, Z.l2, N, Z.plate); + for (const w of L2.walls) { + if (w[0] <= x && w[2] >= x && w[3] - w[1] < 10 && w[1] > 10 && w[3] < N - 10) + L.rect(w[1], Z.l2, w[3], CLG2); + } + // The floor: the rim at each end, the sheathing over the trusses. + L.rect(0.5, Z.tieBeam, 2, Z.l2 - 0.75); + L.rect(N - 2, Z.tieBeam, N - 0.5, Z.l2 - 0.75); + L.line(0, Z.l2, N, Z.l2); + L.line(0, Z.l2 - 0.75, N, Z.l2 - 0.75); + // The roof, cut through the east hip plane: up from the south eave, flat, down to the north. + const prof: [number, number][] = []; + for (const y of [ + ROOF.eave[1], + ROOF.eave[1] + (ROOF.eave[2] - x), + ROOF.eave[3] - (ROOF.eave[2] - x), + ROOF.eave[3], + ]) + prof.push([y, roofTop(x, y)]); + L.poly(prof); + L.poly(prof.map(([y, z]) => [y, z - 5])); + L.line(prof[0]![0], prof[0]![1], prof[0]![0], prof[0]![1] - 5 - Z.fascia + 5); + L.line(prof[3]![0], prof[3]![1], prof[3]![0], prof[3]![1] - Z.fascia); + // Soffits. + L.line(ROOF.eave[1], Z.soffit, 0, Z.soffit); + L.line(N, Z.soffit, ROOF.eave[3], Z.soffit); + // The lanai: its slab, stem wall and footing, the beam, the shed roof on its ledger. + L.rect(N, Z.lanai - SLAB, lan.y1, Z.lanai); + L.rect(lan.y1 - 8, FOOT.top, lan.y1, Z.lanai - SLAB); + L.rect(lan.y1 - 4 - FOOT.w / 2, FOOT.bottom, lan.y1 - 4 + FOOT.w / 2, FOOT.top); + L.poly( + [ + [N, lanaiTop(N)], + [lan.roofEnd, lanaiTop(lan.roofEnd)], + [lan.roofEnd, lanaiTop(lan.roofEnd) - 8], + [N, lanaiTop(N) - 9], + ], + false, + ); + L.rect(lan.y1 - 2 - LB.width, LB.top - LB.depth, lan.y1 - 2, LB.top); // LB-1, cut + L.rect(N, lanaiTop(N) - 9 - 9.25, N + 1.5, lanaiTop(N) - 9); // the 2x10 ledger + // The pool deck north of the lanai. + L.rect(lan.y1, Z.deck - 4, lan.y1 + 90, Z.deck); + }); + + // Seen beyond the cut, thin: the ridge beyond, the lanai post, the window's and slider's frames. + d.layer(LAYERS.elevFine, {}, () => { + const yA = ROOF.eave[1] + (ROOF.eave[2] - x); + const yB = ROOF.eave[3] - (ROOF.eave[2] - x); + L.poly([ + [yA, roofTop(x, yA)], + [ROOF.ridgeY, Z.ridge], + [yB, roofTop(x, yB)], + ]); + L.rect(lan.y1 - 12, Z.lanai, lan.y1, LB.top - LB.depth); // the 12" column beyond + for (const z of [LB.top - 3, LB.top - LB.depth + 3]) + for (const dy of [2.3, 5.7]) bar(L, lan.y1 - 2 - LB.width + dy, z); + if (winS) { + L.rect(cmuS[0] + 2, sill, cmuS[1] - 2, head); + L.line(cmuS[0] - 2, sill, cmuS[1], sill); + } + if (slider) L.rect(cmuN[0] + 2, Z.ff, cmuN[1] - 2, sliderHead); + // Floor trusses, open web, at 24" o.c.: their chords and webs as they are seen. + const t0 = cmuS[1] + 1; + const t1 = cmuN[0] - 1; + L.line(t0, Z.tieBeam + 3.5, t1, Z.tieBeam + 3.5); + L.line(t0, Z.l2 - 0.75 - 3.5, t1, Z.l2 - 0.75 - 3.5); + const panel = 30; + for (let y = t0, k = 0; y + panel <= t1; y += panel, k++) { + if (k % 2 === 0) L.line(y, Z.tieBeam + 3.5, y + panel, Z.l2 - 4.25); + else L.line(y, Z.l2 - 4.25, y + panel, Z.tieBeam + 3.5); + } + // Roof trusses: the bottom chord and webs of the step-down hip truss at the cut. + L.line(0, Z.plate + 3.5, N, Z.plate + 3.5); + L.line(0, Z.plate, N, Z.plate); + const webs = [60, 120, 174, 264, 354, 408, 468]; + for (const y of webs) L.line(y, Z.plate + 3.5, y, roofTop(x, y) - 5); + for (let k = 0; k + 1 < webs.length; k++) { + const [a, b] = [webs[k]!, webs[k + 1]!]; + if (k < 3) L.line(a, Z.plate + 3.5, b, roofTop(x, b) - 5); + else L.line(a, roofTop(x, a) - 5, b, Z.plate + 3.5); + } + // Ceilings: gypsum under the floor trusses and the roof trusses; the owner's tray. + L.line(cmuS[1], Z.tieBeam - 0.5, cmuN[0], Z.tieBeam - 0.5); + L.line(6, CLG2 - 0.625, 300, CLG2 - 0.625); + L.poly([ + [300, CLG2 - 0.625], + [312, CLG2 + 11.4], + [478, CLG2 + 11.4], + [490, CLG2 - 0.625], + [N - 6, CLG2 - 0.625], + ]); + }); + + // Patterns, as lines (tsdraft has no hatch). + d.layer(LAYERS.patt, {}, () => { + const p = v.p(0.045); + for (const [a, b] of [cmuS, cmuN]) { + hatch(d, L.box(a, FOOT.top, b, Z.ff - SLAB), p); + concrete( + d, + L.box((a + b) / 2 - FOOT.w / 2, FOOT.bottom, (a + b) / 2 + FOOT.w / 2, FOOT.top), + v.p(0.06), + 11, + ); + concrete(d, L.box(a, TB, b, Z.tieBeam), v.p(0.05), 5); + } + if (winS) { + hatch(d, L.box(cmuS[0], Z.ff, cmuS[1], sill), p); + hatch(d, L.box(cmuS[0], head, cmuS[1], TB), p); + } + hatch(d, L.box(cmuN[0], slider ? sliderHead : Z.ff, cmuN[1], TB), p); + hatch(d, L.box(lan.y1 - 8, FOOT.top, lan.y1, Z.lanai - SLAB), p); + concrete( + d, + L.box(lan.y1 - 4 - FOOT.w / 2, FOOT.bottom, lan.y1 - 4 + FOOT.w / 2, FOOT.top), + v.p(0.06), + 29, + ); + concrete(d, L.box(lan.y1 - 2 - LB.width, LB.top - LB.depth, lan.y1 - 2, LB.top), v.p(0.05), 31); + // Earth below grade, and compacted fill under the slabs. + const holes = [ + L.box(-FOOT.w / 2 + 4, FOOT.bottom, FOOT.w / 2 + 4, Z.grade), + L.box(N - 4 - FOOT.w / 2, FOOT.bottom, N - 4 + FOOT.w / 2, Z.grade), + L.box(lan.y1 - 4 - FOOT.w / 2, FOOT.bottom, lan.y1 - 4 + FOOT.w / 2, Z.grade), + ]; + earth(d, L.box(-80, Z.grade - 48, lan.y1 + 70, Z.grade), holes, v.p(0.16)); + dots(d, L.box(cmuS[1] + 2, Z.grade + 2, cmuN[0] - 2, Z.ff - SLAB - 2), v.p(0.09), 5); + dots(d, L.box(N + 2, Z.grade + 2, lan.y1 - 10, Z.lanai - SLAB - 2), v.p(0.09), 9); + // Attic insulation over the second floor ceiling, R-38. + insulation(d, L.box(30, Z.plate + 3.5, N - 30, Z.plate + 13.5)); + // Batts in the second floor's outside walls. + battUp(d, L.box(0.75, Z.l2 + 2, 5.5, Z.plate - 3)); + battUp(d, L.box(N - 5.5, Z.l2 + 2, N - 0.75, Z.plate - 3)); + }); + + // Grade, heavy, outside the walls. + d.layer(LAYERS.grade, {}, () => { + L.line(-100, Z.grade, 0, Z.grade); + }); + // The lanai's guard: cut at its north edge and seen along its west side, column to house. + guardElev(L, N, lan.y1 - 12, Z.lanai); + floodLine(L, v, -60, lan.y1 + 60, Z.bfe, `B.F.E. ${CRITERIA.flood.bfe.toFixed(2)}' NAVD`); + floodLine(L, v, -60, lan.y1 + 60, Z.dfe, `D.F.E. ${CRITERIA.flood.dfe.toFixed(2)}' NAVD`); + + // Room names, and the attic. + d.layer(LAYERS.anno, {}, () => { + const name = (h: number, z: number, lines: string[]) => + lines.forEach((t, k) => + put(d, L.P(h, z - k * v.p(H.note) * 1.6), t, v.p(k ? H.note : H.label), { + align: "center", + }), + ); + const room = (r: { name: string; rect: readonly number[]; ceiling?: string }, z: number) => + name((Math.max(r.rect[1]!, 8) + Math.min(r.rect[3]!, N - 8)) / 2, z, [ + r.name, + ...(r.ceiling ? [r.ceiling] : []), + ]); + for (const r of L1.rooms) + if (r.rect[0] <= x && r.rect[2] >= x && r.name !== "COVERED LANAI") room(r, 70); + for (const r of L2.rooms) if (r.rect[0] <= x && r.rect[2] >= x) room(r, Z.l2 + 70); + name((N + lan.y1) / 2, 50, ["COVERED", "LANAI"]); + name(264, Z.plate + 50, ["ATTIC"]); + }); + + // Levels and dimensions, on the left. + const colA = -ov - v.p(1.1); + const levels: [number, string][] = [ + [Z.ridge, "RIDGE (BEYOND)"], + [Z.plate, "TOP OF PLATE"], + [Z.l2, "SECOND FLOOR"], + [Z.tieBeam, "TOP OF TIE BEAM"], + [Z.ff, "FIRST FLOOR (F.F.E.)"], + [Z.grade, "FINISH GRADE"], + ]; + for (const [z, nm] of levels) + levelLeft( + L, + v, + colA, + z, + z === Z.ridge ? ROOF.ridgeY - 96 : z === Z.grade ? -100 : 0, + nm, + `EL. ${navd(z).toFixed(2)}' NAVD`, + ); + d.layer(LAYERS.dims, {}, () => { + dimString( + d, + v, + [Z.grade, Z.ff, Z.tieBeam, Z.l2, Z.plate, Z.ridge].map((z): Point => L.P(-ov, z)), + "y", + o[0] - ov - v.p(0.45), + ); + }); + + // Notes, few: the wall section has the rest. The lanai's go in the space over its roof, the + // slab's under the grade. + const notes: Note[] = [ + { + at: [lan.roofEnd - 40, lanaiTop(lan.roofEnd - 40)], + elbow: [lan.roofEnd - 30, Z.plate + 52], + side: -1, + lines: ["LANAI: MT-1 MONO TRUSSES", 'AT 24" O.C., 3:12, ON 2X10', "P.T. LEDGER, SEE S-102"], + }, + { + at: [lan.y1 - 6, LB.top - 8], + elbow: [lan.y1 + 6, Z.plate + 10], + side: 1, + lines: ['LB-1: 8"X20" CONC. BEAM', 'ON 12"X12" COLUMNS,', "C5 AND C7, S-102"], + }, + { + at: [lan.y1 - 40, Z.lanai - 2], + elbow: [lan.y1 - 70, Z.grade - 48 - v.p(0.3)], + lines: [ + `4" CONC. SLAB AT EL. ${LANAI_SLAB.slab.toFixed(2)}' ON FILL`, + 'IN 8" CMU STEM WALL, SEE S-101;', + `${LANAI_SLAB.guard}" GUARD ON 3 SIDES, STEPS`, + `TO POOL DECK (${LANAI_SLAB.deck.toFixed(2)}'), SEE A-201`, + ], + }, + ]; + noteStack(L, v, 0, notes); + // The wall section's callout: a phantom box around the south wall and a bubble. + d.layer(LAYERS.symb, {}, () => { + const boxTop = Z.plate + 30; + L.d.polyline( + [L.P(-30, FOOT.bottom - 6), L.P(30, FOOT.bottom - 6), L.P(30, boxTop), L.P(-30, boxTop)], + true, + { linetype: "PHANTOM" }, + ); + L.d.insert("SECTION MARK", L.P(0, boxTop + v.p(0.45)), { + scale: v.p(1), + attributes: { SECT: "2", SHEET: "A-301" }, + }); + put(d, L.P(v.p(0.5), boxTop + v.p(0.45) - v.p(0.3)), "SIM.", v.p(H.note)); + }); + return { + left: colA - v.p(0.15) - v.p(Math.max(...levels.map(([, n]) => width(n, H.note)))), + right: lan.y1 + 90, + bottom: Z.grade - 48 - v.p(1.0), + top: Z.ridge + v.p(0.3), + }; +} + +// --------------------------------------------------------------------------------------------- +// The wall section: h = 0 is the outside face of the CMU and of the sheathing, outside to the +// left; z from the first floor. + +const WS = { + stucco1: 0.625, // on CMU + stucco2: 0.875, // on lath over sheathing + osb: 0.4375, + stud: 5.5, + gwb: 0.5, + inside: 48, // how far the section runs into the house +}; + +function wallSection(d: D, v: View): Extent { + const o = ORIGIN.wall; + const L = local(d, o); + const R = WS.inside; + const studIn = WS.osb + WS.stud; // the inside face of the 2x6 studs + const atWall = Z.plate + HEIGHTS.heel; // the roof's top over the outside face of the wall + const roofZ = (h: number) => atWall + slope * h; + const deck = 1.25; // metal panel, underlayment and deck under the roof's top line + const chord = 3.5 / Math.cos(Math.atan(slope)); // a 2x4 top chord's depth, plumb + const rim = { h0: 0.5, h1: 2 }; + + d.layer(LAYERS.sect, {}, () => { + // Footing and stem wall. + L.rect(4 - FOOT.w / 2, FOOT.bottom, 4 + FOOT.w / 2, FOOT.top); + L.rect(0, FOOT.top, 8, Z.ff - SLAB); + // Slab, on the stem wall. + L.poly([ + [0, Z.ff - SLAB], + [R, Z.ff - SLAB], + ]); + L.poly([ + [0, Z.ff], + [R, Z.ff], + ]); + // First floor CMU and tie beam. + L.rect(0, Z.ff, 8, TB); + L.rect(0, TB, 8, Z.tieBeam); + // The rim and floor sheathing. + lumber(L, rim.h0, Z.tieBeam, rim.h1, Z.l2 - 0.75); + L.rect(0, Z.l2 - 0.75, R, Z.l2); + // The second floor wall: the plates, cut, and the cavity. + lumber(L, WS.osb, Z.l2, studIn, Z.l2 + 1.5); + lumber(L, WS.osb, Z.plate - 3, studIn, Z.plate - 1.5); + lumber(L, WS.osb, Z.plate - 1.5, studIn, Z.plate); + L.line(0, Z.l2 - 10, 0, Z.plate); // sheathing's outside face + L.line(WS.osb, Z.l2 + 1.5, WS.osb, Z.plate - 3); + L.line(studIn, Z.l2 + 1.5, studIn, Z.plate - 3); + // Roof: the panel and deck, cut, from the fascia in. + L.poly([ + [-ov, roofZ(-ov)], + [R, roofZ(R)], + ]); + L.poly([ + [-ov, roofZ(-ov) - deck], + [R, roofZ(R) - deck], + ]); + }); + + d.layer(LAYERS.sectFine, {}, () => { + // Stucco, outside: 5/8" on the CMU, 7/8" on lath over the sheathing. + L.line(-WS.stucco1, Z.grade - 2, -WS.stucco1, Z.l2 - 10); + L.line(-WS.stucco2, Z.l2 - 6, -WS.stucco2, Z.soffit); + L.line(-WS.stucco1, Z.l2 - 10, -WS.stucco2, Z.l2 - 6); // casing bead and control joint + // Inside the CMU: 3/4" furring with foil-faced insulation, and gypsum. + L.line(8.75, Z.ff + 4, 8.75, Z.tieBeam - 0.5); + L.line(9.25, Z.ff + 4, 9.25, Z.tieBeam - 0.5); + L.rect(9.25, Z.ff, 9.75, Z.ff + 4); // base + // First floor ceiling and the floor truss, seen. + L.line(8, Z.tieBeam - 0.5, R, Z.tieBeam - 0.5); + L.rect(rim.h1, Z.tieBeam, R, Z.tieBeam + 3.5); // bottom chord + L.rect(rim.h1, Z.l2 - 0.75 - 3.5, R, Z.l2 - 0.75); // top chord + L.line(rim.h1 + 3.5, Z.tieBeam + 3.5, rim.h1 + 3.5, Z.l2 - 4.25); // end vertical + L.line(rim.h1 + 3.5, Z.tieBeam + 3.5, rim.h1 + 30, Z.l2 - 4.25); + L.line(rim.h1, Z.tieBeam + 3.5, rim.h1, Z.l2 - 4.25); + // Second floor gypsum. + L.line(studIn + WS.gwb, Z.l2 + 4, studIn + WS.gwb, Z.plate - 0.625); + L.rect(studIn + WS.gwb, Z.l2, studIn + WS.gwb + 0.5, Z.l2 + 4); + // Roof truss: the bottom chord on the plate, the heel, the top chord, the ceiling under it. + L.rect(0, Z.plate, R, Z.plate + 3.5); + L.line(0, Z.plate + 3.5, 0, roofZ(0) - deck); + L.line(3.5, Z.plate + 3.5, 3.5, roofZ(3.5) - deck - chord); + L.poly([ + [-ov + 1.5, roofZ(-ov + 1.5) - deck - chord], + [R, roofZ(R) - deck - chord], + ]); + L.line(studIn, Z.plate - 0.625, R, Z.plate - 0.625); // 5/8" gypsum ceiling + L.line(3.5, Z.plate + 3.5, 30, roofZ(30) - deck - chord); // a web + // Sub-fascia, aluminum fascia, vented soffit, baffle. + L.rect(-ov, Z.soffit, -ov + 1.5, roofZ(-ov) - deck); + L.poly([ + [-ov - 0.5, roofZ(-ov) + 0.5], + [-ov - 0.5, Z.soffit - 0.5], + [-WS.stucco2, Z.soffit - 0.5], + ]); + L.line(-ov + 1.5, Z.soffit, -WS.stucco2, Z.soffit); + L.poly( + [ + [2, roofZ(2) - deck - chord - 1.5], + [2, Z.plate + 5], + [26, Z.plate + 5], + [26, roofZ(26) - deck - chord - 1.5], + ], + false, + ); + // The vapor retarder under the slab, dashed. + L.d.line(L.P(8, Z.ff - SLAB - 0.4), L.P(R, Z.ff - SLAB - 0.4), { linetype: "DASHED" }); + // Rebar: #5 vertical, hooked into the footing; (3) #5 in the footing; TB-1's (2) #5, placed + // as 3/S-501 places them. + L.poly([ + [4 + FOOT.w / 2 - 3, FOOT.bottom + 3], + [4, FOOT.bottom + 3], + [4, Z.tieBeam - 2.5], + ]); + for (const h of [4 - FOOT.w / 2 + 3.31, 4, 4 + FOOT.w / 2 - 3.31]) + bar(L, h, FOOT.bottom + 3.31); + for (const h of [2.3, 5.7]) bar(L, h, Z.tieBeam - 3); + // A slab dowel and the welded wire. + L.d.line(L.P(10, Z.ff - 2), L.P(R, Z.ff - 2), { linetype: "DASHED" }); + // Straps: the embedded strap from the tie beam to the truss, the stud strap past the floor, + // the hurricane clip at the heel. + L.poly([ + [rim.h1 + 1.5, Z.tieBeam - 8], + [rim.h1 + 1.5, Z.tieBeam + 12], + ]); + L.poly([ + [WS.osb + 0.2, Z.tieBeam - 10], + [WS.osb + 0.2, Z.l2 + 26], + ]); + L.poly([ + [studIn - 0.2, Z.plate - 12], + [studIn - 0.2, Z.plate + 0.2], + [studIn + 3, Z.plate + 0.2], + [studIn + 3, Z.plate + 3.7], + ]); + }); + + // Patterns. + d.layer(LAYERS.patt, {}, () => { + hatch(d, L.box(0, FOOT.top, 8, Z.ff - SLAB), v.p(0.06)); + hatch(d, L.box(0, Z.ff, 8, TB), v.p(0.06)); + concrete(d, L.box(4 - FOOT.w / 2, FOOT.bottom, 4 + FOOT.w / 2, FOOT.top), v.p(0.08), 13); + concrete(d, L.box(0, TB, 8, Z.tieBeam), v.p(0.08), 17); + concrete(d, L.box(0, Z.ff - SLAB, R, Z.ff), v.p(0.06), 19); + earth( + d, + L.box(-40, FOOT.bottom - 10, -0.7, Z.grade), + [L.box(4 - FOOT.w / 2, FOOT.bottom, 4 + FOOT.w / 2, FOOT.top)], + v.p(0.2), + ); + earth(d, L.box(4 + FOOT.w / 2 + 1, FOOT.bottom - 10, R, FOOT.top), [], v.p(0.2)); + dots(d, L.box(8.5, FOOT.top + 1, R, Z.ff - SLAB - 1), v.p(0.12), 23); + battUp(d, L.box(WS.osb + 0.2, Z.l2 + 2, WS.stud - 0.4, Z.plate - 3.5)); + insulation(d, L.box(26, Z.plate + 3.5, R, Z.plate + 13.5)); + }); + + d.layer(LAYERS.grade, {}, () => L.line(-40, Z.grade, -WS.stucco1, Z.grade)); + d.layer(LAYERS.sectFine, {}, () => { + for (const z of [Z.ff - SLAB - 6, Z.tieBeam + 6, Z.plate + 6]) + breakLine(d, L.P(R, z - 14), L.P(R, z + 14), v.p(0.1)); + breakLine(d, L.P(R - 3, roofZ(R - 3) - 10), L.P(R + 3, roofZ(R + 3) + 6), v.p(0.08)); + }); + + // Levels, on the right. + const right = R + v.p(0.4); + const levels: [number, string][] = [ + [Z.plate, "TOP OF PLATE"], + [Z.l2, "SECOND FLOOR"], + [Z.tieBeam, "TOP OF TIE BEAM"], + [Z.ff, "F.F.E."], + ]; + d.layer(LAYERS.symb, {}, () => { + for (const [z, nm] of levels) { + L.d.line(L.P(R, z), L.P(right - v.p(0.08), z)); + L.d.insert("LEVEL", L.P(right, z), { + scale: v.p(1), + attributes: { NAME: nm, EL: `EL. ${navd(z).toFixed(2)}'` }, + }); + } + }); + + // Notes: outside on the left, inside on the right. + const outside: Note[] = [ + { + at: [-ov / 2, roofZ(-ov / 2)], + lines: [ + "STANDING SEAM METAL ROOF PANELS,", + "SELF-ADHERED UNDERLAYMENT,", + '19/32" CDX PLY. DECK, 8D RS NAILS', + 'AT 4" O.C. EDGES / 6" FIELD', + ], + }, + { at: [-ov - 0.5, Z.soffit + 2], lines: ["ALUM. FASCIA OVER 2X6 SUB-FASCIA"] }, + { at: [-ov / 2, Z.soffit - 0.5], lines: ["VENTED ALUM. SOFFIT, 9 SQ. IN./FT."] }, + { + at: [-WS.stucco2, Z.l2 + 50], + lines: [ + '7/8" 3-COAT STUCCO ON 2.5 LB.', + "METAL LATH OVER 2 LAYERS GRADE D", + 'PAPER, 7/16" OSB, 8D AT 4" EDGES', + 'AND 8" FIELD (S-501)', + ], + }, + { + at: [-WS.stucco2, Z.l2 - 7], + lines: ["CASING BEAD AND CONTROL JOINT", "AT THE CHANGE OF SUBSTRATE"], + }, + { + at: [-WS.stucco1, 60], + lines: ['5/8" 2-COAT STUCCO ON 8" CMU,', "f'm = 2,000 PSI, TYPE S MORTAR"], + }, + { at: [-WS.stucco1, Z.grade + 14], lines: ["STUCCO ON CMU STEM WALL"] }, + { + at: [-30, Z.grade], + lines: [ + `FINISH GRADE EL. ${CRITERIA.flood.finishGrade.toFixed(2)}',`, + 'SLOPE AWAY 6" IN 10\'-0"', + ], + }, + { + at: [4 - FOOT.w / 2, FOOT.bottom + 6], + lines: [`${FOOT.w}"X${Z.footingDepth}" CONT. FOOTING, (3) #5`, 'CONT., 3" CLR., SEE 1/S-501'], + }, + ]; + const inside: Note[] = [ + { + at: [36, roofZ(36) - deck - chord], + lines: [ + "PRE-ENG. WOOD ROOF TRUSSES AT", + `24" O.C., ${CRITERIA.dead.roof} PSF DEAD, SEE S-102`, + ], + }, + { + at: [studIn + 3, Z.plate + 2], + lines: ["TRUSS STRAP 'C1' AT EACH TRUSS", "END, UPLIFT PER S-102 SCHEDULE"], + }, + { at: [40, Z.plate + 10], lines: ["R-38 BLOWN INSULATION,", "BAFFLE AT EAVE"] }, + { at: [40, Z.plate - 0.625], lines: ['5/8" GWB CEILING'] }, + { + at: [WS.stud - 1, Z.l2 + 80], + lines: ['2X6 STUDS AT 16" O.C., R-21', 'BATTS, 1/2" GWB, DBL. TOP PLATE'], + }, + { + at: [WS.osb + 0.2, Z.l2 + 22], + lines: ["EMBEDDED STRAP 'C2', STUD TO", 'TIE BEAM, AT 32" O.C., 3/S-501'], + }, + { + at: [30, Z.tieBeam + 8], + lines: ['16" FLOOR TRUSSES AT 24" O.C.,', '3/4" T&G OSB, GLUED AND NAILED'], + }, + { + at: [rim.h1 + 1.5, Z.tieBeam - 6], + lines: ["EMBEDDED TRUSS ANCHOR 'C4'", "AT EACH FLOOR TRUSS, 3/S-501"], + }, + { at: [5.7, Z.tieBeam - 3], lines: ["8\"X16\" TIE BEAM 'TB-1',", "(2) #5 CONT., SEE S-102"] }, + { at: [9, 70], lines: ['3/4" P.T. FURRING, R-4.1 FOIL-', 'FACED INSUL., 1/2" GWB'] }, + { at: [4, 30], lines: ['#5 VERT. AT 48" O.C. AND AT', "OPENINGS, IN GROUTED CELLS"] }, + { at: [24, Z.ff - 2], lines: ['4" CONC. SLAB, 6X6-W1.4XW1.4', "W.W.F., SEE S-101"] }, + { + at: [30, Z.ff - SLAB - 0.4], + lines: ["10 MIL VAPOR RETARDER, TERMITE", "TREATMENT PER FBC-R R318"], + }, + { at: [30, -20], lines: ["CLEAN FILL COMPACTED TO 95%", '(ASTM D1557) IN 12" LIFTS'] }, + { at: [5, FOOT.bottom + 3], lines: ["#5 STD. HOOK INTO FOOTING"] }, + ]; + const leftH = -ov - v.p(0.5); + const rightH = right + v.p(1.5); + const lowL = noteStack(L, v, leftH, outside); + const bands = levels.map(([z]): [number, number] => [z - v.p(0.55), z + v.p(0.2)]); + const lowR = noteStack(L, v, rightH, inside, Infinity, bands); + return { + left: leftH - v.p(0.3) - v.p(Math.max(...outside.map((n) => noteWidth(n.lines)))), + right: rightH + v.p(0.3) + v.p(Math.max(...inside.map((n) => noteWidth(n.lines)))), + bottom: Math.min(lowL, lowR, FOOT.bottom - 10) - v.p(0.1), + top: roofZ(R) + v.p(0.3), + }; +} + +/** A key plan of the first floor at 1/16" = 1'-0", with the building section's cut. */ +function keyPlan(d: D, v: View): Extent { + const o = ORIGIN.key; + drawPlan(d, o, L1, ARCH_LAYERS, "L1"); + const P = (x: number, y: number): Point => [o[0] + x, o[1] + y]; + d.layer(LAYERS.symb, {}, () => { + const y0 = -v.p(0.45); + const y1 = LANAI[3] + v.p(0.45); + d.line(P(CUT_X, y0 + v.p(0.22)), P(CUT_X, y1 - v.p(0.22)), { + linetype: "PHANTOM", + lineweight: 0.35, + }); + // The kit's mark, as A-101 and A-102 draw the same cut: the bubble upright, the arrow west. + sectionCut(d, v, P(CUT_X, y0), "W", "1", "A-301"); + sectionCut(d, v, P(CUT_X, y1), "W", "1", "A-301"); + }); + d.layer(LAYERS.anno, {}, () => { + put(d, P(312, -v.p(0.9)), "KEY PLAN, FIRST FLOOR", v.p(H.note), { align: "center" }); + }); + return { left: -v.p(0.4), right: 624 + v.p(0.4), bottom: -v.p(1.05), top: LANAI[3] + v.p(0.8) }; +} + +/** + * The attic's ventilation, worked out as A-103 works it out: 1/150 of the attic floor, met by the + * soffits, the ridge vent and the off-ridge vents the hip roof needs for its upper half. + */ +const VENTILATION = (() => { + const attic = (W * DEPTH) / 144; + const ridge = (((ROOF.ridge[1][0] - ROOF.ridge[0][0]) / 12) * 18) / 144; // 18 sq. in. per foot + const soffit = (((2 * (W + DEPTH + 4 * HEIGHTS.overhang)) / 12) * 9) / 144; // 9 sq. in. per foot + const offRidge = { count: 8, each: 54 }; // as A-103 has them, sq. in. of net free area each + const off = (offRidge.count * offRidge.each) / 144; + return { + attic, + required: attic / 150, + ridge, + soffit, + offRidge, + off, + total: ridge + soffit + off, + }; +})(); + +const SECTION_NOTES = [ + "SEE S-101, S-102 AND S-501 FOR FOUNDATIONS, FRAMING, CONNECTORS AND REINFORCING. WHERE THIS SHEET AND THE STRUCTURAL SHEETS DIFFER, THE STRUCTURAL SHEETS GOVERN.", + `THE LOWEST FLOOR (F.F.E. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD) IS ABOVE THE D.F.E. (${CRITERIA.flood.dfe.toFixed(2)}' NAVD). MATERIALS BELOW THE D.F.E. ARE FLOOD DAMAGE-RESISTANT (FBC-R R322.1.8).`, + "CONNECTORS ARE TAGGED C1 TO C8 AS ON THE S-102 CONNECTOR SCHEDULE. EACH IS RATED FOR AT LEAST THE UPLIFT LISTED THERE, HAS FLORIDA PRODUCT APPROVAL AND IS INSTALLED WITH EVERY FASTENER THE MANUFACTURER CALLS FOR.", + `ROOF AND FLOOR TRUSSES: PRE-ENGINEERED FOR THE LOADS ON S-501 (ROOF ${CRITERIA.live.roof} PSF LIVE, ${CRITERIA.dead.roof} PSF DEAD), WITH SIGNED AND SEALED SHOP DRAWINGS SUBMITTED BEFORE THE FRAMING INSPECTION.`, + `ATTIC VENTILATION (FBC-R R806.2): 1/150 OF THE ${VENTILATION.attic.toLocaleString("en-US")} SQ. FT. ATTIC FLOOR, ${VENTILATION.required.toFixed(2)} SQ. FT. OF NET FREE AREA REQUIRED. ${VENTILATION.total.toFixed(2)} SQ. FT. PROVIDED: VENTED SOFFITS ${VENTILATION.soffit.toFixed(2)}, RIDGE VENT ${VENTILATION.ridge.toFixed(2)} AND ${VENTILATION.offRidge.count} OFF-RIDGE VENTS OF ${VENTILATION.offRidge.each} SQ. IN. ${VENTILATION.off.toFixed(2)}. SEE A-103.`, + 'GARAGE SEPARATION (FBC-R R302.6, TABLE R302.6): 5/8" TYPE X GYPSUM BOARD ON THE GARAGE CEILING BELOW THE HABITABLE ROOMS, AND 1/2" GYPSUM BOARD ON THE GARAGE WALLS. SEE A-101.', +]; + +/** The building envelope's R-values, as the energy form uses them. */ +function envelopeTable(d: D, at: Point, w: number): number { + const rows = [ + ["CEILING UNDER ATTIC", "R-38", "BLOWN FIBERGLASS"], + ["FRAME WALL, 2ND FLOOR", "R-21", "2X6, FIBERGLASS BATTS"], + ["MASS WALL, 1ST FLOOR", "R-4.1", "FOIL-FACED, ON FURRING"], + ["SLAB ON GRADE", "R-0", "NOT REQUIRED (CZ 2)"], + ["WINDOWS", "U-0.40", "SHGC 0.25, IMPACT"], + ]; + const c2 = 0.8; + return table( + d, + at, + "ENVELOPE (FBC-ENERGY R402, CLIMATE ZONE 2)", + [ + { name: "ASSEMBLY", width: (w - c2) * 0.48 }, + { name: "R / U", width: c2, align: "center" }, + { name: "MATERIAL", width: (w - c2) * 0.52 }, + ], + rows, + ); +} + +/** + * The load path, top to bottom, by the tags of the S-102 connector schedule: the products and + * their uplifts are on S-102, so they are not repeated here. + */ +function strapTable(d: D, at: Point, w: number): number { + const rows = [ + ["ROOF TRUSS TO 2ND FLR. TOP PLATE", "C1", "S-102"], + ['2ND FLR. STUD TO TIE BEAM, 32" O.C.', "C2", "3/S-501"], + ["FLOOR TRUSS TO TIE BEAM", "C4", "3/S-501"], + ['TIE BEAM TO FOOTING, 48" O.C.', "#5 VERT.", "1/S-501"], + ["LANAI MONO TRUSS TO LB-1", "C5", "4/S-501"], + ["LANAI COLUMN TO LB-1 AND PAD", "C7", "S-101"], + ]; + const c2 = 0.95; + const c3 = 0.85; + return table( + d, + at, + "UPLIFT LOAD PATH (SCHEDULE ON S-102)", + [ + { name: "CONNECTION", width: w - c2 - c3 }, + { name: "TAG", width: c2, align: "center" }, + { name: "SEE", width: c3, align: "center" }, + ], + rows, + ); +} + +function sectionSheet(d: D): void { + elevSymbols(d); + const v1 = view(d, 48); + const v2 = view(d, 24); + const e1 = buildingSection(d, v1); + const e2 = wallSection(d, v2); + const e3 = keyPlan(d, view(d, 192)); + frame(d, "A-301", (dd, sheet) => { + // The building section, top left. + const w1 = (e1.right - e1.left) / 48 + 0.3; + const h1 = (e1.top - e1.bottom) / 48 + 0.3; + const top = AREA.top - 0.1; + viewport(dd, sheet, { + corner: [AREA.left, top - h1], + size: [w1, h1], + center: [ + ORIGIN.building[0] + (e1.left + e1.right) / 2, + ORIGIN.building[1] + (e1.bottom + e1.top) / 2, + ], + v: v1, + id: "vp-A-301-1", + number: "1", + sheetNo: "A-301", + title: "BUILDING SECTION", + titleAt: [AREA.left + 0.2, top - h1 - 0.45], + }); + // The wall section, on the right, full height. + const w2 = (e2.right - e2.left) / 24 + 0.3; + const h2 = (e2.top - e2.bottom) / 24 + 0.3; + const x2 = AREA.right - w2; + viewport(dd, sheet, { + corner: [x2, top - h2], + size: [w2, h2], + center: [ + ORIGIN.wall[0] + (e2.left + e2.right) / 2, + ORIGIN.wall[1] + (e2.bottom + e2.top) / 2, + ], + v: v2, + id: "vp-A-301-2", + number: "2", + sheetNo: "A-301", + title: "TYPICAL WALL SECTION", + titleAt: [x2 + 0.2, top - h2 - 0.45], + }); + // Under the building section: notes and two tables. + const y0 = top - h1 - 1.2; + const colW = Math.min(6.2, (x2 - AREA.left - 0.9) / 3); + let y = noteColumn(dd, [AREA.left + 0.2, y0], colW, "SECTION NOTES", SECTION_NOTES); + void y; + envelopeTable(dd, [AREA.left + 0.5 + colW, y0], colW); + strapTable(dd, [AREA.left + 0.8 + 2 * colW, y0], colW); + // The key plan, bottom left. + const v3 = view(dd, 192); + const kw = (e3.right - e3.left) / 192 + 0.2; + const kh = (e3.top - e3.bottom) / 192 + 0.2; + viewport(dd, sheet, { + corner: [AREA.left + 0.3, AREA.bottom + 0.9], + size: [kw, kh], + center: [ORIGIN.key[0] + (e3.left + e3.right) / 2, ORIGIN.key[1] + (e3.bottom + e3.top) / 2], + v: v3, + id: "vp-A-301-3", + number: "3", + sheetNo: "A-301", + title: "KEY PLAN", + scale: 'SCALE: 1/16" = 1\'-0"', + titleAt: [AREA.left + 0.4, AREA.bottom + 0.5], + }); + void ftIn; + }); +} + +export const SECTION_SHEETS: Record = { + "A-301": sectionSheet, +}; diff --git a/examples/plan-set/sheets/elevations-symbols.ts b/examples/plan-set/sheets/elevations-symbols.ts new file mode 100644 index 0000000..f4e47c6 --- /dev/null +++ b/examples/plan-set/sheets/elevations-symbols.ts @@ -0,0 +1,48 @@ +// Symbols the elevation and section sheets need that the kit does not have. The kit's LEVEL +// writes its text to the right of the target; a level on the left of a view needs it on the +// left. Defined once per drawing, the first time a sheet of this module draws. +import { H, LAYERS, NOTES, type D, type View } from "../lib/kit.ts"; +import type { Local } from "./elevations-lib.ts"; + +const defined = new WeakSet(); + +export function elevSymbols(d: D): void { + if (defined.has(d)) return; + defined.add(d); + d.block("LEVEL-L", {}, () => { + const r = 0.08; + d.circle([0, 0], r); + d.line([-r, 0], [r, 0]); + d.line([0, -r], [0, r]); + d.attribute("NAME", [-r - 0.06, 0.03], { + value: "LEVEL", + height: H.note, + style: NOTES, + align: "right", + }); + d.attribute("EL", [-r - 0.06, -0.03], { + value: "EL. 0.00'", + height: H.note, + style: NOTES, + align: "right", + valign: "top", + }); + }); +} + +/** A level marker on the left of a view: its line from `from` (local h) to the target at `h`. */ +export function levelLeft( + L: Local, + v: View, + h: number, + z: number, + from: number, + name: string, + elev: string, +): void { + const r = v.p(0.08); + L.d.layer(LAYERS.symb, {}, () => { + L.d.line(L.P(from, z), L.P(h + r, z)); + L.d.insert("LEVEL-L", L.P(h, z), { scale: v.p(1), attributes: { NAME: name, EL: elev } }); + }); +} diff --git a/examples/plan-set/sheets/elevations.ts b/examples/plan-set/sheets/elevations.ts new file mode 100644 index 0000000..35b1b47 --- /dev/null +++ b/examples/plan-set/sheets/elevations.ts @@ -0,0 +1,798 @@ +// A-201 and A-202: the four exterior elevations at 1/4" = 1'-0", drawn from the model: the +// walls, the openings at their real positions and heights, the hip roof, the lanai, the grade +// and the flood levels. A-301, the building and wall sections, is in elevations-section.ts. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; +import { + DEPTH, + ENTRY, + FLOOD_VENTS, + HEIGHTS, + L1, + L2, + LANAI, + LANAI_SLAB, + ROOF, + W, + WINDOW_TYPES, + type Opening, + type Rect, +} from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + ftIn, + H, + LAYERS, + noteColumn, + put, + table, + view, + viewport, + width, + type D, + type SheetDraw, + type View, +} from "../lib/kit.ts"; +import type { Box } from "../../lib/drafting.ts"; +import { + el, + floodLine, + guardElev, + local, + navd, + noteStack, + noteWidth, + riser, + stairFront, + stairProfile, + Z, + type Local, + type Note, +} from "./elevations-lib.ts"; +import { SECTION_SHEETS } from "./elevations-section.ts"; +import { elevSymbols, levelLeft } from "./elevations-symbols.ts"; + +type FaceKey = "S" | "N" | "E" | "W"; + +interface Face { + readonly key: FaceKey; + readonly title: string; + /** The length of the main block's face. */ + readonly len: number; + /** An opening's cut as its span along the view. */ + readonly span: (r: Rect) => [number, number]; + /** Whether a cut is in this face's wall. */ + readonly on: (r: Rect) => boolean; +} + +const FACES: Record = { + S: { + key: "S", + title: "SOUTH (FRONT) ELEVATION", + len: W, + span: (r) => [r[0], r[2]], + on: (r) => r[1] === 0, + }, + N: { + key: "N", + title: "NORTH (REAR) ELEVATION", + len: W, + span: (r) => [W - r[2], W - r[0]], + on: (r) => r[3] === DEPTH, + }, + E: { + key: "E", + title: "EAST (RIGHT SIDE) ELEVATION", + len: DEPTH, + span: (r) => [r[1], r[3]], + on: (r) => r[2] === W, + }, + W: { + key: "W", + title: "WEST (LEFT SIDE) ELEVATION", + len: DEPTH, + span: (r) => [DEPTH - r[3], DEPTH - r[1]], + on: (r) => r[0] === 0, + }, +}; + +/** Model-space origins of the views, inside this module's range (x 80000 to 199999). */ +export const ORIGIN: Record = { + S: [82000, 0], + N: [82000, 3000], + E: [86000, 0], + W: [86000, 3000], +}; + +const GARAGE: Rect = [0, 0, 272, 296]; +const inGarage = (r: Rect) => { + const cx = (r[0] + r[2]) / 2; + const cy = (r[1] + r[3]) / 2; + return cx > GARAGE[0] && cx < GARAGE[2] && cy > GARAGE[1] && cy < GARAGE[3]; +}; + +/** The bottom and top of an opening, in inches above the first floor. */ +function heights(level: "L1" | "L2", o: Opening): [number, number] { + const base = level === "L2" ? HEIGHTS.l2 : 0; + if (o.kind === "window") { + const head = base + (o.head ?? 96); + return [head - (o.height ?? 48), head]; + } + const floor = level === "L1" && inGarage(o.cut) ? Z.garage : base; + return [floor, floor + (o.height ?? 80)]; +} + +/** + * Flood vents, 16" x 8", from the model: at a point along the face. Only the west wall's show + * in an elevation; the east wall's vent faces the entry porch, inside its recess. + */ +const VENTS: { face: FaceKey; at: number }[] = FLOOD_VENTS.filter((x) => x.wall === "W").map( + (x) => ({ face: "W", at: DEPTH - x.at[1] }), +); +const VENT = { w: 16, h: 8, bottom: Z.garage + 2 }; + +// The lanai: 12" square columns at its outer corners, LB-1 (8" x 20") on them, mono trusses on +// a ledger, as S-101 and S-102 have it. +const LAN = (() => { + const roof = ROOF.lanai.rect; + const thick = 8; // the fascia, and the roof's depth at the eave + const lowTop = ROOF.lanai.low; + const slope = HEIGHTS.lanaiPitch / 12; + const topAt = (y: number) => ROOF.lanai.high - (y - DEPTH) * slope; + const postY: [number, number] = [LANAI[3] - 12, LANAI[3]]; + const beamTop = HEIGHTS.lanaiPlate; + return { roof, thick, lowTop, topAt, postY, beamTop, beamBottom: beamTop - 20, slab: Z.lanai }; +})(); + +/** The lanai steps' riser, and the entry stair's. */ +const LANAI_RISER = riser(Z.lanai, Z.deck, LANAI_SLAB.steps.risers); +const ENTRY_RISER = riser(Z.porch, Z.grade, ENTRY.risers); +const inches1 = (x: number): string => `${x.toFixed(1)}"`; + +/** The roof's outline in a view: the eave's span and the ridge's span at the top. */ +function roofOutline(f: Face): [number, number][] { + const o = HEIGHTS.overhang; + const run = (ROOF.eave[3] - ROOF.eave[1]) / 2; + if (f.key === "S" || f.key === "N") { + return [ + [-o, Z.eave], + [-o + run, Z.ridge], + [W + o - run, Z.ridge], + [W + o, Z.eave], + ]; + } + return [ + [-o, Z.eave], + [DEPTH / 2, Z.ridge], + [DEPTH + o, Z.eave], + ]; +} + +/** Vertical lines inside a convex outline, `step` apart: standing seams, for a hatch. */ +function seams( + L: Local, + outline: readonly [number, number][], + h0: number, + h1: number, + step: number, +): void { + for (let h = h0 + step; h < h1 - 1; h += step) { + // Where the vertical line at h meets the outline's edges. + const zs: number[] = []; + outline.forEach((a, k) => { + const b = outline[(k + 1) % outline.length]!; + if ((a[0] - h) * (b[0] - h) < 0 || (a[0] === h && b[0] !== h)) { + const t = (h - a[0]) / (b[0] - a[0]); + zs.push(a[1] + t * (b[1] - a[1])); + } + }); + if (zs.length >= 2) { + const lo = Math.min(...zs); + const hi = Math.max(...zs); + if (hi - lo > 1) L.d.line(L.P(h, lo), L.P(h, hi)); + } + } +} + +/** A window in elevation: its stucco trim, its frame, sash lines and tag. */ +function windowElev( + L: Local, + v: View, + h0: number, + h1: number, + z0: number, + z1: number, + tag: string, +): void { + const kind = WINDOW_TYPES[tag]?.kind ?? ""; + const hm = (h0 + h1) / 2; + const zm = (z0 + z1) / 2; + L.d.layer(LAYERS.elevFine, {}, () => { + // The stucco trim band. At the second floor the head meets the soffit, so the band stops there. + L.rect(h0 - 4, z0 - 3, h1 + 4, Math.min(z1 + 4, Z.soffit)); + L.line(h0 - 6, z0 - 3, h1 + 6, z0 - 3); // the sill projects + L.rect(h0 + 2, z0 + 2, h1 - 2, z1 - 2); + }); + L.d.layer(LAYERS.glaz, {}, () => { + L.rect(h0, z0, h1, z1); + if (kind === "TWIN SINGLE HUNG") { + L.line(hm, z0, hm, z1); + L.line(h0 + 2, zm, h1 - 2, zm); + } else if (kind === "SINGLE HUNG") { + L.line(h0 + 2, zm, h1 - 2, zm); + } else if (kind === "CASEMENT") { + // The operation lines meet on the hinge side. + L.d.polyline([L.P(h0 + 2, z0 + 2), L.P(h1 - 2, zm), L.P(h0 + 2, z1 - 2)], false, { + linetype: "DASHED", + }); + } else if (kind === "AWNING") { + L.d.polyline([L.P(h0 + 2, z0 + 2), L.P(hm, z1 - 2), L.P(h1 - 2, z0 + 2)], false, { + linetype: "DASHED", + }); + } + }); + L.d.layer(LAYERS.tags, {}, () => { + L.d.insert("WINDOW TAG", L.P(hm, z0 - 3 - v.p(0.2)), { + scale: v.p(1), + attributes: { WINDOW: tag }, + }); + }); +} + +/** A door in elevation, by kind. */ +function doorElev( + L: Local, + v: View, + h0: number, + h1: number, + z0: number, + z1: number, + o: Opening, +): void { + L.d.layer(LAYERS.elevFine, {}, () => L.rect(h0 - 4, z0, h1 + 4, z1 + 4)); + L.d.layer(LAYERS.door, {}, () => { + L.rect(h0, z0, h1, z1); + if (o.kind === "overhead") { + for (let k = 1; k < 4; k++) + L.line(h0, z0 + ((z1 - z0) * k) / 4, h1, z0 + ((z1 - z0) * k) / 4); + // A row of lites in the top section. + const n = 8; + const step = (h1 - h0) / n; + for (let k = 0; k < n; k++) + L.rect(h0 + k * step + 3, z1 - (z1 - z0) / 4 + 4, h0 + (k + 1) * step - 3, z1 - 5); + } else if (o.kind === "slider") { + const third = (h1 - h0) / 3; + for (let k = 1; k < 3; k++) L.line(h0 + k * third, z0, h0 + k * third, z1); + for (let k = 0; k < 3; k++) + L.rect(h0 + k * third + 3, z0 + 4, h0 + (k + 1) * third - 3, z1 - 3); + } else { + L.rect(h0 + 5, z0 + 10, h1 - 5, z1 - 6); // a full lite, impact glass + L.d.circle(L.P(o.hinge === "end" ? h0 + 4 : h1 - 4, z0 + 38), 1); + } + }); + if (o.tag) { + L.d.layer(LAYERS.tags, {}, () => { + L.d.insert("DOOR TAG", L.P((h0 + h1) / 2, (z0 + z1) / 2), { + scale: v.p(1), + attributes: { DOOR: o.tag! }, + }); + }); + } +} + +interface Extent { + left: number; + right: number; + bottom: number; + top: number; +} + +/** Draws one elevation at its origin. Returns its extent in local coordinates. */ +function elevation(d: D, v: View, key: FaceKey): Extent { + const f = FACES[key]; + const o = ORIGIN[key]; + const len = f.len; + const ov = HEIGHTS.overhang; + const front: Box[] = []; // what stands in front of the house's face, for hidden parts + const F = local(d, o); + const box = F.box; + + // --- The lanai, in front of the north face or in profile at the sides. --------------------- + let lanaiLeft = -ov; + let lanaiRight = len + ov; + d.layer(LAYERS.elev, {}, () => { + if (key === "N") { + const [a, b] = f.span(LAN.roof); + const [p0, p1] = f.span([LANAI[0], 0, LANAI[0] + 12, 0]); + const [q0, q1] = f.span([LANAI[2] - 12, 0, LANAI[2], 0]); + F.rect(a, LAN.lowTop - LAN.thick, b, LAN.lowTop); // fascia + F.rect(a, LAN.lowTop, b, ROOF.lanai.high); // the roof, seen from its low side + d.layer(LAYERS.elevFine, {}, () => + seams( + F, + [ + [a, LAN.lowTop], + [b, LAN.lowTop], + [b, ROOF.lanai.high], + [a, ROOF.lanai.high], + ], + a, + b, + 16, + ), + ); + F.rect(Math.min(p0, q0), LAN.beamBottom, Math.max(p1, q1), LAN.lowTop - LAN.thick); // beam + F.rect(p0, LAN.slab, p1, LAN.beamBottom); + F.rect(q0, LAN.slab, q1, LAN.beamBottom); + const [s0, s1] = f.span(LANAI); + // The steps to the pool deck, in front of the stem wall, which breaks behind them. + const [t0, t1] = f.span([LANAI_SLAB.steps.x0, 0, LANAI_SLAB.steps.x1, 0]); + local(d, o, [box(t0, Z.grade - 1, t1, LAN.slab + 1)]).rect(s0, Z.grade, s1, LAN.slab); // the slab edge and stem wall + stairFront(F, t0, t1, LAN.slab, Z.deck, LANAI_SLAB.steps.risers); + F.line(s0 - 24, Z.deck, s1 + 24, Z.deck); // the pool deck's edge + // 36" guards on the open side, column to column, stopped at the steps. + const c0 = Math.min(p1, q1); + const c1 = Math.max(p0, q0); + guardElev(F, c0, t0, LAN.slab); + guardElev(F, t1, c1, LAN.slab); + front.push( + box(a, LAN.lowTop - LAN.thick, b, ROOF.lanai.high), + box(Math.min(p0, q0), LAN.beamBottom, Math.max(p1, q1), LAN.lowTop), + box(p0, LAN.slab, p1, LAN.beamBottom), + box(q0, LAN.slab, q1, LAN.beamBottom), + box(s0, Z.grade, s1, LAN.slab), + ); + } else if (key === "E" || key === "W") { + // h of a building y, for this face. + const hy = (y: number) => (key === "E" ? y : DEPTH - y); + const dir: 1 | -1 = key === "E" ? 1 : -1; + const y1 = LAN.roof[3]; + F.poly( + [ + [hy(DEPTH), ROOF.lanai.high], + [hy(y1), LAN.lowTop], + [hy(y1), LAN.lowTop - LAN.thick], + [hy(DEPTH), ROOF.lanai.high - LAN.thick - 9], + ], + false, + ); + F.rect(hy(LAN.postY[0]), LAN.slab, hy(LAN.postY[1]), LAN.beamBottom); + F.rect(hy(LAN.postY[0]), LAN.beamBottom, hy(LAN.postY[1]), LAN.beamTop); + F.rect(hy(DEPTH), Z.grade, hy(LANAI[3]), LAN.slab); + // The side guard, house wall to column, and the steps in profile down to the pool deck. + guardElev( + F, + Math.min(hy(DEPTH), hy(LAN.postY[0])), + Math.max(hy(DEPTH), hy(LAN.postY[0])), + LAN.slab, + ); + const s = LANAI_SLAB.steps; + stairProfile(F, hy(LANAI[3]), dir, LAN.slab, Z.deck, s.risers, s.tread); + const deckEnd = hy(LANAI[3]) + dir * ((s.risers - 1) * s.tread + 30); + F.line(hy(LANAI[3]), Z.deck, deckEnd, Z.deck); + if (key === "E") lanaiRight = Math.max(lanaiRight, y1, deckEnd); + else lanaiLeft = Math.min(lanaiLeft, hy(y1), deckEnd); + // The entry stair, south of the porch, in profile. + const entryEnd = hy(0) - dir * ((ENTRY.risers - 1) * ENTRY.tread); + stairProfile(F, hy(0), -dir as 1 | -1, Z.porch, Z.grade, ENTRY.risers, ENTRY.tread); + if (key === "E") lanaiLeft = Math.min(lanaiLeft, entryEnd - 12); + else lanaiRight = Math.max(lanaiRight, entryEnd + 12); + } + }); + + // The openings in this face, so the wall's lines stop at them. + const openings: { level: "L1" | "L2"; op: Opening; h: [number, number]; z: [number, number] }[] = + []; + for (const [levelKey, level] of [ + ["L1", L1], + ["L2", L2], + ] as const) { + for (const op of level.openings) { + if (!op.exterior || !f.on(op.cut)) continue; + if (key === "S" && op.tag === "101") continue; + openings.push({ level: levelKey, op, h: f.span(op.cut), z: heights(levelKey, op) }); + } + } + const holes = openings.map((x) => box(x.h[0] - 4, x.z[0] - 3, x.h[1] + 4, x.z[1] + 4)); + if (key === "S") holes.push(box(272, Z.porch, 376, Z.tieBeam - 16)); + const L = local(d, o, front); + const Lw = local(d, o, [...front, ...holes]); + + // --- The walls. --------------------------------------------------------------------------- + d.layer(LAYERS.elev, {}, () => { + L.line(0, Z.grade, 0, Z.soffit); + L.line(len, Z.grade, len, Z.soffit); + L.rect(-ov, Z.soffit, len + ov, Z.eave); // fascia + L.line(0, Z.soffit, len, Z.soffit); + }); + d.layer(LAYERS.elevFine, {}, () => { + Lw.line(0, Z.ff, len, Z.ff); // control joint at the floor + Lw.line(0, Z.tieBeam, len, Z.tieBeam); // the band at the floor line + Lw.line(0, Z.l2, len, Z.l2); + }); + // The roof, with standing seams. + const outline = roofOutline(f); + d.layer(LAYERS.elev, {}, () => F.poly(outline, false)); + d.layer(LAYERS.elevFine, {}, () => seams(F, outline, -ov, len + ov, 16)); + + // The entry porch in the south face: an opening under the second floor, the door beyond. + if (key === "S") { + const p0 = 272; + const p1 = 376; + const top = Z.tieBeam - 16; + d.layer(LAYERS.elev, {}, () => { + L.poly( + [ + [p0, Z.porch], + [p0, top], + [p1, top], + [p1, Z.porch], + ], + false, + ); + L.line(p0, Z.porch, p1, Z.porch); + }); + const door = L1.openings.find((x) => x.tag === "101")!; + doorElev(L, v, door.cut[0], door.cut[2], Z.ff, Z.ff + (door.height ?? 80), door); + // The entry stair from the walk to the porch, handrails both sides, and the 36" guard along + // the porch's open edge either side of it. + stairFront(L, ENTRY.x0, ENTRY.x1, Z.porch, Z.grade, ENTRY.risers); + guardElev(L, p0, ENTRY.x0, Z.porch); + guardElev(L, ENTRY.x1, p1, Z.porch); + } + + // --- Openings. ---------------------------------------------------------------------------- + for (const { op, h, z } of openings) { + if (op.kind === "window") windowElev(L, v, h[0], h[1], z[0], z[1], op.tag ?? ""); + else doorElev(L, v, h[0], h[1], z[0], z[1], op); + } + + // --- Flood vents. ------------------------------------------------------------------------- + const vents = VENTS.filter((x) => x.face === key); + d.layer(LAYERS.elevFine, {}, () => { + for (const vent of vents) { + const h0 = vent.at - VENT.w / 2; + const h1 = vent.at + VENT.w / 2; + L.rect(h0, VENT.bottom, h1, VENT.bottom + VENT.h); + for (let k = 1; k < 4; k++) L.line(h0 + 1, VENT.bottom + k * 2, h1 - 1, VENT.bottom + k * 2); + } + }); + + // --- Below grade: the footing and stem wall, hidden. ------------------------------------- + d.layer(LAYERS.ftng, {}, () => { + // 20" wide on the 8" wall, so 6" past each face; its top 6" below finish grade (1/S-501). + const past = (Z.footingWidth - 8) / 2; + F.rect(-past, Z.footingTop - Z.footingDepth, len + past, Z.footingTop); + }); + + // --- Grade, heavy, past both ends. ------------------------------------------------------ + const left = Math.min(lanaiLeft, -ov); + const right = Math.max(lanaiRight, len + ov); + d.layer(LAYERS.grade, {}, () => { + F.line(left - v.p(0.5), Z.grade, right + v.p(0.5), Z.grade); + }); + + // --- Flood levels, dashed, labeled at the end that has room. ---------------------------- + // The labels end at the face's right end, or left of the flood vents where the vents are there. + const ventsHere = VENTS.filter((x) => x.face === key); + const labelAt = ventsHere.length ? Math.min(...ventsHere.map((x) => x.at)) - VENT.w : len; + floodLine( + F, + v, + 0, + len, + Z.bfe, + `B.F.E. ${CRITERIA.flood.bfe.toFixed(2)}' NAVD (ZONE ${CRITERIA.flood.zone})`, + labelAt, + ); + floodLine( + F, + v, + 0, + len, + Z.dfe, + `D.F.E. ${CRITERIA.flood.dfe.toFixed(2)}' NAVD (BFE + 1'-0")`, + labelAt, + ); + + // --- Levels, on the left, and the height dimensions between them. ----------------------- + const colA = left - v.p(1.05); + const colB = colA - v.p(1.45); + const garage = key === "S" || key === "W"; + const levels: { z: number; name: string; from: number; col: number }[] = [ + { + z: Z.ridge, + name: "RIDGE", + from: f.key === "S" || f.key === "N" ? -ov + (ROOF.eave[3] - ROOF.eave[1]) / 2 : len / 2, + col: colA, + }, + { z: Z.mean, name: "MEAN ROOF HT.", from: -ov + (Z.mean - Z.eave) * 2, col: colA }, + { z: Z.plate, name: "TOP OF PLATE", from: 0, col: colA }, + { z: Z.l2, name: "SECOND FLOOR", from: 0, col: colA }, + { z: Z.tieBeam, name: "TOP OF TIE BEAM", from: 0, col: colA }, + { z: Z.ff, name: "FIRST FLOOR (F.F.E.)", from: 0, col: colA }, + ...(garage ? [{ z: Z.garage, name: "GARAGE SLAB", from: 0, col: colA }] : []), + { z: Z.grade, name: "FINISH GRADE", from: left - v.p(0.5), col: garage ? colB : colA }, + ]; + for (const lv of levels) + levelLeft(L, v, lv.col, lv.z, lv.from, lv.name, `EL. ${navd(lv.z).toFixed(2)}' NAVD`); + d.layer(LAYERS.dims, {}, () => { + const at = o[0] + left - v.p(0.45); + const pts = [Z.grade, Z.ff, Z.tieBeam, Z.l2, Z.plate].map((z): Point => + L.P(Math.min(0, left), z), + ); + dimString(d, v, pts, "y", at); + // The building height, from the design flood elevation to the mean roof height. + const x2 = o[0] + right + v.p(0.45); + d.linearDimension(L.P(right, Z.dfe), L.P(right, Z.mean), [x2, 0], { + style: v.dim, + along: "y", + text: `${ftIn(Z.mean - Z.dfe)} BLDG. HT., D.F.E. TO MEAN ROOF (${CRITERIA.zoning.maxHeight}'-0" MAX.)`, + }); + }); + + // --- Notes, on the right. --------------------------------------------------------------- + const notes: Note[] = []; + const roofAt = f.key === "S" || f.key === "N" ? len * 0.78 : len * 0.62; + const roofZ = Z.eave + Math.min(roofAt + ov, len + ov - roofAt) * 0.5 * 0.5; + notes.push({ + at: [roofAt, roofZ], + lines: ["STANDING SEAM METAL ROOFING, 24 GA.,", "6:12, ON SELF-ADHERED UNDERLAYMENT"], + }); + notes.push({ + at: [len + ov - 2, Z.eave - 3], + lines: ["ALUMINUM FASCIA AND", "VENTED ALUMINUM SOFFIT"], + }); + notes.push({ + at: [len - 20, Z.l2 + 70], + lines: ['STUCCO ON LATH OVER 7/16" OSB', 'ON 2X6 STUDS AT 16" O.C.'], + }); + notes.push({ + at: [len - 10, (Z.tieBeam + Z.l2) / 2], + lines: ["STUCCO BAND AT FLOOR LINE,", "CONTROL JOINT TOP AND BOTTOM"], + }); + notes.push({ at: [len - 20, 50], lines: ['5/8" STUCCO ON 8" CMU'] }); + notes.push({ + at: [len - 100, Z.ff - 3], + lines: ["STUCCO ON CMU STEM WALL,", "COMPACTED FILL BELOW SLAB"], + }); + // Notes about what stands at grade go under the grade, below what they point at, in one row: + // the viewport's height leaves no room for a second. + const under = Z.grade - v.p(0.75); + let deepest = under; + const below = (n: Note): void => { + notes.push(n); + deepest = Math.min(deepest, n.elbow![1]); + }; + const entryNote = [ + "ENTRY STAIR: " + + ENTRY.risers + + " RISERS AT " + + inches1(ENTRY_RISER) + + ", " + + (ENTRY.risers - 1) + + " TREADS", + "AT " + ENTRY.tread + '", HANDRAILS BOTH SIDES AT 34"-38"', + ]; + const stepsNote = [ + `LANAI STEPS: ${LANAI_SLAB.steps.risers} RISERS AT ${inches1(LANAI_RISER)}, ${LANAI_SLAB.steps.tread}" TREADS,`, + `HANDRAILS BOTH SIDES, TO POOL DECK EL. ${LANAI_SLAB.deck.toFixed(2)}'`, + ]; + const guardNote = (where: string) => [ + `${LANAI_SLAB.guard}" GUARD AT ${where},`, + '4" SPHERE MAX. OPENING (R312.1)', + ]; + if (vents.length) { + const vent = vents[vents.length - 1]!; + below({ + at: [vent.at, VENT.bottom], + elbow: [vent.at - v.p(0.6), under], + lines: [ + `ENGINEERED FLOOD VENT, ${vents.length} HERE, ${FLOOD_VENTS.length} IN ALL,`, + `EACH CERTIFIED FOR 200 SQ. FT., SEE S-101`, + ], + }); + } + if (key === "W") { + const door = L1.openings.find((x) => x.tag === "109")!; + const [d0, d1] = f.span(door.cut); + below({ + at: [(d0 + d1) / 2, Z.grade], + elbow: [(d0 + d1) / 2 + v.p(0.6), under], + lines: [ + 'CONC. LANDING AT DOOR 109, 36" DEEP,', + `${ftIn(Z.garage - Z.grade)} BELOW THRESHOLD (R311.3.2)`, + ], + }); + below({ + at: [-80, Z.lanai + 30], + elbow: [-80 + v.p(0.5), under], + lines: guardNote("LANAI, 3 SIDES"), + }); + below({ + at: [DEPTH - LANAI[3] - 20, Z.deck + 10], + elbow: [DEPTH - LANAI[3] - 20 - v.p(0.4), under], + lines: stepsNote, + }); + notes.push({ at: [DEPTH + 30, Z.grade + 14], lines: entryNote }); + } + if (key === "E") { + below({ at: [-22, Z.grade + 10], elbow: [-22 - v.p(0.5), under], lines: entryNote }); + notes.push({ at: [LANAI[3] - 60, Z.lanai + 30], lines: guardNote("LANAI, 3 SIDES") }); + notes.push({ at: [LANAI[3] + 16, Z.deck + 10], lines: stepsNote }); + } + if (key === "N") { + const [t0, t1] = f.span([LANAI_SLAB.steps.x0, 0, LANAI_SLAB.steps.x1, 0]); + below({ + at: [50, Z.lanai + 30], + elbow: [50 - v.p(0.5), under], + lines: guardNote("LANAI, 3 SIDES"), + }); + below({ + at: [t1 - 4, Z.deck + LANAI_RISER * 1.5], + elbow: [t1 + v.p(0.6), under], + lines: stepsNote, + }); + void t0; + } + if (key === "S") { + below({ + at: [ENTRY.x1 - 4, Z.grade + ENTRY_RISER * 1.5], + elbow: [ENTRY.x1 + v.p(0.5), under], + lines: entryNote, + }); + below({ + at: [292, Z.porch + 30], + elbow: [292 - v.p(0.5), under], + lines: guardNote("PORCH EDGE"), + }); + } + const w = [...L1.openings, ...L2.openings].find( + (x) => x.kind === "window" && x.exterior && f.on(x.cut), + ); + if (w) { + const [h0] = f.span(w.cut); + const [, z1] = heights(L2.openings.includes(w) ? "L2" : "L1", w); + notes.push({ at: [h0, z1 - 4], lines: ["IMPACT RATED WINDOW,", "SEE A-601"] }); + } + if (key === "N") + notes.push({ + at: [f.span(LAN.roof)[1] - 30, (LAN.lowTop + ROOF.lanai.high) / 2], + lines: ["LANAI ROOF, STANDING SEAM", "METAL AT 3:12, SEE S-102"], + }); + if (key === "S") + below({ + at: [40, Z.garage + 20], + elbow: [40 - v.p(0.6), under], + lines: ["16'-0\" X 8'-0\" IMPACT", "OVERHEAD DOOR, SEE A-601"], + }); + notes.push({ + at: [right + v.p(0.3), Z.grade], + lines: [ + `FINISH GRADE EL. ${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD, SLOPE`, + 'AWAY 6" IN 10\'-0" (FBC-R R401.3)', + ], + }); + const noteH = right + v.p(0.85); + const lowest = noteStack(L, v, noteH, notes); + + // A heading under the view that names the flood zone. + const textLeft = + (garage ? colB : colA) - + v.p(0.1) - + v.p(Math.max(...levels.map((lv) => width(lv.name, H.note)))); + const noteRight = noteH + v.p(0.25) + v.p(Math.max(...notes.map((n) => noteWidth(n.lines)))); + return { + left: textLeft, + right: noteRight, + bottom: Math.min(lowest, deepest - v.p(0.4)), + top: Z.ridge + v.p(0.4), + }; +} + +const ELEV_NOTES = [ + "ELEVATIONS IN FEET ARE IN NAVD 88. HEIGHTS ARE TO THE TOP OF CONCRETE OR THE TOP OF FRAMING.", + `FLOOD ZONE ${CRITERIA.flood.zone}, PANEL ${CRITERIA.flood.panel}. B.F.E. ${CRITERIA.flood.bfe.toFixed(2)}', D.F.E. ${CRITERIA.flood.dfe.toFixed(2)}', F.F.E. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD. THE LOWEST FLOOR IS ${ftIn((CRITERIA.flood.ffe - CRITERIA.flood.bfe) * 12)} ABOVE THE B.F.E. (FBC-R R322.2.1).`, + "THE GARAGE IS BELOW THE D.F.E.: PARKING, ACCESS AND STORAGE ONLY. FLOOD DAMAGE-RESISTANT MATERIALS BELOW THE D.F.E. FLOOD VENTS PER S-101.", + 'STUCCO: 3 COATS, 7/8" ON LATH OVER FRAMING, 5/8" ON MASONRY, WITH CONTROL JOINTS AT 144 SQ. FT. MAX. AND AT EVERY CHANGE OF SUBSTRATE (ASTM C926, C1063).', + `ALL WINDOWS AND DOORS IMPACT RATED, WITH FLORIDA PRODUCT APPROVAL, FOR THE DESIGN PRESSURES ON A-601. WINDOW HEADS: FIRST FLOOR ${ftIn(headOf(L1))}, SECOND FLOOR ${ftIn(headOf(L2))} A.F.F.; GARAGE WINDOW ${ftIn(garageHead())} ABOVE THE GARAGE SLAB.`, + 'ROOFING: STANDING SEAM METAL WITH FLORIDA PRODUCT APPROVAL, ON SELF-ADHERED UNDERLAYMENT OVER 19/32" PLYWOOD DECK (FBC-R R905.10).', + `BUILDING HEIGHT IS MEASURED FROM THE D.F.E. (${CRITERIA.flood.dfe.toFixed(2)}' NAVD) TO THE MEAN ROOF HEIGHT: ${ftIn(Z.mean - Z.dfe)}, ${CRITERIA.zoning.maxHeight}'-0" MAX. IN ${CRITERIA.zoning.district}.`, + `FINISH GRADE ${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD AT THE FOUNDATION (EXISTING ${CRITERIA.flood.grade.toFixed(2)}'), SLOPING AWAY 6" IN THE FIRST 10'-0" TO THE SWALES (FBC-R R401.3). SEE C-101.`, + `ENTRY STAIR: ${ENTRY.risers} RISERS OF ${inches1(ENTRY_RISER)} AND ${ENTRY.risers - 1} TREADS OF ${ENTRY.tread}" FROM THE PORCH (${ENTRY.slab.toFixed(2)}') TO THE WALK. LANAI STEPS: ${LANAI_SLAB.steps.risers} RISERS OF ${inches1(LANAI_RISER)} AND ${LANAI_SLAB.steps.risers - 1} TREADS OF ${LANAI_SLAB.steps.tread}" TO THE POOL DECK (${LANAI_SLAB.deck.toFixed(2)}'). HANDRAILS BOTH SIDES OF EACH, 34" TO 38" ABOVE THE NOSINGS (FBC-R R311.7.8).`, + `GUARDS: ${LANAI_SLAB.guard}" HIGH AT THE ENTRY PORCH'S OPEN EDGE AND THE LANAI'S WEST, NORTH AND EAST SIDES, WHERE THE SLAB IS MORE THAN 30" ABOVE GRADE. NO OPENING PASSES A 4" SPHERE (FBC-R R312.1).`, +]; + +/** The head height of a level's windows, above its floor: the most common one. */ +function headOf(level: typeof L1): number { + const heads = level.openings + .filter((o) => o.kind === "window" && !inGarage(o.cut)) + .map((o) => o.head ?? 96); + return heads.sort( + (a, b) => heads.filter((x) => x === b).length - heads.filter((x) => x === a).length, + )[0]!; +} +/** The garage window's head above the garage slab. */ +function garageHead(): number { + return (L1.openings.find((o) => o.kind === "window" && inGarage(o.cut))?.head ?? 96) - Z.garage; +} + +/** The levels as a table, for the notes column. */ +function levelTable(d: D, at: Point, w: number): number { + const rows: [string, number][] = [ + ["RIDGE", Z.ridge], + ["MEAN ROOF HEIGHT", Z.mean], + ["TOP OF PLATE, 2ND FLOOR", Z.plate], + ["SECOND FLOOR", Z.l2], + ["TOP OF TIE BEAM", Z.tieBeam], + ["FIRST FLOOR (F.F.E.)", Z.ff], + ["ENTRY PORCH AND LANAI SLAB", Z.porch], + ["D.F.E.", Z.dfe], + ["B.F.E.", Z.bfe], + ["GARAGE SLAB", Z.garage], + ["POOL DECK", Z.deck], + ["FINISH GRADE AT FOUNDATION", Z.grade], + ["EXISTING GRADE", Z.existing], + ]; + const c2 = 1.0; + const c3 = 0.9; + return table( + d, + at, + "LEVELS", + [ + { name: "LEVEL", width: w - c2 - c3 }, + { name: "ABOVE F.F.", width: c2, align: "right" }, + { name: "NAVD 88", width: c3, align: "right" }, + ], + rows.map(([name, z]) => [name, z >= 0 ? ftIn(z) : `-${ftIn(-z)}`, `${navd(z).toFixed(2)}'`]), + ); +} + +const VP = { h: 10.25 }; + +function elevationSheet(d: D, number: string, keys: readonly [FaceKey, FaceKey]): void { + elevSymbols(d); + const v = view(d, 48); + const extents = keys.map((k) => elevation(d, v, k)); + // Both views get the width of the wider, so their titles and the notes line up. + const vpW = Math.max(...extents.map((e) => (e.right - e.left) / v.ratio)) + 0.3; + frame(d, number, (dd, sheet) => { + keys.forEach((k, i) => { + const e = extents[i]!; + const top = i === 0 ? AREA.top : AREA.top - VP.h - 0.95; + const cx = (e.left + e.right) / 2; + const cz = (e.bottom + e.top) / 2; + const o = ORIGIN[k]; + viewport(dd, sheet, { + corner: [AREA.left, top - VP.h], + size: [vpW, VP.h], + center: [o[0] + cx, o[1] + cz], + v, + id: `vp-${number}-${k}`, + number: String(i + 1), + sheetNo: number, + title: FACES[k].title, + titleAt: [AREA.left + 0.2, top - VP.h - 0.45], + }); + }); + const x = AREA.left + vpW + 0.4; + const w = AREA.right - x; + let y = AREA.top - 0.1; + y = noteColumn(dd, [x, y], w, "ELEVATION NOTES", ELEV_NOTES); + y -= 0.25; + y = levelTable(dd, [x, y], w); + void width; + void put; + void H; + void el; + }); +} + +export const SHEETS: Record = { + "A-201": (d) => elevationSheet(d, "A-201", ["S", "N"]), + "A-202": (d) => elevationSheet(d, "A-202", ["E", "W"]), + ...SECTION_SHEETS, +}; diff --git a/examples/plan-set/sheets/mep-e101.ts b/examples/plan-set/sheets/mep-e101.ts new file mode 100644 index 0000000..cf16921 --- /dev/null +++ b/examples/plan-set/sheets/mep-e101.ts @@ -0,0 +1,752 @@ +// E-101: the power and lighting plans of both floors at 3/16" = 1'-0", over a screened base +// plan, with the legend, the panel schedule, the load calculation and the notes, all from the +// circuits and devices in `mep-power.ts`. +import type { Point } from "../../../src/index.ts"; +import { L1, L2, type Level } from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { arrowhead } from "../../lib/drafting.ts"; +import { + AREA, + H, + LAYERS, + noteColumn, + put, + table, + view, + viewport, + width, + type D, + type View, +} from "../lib/kit.ts"; +import { BASE_LAYERS, drawPlan } from "../lib/plan.ts"; +import { + CIRCUITS, + circuit, + circuitLoads, + DEVICES, + isLight, + isReceptacle, + isSwitch, + loadCalc, + PANEL, + PANEL_AT, + WIRE, + type Device, + type Floor, +} from "./mep-power.ts"; +import { center, EQUIP } from "./mep-equip.ts"; + +export const E_ORIGIN: Record = { L1: [310000, 0], L2: [350000, 0] }; + +const LEVELS: Record = { L1, L2 }; + +/** An arc from `a` to `b` that bows to the left of a to b by `bulge` of the chord. */ +function arcBetween( + d: D, + a: Point, + b: Point, + bulge: number, + trim: number, + linetype?: string, + maxSag = Infinity, +): void { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const c0 = Math.hypot(dx, dy); + if (c0 - 2 * trim <= c0 * 0.05) return; + const ux = dx / c0; + const uy = dy / c0; + const p: Point = [a[0] + ux * trim, a[1] + uy * trim]; + const q: Point = [b[0] - ux * trim, b[1] - uy * trim]; + const c = c0 - 2 * trim; + const s = Math.min(bulge * c, maxSag); + const r = (c * c) / 4 / (2 * s) + s / 2; + const mx = (p[0] + q[0]) / 2; + const my = (p[1] + q[1]) / 2; + const nx = -uy; + const ny = ux; + const center: Point = [mx - nx * (r - s), my - ny * (r - s)]; + const ang = (pt: Point) => (Math.atan2(pt[1] - center[1], pt[0] - center[0]) * 180) / Math.PI; + d.arc(center, r, ang(q), ang(p), linetype ? { linetype } : {}); +} + +/** The longest jump between two outlets drawn as one run of a circuit, in inches: 25 ft. */ +const JUMP = 300; + +/** Splits items into groups whose members are within `jump` of another member. */ +function clusters(items: readonly T[], jump: number): T[][] { + const out: T[][] = []; + const left = [...items]; + while (left.length) { + const group = [left.shift()!]; + for (let k = 0; k < group.length; k++) { + for (let j = left.length - 1; j >= 0; j--) { + const a = group[k]!.at; + const b = left[j]!.at; + if (Math.hypot(a[0] - b[0], a[1] - b[1]) <= jump) group.push(...left.splice(j, 1)); + } + } + out.push(group); + } + return out; +} + +/** The shortest tree joining the items, grown from `root` (Prim). Each edge is [from, to]. */ +function spanningTree(items: readonly T[], root: T): [Point, Point][] { + const inTree = [root]; + const left = items.filter((x) => x !== root); + const edges: [Point, Point][] = []; + while (left.length) { + let best: [number, number] = [0, 0]; + let bestD = Infinity; + inTree.forEach((a, i) => + left.forEach((b, j) => { + const dd = Math.hypot(a.at[0] - b.at[0], a.at[1] - b.at[1]); + if (dd < bestD) { + bestD = dd; + best = [i, j]; + } + }), + ); + const b = left.splice(best[1], 1)[0]!; + edges.push([inTree[best[0]]!.at, b.at]); + inTree.push(b); + } + return edges; +} + +/** Orders points as a path by nearest neighbour, from the first. */ +function chain(items: readonly T[], from: Point): T[] { + const left = [...items]; + const out: T[] = []; + let here = from; + while (left.length) { + let best = 0; + let bestD = Infinity; + left.forEach((x, k) => { + const dd = Math.hypot(x.at[0] - here[0], x.at[1] - here[1]); + if (dd < bestD) { + bestD = dd; + best = k; + } + }); + const next = left.splice(best, 1)[0]!; + out.push(next); + here = next.at; + } + return out; +} + +/** A box in model space: x0, y0, x1, y1. */ +type Box = [number, number, number, number]; +const hits = (a: Box, b: Box) => a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]; +/** The box a text fills, from `measureText` by way of `width`. */ +const textBox = ( + at: Point, + value: string, + h: number, + paperH: number, + align: "left" | "right" | "center", +): Box => { + const w = (width(value, paperH) * h) / paperH; + const x0 = align === "left" ? at[0] : align === "right" ? at[0] - w : at[0] - w / 2; + return [x0, at[1], x0 + w, at[1] + h]; +}; + +const layerOf = (dv: Device) => + isReceptacle(dv.block) || dv.block === "E-220" || dv.block === "E-PANEL" || dv.block === "E-DISC" + ? LAYERS.powr + : LAYERS.lite; + +/** Where a room's name goes when the bottom of the room is taken by devices. */ +const ROOM_NAME_AT: Record = { "L1/FOYER": [349, 140], "L2/BATH 2": [110, 220] }; + +/** + * Low-voltage outlets, which the kit has no symbols for: a TV/data outlet (a triangle) and a + * doorbell button and chime. Defined here, in paper inches like the kit's symbols, by `e101`, + * which runs once per drawing. + */ +const LOW_VOLTAGE: { block: string; floor: Floor; at: Point; rot: number }[] = [ + { block: "E-TVDATA", floor: "L1", at: [610, 360], rot: 90 }, + { block: "E-TVDATA", floor: "L1", at: [610, 100], rot: 90 }, + { block: "E-TVDATA", floor: "L2", at: [12, 105], rot: -90 }, + { block: "E-TVDATA", floor: "L2", at: [12, 400], rot: -90 }, + { block: "E-TVDATA", floor: "L2", at: [612, 395], rot: 90 }, + { block: "E-TVDATA", floor: "L2", at: [374, 400], rot: 90 }, + { block: "E-BELL", floor: "L1", at: [372, 66], rot: 0 }, + { block: "E-CHIME", floor: "L1", at: [349, 278], rot: 0 }, +]; + +function defineLowVoltage(d: D): void { + d.block("E-TVDATA", {}, () => { + d.polyline( + [ + [-0.08, 0], + [0.08, 0], + [0, 0.12], + ], + true, + ); + d.line([0, 0], [0, 0.12]); + }); + d.block("E-BELL", {}, () => { + d.circle([0, 0], 0.05); + d.circle([0, 0], 0.02); + }); + d.block("E-CHIME", {}, () => { + d.rect([-0.08, -0.05], 0.16, 0.1); + d.text([0, 0], "CH", { height: 0.05, style: "NOTES", align: "center", valign: "middle" }); + }); +} + +/** One floor's power and lighting plan, in model space at its origin. */ +function powerPlan(d: D, v: View, floor: Floor): void { + const origin = E_ORIGIN[floor]; + const level = LEVELS[floor]; + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + drawPlan(d, origin, level, BASE_LAYERS(LAYERS.base), floor); + // Room names, screened with the base. + d.layer(LAYERS.base, {}, () => { + for (const r of level.rooms) { + const [x0, y0, x1] = r.rect; + const at: Point = ROOM_NAME_AT[`${floor}/${r.name}`] ?? [(x0 + x1) / 2, y0 + v.p(0.12)]; + if (x1 - x0 < v.p(width(r.name, H.note) + 0.1)) continue; + put(d, o(at), r.name, v.p(H.note), { align: "center" }); + } + }); + const here = DEVICES.filter((x) => x.floor === floor); + // What is already on the plan, for placing the home runs' labels clear of it. + const taken: Box[] = here.map((dv) => { + const r = v.p(dv.block === "E-FAN" ? 0.3 : 0.12); + return [o(dv.at)[0] - r, o(dv.at)[1] - r, o(dv.at)[0] + r, o(dv.at)[1] + r]; + }); + for (const x of LOW_VOLTAGE.filter((y) => y.floor === floor)) { + const [cx, cy] = o(x.at); + taken.push([cx - v.p(0.12), cy - v.p(0.12), cx + v.p(0.12), cy + v.p(0.12)]); + } + for (const r of level.rooms) { + const [x0, y0, x1] = r.rect; + const at = ROOM_NAME_AT[`${floor}/${r.name}`] ?? [(x0 + x1) / 2, y0 + v.p(0.12)]; + taken.push(textBox(o(at), r.name, v.p(H.note), H.note, "center")); + } + // The symbols. + for (const dv of here) { + d.layer(layerOf(dv), {}, () => { + d.insert(dv.block, o(dv.at), { scale: v.p(1), rotation: dv.rot }); + }); + } + // Low-voltage outlets. + d.layer(LAYERS.eAnno, {}, () => { + for (const x of LOW_VOLTAGE.filter((y) => y.floor === floor)) + d.insert(x.block, o(x.at), { scale: v.p(1), rotation: x.rot }); + }); + // Tags beside the symbols: a switch's letter, WP, DW and so on. + d.layer(LAYERS.eAnno, {}, () => { + for (const dv of here) { + if (!dv.tag) continue; + const small = v.p(H.small); + if (isSwitch(dv.block)) { + put(d, o([dv.at[0] + v.p(0.07), dv.at[1] + v.p(0.03)]), dv.tag, small); + continue; + } + const side = + dv.rot === 90 || dv.rot === -90 ? [v.p(0.13), -v.p(0.03)] : [v.p(0.12), v.p(0.06)]; + const at = o([dv.at[0] + side[0]!, dv.at[1] + side[1]!]); + put(d, at, dv.tag, small); + taken.push(textBox(at, dv.tag, small, H.small, "left")); + } + // Every light's switch letter, beside the fixture, as an electrician reads a plan. + for (const dv of here) { + if (!isLight(dv.block) || !dv.group || dv.group.length > 2) continue; + put(d, o([dv.at[0] + v.p(0.1), dv.at[1] - v.p(0.16)]), dv.group, v.p(H.small)); + } + }); + const trim = v.p(0.1); + // Switch legs: dashed, from the first switch to each fixture of its group in turn, and between + // the switches of a three-way. + d.layer(LAYERS.circ, {}, () => { + const groups = new Map(); + for (const dv of here) + if (dv.group) + groups.set(`${dv.ckt}/${dv.group}`, [...(groups.get(`${dv.ckt}/${dv.group}`) ?? []), dv]); + for (const g of groups.values()) { + const switches = g.filter((x) => isSwitch(x.block)); + const fixtures = g.filter((x) => !isSwitch(x.block) && !isReceptacle(x.block)); + const first = switches[0]; + if (!first) continue; + let prev = first.at; + for (const f of chain(fixtures, first.at)) { + arcBetween(d, o(prev), o(f.at), 0.18, trim, "HIDDEN", v.p(0.14)); + prev = f.at; + } + for (const s of switches.slice(1)) + arcBetween(d, o(first.at), o(s.at), 0.12, trim, "HIDDEN", v.p(0.14)); + } + }); + // Circuits: solid arcs through each circuit's outlets, and a home run toward the panel. + d.layer(LAYERS.circ, {}, () => { + for (const c of CIRCUITS) { + const ds = here.filter((x) => x.ckt === c.key && x.block !== "E-PANEL"); + // A switch group joins the circuit at its first switch. + const seen = new Set(); + const nodes = ds.filter((x) => { + if (!x.group) return true; + if (!isSwitch(x.block) || seen.has(x.group)) return false; + seen.add(x.group); + return true; + }); + if (!nodes.length) continue; + // A circuit whose outlets lie far apart is drawn as clusters, each with its home run. + for (const cluster of clusters(nodes, JUMP)) { + const root = chain(cluster, PANEL_AT)[0]!; + for (const [a, b] of spanningTree(cluster, root)) + arcBetween(d, o(a), o(b), 0.15, trim, undefined, v.p(0.12)); + homeRun(d, v, o(root.at), o(PANEL_AT), c.label, taken); + } + } + }); + equipment(d, v, floor, o); +} + +/** + * A home run: a short line from the first outlet toward the panel, an arrowhead, the circuit. + * The label takes the first of a few places round the tip that is clear of `taken`, trying a + * longer arrow when none is. + */ +function homeRun(d: D, v: View, from: Point, panel: Point, label: string, taken: Box[]): void { + const dx = panel[0] - from[0]; + const dy = panel[1] - from[1]; + const len = Math.hypot(dx, dy); + if (len < v.p(0.3)) return; + const ux = dx / len; + const uy = dy / len; + const h = v.p(H.small); + const g = v.p(0.04); + let best: { tip: Point; at: Point; align: "left" | "right"; box: Box } | undefined; + for (const reach of [0.42, 0.6, 0.8]) { + const tip: Point = [from[0] + ux * v.p(reach), from[1] + uy * v.p(reach)]; + const tries: [Point, "left" | "right"][] = [ + [[tip[0] + g, tip[1] + g], "left"], + [[tip[0] - g, tip[1] + g], "right"], + [[tip[0] + g, tip[1] - g - h], "left"], + [[tip[0] - g, tip[1] - g - h], "right"], + ]; + if (ux < 0) tries.unshift(tries.splice(1, 1)[0]!); + for (const [at, align] of tries) { + const box = textBox(at, label, h, H.small, align); + const candidate = { tip, at, align, box }; + best ??= candidate; + if (!taken.some((t) => hits(t, box))) { + best = candidate; + break; + } + } + if (best && !taken.some((t) => hits(t, best!.box))) break; + best = reach === 0.8 ? best : undefined; + } + const { tip, at, align, box } = best!; + const a: Point = [from[0] + ux * v.p(0.1), from[1] + uy * v.p(0.1)]; + d.line(a, tip); + arrowhead(d, tip, a, v.p(0.08)); + d.layer(LAYERS.eAnno, {}, () => put(d, at, label, h, { align })); + taken.push(box); +} + +/** The equipment drawn as outlines: heat pumps, air handlers, the meter, with their notes. */ +function equipment(d: D, v: View, floor: Floor, o: (p: Point) => Point): void { + const note = (at: Point, lines: string[], align: "left" | "right" = "left") => + d.layer(LAYERS.eAnno, {}, () => + lines.forEach((t, k) => + put(d, o([at[0], at[1] - k * v.p(H.small * 1.7)]), t, v.p(H.small), { align }), + ), + ); + const box = (r: readonly [number, number, number, number], linetype?: string) => + d.rect(o([r[0], r[1]]), r[2] - r[0], r[3] - r[1], linetype ? { linetype } : {}); + d.layer(LAYERS.powr, {}, () => { + if (floor === "L1") { + box(EQUIP.hp1); + box(EQUIP.hp2); + box(EQUIP.ahu1); + // The meter on the garage's west face, behind the panel. + d.circle(o([-7, PANEL_AT[1]]), v.p(0.09)); + } else { + box(EQUIP.ahu2, "HIDDEN"); + } + }); + if (floor === "L1") { + d.layer(LAYERS.eAnno, {}, () => + put(d, o([-7, PANEL_AT[1]]), "M", v.p(H.small), { align: "center", valign: "middle" }), + ); + note([630, 312], ["HP-1, HP-2 ON", "PAD AT D.F.E."]); + note([-64, 236], ["METER/MAIN", "200A, SERVICE", "DISC. (230.85)", "+ SPD (230.67)"]); + note([24, 222], [`PANEL "${PANEL.name}"`, `${PANEL.amps}A, 40 SP.`]); + note([center(EQUIP.ahu1)[0], EQUIP.ahu1[1] - 10], ["AHU-1 ON STAND"], "right"); + } else { + note([EQUIP.ahu2[2] + 4, EQUIP.ahu2[1] + 6], ["AHU-2 IN ATTIC", "ABOVE (M-101)"]); + } +} + +// --------------------------------------------------------------------------------------------- +// Paper space. + +/** The symbol legend, with the kit's blocks at paper size. */ +function legend(d: D, at: Point, w: number): number { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "ELECTRICAL LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.22; + }); + const rows: [string, (c: Point) => void][] = [ + ["DUPLEX RECEPT., 20A, TR", (c) => d.insert("E-DUPLEX", c)], + [ + "GFCI DUPLEX RECEPT., TR (WP: WR, IN-USE COVER)", + (c) => d.insert("E-GFCI", [c[0] - 0.06, c[1]]), + ], + ["240V RECEPT. / OUTLET, NEMA PER EQUIPMENT", (c) => d.insert("E-220", c)], + ["SINGLE-POLE SWITCH, LETTER = FIXTURES", (c) => d.insert("E-SWITCH", c)], + ["THREE-WAY SWITCH", (c) => d.insert("E-SWITCH3", c)], + ["CEILING LIGHT FIXTURE", (c) => d.insert("E-CEILING", c)], + ["RECESSED LED DOWNLIGHT, IC RATED", (c) => d.insert("E-RECESSED", c)], + [ + "CEILING FAN W/ LIGHT KIT, FAN-RATED BOX", + (c) => d.insert("E-FAN", [c[0], c[1]], { scale: 0.6 }), + ], + ["WALL-MOUNTED LIGHT FIXTURE", (c) => d.insert("E-WALLLIGHT", [c[0], c[1] - 0.04])], + ["SMOKE / CO ALARM, 120V, INTERCONNECTED", (c) => d.insert("E-SMOKE", c)], + ["EXHAUST FAN, 50 CFM MIN., DUCTED OUT", (c) => d.insert("E-EXHAUST", c)], + ["PANELBOARD", (c) => d.insert("E-PANEL", c)], + ["DISCONNECT SWITCH", (c) => d.insert("E-DISC", c)], + ["TV / DATA OUTLET (CAT 6 + RG6)", (c) => d.insert("E-TVDATA", [c[0], c[1] - 0.06])], + [ + "DOORBELL BUTTON / CHIME", + (c) => { + d.insert("E-BELL", [c[0] - 0.12, c[1]]); + d.insert("E-CHIME", [c[0] + 0.12, c[1]]); + }, + ], + ]; + for (const [label, draw] of rows) { + d.layer(LAYERS.eAnno, {}, () => draw([at[0] + 0.3, y + 0.035])); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.7, y], label, H.note, { valign: "middle" })); + y -= 0.3; + } + // The line types of the wiring. + const lineRow = (label: string, draw: (a: Point, b: Point) => void) => { + d.layer(LAYERS.circ, {}, () => draw([at[0] + 0.05, y], [at[0] + 0.55, y])); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.7, y], label, H.note, { valign: "middle" })); + y -= 0.3; + }; + lineRow("BRANCH CIRCUIT, CONCEALED", (a, b) => arcBetween(d, a, b, 0.15, 0)); + lineRow("SWITCH LEG", (a, b) => arcBetween(d, a, b, 0.15, 0, "HIDDEN")); + lineRow("HOME RUN TO PANEL A, CIRCUIT NO.", (a, b) => { + d.line(a, b); + arrowhead(d, b, a, 0.08); + put(d, [b[0] - 0.4, b[1] + 0.04], "A-1", H.small); + }); + return y; +} + +/** The panel schedule: odd circuits on the left, even on the right, in breaker order. */ +function panelSchedule(d: D, at: Point): { y: number; width: number } { + const loads = circuitLoads(); + const byPos = new Map(); + for (const c of loads) c.positions.forEach((p, k) => byPos.set(p, { c, pole: k })); + const half = (pos: number): string[] => { + const hit = byPos.get(pos); + if (!hit) return [String(pos), "SPACE", "", "", ""]; + const { c, pole } = hit; + const per = Math.round(c.va / c.poles); + const spare = c.desc === "SPARE"; + const bkr = `${c.amps}/${c.poles}${c.type ? ` ${c.type}` : ""}`; + if (pole > 0) return [String(pos), "--", "", "", String(per)]; + const desc = c.count && !c.fixed ? `${c.desc} (${c.count})` : c.desc; + return [String(pos), desc, bkr, spare ? "" : WIRE[c.amps]!, spare ? "" : String(per)]; + }; + const rows: string[][] = []; + const phase = { A: 0, B: 0 }; + for (let r = 0; r < PANEL.spaces / 2; r++) { + const ph = r % 2 === 0 ? "A" : "B"; + const left = half(2 * r + 1); + const right = half(2 * r + 2); + phase[ph] += Number(left[4] || 0) + Number(right[4] || 0); + rows.push([...left, ph, ...right.slice(1).reverse(), right[0]!]); + } + const cols = [ + { name: "CKT", width: 0.42, align: "center" as const }, + { name: "DESCRIPTION", width: 2.75 }, + { name: "BKR", width: 0.72, align: "center" as const }, + { name: "WIRE", width: 0.5, align: "center" as const }, + { name: "VA", width: 0.55, align: "right" as const }, + { name: "PH", width: 0.36, align: "center" as const }, + { name: "VA", width: 0.55, align: "right" as const }, + { name: "WIRE", width: 0.5, align: "center" as const }, + { name: "BKR", width: 0.72, align: "center" as const }, + { name: "DESCRIPTION", width: 2.75, align: "right" as const }, + { name: "CKT", width: 0.42, align: "center" as const }, + ]; + const total = cols.reduce((s, c) => s + c.width, 0); + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], `PANEL "${PANEL.name}" SCHEDULE`, H.label, { valign: "top" }); + y -= H.label + 0.1; + const info = `${PANEL.amps}A, ${PANEL.volts}, ${PANEL.spaces} SPACES, ${PANEL.main}A MAIN BKR., ${PANEL.aic} KAIC, SURFACE, GARAGE`; + put(d, [at[0], y], info, H.note, { valign: "top" }); + y -= H.note + 0.1; + }); + y = table(d, [at[0], y], "", cols, rows, { height: H.note, rowHeight: 0.2 }); + const connected = phase.A + phase.B; + d.layer(LAYERS.text, {}, () => { + y -= 0.2; + put( + d, + [at[0], y], + `CONNECTED LOAD: PHASE A ${phase.A.toLocaleString("en-US")} VA, PHASE B ${phase.B.toLocaleString("en-US")} VA, TOTAL ${connected.toLocaleString("en-US")} VA`, + H.note, + ); + y -= 0.2; + put( + d, + [at[0], y], + "BKR: AF = COMBINATION AFCI, GF = GFCI, DF = DUAL FUNCTION AFCI/GFCI.", + H.note, + ); + y -= 0.2; + put( + d, + [at[0], y], + "(N) = NUMBER OF OUTLETS AND FIXTURES ON THE CIRCUIT, AS DRAWN ON THE PLANS.", + H.note, + ); + }); + return { y: y - 0.1, width: total }; +} + +/** The service load calculation, NEC 220.82. */ +function loadCalcTable(d: D, at: Point, w: number): number { + const lc = loadCalc(); + const f = (n: number) => Math.round(n).toLocaleString("en-US"); + const rows: string[][] = [ + [`GENERAL LTG. + RECEPT., 3 VA/SF X ${f(lc.living)} SF`, f(lc.lighting)], + [`SMALL APPLIANCE, ${lc.sabc / 1500} X 1,500 VA`, f(lc.sabc)], + [`LAUNDRY, ${lc.laundry / 1500} X 1,500 VA`, f(lc.laundry)], + ...lc.appliances.map((c) => [`${c.desc}, NAMEPLATE`, f(c.va)]), + ["GENERAL LOAD, 220.82(B)", f(lc.general)], + ["FIRST 10,000 VA AT 100% + REST AT 40%", f(lc.generalDemand)], + ["HEAT PUMPS + AHU STRIP HEAT AT 100%, 220.82(C)", f(lc.hvac)], + ["TOTAL DEMAND LOAD", f(lc.total)], + [`SERVICE: ${f(lc.total)} VA / 240 V`, `${lc.amps.toFixed(0)} A`], + ]; + const y = table( + d, + at, + "SERVICE LOAD CALCULATION (NEC 220.82)", + [ + { name: "LOAD", width: w - 0.85 }, + { name: "VA", width: 0.85, align: "right" }, + ], + rows, + { height: H.note, rowHeight: 0.2 }, + ); + let yy = y - 0.18; + d.layer(LAYERS.text, {}, () => { + const ok = lc.amps <= PANEL.amps; + const lines = [ + `${lc.amps.toFixed(0)} A ${ok ? "<=" : ">"} ${PANEL.amps} A SERVICE: ${ok ? "OK" : "TOO SMALL"}.`, + "SERVICE CONDUCTORS 2/0 CU OR 4/0 AL (NEC 310.12).", + "GEC #4 CU TO CONCRETE-ENCASED ELECTRODE (250.52(A)(3)).", + ]; + for (const line of lines) { + put(d, [at[0], yy], line, H.note); + yy -= 0.19; + } + }); + if (lc.amps > PANEL.amps) + throw new Error(`The load, ${lc.amps.toFixed(0)} A, is more than the ${PANEL.amps} A service`); + return yy - 0.1; +} + +/** The lighting fixture schedule: each fixture type, and how many the plans hold. */ +function fixtureSchedule(d: D, at: Point): number { + const types: [string, Device["block"], string, string, string][] = [ + [ + "A", + "E-RECESSED", + '6" LED DOWNLIGHT, IC, AIR-TIGHT, 3000K (WET LISTED AT SHOWER/TUB)', + "15", + "RECESSED", + ], + [ + "B", + "E-CEILING", + "LED CEILING FIXTURE / PENDANT, 3000K, OWNER SELECTED", + "60", + "CEILING / PENDANT", + ], + ["C", "E-FAN", '52" CEILING FAN W/ LED LIGHT KIT (WET RATED AT LANAI)', "100", "FAN-RATED BOX"], + [ + "D", + "E-WALLLIGHT", + "LED WALL SCONCE / VANITY / EXTERIOR WET LISTED", + "40", + 'WALL, 72" A.F.F.', + ], + ["E", "E-EXHAUST", "EXHAUST FAN, 50 CFM MIN., DUCTED OUTSIDE, ENERGY STAR", "50", "CEILING"], + ["F", "E-SMOKE", "SMOKE / CO ALARM, 120 V, 10-YR BATTERY BACKUP", "5", "CEILING"], + ]; + const rows = types.map(([t, block, desc, va, mount]) => [ + t, + desc, + va, + mount, + String(DEVICES.filter((x) => x.block === block).length), + ]); + return table( + d, + at, + "LIGHTING FIXTURE SCHEDULE", + [ + { name: "TYPE", width: 0.5, align: "center" }, + { name: "DESCRIPTION", width: 5.6 }, + { name: "VA", width: 0.5, align: "right" }, + { name: "MOUNTING", width: 1.6 }, + { name: "QTY", width: 0.5, align: "right" }, + ], + rows, + { height: H.note, rowHeight: 0.22 }, + ); +} + +/** The service one-line diagram, not to scale, from the utility to panel A. */ +function oneLine(d: D, at: Point, w: number): number { + const [x0, top] = at; + const cx = x0 + 1.0; + const lbl = (p: Point, t: string) => put(d, p, t, H.note, { valign: "middle" }); + d.layer(LAYERS.text, {}, () => { + put(d, [x0, top], "SERVICE ONE-LINE DIAGRAM", H.label, { valign: "top" }); + d.line([x0, top - H.label - 0.06], [x0 + w, top - H.label - 0.06], { lineweight: 0.35 }); + put(d, [x0, top - H.label - 0.2], "NOT TO SCALE", H.small, { valign: "top" }); + }); + let y = top - 0.85; + d.layer(LAYERS.powr, {}, () => { + // The utility's transformer. + d.circle([cx, y], 0.16); + d.circle([cx, y - 0.22], 0.16); + lbl([cx + 0.35, y - 0.11], "UTILITY PAD-MOUNT TRANSFORMER"); + y -= 0.38; + d.line([cx, y], [cx, y - 0.7]); + lbl([cx + 0.35, y - 0.35], "UNDERGROUND LATERAL BY UTILITY"); + y -= 0.7; + // The meter. + d.circle([cx, y - 0.2], 0.2); + put(d, [cx, y - 0.2], "M", H.label, { align: "center", valign: "middle" }); + lbl([cx + 0.35, y - 0.2], "METER, 200 A, ABOVE D.F.E."); + y -= 0.4; + d.line([cx, y], [cx, y - 0.3]); + y -= 0.3; + // The service disconnect: the main breaker in the meter/main, where the neutral and the + // grounding meet: the main bonding jumper and the grounding electrode conductor. + d.rect([cx - 0.25, y - 0.55], 0.5, 0.55); + d.arc([cx + 0.05, y - 0.27], 0.15, 110, 250); + lbl([cx + 0.4, y - 0.1], "MAIN BKR. 200 A = SERVICE DISCONNECT,"); + lbl([cx + 0.4, y - 0.28], '"EMERGENCY DISCONNECT" (230.85); SPD (230.67)'); + lbl([cx + 0.4, y - 0.46], "MAIN BONDING JUMPER HERE (250.24(B), 250.28)"); + // The GEC from the service disconnect to the footing steel. + const gy0 = y - 0.27; + d.line([cx - 0.25, gy0], [cx - 0.7, gy0]); + d.line([cx - 0.7, gy0], [cx - 0.7, gy0 - 0.45]); + for (let k = 0; k < 3; k++) + d.line( + [cx - 0.7 - 0.12 + 0.04 * k, gy0 - 0.45 - 0.07 * k], + [cx - 0.7 + 0.12 - 0.04 * k, gy0 - 0.45 - 0.07 * k], + ); + y -= 0.55; + lbl([cx + 0.4, y - 0.12], "#4 CU GEC TO FOOTING STEEL, AT THE SERVICE"); + lbl([cx + 0.4, y - 0.3], "(250.24(A), 250.52(A)(3)); BOND METAL WATER"); + lbl([cx + 0.4, y - 0.48], "PIPE, IF ANY (250.104)"); + // The feeder to panel A: four wires, the fourth the equipment grounding conductor. + d.line([cx, y], [cx, y - 1.3]); + for (let k = 0; k < 4; k++) { + const ty = y - 0.75 + k * 0.07; + d.line([cx - 0.07, ty - 0.035], [cx + 0.07, ty + 0.035]); + } + put(d, [cx - 0.1, y - 0.62], "G", H.small, { align: "right", valign: "middle" }); + lbl([cx + 0.4, y - 0.7], "4-WIRE FEEDER: (3) 2/0 CU + #6 CU EGC,"); + lbl([cx + 0.4, y - 0.88], "OR (3) 4/0 AL + #4 AL EGC (215.6, 250.122),"); + lbl([cx + 0.4, y - 1.06], 'IN 2" PVC SCH. 80'); + y -= 1.3; + // Panel A. + d.rect([cx - 0.45, y - 0.6], 0.9, 0.6); + put(d, [cx, y - 0.3], '"A"', H.label, { align: "center", valign: "middle" }); + lbl([cx + 0.6, y - 0.12], `PANEL "A", ${PANEL.amps} A, ${PANEL.spaces} SP., GARAGE`); + lbl([cx + 0.6, y - 0.3], "FEEDER PANEL: NEUTRAL ISOLATED, NO MBJ;"); + lbl([cx + 0.6, y - 0.48], "EGC BONDED TO ENCLOSURE (250.24(A)(5))"); + const gy = y - 0.6; + y = gy - 0.2; + }); + return y; +} + +const NOTES = [ + "ALL WORK PER NFPA 70, NATIONAL ELECTRICAL CODE, 2020 EDITION, AS ADOPTED BY THE 2023 FBC, AND THE SERVING UTILITY'S RULES. CONTRACTOR VERIFIES THE SERVICE POINT WITH THE UTILITY.", + 'SERVICE: 200 A, 120/240 V, 1 PHASE, 3 WIRE, UNDERGROUND. METER/MAIN OUTDOORS ON THE GARAGE WEST WALL; ITS MAIN BREAKER IS THE SERVICE DISCONNECT, MARKED "EMERGENCY DISCONNECT, SERVICE DISCONNECT" (NEC 230.85), WITH THE MAIN BONDING JUMPER AND THE GEC THERE (250.24). PANEL A IS A FEEDER PANEL: 4-WIRE FEEDER WITH AN EGC (215.6), NEUTRAL ISOLATED FROM THE ENCLOSURE (250.24(A)(5)). TYPE 1 OR 2 SPD AT THE SERVICE (NEC 230.67).', + "AFCI: EVERY 120 V, 15 AND 20 A BRANCH CIRCUIT SUPPLYING OUTLETS OR DEVICES IN KITCHENS, FAMILY AND LIVING ROOMS, DENS, BEDROOMS, CLOSETS, HALLWAYS, LAUNDRY AREAS AND SIMILAR ROOMS IS PROTECTED BY A LISTED COMBINATION-TYPE AFCI AT THE PANEL (NEC 210.12(A)).", + `GFCI: EVERY 125 V THROUGH 250 V RECEPTACLE IN BATHROOMS, GARAGES, OUTDOORS, KITCHENS WHERE SERVING COUNTERTOPS, WITHIN 6 FT OF A SINK, AND IN LAUNDRY AREAS, INCLUDING THE 240 V DRYER RECEPTACLE ON A 2-POLE GFCI BREAKER (CKT. ${circuit("DRYER").label}), AND THE DISHWASHER OUTLET, IS GFCI PROTECTED (NEC 210.8(A), 210.8(D)).`, + "TAMPER-RESISTANT: ALL 15 AND 20 A, 125 AND 250 V NONLOCKING RECEPTACLES ARE LISTED TAMPER-RESISTANT (NEC 406.12). OUTDOOR RECEPTACLES ARE WEATHER-RESISTANT WITH IN-USE COVERS (NEC 406.9(B)).", + 'RECEPTACLES: NO POINT ALONG A WALL LINE MORE THAN 6 FT FROM A RECEPTACLE (NEC 210.52(A)); NO POINT ALONG A COUNTERTOP MORE THAN 24" FROM ONE (210.52(C)(1)); ISLAND OUTLETS PER 210.52(C)(2). AT LEAST ONE OUTDOOR RECEPTACLE AT THE FRONT AND THE BACK (210.52(E)).', + "BRANCH CIRCUITS: TWO OR MORE 20 A SMALL-APPLIANCE CIRCUITS, ONE 20 A LAUNDRY, ONE 20 A BATHROOM AND ONE 20 A GARAGE CIRCUIT (NEC 210.11(C)). COPPER, TYPE NM-B OR THHN/THWN-2; #14 MIN. ON 15 A, #12 ON 20 A.", + "SMOKE AND CO ALARMS: COMBINATION SMOKE/CO ALARMS IN EACH SLEEPING ROOM, OUTSIDE EACH SLEEPING AREA AND ON EACH STORY (FBC-R R314.3, R315.3), 120 V WITH BATTERY BACKUP, INTERCONNECTED SO THAT ALL SOUND WHEN ONE DOES (R314.4, R315.5). LISTED TO UL 217 AND UL 2034.", + "HVAC (SEE M-101): DISCONNECT WITHIN SIGHT OF EACH HEAT PUMP AND AIR HANDLER (NEC 440.14), 125 V SERVICE RECEPTACLE WITHIN 25 FT (NEC 210.63). ATTIC: LIGHTS AT THE ACCESS AND AT AHU-2, SWITCHED AT THE ACCESS OPENING, AND A GFCI RECEPTACLE AT AHU-2 (FBC-R M1305.1.3.1, NEC 210.70(A)(3)). BREAKERS PER THE UNIT'S NAMEPLATE MOCP; VERIFY BEFORE ROUGH-IN.", + "FLOOD ZONE AE: THE METER, PANEL, CONDENSERS, AIR HANDLERS AND WATER HEATER ARE AT OR ABOVE THE D.F.E. OF 10.00' NAVD (FBC-R R322.1.6). WIRING BELOW THE D.F.E. IN THE GARAGE IS LIMITED TO WHAT THE GARAGE NEEDS AND IS GFCI PROTECTED.", + "LIGHTING: ALL PERMANENTLY INSTALLED FIXTURES ARE HIGH-EFFICACY (FBC-EC R404.1). RECESSED FIXTURES IN INSULATED CEILINGS ARE IC-RATED, AIR-TIGHT. FIXTURES OVER TUBS AND SHOWERS ARE LISTED FOR DAMP LOCATIONS. CLOSET FIXTURES PER NEC 410.16.", + 'MOUNTING HEIGHTS TO CENTER UNLESS NOTED: RECEPTACLES 16", COUNTER RECEPTACLES 44", SWITCHES 48", EXTERIOR WP RECEPTACLES 18" ABOVE GRADE MIN. CEILING FANS ON LISTED FAN-SUPPORT BOXES (NEC 314.27(C)).', +]; + +function e101(d: D): void { + defineLowVoltage(d); + const v = view(d, 64); + powerPlan(d, v, "L1"); + powerPlan(d, v, "L2"); + frame(d, "E-101", (dd, sheet) => { + const vpW = 12.0; + const vpH = 11.8; + const top = AREA.top - 0.1; + const vpY = top - vpH; + // The views: the house, with the heat pumps outside its east wall and the lanai north. + const middle: Point = [300, 318]; + const vps: [Floor, number, string][] = [ + ["L1", AREA.left + 0.1, "FIRST FLOOR POWER AND LIGHTING PLAN"], + ["L2", AREA.left + 0.1 + vpW + 0.25, "SECOND FLOOR POWER AND LIGHTING PLAN"], + ]; + vps.forEach(([floor, x, title], k) => { + viewport(dd, sheet, { + corner: [x, vpY], + size: [vpW, vpH], + center: [E_ORIGIN[floor][0] + middle[0], E_ORIGIN[floor][1] + middle[1]], + v, + id: `vp-E-101-${floor}`, + number: String(k + 1), + sheetNo: "E-101", + title, + titleAt: [x + 0.1, vpY - 0.45], + }); + dd.layer(LAYERS.text, {}, () => + dd.insert("NORTH ARROW", [x + vpW - 0.6, vpY - 0.5], { scale: 0.8 }), + ); + }); + // Below the views: the legend, the panel schedule, the load calculation. + const belowTop = vpY - 1.0; + legend(dd, [AREA.left + 0.1, belowTop], 4.0); + const sch = panelSchedule(dd, [AREA.left + 4.4, belowTop]); + fixtureSchedule(dd, [AREA.left + 4.4, sch.y - 0.3]); + const lcX = AREA.left + 4.4 + sch.width + 0.35; + loadCalcTable(dd, [lcX, belowTop], AREA.left + 2 * vpW + 0.35 - lcX); + // The notes, down the right side. + const nx = AREA.left + 0.1 + 2 * vpW + 0.25 + 0.35; + const notesEnd = noteColumn(dd, [nx, top], AREA.right - nx, "ELECTRICAL NOTES", NOTES); + oneLine(dd, [nx, notesEnd - 0.4], AREA.right - nx); + }); +} + +export const SHEETS_E = { "E-101": e101 }; diff --git a/examples/plan-set/sheets/mep-equip.ts b/examples/plan-set/sheets/mep-equip.ts new file mode 100644 index 0000000..80f208b --- /dev/null +++ b/examples/plan-set/sheets/mep-equip.ts @@ -0,0 +1,44 @@ +// Where the HVAC equipment stands and how the attic unit is reached, in building coordinates. +// E-101 places the disconnects, receptacles and lights from these; M-101 draws the units. +import type { Point } from "../../../src/index.ts"; +import { ATTIC_ACCESS, type Rect } from "../model/house.ts"; + +/** The units, as rectangles in plan. */ +export const EQUIP = { + /** AHU-1: upflow, on a 30" stand against the garage's north wall, west of WH-1. */ + ahu1: [186, 258, 216, 288] as Rect, + /** AHU-2: horizontal, in the attic over the second floor's landing; supply out the north end. */ + ahu2: [322, 130, 344, 186] as Rect, + /** HP-1 and HP-2 on one pad on the east side, where C-101 puts the condensers. */ + hp1: [636, 196, 672, 232] as Rect, + hp2: [636, 250, 672, 286] as Rect, + pad: [630, 190, 678, 292] as Rect, +}; + +export const center = (r: Rect): Point => [(r[0] + r[2]) / 2, (r[1] + r[3]) / 2]; + +/** + * The attic AHU's access: a 24" floored passageway from the pull-down stair to a 30" x 30" level + * service space on AHU-2's service (west) side. The centerline runs north from the opening's + * edge, then east. + */ +export const ATTIC = { + passage: [ + [239, ATTIC_ACCESS[3], 263, 170], + [263, 146, 292, 170], + ] as Rect[], + service: [292, 143, 322, 173] as Rect, + centerline: [ + [251, ATTIC_ACCESS[3]], + [251, 158], + [292, 158], + ] as Point[], +}; +export const passageLength = (): number => + ATTIC.centerline + .slice(1) + .reduce( + (s, p, k) => + s + Math.abs(p[0] - ATTIC.centerline[k]![0]) + Math.abs(p[1] - ATTIC.centerline[k]![1]), + 0, + ); diff --git a/examples/plan-set/sheets/mep-hvac.ts b/examples/plan-set/sheets/mep-hvac.ts new file mode 100644 index 0000000..ffcf1b1 --- /dev/null +++ b/examples/plan-set/sheets/mep-hvac.ts @@ -0,0 +1,446 @@ +// The HVAC design of the house, once: the equipment and where it stands, the duct systems, the +// exhausts and their terminations, in building coordinates. M-101 draws it; E-101 places its +// disconnects and receptacles from the same positions; P-101 reads the condensate routes. +import type { Point } from "../../../src/index.ts"; +import { L1, L2, type Level, type Rect } from "../model/house.ts"; +import { DEVICES, type Floor } from "./mep-power.ts"; +export { ATTIC, center, EQUIP, passageLength } from "./mep-equip.ts"; + +// --------------------------------------------------------------------------------------------- +// Duct systems. + +export interface Outlet { + readonly room: string; + readonly at: Point; + /** The trunk edge it takes off from, by node names. */ + readonly from: readonly [string, string]; +} + +export interface ReturnGrille { + readonly tag: string; + readonly room: string; + readonly at: Point; + /** The grille's face, square, in inches. */ + readonly face: number; + /** The duct from the grille to the unit, as a path. */ + readonly path: readonly Point[]; + readonly diameter: number; +} + +export interface DuctSystem { + readonly unit: "AHU-1" | "AHU-2"; + readonly floor: Floor; + readonly level: Level; + readonly cfm: number; + /** The trunks' depth, in inches. */ + readonly depth: number; + readonly nodes: Readonly>; + /** Trunk edges, parent to child, from the unit's supply at node R. */ + readonly trunks: readonly (readonly [string, string])[]; + readonly outlets: readonly Outlet[]; + readonly returns: readonly ReturnGrille[]; + /** Jump ducts: a ceiling grille in a closed room to one outside it. */ + readonly jumps: readonly (readonly [Point, Point])[]; +} + +export const SYSTEMS: readonly DuctSystem[] = [ + { + unit: "AHU-1", + floor: "L1", + level: L1, + cfm: 1200, + depth: 10, + nodes: { + R: [201, 273], + a: [201, 400], + w: [60, 400], + b: [349, 400], + c: [500, 400], + e: [575, 400], + s: [500, 110], + }, + trunks: [ + ["R", "a"], + ["a", "w"], + ["a", "b"], + ["b", "c"], + ["c", "e"], + ["c", "s"], + ], + outlets: [ + { room: "KITCHEN", at: [180, 362], from: ["a", "w"] }, + { room: "KITCHEN", at: [300, 462], from: ["a", "b"] }, + { room: "LAUNDRY", at: [100, 352], from: ["a", "w"] }, + { room: "PWDR.", at: [45, 452], from: ["a", "w"] }, + { room: "FOYER", at: [349, 140], from: ["a", "b"] }, + { room: "GREAT ROOM", at: [450, 488], from: ["b", "c"] }, + { room: "GREAT ROOM", at: [596, 330], from: ["c", "e"] }, + { room: "GREAT ROOM", at: [440, 250], from: ["c", "s"] }, + { room: "STUDY", at: [450, 48], from: ["c", "s"] }, + { room: "STUDY", at: [560, 48], from: ["c", "s"] }, + ], + returns: [ + { + tag: "RA-1", + room: "GREAT ROOM", + at: [408, 330], + face: 24, + path: [ + [396, 330], + [180, 330], + [180, 282], + ], + diameter: 18, + }, + ], + jumps: [ + [ + [398, 170], + [366, 170], + ], + ], + }, + { + unit: "AHU-2", + floor: "L2", + level: L2, + cfm: 1200, + depth: 10, + nodes: { + R: [333, 186], + n: [333, 400], + m: [333, 290], + w1: [110, 290], + ws: [110, 110], + wn: [110, 420], + e1: [500, 290], + es: [500, 110], + en: [500, 450], + }, + trunks: [ + ["R", "m"], + ["m", "n"], + ["m", "w1"], + ["w1", "ws"], + ["w1", "wn"], + ["m", "e1"], + ["e1", "es"], + ["e1", "en"], + ], + outlets: [ + { room: "BEDROOM 2", at: [56, 48], from: ["w1", "ws"] }, + { room: "BEDROOM 2", at: [40, 160], from: ["w1", "ws"] }, + { room: "BATH 2", at: [176, 262], from: ["w1", "ws"] }, + { room: "BEDROOM 3", at: [40, 400], from: ["w1", "wn"] }, + { room: "BEDROOM 3", at: [160, 492], from: ["w1", "wn"] }, + { room: "LOFT", at: [298, 350], from: ["m", "n"] }, + { room: "LOFT", at: [272, 470], from: ["m", "n"] }, + { room: "HALL", at: [326, 86], from: ["e1", "es"] }, + { room: "OWNER'S BATH", at: [440, 96], from: ["e1", "es"] }, + { room: "OWNER'S BATH", at: [562, 112], from: ["e1", "es"] }, + { room: "OWNER'S CLOSET", at: [562, 218], from: ["e1", "es"] }, + { room: "OWNER'S BEDROOM", at: [504, 496], from: ["e1", "en"] }, + { room: "OWNER'S BEDROOM", at: [596, 448], from: ["e1", "en"] }, + { room: "OWNER'S BEDROOM", at: [596, 340], from: ["e1", "en"] }, + ], + returns: [ + { + tag: "RA-2", + room: "LANDING", + at: [352, 114], + face: 24, + path: [ + [340, 114], + [333, 114], + [333, 130], + ], + diameter: 18, + }, + ], + jumps: [ + [ + [196, 176], + [244, 176], + ], + [ + [196, 420], + [250, 420], + ], + [ + [404, 460], + [360, 460], + ], + ], + }, +]; + +const area = (r: Rect) => ((r[2] - r[0]) * (r[3] - r[1])) / 144; +const roomRect = (level: Level, name: string): Rect => { + const r = level.rooms.find((x) => x.name === name); + if (!r) throw new Error(`No room ${name} on ${level.name}`); + return r.rect; +}; + +/** Each room's supply: its share of the system's air by floor area, split over its outlets. */ +export function roomAir( + s: DuctSystem, +): { room: string; outlets: number; cfm: number; each: number; neck: number }[] { + const names = [...new Set(s.outlets.map((o) => o.room))]; + const areas = names.map((n) => { + const room = s.level.rooms.find((x) => x.name === n)!; + return room.area ?? area(roomRect(s.level, n)); + }); + const total = areas.reduce((a, b) => a + b, 0); + return names.map((room, k) => { + const outlets = s.outlets.filter((o) => o.room === room).length; + const each = Math.round((s.cfm * areas[k]!) / total / outlets / 5) * 5; + return { room, outlets, cfm: each * outlets, each, neck: flexSize(each) }; + }); +} + +/** A flex run-out's size for its air, at about 0.08 in. w.c. per 100 ft. */ +export function flexSize(cfm: number): number { + const sizes: [number, number][] = [ + [6, 100], + [7, 140], + [8, 190], + [9, 250], + [10, 320], + [12, 500], + ]; + const hit = sizes.find(([, max]) => cfm <= max); + if (!hit) throw new Error(`No flex size for ${cfm} CFM`); + return hit[0]; +} + +/** A rectangular trunk's width for its air at 900 fpm, at the system's depth. */ +export const trunkWidth = (cfm: number, depth: number): number => + Math.max(10, Math.ceil((cfm * 144) / 900 / depth / 2) * 2); + +/** The point of segment a-b nearest p. */ +export function project(p: Point, a: Point, b: Point): Point { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const t = Math.max( + 0, + Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy)), + ); + return [a[0] + t * dx, a[1] + t * dy]; +} + +/** Each trunk edge with the air it carries: its own outlets and everything downstream. */ +export function trunkAir( + s: DuctSystem, +): { from: string; to: string; cfm: number; width: number }[] { + const each = new Map(roomAir(s).map((r) => [r.room, r.each])); + const own = (e: readonly [string, string]) => + s.outlets + .filter((o) => o.from[0] === e[0] && o.from[1] === e[1]) + .reduce((t, o) => t + each.get(o.room)!, 0); + const below = (e: readonly [string, string]): number => + own(e) + s.trunks.filter((x) => x[0] === e[1]).reduce((t, x) => t + below(x), 0); + return s.trunks.map((e) => { + const cfm = below(e); + return { from: e[0], to: e[1], cfm, width: trunkWidth(cfm, s.depth) }; + }); +} + +// --------------------------------------------------------------------------------------------- +// Exhausts. + +export interface Exhaust { + readonly tag: string; + readonly serves: string; + readonly floor: Floor; + readonly at: Point; + readonly cfm: number; + readonly duct: string; + /** The duct's route to its termination, the last point being the cap. */ + readonly route: readonly Point[]; + readonly cap: "WALL" | "ROOF"; + readonly capNote: string; + /** The E-101 circuit, by key. */ + readonly ckt: string; +} + +/** The bath fans are E-101's exhaust devices; this finds each by where it is. */ +const fan = (floor: Floor, at: Point) => { + const dv = DEVICES.find( + (x) => x.block === "E-EXHAUST" && x.floor === floor && x.at[0] === at[0] && x.at[1] === at[1], + ); + if (!dv) throw new Error(`No exhaust fan on E-101 at ${floor} ${at.join(",")}`); + return dv; +}; + +export const EXHAUSTS: readonly Exhaust[] = (() => { + const f1 = fan("L1", [22, 496]); + const f2 = fan("L2", [96, 285]); + const f3 = fan("L2", [570, 75]); + const f4 = fan("L2", [596, 140]); + return [ + { + tag: "EF-1", + serves: "PWDR.", + floor: "L1", + at: f1.at, + cfm: 50, + duct: '4" ALUM.', + route: [f1.at, [22, 528]], + cap: "WALL", + capNote: "WALL CAP, N. WALL", + ckt: f1.ckt, + }, + { + tag: "EF-2", + serves: "BATH 2", + floor: "L2", + at: f2.at, + cfm: 80, + duct: '4" INSUL. FLEX', + route: [f2.at, [70, 285], [70, 250]], + cap: "ROOF", + capNote: "ROOF CAP", + ckt: f2.ckt, + }, + { + tag: "EF-3", + serves: "OWNER'S SHOWER", + floor: "L2", + at: f3.at, + cfm: 80, + duct: '4" INSUL. FLEX', + route: [f3.at, [596, 75], [596, 40]], + cap: "ROOF", + capNote: "ROOF CAP", + ckt: f3.ckt, + }, + { + tag: "EF-4", + serves: "OWNER'S WC", + floor: "L2", + at: f4.at, + cfm: 80, + duct: '4" INSUL. FLEX', + route: [f4.at, [596, 176]], + cap: "ROOF", + capNote: "ROOF CAP", + ckt: f4.ckt, + }, + { + tag: "KH-1", + serves: "RANGE (MICRO-HOOD)", + floor: "L1", + at: [150, 394], + cfm: 300, + duct: '6" GALV. STEEL', + route: [ + [150, 394], + [150, 528], + ], + cap: "WALL", + capNote: "WALL CAP, N. WALL", + ckt: "MW", + }, + { + tag: "DE-1", + serves: "CLOTHES DRYER", + floor: "L1", + at: [55, 405], + cfm: 0, + duct: '4" RIGID METAL', + route: [ + [55, 405], + [0, 405], + ], + cap: "WALL", + capNote: "WALL CAP, W. WALL", + ckt: "DRYER", + }, + ]; +})(); + +/** The dryer duct's developed length, FBC-R M1502.4.5.1: run plus 5 ft per 90 degree elbow. */ +export function dryerLength(): { run: number; elbows: number; total: number } { + const de = EXHAUSTS.find((x) => x.tag === "DE-1")!; + const run = + de.route + .slice(1) + .reduce((s, p, k) => s + Math.hypot(p[0] - de.route[k]![0], p[1] - de.route[k]![1]), 0) / 12; + // One 90 at the dryer's outlet, turning along the wall; the wall cap is straight through. + const elbows = 1; + const total = run + 5 * elbows; + if (total > 35) throw new Error(`The dryer duct is ${total.toFixed(1)} ft developed, past 35 ft`); + return { run, elbows, total }; +} + +// --------------------------------------------------------------------------------------------- +// Piping and controls. + +/** Refrigerant line sets, from each heat pump to its air handler. AHU-2's rises in the east wall. */ +export const LINESETS: { floor: Floor; path: Point[] }[] = [ + { + floor: "L1", + path: [ + [636, 214], + [610, 214], + [610, 304], + [222, 304], + [222, 280], + [216, 280], + ], + }, + { + floor: "L2", + path: [ + [624, 268], + [596, 268], + [596, 236], + [360, 236], + [360, 160], + [344, 160], + ], + }, +]; + +/** + * Condensate drains and the WH-1 pan drain, to the outside 6" to 24" above grade. The garage's + * front pier holds a flood vent at x 240, so the two drains leave it east of the vent. + */ +export const DRAINS: { floor: Floor; label: string; path: Point[] }[] = [ + { + floor: "L1", + label: "AHU-1 COND.", + path: [ + [214, 262], + [214, 238], + [254, 238], + [254, -6], + ], + }, + { + floor: "L1", + label: "WH-1 PAN", + path: [ + [257, 256], + [260, 256], + [260, -6], + ], + }, + { + floor: "L2", + label: "AHU-2 COND.", + path: [ + [344, 140], + [370, 140], + [370, 230], + [604, 230], + [604, 262], + [624, 262], + ], + }, +]; + +export const THERMOSTATS: { tag: string; floor: Floor; at: Point }[] = [ + { tag: "T-1", floor: "L1", at: [374, 192] }, + { tag: "T-2", floor: "L2", at: [374, 330] }, +]; diff --git a/examples/plan-set/sheets/mep-m101.ts b/examples/plan-set/sheets/mep-m101.ts new file mode 100644 index 0000000..e6f8bc8 --- /dev/null +++ b/examples/plan-set/sheets/mep-m101.ts @@ -0,0 +1,712 @@ +// M-101: the mechanical plans of both floors at 3/16" = 1'-0" over a screened base plan, with the +// equipment, fan and room airflow schedules and the notes, all from `mep-hvac.ts`. +import type { Point } from "../../../src/index.ts"; +import { ATTIC_ACCESS, L1, L2, type Level, type Rect } from "../model/house.ts"; +import { CRITERIA } from "../project.ts"; +import { frame } from "../lib/frame.ts"; +import { arrowhead } from "../../lib/drafting.ts"; +import { + AREA, + ftIn, + H, + LAYERS, + leaderNote, + noteColumn, + put, + table, + view, + viewport, + width, + type D, + type View, +} from "../lib/kit.ts"; +import { BASE_LAYERS, drawPlan } from "../lib/plan.ts"; +import { circuit, type Floor } from "./mep-power.ts"; +import { + ATTIC, + center, + DRAINS, + dryerLength, + EQUIP, + EXHAUSTS, + LINESETS, + passageLength, + project, + roomAir, + SYSTEMS, + THERMOSTATS, + trunkAir, + type DuctSystem, +} from "./mep-hvac.ts"; + +export const M_ORIGIN: Record = { L1: [320000, 0], L2: [335000, 0] }; +const LEVELS: Record = { L1, L2 }; + +/** Layers this sheet adds: the kit has none for HVAC. */ +const ML = { + eqpm: "M-HVAC-EQPM", + sup: "M-HVAC-SDFF", + ret: "M-HVAC-RDFF", + exh: "M-EXHS-DUCT", + refg: "M-REFG-PIPE", + cond: "M-COND-PIPE", + attic: "M-HVAC-ATTC", + anno: "M-ANNO-TEXT", +} as const; + +function defineMechLayers(d: D): void { + d.layer(ML.eqpm, { color: 7, lineweight: 0.5 }); + d.layer(ML.sup, { color: 7, lineweight: 0.35 }); + d.layer(ML.ret, { color: 7, lineweight: 0.35, linetype: "HIDDEN" }); + d.layer(ML.exh, { color: 7, lineweight: 0.25 }); + d.layer(ML.refg, { color: 7, lineweight: 0.18, linetype: "DASHED" }); + d.layer(ML.cond, { color: 7, lineweight: 0.18, linetype: "PHANTOM" }); + d.layer(ML.attic, { color: 7, lineweight: 0.25, linetype: "DASHED" }); + d.layer(ML.anno, { color: 7, lineweight: 0.25 }); +} + +// --- Symbols, drawn at a size so the plan (true size) and the legend (paper) share them. -------- + +/** A 4-way ceiling supply diffuser: its face with both diagonals. */ +function supplySym(d: D, c: Point, s: number): void { + d.rect([c[0] - s / 2, c[1] - s / 2], s, s); + d.line([c[0] - s / 2, c[1] - s / 2], [c[0] + s / 2, c[1] + s / 2]); + d.line([c[0] - s / 2, c[1] + s / 2], [c[0] + s / 2, c[1] - s / 2]); +} +/** A return or transfer grille: its face with one diagonal. */ +function returnSym(d: D, c: Point, s: number): void { + d.rect([c[0] - s / 2, c[1] - s / 2], s, s); + d.line([c[0] - s / 2, c[1] - s / 2], [c[0] + s / 2, c[1] + s / 2]); +} +/** A thermostat: a circle with a T. */ +function tstatSym(d: D, c: Point, r: number): void { + d.circle(c, r); + put(d, c, "T", r * 1.1, { align: "center", valign: "middle" }); +} +/** A termination: a wall cap as a box on the wall, a roof cap as a circle. */ +function capSym(d: D, c: Point, s: number, kind: "WALL" | "ROOF"): void { + if (kind === "ROOF") { + d.circle(c, s / 2); + d.circle(c, s / 4); + } else { + d.rect([c[0] - s / 2, c[1] - s / 2], s, s); + d.line([c[0] - s / 2, c[1]], [c[0] + s / 2, c[1]]); + } +} + +/** A straight duct between two points as two lines `w` apart. */ +function doubleLine(d: D, a: Point, b: Point, w: number, extend = 0): void { + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (len < 1e-6) return; + const ux = (b[0] - a[0]) / len; + const uy = (b[1] - a[1]) / len; + const a2: Point = [a[0] - ux * extend, a[1] - uy * extend]; + const b2: Point = [b[0] + ux * extend, b[1] + uy * extend]; + for (const k of [-1, 1]) { + const nx = -uy * (w / 2) * k; + const ny = ux * (w / 2) * k; + d.line([a2[0] + nx, a2[1] + ny], [b2[0] + nx, b2[1] + ny]); + } +} + +// --- The plans. -------------------------------------------------------------------------------- + +/** Where a room's name sits when the default spot is taken by ducts. */ +const ROOM_NAME_AT: Record = { + "L1/FOYER": [349, 230], + "L1/KITCHEN": [258, 505], + "L1/LAUNDRY": [70, 320], + "L2/BATH 2": [150, 220], + "L2/LOFT": [300, 505], + "L2/HALL": [300, 90], + "L2/OWNER'S CLOSET": [450, 250], +}; + +function mechPlan(d: D, v: View, floor: Floor): void { + const origin = M_ORIGIN[floor]; + const level = LEVELS[floor]; + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + const small = v.p(H.small); + const note = v.p(H.note); + drawPlan(d, origin, level, BASE_LAYERS(LAYERS.base), floor); + d.layer(LAYERS.base, {}, () => { + for (const r of level.rooms) { + const [x0, y0, x1] = r.rect; + const at = ROOM_NAME_AT[`${floor}/${r.name}`] ?? [(x0 + x1) / 2, y0 + v.p(0.12)]; + if (x1 - x0 < v.p(width(r.name, H.note) + 0.1)) continue; + put(d, o(at), r.name, note, { align: "center" }); + } + }); + const label = ( + at: Point, + lines: string[], + align: "left" | "right" | "center" = "left", + h = small, + ) => + d.layer(ML.anno, {}, () => + lines.forEach((t, k) => put(d, o([at[0], at[1] - k * h * 1.7]), t, h, { align })), + ); + + for (const s of SYSTEMS.filter((x) => x.floor === floor)) ductSystem(d, v, s, o); + equipment(d, v, floor, o, label); + exhausts(d, v, floor, o, label); + + // Refrigerant line sets and condensate drains. + d.layer(ML.refg, {}, () => { + for (const l of LINESETS.filter((x) => x.floor === floor)) d.polyline(l.path.map(o)); + }); + d.layer(ML.cond, {}, () => { + for (const r of DRAINS.filter((x) => x.floor === floor)) { + d.polyline(r.path.map(o)); + const end = r.path.at(-1)!; + const prev = r.path.at(-2)!; + arrowhead(d, o(end), o(prev), v.p(0.07)); + } + }); + // Thermostats. + d.layer(ML.eqpm, {}, () => { + for (const t of THERMOSTATS.filter((x) => x.floor === floor)) { + tstatSym(d, o(t.at), v.p(0.06)); + d.layer(ML.anno, {}, () => + put(d, o([t.at[0] - v.p(0.1), t.at[1]]), t.tag, small, { + align: "right", + valign: "middle", + }), + ); + } + }); +} + +/** A system's trunks, run-outs, outlets, returns and jump ducts. */ +function ductSystem(d: D, v: View, s: DuctSystem, o: (p: Point) => Point): void { + const small = v.p(H.small); + const air = new Map(roomAir(s).map((r) => [r.room, r])); + const trunks = trunkAir(s); + const face = 12; + d.layer(ML.sup, {}, () => { + for (const t of trunks) { + const a = s.nodes[t.from]!; + const b = s.nodes[t.to]!; + doubleLine(d, o(a), o(b), t.width, t.from === "R" ? 0 : t.width / 2); + } + // Each trunk's size, beside it, along it. + for (const t of trunks) { + const a = s.nodes[t.from]!; + const b = s.nodes[t.to]!; + const horizontal = Math.abs(b[1] - a[1]) < 1; + const mid: Point = TRUNK_LABEL_AT[`${s.unit}/${t.from}-${t.to}`] ?? [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + ]; + const off = t.width / 2 + v.p(0.04); + d.layer(ML.anno, {}, () => + horizontal + ? put(d, o([mid[0], mid[1] + off]), `${t.width}x${s.depth}`, small, { align: "center" }) + : put(d, o([mid[0] - off, mid[1]]), `${t.width}x${s.depth}`, small, { + align: "center", + rotation: 90, + }), + ); + } + // Run-outs and outlets. + for (const out of s.outlets) { + const a = s.nodes[out.from[0]]!; + const b = s.nodes[out.from[1]]!; + const take = project(out.at, a, b); + const len = Math.hypot(out.at[0] - take[0], out.at[1] - take[1]); + if (len > face / 2) { + const ux = (out.at[0] - take[0]) / len; + const uy = (out.at[1] - take[1]) / len; + // Stop at the face's edge along the run's direction. + const k = face / 2 / Math.max(Math.abs(ux), Math.abs(uy)); + d.line(o(take), o([out.at[0] - ux * k, out.at[1] - uy * k])); + } + supplySym(d, o(out.at), face); + const r = air.get(out.room)!; + const at = OUTLET_TAG_AT[`${s.floor}/${out.at[0]},${out.at[1]}`] ?? [ + out.at[0] + face / 2 + v.p(0.03), + out.at[1] + face / 2 + v.p(0.02), + ]; + d.layer(ML.anno, {}, () => + put(d, o(at), `${r.neck}"Ø/${r.each}`, small, { + align: at[0] < out.at[0] ? "right" : "left", + }), + ); + } + }); + // Returns: the grille, and its duct to the unit, dashed. + for (const r of s.returns) { + d.layer(ML.ret, {}, () => { + for (let k = 1; k < r.path.length; k++) + doubleLine(d, o(r.path[k - 1]!), o(r.path[k]!), r.diameter, k === 1 ? 0 : r.diameter / 2); + }); + d.layer(ML.sup, {}, () => returnSym(d, o(r.at), r.face)); + } + // Jump ducts: a grille each side, a dashed line between. + for (const [a, b] of s.jumps) { + d.layer(ML.sup, {}, () => { + returnSym(d, o(a), 10); + returnSym(d, o(b), 10); + }); + d.layer(ML.ret, {}, () => + d.line( + o([a[0] + Math.sign(b[0] - a[0]) * 5, a[1]]), + o([b[0] - Math.sign(b[0] - a[0]) * 5, b[1]]), + ), + ); + d.layer(ML.anno, {}, () => + put(d, o([(a[0] + b[0]) / 2, a[1] + 6 + v.p(0.03)]), "J", small, { align: "center" }), + ); + } +} + +/** Where an outlet's tag goes, by floor and outlet position, when the default spot is taken. */ +const OUTLET_TAG_AT: Record = { + "L1/349,140": [339, 152], + "L1/596,330": [588, 350], + "L1/560,48": [552, 64], + "L1/45,452": [36, 466], + "L2/40,160": [32, 176], + "L2/40,400": [32, 416], + "L2/596,448": [588, 466], + "L2/596,340": [588, 358], + "L2/562,218": [554, 232], + "L2/562,112": [554, 128], +}; +/** Where a trunk's size goes, by unit and edge, when its middle is taken. */ +const TRUNK_LABEL_AT: Record = { + "AHU-1/R-a": [201, 350], + "AHU-2/R-m": [333, 260], +}; + +type Label = (at: Point, lines: string[], align?: "left" | "right" | "center", h?: number) => void; + +/** The air handlers, the heat pumps, the attic passage and service space, and their notes. */ +function equipment(d: D, v: View, floor: Floor, o: (p: Point) => Point, label: Label): void { + const box = (r: Rect, linetype?: string) => + d.rect(o([r[0], r[1]]), r[2] - r[0], r[3] - r[1], linetype ? { linetype } : {}); + const tag = (r: Rect, t: string) => + d.layer(ML.anno, {}, () => + put(d, o(center(r)), t, v.p(H.small), { align: "center", valign: "middle" }), + ); + const dfe = CRITERIA.flood.dfe.toFixed(2); + if (floor === "L1") { + d.layer(ML.eqpm, {}, () => { + box(EQUIP.ahu1); + box(EQUIP.pad); + box(EQUIP.hp1); + box(EQUIP.hp2); + for (const r of [EQUIP.hp1, EQUIP.hp2]) d.circle(o(center(r)), 14); + }); + tag(EQUIP.ahu1, "AHU-1"); + d.layer(ML.anno, {}, () => { + for (const [r, t] of [ + [EQUIP.hp1, "HP-1"], + [EQUIP.hp2, "HP-2"], + ] as const) + put(d, o(center(r)), t, v.p(H.small), { align: "center", valign: "middle" }); + }); + label([632, 180], ["HP-1, HP-2 ON", "CONC. PAD, TOP", `EL. ${dfe}' MIN.`]); + d.layer(ML.anno, {}, () => { + leaderNote(d, v, o([196, 266]), o([150, 200]), [ + "AHU-1 ON STAND, BASE", + `ABOVE D.F.E. ${dfe}'`, + "STEEL BOLLARD (NOTE 5)", + ]); + leaderNote(d, v, o([201, 284]), o([110, 120]), [ + "26 GA. STEEL SUPPLY", + "PLENUM AND RETURN,", + "NO OPENINGS IN GARAGE", + "(FBC-R R302.5.2)", + ]); + leaderNote(d, v, o([254, -6]), o([150, -14]), [ + "AHU-1 COND. + WH-1 PAN", + 'DRAIN, 6" TO 24" ABOVE', + "GRADE (NOTE 7)", + ]); + put(d, o([430, 308]), "REFRIG. LINE SET TO HP-1", v.p(H.small)); + leaderNote(d, v, o([396, 330]), o([430, 300]), ["RA-1, 24X24 FILTER", 'GRILLE, 18"Ø RA']); + }); + } else { + d.layer(ML.eqpm, {}, () => box(EQUIP.ahu2)); + tag(EQUIP.ahu2, "AHU-2"); + // The passageway and the service space, on the attic floor. + d.layer(ML.attic, {}, () => { + const outline: Point[] = [ + [ATTIC.passage[0]![0], ATTIC.passage[0]![1]], + [ATTIC.passage[0]![2], ATTIC.passage[0]![1]], + [ATTIC.passage[1]![0], ATTIC.passage[1]![1]], + [ATTIC.service[0], ATTIC.passage[1]![1]], + [ATTIC.service[0], ATTIC.service[1]], + [ATTIC.service[2], ATTIC.service[1]], + [ATTIC.service[2], ATTIC.service[3]], + [ATTIC.service[0], ATTIC.service[3]], + [ATTIC.service[0], ATTIC.passage[1]![3]], + [ATTIC.passage[0]![0], ATTIC.passage[0]![3]], + ]; + d.polyline(outline.map(o), true); + }); + d.layer(ML.eqpm, {}, () => { + const [a0, b0, a1, b1] = ATTIC_ACCESS; + d.rect(o([a0, b0]), a1 - a0, b1 - b0); + }); + const len = passageLength(); + if (len > 240) throw new Error(`The attic passageway is ${ftIn(len)}, past 20 ft`); + d.layer(ML.anno, {}, () => { + leaderNote(d, v, o([240, 40]), o([150, 40]), [ + 'ATTIC ACCESS, 30"X54"', + "PULL-DOWN STAIR (A-102)", + ]); + leaderNote(d, v, o([251, 120]), o([160, 130]), [ + '24" FLOORED PASSAGE,', + `${ftIn(len)} LONG (20' MAX.)`, + ]); + leaderNote(d, v, o([307, 165]), o([250, 205]), ['30"X30" LEVEL', "SERVICE SPACE"]); + leaderNote(d, v, o([344, 180]), o([396, 205]), [ + "AHU-2 ON", + "PLATFORM, AUX.", + "PAN + FLOAT SW.", + "(NOTE 6)", + ]); + leaderNote(d, v, o([364, 114]), o([396, 128]), ["RA-2, 24X24", "FILTER GRILLE,", '18"Ø RA']); + }); + label([606, 300], ["LINE SET +", "COND. IN EXT.", "WALL CHASE"], "right"); + } +} + +/** The exhaust fans, the hood and the dryer, their ducts and terminations. */ +function exhausts(d: D, v: View, floor: Floor, o: (p: Point) => Point, label: Label): void { + for (const x of EXHAUSTS.filter((e) => e.floor === floor)) { + d.layer(ML.exh, {}, () => { + if (x.tag.startsWith("EF")) d.insert("E-EXHAUST", o(x.at), { scale: v.p(1) }); + else d.rect(o([x.at[0] - 6, x.at[1] - 6]), 12, 12); + const pts = [...x.route]; + // Start the duct at the fan's edge. + const a = pts[0]!; + const b = pts[1]!; + const len = Math.hypot(b[0] - a[0], b[1] - a[1]); + const k = Math.min(v.p(0.09), len / 2); + pts[0] = [a[0] + ((b[0] - a[0]) / len) * k, a[1] + ((b[1] - a[1]) / len) * k]; + d.polyline(pts.map(o)); + capSym(d, o(x.route.at(-1)!), 8, x.cap); + }); + const at = EXH_TAG_AT[x.tag] ?? [x.at[0] + v.p(0.12), x.at[1] - v.p(0.2)]; + label(at, [x.tag]); + } +} +const EXH_TAG_AT: Record = { + "EF-1": [30, 505], + "KH-1": [158, 380], + "DE-1": [16, 393], + "EF-2": [78, 300], + "EF-3": [552, 84], + "EF-4": [574, 150], +}; + +// --- Paper space. ------------------------------------------------------------------------------ + +function legend(d: D, at: Point, w: number): number { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "MECHANICAL LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.22; + }); + const rows: [string, string, (a: Point, b: Point) => void][] = [ + [ + "CEILING SUPPLY DIFFUSER, 4-WAY", + ML.sup, + (a, b) => supplySym(d, [(a[0] + b[0]) / 2, a[1]], 0.16), + ], + [ + 'TAG: NECK SIZE / CFM, E.G. 8"Ø/150', + ML.anno, + (a, b) => + put(d, [(a[0] + b[0]) / 2, a[1]], '8"Ø/150', H.small, { + align: "center", + valign: "middle", + }), + ], + [ + "RETURN GRILLE / JUMP DUCT GRILLE (J)", + ML.sup, + (a, b) => returnSym(d, [(a[0] + b[0]) / 2, a[1]], 0.16), + ], + ["SUPPLY TRUNK, RECT., SIZE W X D", ML.sup, (a, b) => doubleLine(d, a, b, 0.12)], + ["FLEX RUN-OUT, R-6 (R-8 IN ATTIC)", ML.sup, (a, b) => d.line(a, b)], + ["RETURN DUCT", ML.ret, (a, b) => doubleLine(d, a, b, 0.12)], + [ + "EXHAUST FAN / DUCT", + ML.exh, + (a, b) => { + d.insert("E-EXHAUST", [a[0] + 0.1, a[1]]); + d.line([a[0] + 0.2, a[1]], b); + }, + ], + [ + "WALL CAP / ROOF CAP, BACKDRAFT DAMPER", + ML.exh, + (a, b) => { + capSym(d, [a[0] + 0.12, a[1]], 0.12, "WALL"); + capSym(d, [b[0] - 0.12, a[1]], 0.12, "ROOF"); + }, + ], + ["THERMOSTAT", ML.eqpm, (a, b) => tstatSym(d, [(a[0] + b[0]) / 2, a[1]], 0.06)], + ["REFRIGERANT LINE SET", ML.refg, (a, b) => d.line(a, b)], + ["CONDENSATE / PAN DRAIN", ML.cond, (a, b) => d.line(a, b)], + [ + "ATTIC PASSAGEWAY / SERVICE SPACE", + ML.attic, + (a, b) => d.rect([a[0], a[1] - 0.06], b[0] - a[0], 0.12), + ], + ]; + for (const [text, layer, draw] of rows) { + d.layer(layer, {}, () => draw([at[0] + 0.05, y], [at[0] + 0.6, y])); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.75, y], text, H.note, { valign: "middle" })); + y -= 0.28; + } + return y; +} + +function equipmentSchedule(d: D, at: Point): number { + const row = ( + tag: string, + type: string, + where: string, + cfm: string, + cool: string, + heat: string, + eff: string, + key: string, + mca: number, + remarks: string, + ) => { + const c = circuit(key); + return [ + tag, + type, + where, + "3.0", + cfm, + cool, + heat, + eff, + `240V/1PH, MCA ${mca}, MOCP ${c.amps}`, + c.label, + remarks, + ]; + }; + const dfe = `${CRITERIA.flood.dfe.toFixed(2)}'`; + const rows = [ + row( + "AHU-1", + "AIR HANDLER, VAR. SPEED, UPFLOW", + `GARAGE, ON STAND ABOVE ${dfe}`, + "1,200", + "--", + "5 KW STRIP", + "--", + "AHU1", + 30, + "26 GA. STEEL IN GARAGE; FILTER AT RA-1", + ), + row( + "HP-1", + "SPLIT HEAT PUMP, OUTDOOR", + `EAST SIDE, PAD AT ${dfe} MIN.`, + "--", + "34,000", + "33,000 @ 47F", + "15.2 / 7.8", + "HP1", + 18, + "AHRI MATCHED W/ AHU-1; LOCKING CAPS", + ), + row( + "AHU-2", + "AIR HANDLER, VAR. SPEED, HORIZ.", + "ATTIC, OVER L2 LANDING", + "1,200", + "--", + "5 KW STRIP", + "--", + "AHU2", + 30, + "AUX. PAN + FLOAT SWITCH; FILTER AT RA-2", + ), + row( + "HP-2", + "SPLIT HEAT PUMP, OUTDOOR", + `EAST SIDE, PAD AT ${dfe} MIN.`, + "--", + "34,000", + "33,000 @ 47F", + "15.2 / 7.8", + "HP2", + 18, + "AHRI MATCHED W/ AHU-2; LOCKING CAPS", + ), + ]; + return table( + d, + at, + "MECHANICAL EQUIPMENT SCHEDULE", + [ + { name: "TAG", width: 0.55 }, + { name: "TYPE", width: 2.85 }, + { name: "LOCATION", width: 2.75 }, + { name: "TONS", width: 0.5, align: "center" }, + { name: "CFM", width: 0.55, align: "right" }, + { name: "COOL BTUH", width: 0.95, align: "right" }, + { name: "HEAT", width: 1.25, align: "center" }, + { name: "SEER2/HSPF2", width: 1.15, align: "center" }, + { name: "ELECTRICAL", width: 2.4 }, + { name: "CKT", width: 0.75, align: "center" }, + { name: "REMARKS", width: 3.5 }, + ], + rows, + { height: H.note, rowHeight: 0.22 }, + ); +} + +function fanSchedule(d: D, at: Point): number { + const rows = EXHAUSTS.map((x) => [ + x.tag, + x.serves, + x.cfm ? String(x.cfm) : "--", + x.duct, + x.capNote, + circuit(x.ckt).label, + ]); + const y = table( + d, + at, + "EXHAUST SCHEDULE", + [ + { name: "TAG", width: 0.55 }, + { name: "SERVES", width: 1.85 }, + { name: "CFM", width: 0.5, align: "right" }, + { name: "DUCT", width: 1.45 }, + { name: "TERMINATION, BACKDRAFT DAMPER", width: 2.65 }, + { name: "CKT", width: 0.7, align: "center" }, + ], + rows, + { height: H.note, rowHeight: 0.22 }, + ); + const dl = dryerLength(); + let yy = y - 0.2; + d.layer(LAYERS.text, {}, () => { + for (const line of [ + `DE-1: ${dl.run.toFixed(1)} FT + ${dl.elbows} ELBOW X 5 FT = ${dl.total.toFixed(1)} FT DEVELOPED, 35 FT MAX. (M1502.4.5.1).`, + "CFM AT 0.25 IN. W.C.; BATH 50 CFM MIN., KITCHEN 100 MIN. (M1505).", + ]) { + put(d, [at[0], yy], line, H.note); + yy -= 0.19; + } + }); + return yy; +} + +function airSchedule(d: D, at: Point): number { + const rows: string[][] = []; + for (const s of SYSTEMS) { + const rs = roomAir(s); + for (const r of rs) + rows.push([r.room, s.unit, String(r.outlets), `${r.neck}"Ø`, String(r.cfm)]); + rows.push([ + `TOTAL ${s.unit} SUPPLY`, + "", + String(rs.reduce((t, r) => t + r.outlets, 0)), + "", + rs.reduce((t, r) => t + r.cfm, 0).toLocaleString("en-US"), + ]); + for (const g of s.returns) + rows.push([ + `${g.tag} RETURN, ${g.room}`, + s.unit, + "1", + `${g.diameter}"Ø`, + s.cfm.toLocaleString("en-US"), + ]); + } + return table( + d, + at, + "SUPPLY AND RETURN AIR BY ROOM", + [ + { name: "ROOM", width: 2.4 }, + { name: "UNIT", width: 0.7, align: "center" }, + { name: "OUTLETS", width: 0.75, align: "center" }, + { name: "NECK", width: 0.6, align: "center" }, + { name: "CFM", width: 0.65, align: "right" }, + ], + rows, + { height: H.note, rowHeight: 0.19 }, + ); +} + +const NOTES = (): string[] => { + const len = passageLength(); + return [ + "CODES: 2023 FBC-R CHAPTERS 12 THROUGH 24 (MECHANICAL) GOVERN THIS ONE-FAMILY DWELLING, WITH THE 2023 FBC, ENERGY CONSERVATION (RESIDENTIAL). SECTIONS CITED ARE FBC-R UNLESS NOTED FBC-EC.", + "SIZING: LOADS BY ACCA MANUAL J, EQUIPMENT BY MANUAL S (M1401.3, FBC-EC R403.7), DUCTS BY MANUAL D (M1601.1). THE REPORTS ARE SUBMITTED WITH THE PERMIT AND GOVERN; ROOM AIRFLOWS SHOWN ARE DESIGN VALUES TO BE BALANCED TO THEM.", + "DUCTS: SHEET METAL TRUNKS AND LISTED FLEX (M1601.1.1), SUPPORTED PER M1601.4.4, SEALED WITH MASTIC OR LISTED TAPE (M1601.4.1, FBC-EC R403.3.2). INSULATED R-8 IN THE ATTIC, R-6 ELSEWHERE OUTSIDE THE THERMAL ENVELOPE (FBC-EC R403.3.1). TESTED FOR TOTAL LEAKAGE PER FBC-EC R403.3.3.", + 'FIRST FLOOR DUCTS RUN IN THE FLOOR TRUSS SPACE. TRUSS MANUFACTURER TO PROVIDE A 24" X 12" CHASE ALONG EACH TRUNK; WHERE A TRUNK MEETS A BEARING LINE OR BEAM, DROP IT INTO A FRAMED SOFFIT, LOCATED ON THIS SHEET AND FRAMED BY THE TRUSS MANUFACTURER\'S CHASE.', + `GARAGE (R302.5.2): DUCTS IN THE GARAGE AND DUCTS THROUGH ITS WALLS OR CEILING ARE 26 GA. SHEET STEEL MIN. WITH NO OPENINGS INTO THE GARAGE. NO SUPPLY OR RETURN OUTLET IN THE GARAGE. AHU-1'S FILTER IS AT RA-1, AND ITS CABINET AND RETURN ARE SEALED. AHU-1 STAND BASE ABOVE THE D.F.E. ${CRITERIA.flood.dfe.toFixed(2)}' NAVD (R322.1.6); 4" STEEL PIPE BOLLARD IN FRONT OF AHU-1 AND WH-1 (M1307.3.1).`, + `ATTIC UNIT (M1305.1.3): AHU-2 IS REACHED THROUGH THE 30" X 54" PULL-DOWN STAIR (A-102), LARGE ENOUGH TO REMOVE IT, BY A PASSAGEWAY ${ftIn(len)} LONG (20 FT MAX.), 30" HIGH MIN., WITH SOLID FLOORING 24" WIDE, TO A LEVEL 30" X 30" SERVICE SPACE AT ITS SERVICE SIDE. LIGHT SWITCHED AT THE ACCESS AND A RECEPTACLE AT THE UNIT ON E-101 (M1305.1.3.1). PLATFORM AND UNIT LOAD BY THE TRUSS ENGINEER.`, + 'CONDENSATE (M1411.3): 3/4" PVC SCH. 40, TRAPPED, 1/8" PER FT. MIN. AHU-1\'S DRAIN RUNS BESIDE THE WH-1 PAN DRAIN THROUGH THE FRONT WALL, ENDING 6" TO 24" ABOVE GRADE ON SPLASH BLOCKS. AHU-2\'S RUNS WITH ITS LINE SET DOWN THE EAST WALL TO GRADE AT THE PAD. AHU-2 HAS AN AUXILIARY PAN WITH A WATER-LEVEL SWITCH THAT SHUTS THE UNIT OFF (M1411.3.1).', + "EXHAUST (M1501, M1504): EVERY EXHAUST DISCHARGES OUTDOORS, NEVER INTO THE ATTIC, SOFFIT OR A CRAWL SPACE, THROUGH A CAP WITH A BACKDRAFT DAMPER 3 FT MIN. FROM OPERABLE OPENINGS AND PROPERTY LINES AND 10 FT FROM MECHANICAL AIR INTAKES (R303.5).", + "RANGE HOOD KH-1 (M1503): SMOOTH, AIRTIGHT SINGLE-WALL GALVANIZED STEEL DUCT WITH A BACKDRAFT DAMPER, TO THE NORTH WALL. 300 CFM, UNDER 400 CFM, SO NO MAKEUP AIR IS REQUIRED.", + 'DRYER DE-1 (M1502): 4" RIGID METAL DUCT, 0.016" MIN., SMOOTH INSIDE, JOINTS LAPPED IN THE DIRECTION OF FLOW, NO SCREWS THROUGH IT. WALL CAP WITH A BACKDRAFT DAMPER AND NO SCREEN, 3 FT FROM OPENINGS (M1502.3). LENGTH LABEL WITHIN 6 FT OF THE CONNECTION (M1502.4.6).', + "REFRIGERANT: LINE SETS INSULATED AND PROTECTED FROM SUN AND DAMAGE OUTDOORS (M1411.6), LOCKING TAMPER-RESISTANT CAPS ON OUTDOOR ACCESS PORTS (M1411.8). HP-1 AND HP-2 ANCHORED TO THE PAD FOR THE WIND DESIGN ON G-001; PAD TOP AT OR ABOVE THE D.F.E. (R322.1.6).", + "VENTILATION: IF THE BLOWER-DOOR TEST (FBC-EC R402.4.1.2) IS UNDER 5 ACH50, ADD WHOLE-HOUSE MECHANICAL VENTILATION PER R303.4 AND M1505.4 BEFORE FINAL.", + 'CONTROLS: PROGRAMMABLE THERMOSTATS T-1 AND T-2 (FBC-EC R403.1.1) THAT KEEP STRIP HEAT OFF WHILE THE HEAT PUMP CAN MEET THE LOAD (R403.1.2). DOOR UNDERCUTS 3/4" TO BATHS AND CLOSETS; JUMP DUCTS (J) FROM THE BEDROOMS AND THE STUDY.', + ]; +}; + +function m101(d: D): void { + defineMechLayers(d); + const v = view(d, 64); + mechPlan(d, v, "L1"); + mechPlan(d, v, "L2"); + frame(d, "M-101", (dd, sheet) => { + const vpW = 12.0; + const vpH = 11.8; + const top = AREA.top - 0.1; + const vpY = top - vpH; + const c: Point = [300, 318]; + const vps: [Floor, number, string][] = [ + ["L1", AREA.left + 0.1, "FIRST FLOOR MECHANICAL PLAN"], + ["L2", AREA.left + 0.1 + vpW + 0.25, "SECOND FLOOR AND ATTIC MECHANICAL PLAN"], + ]; + vps.forEach(([floor, x, title], k) => { + viewport(dd, sheet, { + corner: [x, vpY], + size: [vpW, vpH], + center: [M_ORIGIN[floor][0] + c[0], M_ORIGIN[floor][1] + c[1]], + v, + id: `vp-M-101-${floor}`, + number: String(k + 1), + sheetNo: "M-101", + title, + titleAt: [x + 0.1, vpY - 0.45], + }); + dd.layer(LAYERS.text, {}, () => + dd.insert("NORTH ARROW", [x + vpW - 0.6, vpY - 0.5], { scale: 0.8 }), + ); + }); + const belowTop = vpY - 1.0; + const lx = AREA.left + 0.1; + legend(dd, [lx, belowTop], 4.0); + const tx = lx + 4.4; + const eq = equipmentSchedule(dd, [tx, belowTop]); + const fy = fanSchedule(dd, [tx, eq - 0.35]); + void fy; + airSchedule(dd, [tx + 8.1, eq - 0.35]); + const nx = AREA.left + 0.1 + 2 * vpW + 0.25 + 0.35; + noteColumn(dd, [nx, top], AREA.right - nx, "MECHANICAL NOTES", NOTES()); + }); +} + +export const SHEETS_M = { "M-101": m101 }; diff --git a/examples/plan-set/sheets/mep-p101.ts b/examples/plan-set/sheets/mep-p101.ts new file mode 100644 index 0000000..bae55d0 --- /dev/null +++ b/examples/plan-set/sheets/mep-p101.ts @@ -0,0 +1,1157 @@ +// P-101: the plumbing riser diagrams, drawn on the layout, not to scale. The water diagram is +// an isometric of the pipes at the fixtures' real places in the plans; the DWV diagram is an +// elevation of the stacks along the house, west to east. The fixture schedule adds up the +// drainage and water supply fixture units of the same fixtures. +import type { Point } from "../../../src/index.ts"; +import { HEIGHTS } from "../model/house.ts"; +import { HOUSE_AT, LOT } from "./site.ts"; +import { frame } from "../lib/frame.ts"; +import { arrowhead, earth, hatch, stipple } from "../../lib/drafting.ts"; +import { CRITERIA } from "../project.ts"; +import { + AREA, + dimString, + H, + LAYERS, + leaderNote, + noteColumn, + put, + table, + view, + viewport, + viewTitle, + width, + type D, + type View, +} from "../lib/kit.ts"; +import { circuit } from "./mep-power.ts"; + +/** The water heater detail's origin in model space. */ +const WH_ORIGIN: Point = [390000, 0]; + +/** Layers this sheet adds, for pipes the kit has none for. */ +const HOT = "P-DOMW-HOTW"; +const VENT = "P-SANR-VENT"; + +interface Fixture { + readonly tag: string; + readonly name: string; + readonly room: string; + readonly floor: "L1" | "L2"; + /** Where its supply and waste connect, in building inches, as `lib/plan.ts` draws it. */ + readonly at: Point; + /** Height of the supply stop above its floor. */ + readonly z: number; + readonly hot: boolean; + readonly trap: string; + /** Drainage fixture units, FBC-R Table P3004.1. */ + readonly dfu: number; + /** Water supply fixture units, FBC-R Table P2903.6: total, cold, hot. */ + readonly wsfu: readonly [number, number, number]; + /** The stack its waste joins: 1 west, 2 east, or 0 for the building drain under the slab. */ + readonly stack: 0 | 1 | 2; + /** The size of the supply branch. */ + readonly supply: string; +} + +/** The fixtures, at the places `lib/plan.ts` draws them. */ +export const FIXTURES: readonly Fixture[] = [ + { + tag: "KS", + name: "KITCHEN SINK W/ DISPOSAL", + room: "KITCHEN", + floor: "L1", + at: [252, 508], + z: 20, + hot: true, + trap: '1 1/2"', + dfu: 2, + wsfu: [1.4, 1.0, 1.0], + stack: 0, + supply: '1/2"', + }, + { + tag: "DW", + name: "DISHWASHER", + room: "KITCHEN", + floor: "L1", + at: [290, 505], + z: 6, + hot: true, + trap: "VIA KS", + dfu: 2, + wsfu: [1.4, 0, 1.4], + stack: 0, + supply: '1/2"', + }, + { + tag: "CW", + name: "CLOTHES WASHER BOX", + room: "LAUNDRY", + floor: "L1", + at: [85, 404], + z: 42, + hot: true, + trap: '2"', + dfu: 2, + wsfu: [1.4, 1.0, 1.0], + stack: 1, + supply: '1/2"', + }, + { + tag: "WC-1", + name: "WATER CLOSET, 1.28 GPF TANK", + room: "PWDR.", + floor: "L1", + at: [16, 490], + z: 8, + hot: false, + trap: "INTEGRAL", + dfu: 4, + wsfu: [2.2, 2.2, 0], + stack: 1, + supply: '1/2"', + }, + { + tag: "LAV-1", + name: "LAVATORY", + room: "PWDR.", + floor: "L1", + at: [40, 512], + z: 20, + hot: true, + trap: '1 1/4"', + dfu: 1, + wsfu: [0.7, 0.5, 0.5], + stack: 1, + supply: '1/2"', + }, + { + tag: "WC-2", + name: "WATER CLOSET, 1.28 GPF TANK", + room: "BATH 2", + floor: "L2", + at: [84, 240], + z: 8, + hot: false, + trap: "INTEGRAL", + dfu: 4, + wsfu: [2.2, 2.2, 0], + stack: 1, + supply: '1/2"', + }, + { + tag: "LAV-2", + name: "LAVATORY", + room: "BATH 2", + floor: "L2", + at: [141, 222], + z: 20, + hot: true, + trap: '1 1/4"', + dfu: 1, + wsfu: [0.7, 0.5, 0.5], + stack: 1, + supply: '1/2"', + }, + { + tag: "T-2", + name: "BATHTUB W/ SHOWER", + room: "BATH 2", + floor: "L2", + at: [168, 298], + z: 30, + hot: true, + trap: '1 1/2"', + dfu: 2, + wsfu: [1.4, 1.0, 1.0], + stack: 1, + supply: '1/2"', + }, + { + tag: "LAV-3", + name: "LAVATORY", + room: "OWNER'S BATH", + floor: "L2", + at: [477, 158], + z: 20, + hot: true, + trap: '1 1/4"', + dfu: 1, + wsfu: [0.7, 0.5, 0.5], + stack: 2, + supply: '1/2"', + }, + { + tag: "LAV-4", + name: "LAVATORY", + room: "OWNER'S BATH", + floor: "L2", + at: [525, 158], + z: 20, + hot: true, + trap: '1 1/4"', + dfu: 1, + wsfu: [0.7, 0.5, 0.5], + stack: 2, + supply: '1/2"', + }, + { + tag: "T-3", + name: "BATHTUB, DROP-IN", + room: "OWNER'S BATH", + floor: "L2", + at: [404, 22], + z: 20, + hot: true, + trap: '1 1/2"', + dfu: 2, + wsfu: [1.4, 1.0, 1.0], + stack: 2, + supply: '1/2"', + }, + { + tag: "SH-3", + name: "SHOWER, 2.0 GPM HEAD", + room: "OWNER'S BATH", + floor: "L2", + at: [580, 12], + z: 48, + hot: true, + trap: '2"', + dfu: 2, + wsfu: [1.4, 1.0, 1.0], + stack: 2, + supply: '1/2"', + }, + { + tag: "WC-3", + name: "WATER CLOSET, 1.28 GPF TANK", + room: "OWNER'S BATH", + floor: "L2", + at: [610, 120], + z: 8, + hot: false, + trap: "INTEGRAL", + dfu: 4, + wsfu: [2.2, 2.2, 0], + stack: 2, + supply: '1/2"', + }, + { + tag: "HB-1", + name: "HOSE BIBB, VACUUM BREAKER", + room: "GARAGE FRONT", + floor: "L1", + at: [212, -6], + z: -18, + hot: false, + trap: "--", + dfu: 0, + wsfu: [2.5, 2.5, 0], + stack: 0, + supply: '3/4"', + }, + { + tag: "HB-2", + name: "HOSE BIBB, VACUUM BREAKER", + room: "LANAI", + floor: "L1", + at: [626, 470], + z: 18, + hot: false, + trap: "--", + dfu: 0, + wsfu: [2.5, 2.5, 0], + stack: 0, + supply: '3/4"', + }, +]; + +/** Demand in gpm for a WSFU load (IPC Table E103.3(3)), for reference; the sizing uses Table P2903.7. */ +const DEMAND: readonly [number, number][] = [ + [1, 3.0], + [2, 5.0], + [3, 6.5], + [4, 8.0], + [5, 9.4], + [6, 10.7], + [7, 11.8], + [8, 12.8], + [9, 13.7], + [10, 14.6], + [11, 15.4], + [12, 16.0], + [13, 16.5], + [14, 17.0], + [15, 17.5], + [16, 18.0], + [17, 18.4], + [18, 18.8], + [19, 19.2], + [20, 19.6], + [25, 21.5], + [30, 23.3], + [35, 24.9], + [40, 26.3], + [45, 27.7], + [50, 29.1], +]; +export function demandGpm(wsfu: number): number { + for (let k = 1; k < DEMAND.length; k++) { + const [f0, g0] = DEMAND[k - 1]!; + const [f1, g1] = DEMAND[k]!; + if (wsfu <= f1) return g0 + ((Math.max(wsfu, f0) - f0) / (f1 - f0)) * (g1 - g0); + } + throw new Error(`WSFU ${wsfu} is past the table`); +} + +export const totals = () => { + const sum = (f: (x: Fixture) => number) => + Math.round(FIXTURES.reduce((s, x) => s + f(x), 0) * 10) / 10; + return { + dfu: sum((x) => x.dfu), + wsfu: sum((x) => x.wsfu[0]), + cold: sum((x) => x.wsfu[1]), + hot: sum((x) => x.wsfu[2]), + }; +}; + +// --------------------------------------------------------------------------------------------- +// The sizing basis: FBC-R P2903.7 and Table P2903.7, from the pressure and the developed length. + +type P3 = readonly [number, number, number]; +const L2Z = HEIGHTS.l2; +/** The trunks run in the floor truss space, over the first floor's ceiling. */ +const TZ = HEIGHTS.l1Plate + 8; +const TY = 300; // along the line of the laundry and kitchen's south wall +/** The water meter, where C-101 puts it at the street, in building inches. */ +const METER: Point = [LOT.width - 60 - HOUSE_AT[0], -18 - HOUSE_AT[1]]; +/** The service riser on the garage's front pier, west of the flood vent and the drains. */ +const RISER_X = 224; +const WH: P3 = [ + 240, + 262, + -HEIGHTS.garageDrop + Math.round((CRITERIA.flood.dfe - CRITERIA.flood.garage) * 12), +]; +/** Pipe paths, shared by the isometric and the developed length. */ +const PATH = { + service: [ + [METER[0], METER[1], -48], + [METER[0], -24, -48], + [RISER_X, -24, -48], + [RISER_X, -6, -48], + [RISER_X, -6, -6], + [RISER_X, 8, -6], + ] as P3[], + main: [ + [RISER_X, 8, -6], + [RISER_X, 8, 96], + [RISER_X, 256, 96], + [RISER_X, 256, TZ], + [RISER_X, TY, TZ], + ] as P3[], + whIn: [ + [RISER_X, 256, 96], + [246, 256, 96], + [246, 256, WH[2] + 60], + ] as P3[], + whOut: [ + [234, 268, WH[2] + 60], + [234, 268, TZ - 6], + [234, TY, TZ - 6], + ] as P3[], +}; +/** The assumed minimum static pressure at the meter, until the utility gives it. */ +const STREET_PSI = 60; +const GRADE_AT_METER = 7.2; + +/** FBC-R Table P2903.7, 40 to 60 psi: [meter and service, distribution, WSFU at each length]. */ +const LENGTHS = [40, 60, 80, 100, 150, 200, 250, 300, 400, 500]; +const TABLE_40_60: readonly [string, string, readonly number[]][] = [ + ['3/4"', '1/2"', [3, 2.5, 2, 1.5, 1.5, 1, 1, 0.5, 0.5, 0.5]], + ['3/4"', '3/4"', [9.5, 9.5, 8.5, 7, 5.5, 4.5, 3.5, 3, 2.5, 2]], + ['3/4"', '1"', [32, 32, 32, 26, 18, 13.5, 10.5, 9, 7.5, 6]], + ['1"', '1"', [32, 32, 32, 32, 21, 15, 11.5, 9.5, 7.5, 6.5]], + ['3/4"', '1 1/4"', [32, 32, 32, 32, 32, 32, 32, 27, 21, 16.5]], + ['1"', '1 1/4"', [80, 80, 80, 80, 65, 52, 42, 35, 26, 20]], + ['1 1/2"', '1 1/4"', [80, 80, 80, 80, 75, 59, 48, 39, 28, 21]], +]; + +const pathLength = (pts: readonly P3[]) => + pts + .slice(1) + .reduce( + (t, p, k) => + t + Math.abs(p[0] - pts[k]![0]) + Math.abs(p[1] - pts[k]![1]) + Math.abs(p[2] - pts[k]![2]), + 0, + ); + +/** The sizing: pressure range, developed length, and each segment's size from the table. */ +export function waterSizing() { + const shower = FIXTURES.find((f) => f.tag === "SH-3")!; + const riser: Point = [501, 300]; + // The most remote outlet, SH-3, by way of the water heater: the hot path is the longer. + const remote: P3[] = [ + ...PATH.service, + ...PATH.main.slice(1, 3), + ...PATH.whIn.slice(1), + ...PATH.whOut, + [riser[0] + 8, TY, TZ - 6], + [riser[0] + 8, riser[1], L2Z + 6], + [shower.at[0] + 8, riser[1], L2Z + 6], + [shower.at[0] + 8, shower.at[1], L2Z + 6], + [shower.at[0] + 8, shower.at[1], L2Z + shower.z + 30], + ]; + const length = pathLength(remote) / 12; + const design = length * 1.2; + const col = LENGTHS.findIndex((l) => l >= design); + if (col < 0) throw new Error(`Developed length ${design.toFixed(0)} ft is past Table P2903.7`); + const headEl = CRITERIA.flood.ffe + (L2Z + shower.z + 30) / 12; + const lift = headEl - GRADE_AT_METER; + const psi = STREET_PSI - lift * 0.5; + if (psi < 40 || psi > 60) + throw new Error(`Available pressure ${psi.toFixed(1)} psi is outside the 40 to 60 psi table`); + const t = totals(); + const size = (wsfu: number) => { + const row = TABLE_40_60.find((x) => x[2][col]! >= wsfu); + if (!row) + throw new Error(`No pipe in Table P2903.7 carries ${wsfu} WSFU at ${LENGTHS[col]} ft`); + return row[1]; + }; + const meterRow = TABLE_40_60.find((x) => x[0] === '1"' && x[2][col]! >= t.wsfu); + if (!meterRow) throw new Error("A 1 in. meter does not serve the house"); + const sum = (fs: Fixture[], k: 1 | 2) => + Math.round(fs.reduce((a, f) => a + f.wsfu[k], 0) * 10) / 10; + const hb1 = FIXTURES.find((f) => f.tag === "HB-1")!; + const cwTrunk = Math.round((t.cold - hb1.wsfu[1]) * 10) / 10; + const bath = (room: string) => FIXTURES.filter((f) => f.room === room); + const risers = { + "BATH 2": { cw: sum(bath("BATH 2"), 1), hw: sum(bath("BATH 2"), 2) }, + "OWNER'S BATH": { cw: sum(bath("OWNER'S BATH"), 1), hw: sum(bath("OWNER'S BATH"), 2) }, + }; + return { + length, + design, + column: LENGTHS[col]!, + lift, + psi, + meter: meterRow[0], + main: meterRow[1], + cwTrunk: { wsfu: cwTrunk, size: size(cwTrunk) }, + hw: { wsfu: t.hot, size: size(t.hot) }, + risers: Object.fromEntries( + Object.entries(risers).map(([k, x]) => [ + k, + { cw: size(x.cw), hw: size(x.hw), cwWsfu: x.cw, hwWsfu: x.hw }, + ]), + ) as Record, + }; +} + +/** The building sewer: 4" from the house to the public main, inverts at 1/8" per ft. */ +export function sewer() { + const exit = 6.0; // invert leaving the house's west stem wall, under the laundry + const toCleanout = + (Math.abs(-30 - (120 - HOUSE_AT[0])) + Math.abs(400 - (-12 - HOUSE_AT[1]))) / 12; + const toMain = 25; // half the right-of-way + const slope = 1 / 8 / 12; // ft per ft + const cleanout = exit - toCleanout * slope; + const main = cleanout - toMain * slope; + return { exit, toCleanout, cleanout, main }; +} + +// --------------------------------------------------------------------------------------------- +// The water isometric. + +const C30 = Math.cos(Math.PI / 6); + +/** The isometric: east up and to the right, north up and to the left, height straight up. */ +function iso(origin: Point, s: number, sz: number) { + return ([x, y, z]: P3): Point => [ + origin[0] + (x - y) * C30 * s, + origin[1] + (x + y) * 0.5 * s + z * sz, + ]; +} + +function waterIso(d: D, origin: Point): void { + const P = iso(origin, 0.0116, 0.02); + const pipe = (pts: P3[], hot: boolean) => + d.layer(hot ? HOT : LAYERS.plmb, {}, () => d.polyline(pts.map(P))); + // A hot pipe runs beside its cold one: 8" east and 6" lower. An offset of equal x and y would + // vanish in the isometric, where x - y sets the paper's horizontal. + const off = (p: P3, hot: boolean): P3 => (hot ? [p[0] + 8, p[1], p[2] - 6] : p); + const lbl = (p: P3, text: string, dx = 0.08, dy = 0.05, align: "left" | "right" = "left") => + d.layer(LAYERS.pAnno, {}, () => { + const q = P(p); + put(d, [q[0] + dx, q[1] + dy], text, H.note, { align }); + }); + // The two floors' outlines, for reference. + d.layer(LAYERS.pAnno, {}, () => { + for (const [z, name] of [ + [0, "FIRST FLOOR"], + [L2Z, "SECOND FLOOR"], + ] as const) { + const pts = ( + [ + [0, 0], + [624, 0], + [624, 528], + [0, 528], + ] as const + ).map(([x, y]) => P([x, y, z])); + d.polyline(pts, true, { linetype: "PHANTOM", lineweight: 0.13 }); + put(d, [pts[1]![0] + 0.1, pts[1]![1]], name, H.note, { valign: "middle" }); + } + }); + const sz = waterSizing(); + // The water service, from the meter at the street (C-101), up outside the garage's front pier + // and in, with the main shutoff; HB-1 tees off the riser. + pipe(PATH.service.slice(2), false); + lbl(PATH.service[2]!, `${sz.meter} CW SERVICE FROM METER AT`, 0.1, -0.1); + lbl(PATH.service[2]!, "STREET (C-101), MAIN SHUTOFF", 0.1, -0.26); + const hb1 = FIXTURES.find((f) => f.tag === "HB-1")!; + pipe( + [ + [RISER_X, -6, hb1.z], + [hb1.at[0], hb1.at[1], hb1.z], + ], + false, + ); + lbl([hb1.at[0], hb1.at[1], hb1.z], `HB-1 ${hb1.supply}`, -0.07, -0.04, "right"); + // The main overhead in the garage to WH-1's tee, then up into the floor trusses. + pipe(PATH.main, false); + pipe(PATH.whIn, false); + waterHeater(d, P, WH); + pipe(PATH.whOut, true); + // The trunks, cold and hot, along TY. + const l1 = FIXTURES.filter((f) => f.tag !== "HB-1"); + const east = Math.max(...l1.map((f) => f.at[0])); + const west = Math.min(...l1.map((f) => f.at[0])); + pipe( + [ + [west, TY, TZ], + [east, TY, TZ], + ], + false, + ); + const eastHot = Math.max(...FIXTURES.filter((f) => f.hot).map((f) => f.at[0])); + const westHot = Math.min(...FIXTURES.filter((f) => f.hot).map((f) => f.at[0])); + pipe([off([westHot, TY, TZ], true), off([eastHot, TY, TZ], true)], true); + // The trunks' sizes lie on the pipes in the isometric's plane: turned 30 degrees along the + // east axis and leaned the other way, as iso lettering is. + isoText(d, P([300, TY, TZ]), `${sz.cwTrunk.size} CW TRUNK`, "east", 0.05); + isoText(d, P(off([360, TY, TZ], true)), `${sz.hw.size} HW TRUNK`, "east", -0.17); + // Each fixture: a branch off the trunk, along y to its place, up or down to its stop. The + // second floor's fixtures share a riser per bathroom. + const risers: Record = { "BATH 2": [141, 300], "OWNER'S BATH": [501, 300] }; + void east; + const riserTop = (_room: string) => L2Z + 12; + const drawn = new Set(); + for (const f of FIXTURES) { + if (f.tag === "HB-1") continue; + for (const hot of f.hot ? [false, true] : [false]) { + const [fx, fy] = f.at; + if (f.floor === "L1") { + const a = off([fx, TY, TZ], hot); + const b = off([fx, fy, TZ], hot); + const c = off([fx, fy, f.z], hot); + pipe([a, b, c], hot); + continue; + } + const r = risers[f.room]!; + const top = riserTop(f.room); + const key = `${f.room}/${hot}`; + if (!drawn.has(key)) { + drawn.add(key); + pipe([off([r[0], TY, TZ], hot), off([r[0], r[1], top], hot)], hot); + const rs = sz.risers[f.room]!; + lbl( + off([r[0], r[1], (TZ + top) / 2], hot), + hot ? `${rs.hw} HW` : `${rs.cw} CW`, + hot ? 0.08 : -0.08, + 0, + hot ? "left" : "right", + ); + } + pipe( + [ + off([r[0], r[1], top], hot), + off([fx, r[1], top], hot), + off([fx, fy, top], hot), + off([fx, fy, L2Z + f.z], hot), + ], + hot, + ); + } + // The fixture's label at its cold stop. + const z = (f.floor === "L2" ? L2Z : 0) + f.z; + d.layer(LAYERS.plmb, {}, () => { + const q = P([f.at[0], f.at[1], z]); + d.circle(q, 0.03); + }); + const [dx, dy, align] = LABEL_AT[f.tag] ?? [0.07, -0.04, "left"]; + lbl([f.at[0], f.at[1], z], `${f.tag} ${f.supply}`, dx, dy, align); + } +} + +/** Lettering in the isometric's horizontal plane, along the east or the north axis. */ +function isoText(d: D, at: Point, value: string, axis: "east" | "north", lift: number): void { + const rotation = axis === "east" ? 30 : -30; + const style = axis === "east" ? ISO_EAST : ISO_NORTH; + const n: Point = [-Math.sin((rotation * Math.PI) / 180), Math.cos((rotation * Math.PI) / 180)]; + d.layer(LAYERS.pAnno, {}, () => + d.text([at[0] + n[0] * lift, at[1] + n[1] * lift], value, { + height: H.note, + style, + rotation, + align: "center", + }), + ); +} +const ISO_EAST = "ISO-EAST"; +const ISO_NORTH = "ISO-NORTH"; + +/** Where a fixture's label sits from its stop on the isometric, where the default is crowded. */ +const LABEL_AT: Record = { + KS: [-0.07, 0.05, "right"], + DW: [0.07, -0.12, "left"], + "LAV-2": [0.07, 0.06, "left"], + "WC-2": [0.07, -0.14, "left"], + "HB-2": [0.07, -0.12, "left"], +}; + +/** The water heater: a cylinder drawn as two ellipses and two sides, its T&P line to the floor. */ +function waterHeater(d: D, P: (p: P3) => Point, base: P3): void { + const r = 11; + const ring = (z: number): Point[] => { + const pts: Point[] = []; + for (let k = 0; k < 24; k++) { + const a = (2 * Math.PI * k) / 24; + pts.push(P([base[0] + r * Math.cos(a), base[1] + r * Math.sin(a), z])); + } + return pts; + }; + d.layer(LAYERS.plmb, {}, () => { + d.polyline(ring(base[2]), true); + d.polyline(ring(base[2] + 60), true); + // The two sides, at the ring's leftmost and rightmost points on the paper. + const lo = ring(base[2]); + const hi = ring(base[2] + 60); + const left = lo.reduce((m, p, k) => (p[0] < lo[m]![0] ? k : m), 0); + const right = lo.reduce((m, p, k) => (p[0] > lo[m]![0] ? k : m), 0); + d.line(lo[left]!, hi[left]!); + d.line(lo[right]!, hi[right]!); + // The platform under it, from the garage slab up to the D.F.E. + const slab = -HEIGHTS.garageDrop; + d.polyline([ + P([base[0] - 16, base[1] - 16, slab]), + P([base[0] + 16, base[1] - 16, slab]), + P([base[0] + 16, base[1] - 16, base[2]]), + P([base[0] - 16, base[1] - 16, base[2]]), + ]); + }); + // The T&P relief: off the side, down to 6" above the floor. + d.layer(LAYERS.plmb, {}, () => { + d.polyline([ + P([base[0] + 11, base[1], base[2] + 50]), + P([base[0] + 26, base[1], base[2] + 50]), + P([base[0] + 26, base[1], -24]), + ]); + }); + // The expansion tank on the cold inlet. + d.layer(LAYERS.plmb, {}, () => d.circle(P([236, 256, 88]), 0.07)); + const q = P(base); + d.layer(LAYERS.pAnno, {}, () => { + const lines = [ + "50 GAL. ELECTRIC WATER HEATER (WH-1)", + "ON PLATFORM ABOVE D.F.E., GARAGE NE", + 'CORNER. 3/4" T&P DISCHARGE TO 6" ABOVE', + "FLOOR (FBC-R P2804.6.1); PAN DRAIN TO", + "OUTSIDE (P2801.6.1, M-101). EXPANSION", + "TANK ON CW INLET (P2903.4)", + ]; + const lx = q[0] - 1.0; + const ly = q[1] - 0.55; + lines.forEach((t, k) => put(d, [lx, ly - k * 0.15], t, H.note, { align: "right" })); + d.line([lx + 0.05, ly + 0.03], [q[0] - 0.3, ly + 0.03]); + d.line([q[0] - 0.3, ly + 0.03], [q[0] - 0.15, q[1] - 0.1]); + arrowhead(d, [q[0] - 0.15, q[1] - 0.1], [q[0] - 0.3, ly + 0.03], 0.08); + }); +} + +// --------------------------------------------------------------------------------------------- +// The DWV riser: an elevation along x, west to east. + +function dwvRiser(d: D, at: Point, w: number): void { + const X = (x: number) => at[0] + 0.8 + ((x + 40) / 680) * (w - 1.4); + const Z = { + drain: at[1] + 0.6, + f1: at[1] + 1.4, + f2: at[1] + 4.6, + ceil2: at[1] + 7.3, + roof: at[1] + 8.4, + }; + const lbl = (p: Point, t: string, align: "left" | "right" | "center" = "left", h = H.small) => + d.layer(LAYERS.pAnno, {}, () => put(d, p, t, h, { align, valign: "middle" })); + const san = (pts: Point[]) => d.layer(LAYERS.sanr, {}, () => d.polyline(pts)); + const vent = (pts: Point[]) => d.layer(VENT, {}, () => d.polyline(pts)); + const co = (p: Point, label = "CO") => { + d.layer(LAYERS.sanr, {}, () => d.circle(p, 0.06)); + lbl([p[0], p[1] - 0.15], label, "center"); + }; + const trap = (x: number, y: number) => + d.layer(LAYERS.sanr, {}, () => d.arc([x, y], 0.06, 180, 360)); + // Floors and roof for reference. + d.layer(LAYERS.pAnno, {}, () => { + for (const [y, name] of [ + [Z.f1, "FIRST FLOOR F.F.E. 11.00' NAVD"], + [Z.f2, "SECOND FLOOR"], + [Z.roof, "ROOF"], + ] as const) { + d.line([at[0], y], [at[0] + w, y], { linetype: "PHANTOM", lineweight: 0.13 }); + put(d, [at[0] + w, y + 0.05], name, H.small, { align: "right" }); + } + }); + const s1 = 100; + const s2 = 540; + // The building drain under the slab, sloping to the sewer at the west, and the sewer. + san([ + [X(s2 + 60), Z.drain + 0.12], + [X(-30), Z.drain], + ]); + d.layer(LAYERS.sanr, {}, () => { + const a: Point = [X(-30), Z.drain]; + const b: Point = [X(-30) - 0.3, Z.drain - 0.02]; + d.line(a, b); + arrowhead(d, b, a, 0.09); + }); + const sw = sewer(); + lbl( + [X(-30) - 0.3, Z.drain - 0.2], + `4" PVC BUILDING SEWER, INV. ${sw.exit.toFixed(2)}' AT HOUSE, ${sw.cleanout.toFixed(2)}' AT P.L. C.O.,`, + "left", + H.note, + ); + lbl( + [X(-30) - 0.3, Z.drain - 0.36], + `${sw.main.toFixed(2)}' AT PUBLIC MAIN (C-101; VERIFY WITH UTILITY)`, + "left", + H.note, + ); + lbl( + [X(400), Z.drain - 0.2], + '4" PVC SCH. 40 BUILDING DRAIN AT 1/8" PER FT. MIN., UNDER SLAB', + "center", + H.note, + ); + co([X(-14), Z.f1 - 0.15], "2-WAY CO"); + san([ + [X(-14), Z.drain], + [X(-14), Z.f1 - 0.21], + ]); + co([X(s2 + 60), Z.f1 - 0.15]); + san([ + [X(s2 + 60), Z.drain + 0.12], + [X(s2 + 60), Z.f1 - 0.21], + ]); + // Stacks, up through the roof. + for (const [x, name] of [ + [s1, "STACK 1"], + [s2, "STACK 2"], + ] as const) { + const xb = X(x); + san([ + [xb, Z.drain + (x / 640) * 0.12], + [xb, Z.ceil2], + ]); + vent([ + [xb, Z.ceil2], + [xb, Z.roof + 0.4], + ]); + lbl([xb + 0.08, Z.roof + 0.32], '3" VTR, 6" MIN. ABOVE ROOF', "left", H.note); + lbl([xb + 0.08, (Z.f1 + Z.f2) / 2], `3" ${name}`, "left", H.note); + co([xb + 0.25, Z.f1 + 0.25]); + san([ + [xb, Z.f1 + 0.1], + [xb + 0.25, Z.f1 + 0.19], + ]); + } + // Each fixture: a trap, a drain to its stack or under the slab to the drain, and its vent. + for (const f of FIXTURES) { + if (f.dfu === 0 || f.tag === "DW") continue; + const x = X(f.at[0]); + const floor = f.floor === "L1" ? Z.f1 : Z.f2; + const top = floor + 0.55; + trap(x, top - 0.08); + lbl([x - 0.05, top + 0.13], f.tag, "right", H.note); + const stackX = f.stack === 1 ? X(s1) : f.stack === 2 ? X(s2) : x; + const arm = floor - 0.25; + if (f.floor === "L1") { + // Down through the slab to the building drain, or to the stack's base. + const into = f.stack === 0 ? Z.drain + (f.at[0] / 640) * 0.12 : arm; + san([ + [x, top - 0.14], + [x, arm], + ...(f.stack === 0 ? [[x, into] as Point] : [[stackX, arm] as Point]), + ]); + } else { + san([ + [x, top - 0.14], + [x, arm], + [stackX, arm - 0.04], + ]); + } + lbl([x + 0.06, arm + 0.12], f.trap === "INTEGRAL" ? '3"' : f.trap, "left"); + // The fixture's vent: off its trap arm, up to a vent header. The first floor's vents join + // a header under the second floor that rises in the west wall (WC-1's vent) to the attic, + // so no vent joins a stack below the second floor's fixtures. + const riserVent = f.tag === "WC-1" || f.tag === "KS"; + const ventTop = f.floor === "L1" && !riserVent ? Z.f2 - 0.35 : Z.ceil2 + 0.35; + san([ + [x, top - 0.14], + [x + 0.12, top - 0.14], + ]); + vent([ + [x + 0.12, top - 0.14], + [x + 0.12, ventTop], + ]); + } + // Vent headers, each sloping back to its stack's vent above the highest fixture. + vent([ + [X(16) + 0.12, Z.f2 - 0.35], + [X(85) + 0.12, Z.f2 - 0.35], + ]); + vent([ + [X(16) + 0.12, Z.ceil2 + 0.35], + [X(252) + 0.12, Z.ceil2 + 0.35], + ]); + vent([ + [X(404) + 0.12, Z.ceil2 + 0.35], + [X(610) + 0.12, Z.ceil2 + 0.35], + ]); + lbl([X(252) + 0.18, (Z.f2 + Z.ceil2) / 2], '2" VENT', "left", H.note); + lbl( + [X(320), Z.ceil2 + 0.55], + '2" VENT HEADER IN ATTIC, 1/4" PER FT. BACK TO STACK', + "center", + H.note, + ); + lbl([X(400), Z.f2 - 0.6], '3" PVC AT 1/4" PER FT. TO STACK', "center", H.note); +} + +// --------------------------------------------------------------------------------------------- +// The sheet. + +const NOTES = [ + "ALL WORK PER THE 2023 FBC-R, CHAPTERS 25 THROUGH 33 (PLUMBING), WHICH GOVERN THIS ONE-FAMILY DWELLING, AND THE LOCAL UTILITY'S RULES. SECTIONS CITED ARE FBC-R. THE DIAGRAMS ARE NOT TO SCALE; SEE THE PLANS FOR FIXTURES AND M-101 FOR DRAIN ROUTES.", + 'WATER DISTRIBUTION: PEX-A (ASTM F876/F877) OR CPVC (ASTM D2846), TABLE P2906.5. SIZED BY P2903.7 AND TABLE P2903.7 (SEE THE SIZING BASIS); 1/2" MIN. TO EACH FIXTURE. INSULATE HOT WATER PIPING PER FBC-EC R403.5.3.', + 'DRAINAGE AND VENT: PVC SCHEDULE 40 DWV (ASTM D2665), TABLES P3002.1(1) AND P3002.2. SLOPE 1/4" PER FT. FOR 2 1/2" AND SMALLER, 1/8" PER FT. FOR 3" AND LARGER (TABLE P3005.3).', + "DRAINAGE FIXTURE UNITS PER TABLE P3004.1, TRAPS PER TABLE P3201.7. STACKS PER TABLE P3005.4.1; BUILDING DRAIN AND SEWER PER TABLE P3005.4.2.", + "CLEANOUTS AT THE BASE OF EACH STACK, AT THE JUNCTION OF THE BUILDING DRAIN AND SEWER, AT THE PROPERTY LINE, AND AT EACH CHANGE OF DIRECTION OVER 45 DEGREES, WITH CLEARANCE AND ACCESS (P3005.2).", + 'VENTS: AT LEAST ONE VENT TO OUTDOORS FOR THE BUILDING DRAIN (P3102.1). TERMINALS 6" MIN. ABOVE THE ROOF AND 10 FT FROM, OR 3 FT ABOVE, ANY OPERABLE OPENING WITHIN 10 FT (P3103.1, P3103.5).', + 'WATER HEATER: LISTED, WITH A T&P RELIEF VALVE (P2804) AND A FULL-SIZE DISCHARGE PIPE ENDING 6" MAX. ABOVE THE FLOOR (P2804.6.1). PAN WITH A 3/4" DRAIN TO THE OUTSIDE, ENDING 6" TO 24" ABOVE GRADE (P2801.6.1; ROUTE ON M-101). THERMAL EXPANSION CONTROL (P2903.4). ON A PLATFORM ABOVE THE D.F.E. (R322.1.6).', + "HOSE BIBBS WITH LISTED VACUUM BREAKERS (P2902.4.3). SHOWER AND TUB-SHOWER VALVES PRESSURE-BALANCE OR THERMOSTATIC, LIMITED TO 120 F (P2708.4, P2713.3).", + "TEST: DWV WITH A 10 FT HEAD OF WATER OR 5 PSI AIR FOR 15 MINUTES; WATER PIPING AT THE WORKING PRESSURE WITHOUT LEAKS (P2503.5.1, P2503.7).", + "FLOOD ZONE AE: WATER AND SEWER CONNECTIONS RESIST FLOOD WATER INFILTRATION (R322.1.7). BACKWATER VALVE ON THE BUILDING SEWER IF THE NEXT UPSTREAM MANHOLE COVER IS ABOVE THE LOWEST FIXTURE'S FLOOD-LEVEL RIM (P3008.1).", + 'CONDENSATE (M-101): AHU-1 AND AHU-2 PRIMARY CONDENSATE IN 3/4" PVC, TRAPPED, 1/8" PER FT. MIN., TO THE OUTSIDE (M1411.3); AHU-2 IN THE ATTIC WITH AN AUXILIARY PAN AND A WATER-LEVEL SWITCH (M1411.3.1).', + "LOW-FLOW FIXTURES: WATER CLOSETS 1.28 GPF, LAVATORY FAUCETS 1.5 GPM, SHOWER HEADS 2.0 GPM AT 80 PSI (P2903.2).", +]; + +function fixtureSchedule(d: D, at: Point): number { + const t = totals(); + const num = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(1)); + const rows = FIXTURES.map((f) => [ + f.tag, + f.name, + f.room, + f.trap, + f.supply, + f.hot ? '1/2"' : "--", + num(f.dfu), + num(f.wsfu[1]), + num(f.wsfu[2]), + num(f.wsfu[0]), + ]); + rows.push(["", "TOTAL", "", "", "", "", num(t.dfu), num(t.cold), num(t.hot), num(t.wsfu)]); + return table( + d, + at, + "PLUMBING FIXTURE SCHEDULE", + [ + { name: "TAG", width: 0.6 }, + { name: "FIXTURE", width: 2.75 }, + { name: "LOCATION", width: 1.45 }, + { name: "TRAP", width: 0.9, align: "center" }, + { name: "CW", width: 0.5, align: "center" }, + { name: "HW", width: 0.5, align: "center" }, + { name: "DFU", width: 0.5, align: "right" }, + { name: "WSFU CW", width: 0.8, align: "right" }, + { name: "WSFU HW", width: 0.8, align: "right" }, + { name: "WSFU TOT.", width: 0.85, align: "right" }, + ], + rows, + { height: H.note, rowHeight: 0.21 }, + ); +} + +function sizing(d: D, at: Point, w: number): number { + const t = totals(); + const z = waterSizing(); + const sw = sewer(); + const r2 = z.risers["BATH 2"]!; + const r3 = z.risers["OWNER'S BATH"]!; + const rows = [ + ["STATIC PRESSURE AT METER (ASSUMED; VERIFY W/ UTILITY)", `${STREET_PSI} PSI`], + [ + `LIFT TO SH-3 HEAD, ${z.lift.toFixed(1)} FT X 0.5 PSI/FT`, + `-${(z.lift * 0.5).toFixed(1)} PSI`, + ], + ["AVAILABLE, P2903.7 STEP 2: 40 TO 60 PSI RANGE", `${z.psi.toFixed(1)} PSI`], + [ + `DEVELOPED LENGTH, METER TO SH-3 VIA WH-1, X 1.2`, + `${z.length.toFixed(0)} X 1.2 = ${z.design.toFixed(0)} FT`, + ], + ["TABLE P2903.7 COLUMN", `${z.column} FT`], + ["TOTAL WATER SUPPLY FIXTURE UNITS (WSFU)", t.wsfu.toFixed(1)], + ["METER AND SERVICE / MAIN TO WH-1 TEE", `${z.meter} / ${z.main}`], + [`CW TRUNK, ${z.cwTrunk.wsfu} WSFU`, z.cwTrunk.size], + [`HW: WH-1 INLET AND TRUNK, ${z.hw.wsfu} WSFU`, z.hw.size], + [`BATH 2 RISERS, CW ${r2.cwWsfu} / HW ${r2.hwWsfu} WSFU`, `${r2.cw} / ${r2.hw}`], + [`OWNER'S BATH RISERS, CW ${r3.cwWsfu} / HW ${r3.hwWsfu} WSFU`, `${r3.cw} / ${r3.hw}`], + ["TOTAL DRAINAGE FIXTURE UNITS (DFU)", String(t.dfu)], + ['BUILDING DRAIN, 4" AT 1/8" PER FT., TABLE P3005.4.2', "180 DFU MAX."], + ['STACKS, 3", TABLE P3005.4.1', "48 DFU MAX."], + [`BUILDING SEWER, 4" AT 1/8" PER FT., INV. AT P.L. C.O.`, `${sw.cleanout.toFixed(2)}' NAVD`], + ]; + if (t.dfu > 180) throw new Error(`The building drain carries ${t.dfu} DFU, more than 180`); + return table( + d, + at, + "PIPE SIZING BASIS (FBC-R P2903.7)", + [ + { name: "ITEM", width: w - 1.8 }, + { name: "VALUE", width: 1.8, align: "right" }, + ], + rows, + { height: H.note, rowHeight: 0.2 }, + ); +} + +/** + * The water heater's installation, in elevation, at its real size in model space: the garage + * slab, the platform up to the D.F.E., the pan, the heater, its piping and its relief line. + */ +function heaterDetail(d: D, v: View): void { + const o = (x: number, y: number): Point => [WH_ORIGIN[0] + x, WH_ORIGIN[1] + y]; + const rise = Math.round((CRITERIA.flood.dfe - CRITERIA.flood.garage) * 12); // 18" + const panTop = rise + 2.5; + const top = panTop + 60; + const vx = 38; // the cold shutoff + d.layer(LAYERS.sect, {}, () => { + // The garage slab and the CMU wall behind the heater. + d.polyline([o(-24, 0), o(64, 0)]); + d.polyline([o(-24, -4), o(64, -4)]); + d.polyline([o(-8, -4), o(-8, 124)]); + d.polyline([o(0, 0), o(0, 124)]); + }); + // Patterns, drawn as lines: tsdraft has no hatch entity. The slab is stippled, the earth under + // it woven, the CMU wall cross-hatched. + d.layer(LAYERS.patt, {}, () => { + const [ox, oy] = WH_ORIGIN; + stipple(d, [ox - 24, oy - 4, 88, 4], [], { + spacing: v.p(0.05), + stroke: v.p(0.04), + seed: 7, + margin: 0.4, + }); + earth(d, [ox - 24, oy - 16, 88, 12], [], v.p(0.12)); + hatch(d, [ox - 8, oy, 8, 124], v.p(0.06), 1); + hatch(d, [ox - 8, oy, 8, 124], v.p(0.06), -1); + }); + d.layer(LAYERS.sectFine, {}, () => { + // The platform: framed of treated lumber, its top at the D.F.E. + d.rect(o(2, 0), 34, rise); + d.line(o(2, 0), o(36, rise)); + d.line(o(2, rise), o(36, 0)); + }); + d.layer(LAYERS.plmb, {}, () => { + // The pan, the heater and its top. + d.polyline([o(4, rise + 2.5), o(4, rise), o(34, rise), o(34, rise + 2.5)]); + d.rect(o(7, panTop), 24, 60); + d.line(o(7, top - 4), o(31, top - 4)); + // Hot out at the left, cold in at the right, with its shutoff and the expansion tank. + d.polyline([o(12, top), o(12, 124)]); + d.polyline([o(26, top), o(26, 100), o(50, 100), o(50, 124)]); + d.polyline([o(vx - 2.5, 98), o(vx + 2.5, 102), o(vx + 2.5, 98), o(vx - 2.5, 102)], true); + d.line(o(44, 100), o(44, 94)); + d.rect(o(39, 76), 10, 18); + // The T&P valve on the side, and its discharge down to 6" above the slab. + d.polyline([o(31, top - 10), o(42, top - 10), o(42, 6)]); + // The pan's drain, on its way to the outside (M-101). + d.polyline([o(34, rise + 1.5), o(62, rise + 1.5)]); + }); + // The D.F.E. and the slab's elevation. + d.layer(LAYERS.pAnno, {}, () => { + d.line(o(-24, rise), o(64, rise), { linetype: "DASHED" }); + d.line(o(-60, rise), o(-24, rise), { linetype: "DASHED" }); + put(d, o(-60, rise + v.p(0.06)), `D.F.E. ${CRITERIA.flood.dfe.toFixed(2)}' NAVD`, v.p(H.note)); + put(d, o(-60, v.p(0.06)), `GARAGE SLAB ${CRITERIA.flood.garage.toFixed(2)}' NAVD`, v.p(H.note)); + d.line(o(-60, 0), o(-24, 0)); + }); + d.layer(LAYERS.dims, {}, () => { + dimString(d, v, [o(36, 0), o(36, rise)], "y", WH_ORIGIN[0] + 36 + v.p(0.45)); + }); + const wh = circuit("WH"); + d.layer(LAYERS.pAnno, {}, () => { + const hw = waterSizing().hw.size; + leaderNote(d, v, o(12, 116), o(70, 116), [`${hw} HW TO FIXTURES`]); + leaderNote(d, v, o(50, 118), o(70, 108), [`${hw} CW FROM MAIN`]); + leaderNote(d, v, o(vx, 102), o(70, 100), ["FULL-PORT BALL VALVE"]); + leaderNote(d, v, o(49, 86), o(70, 90), ["THERMAL EXPANSION TANK,", "2 GAL. (FBC-R P2903.4)"]); + leaderNote(d, v, o(31, top - 30), o(70, 74), [ + "WH-1, 50 GAL. ELECTRIC, 240 V,", + `CKT. ${wh.label}, ${wh.amps} A/2P, ${WIRE_OF(wh.amps)} CU;`, + "DISCONNECT WITHIN SIGHT", + ]); + leaderNote(d, v, o(42, 40), o(70, 50), [ + '3/4" T&P DISCHARGE, FULL SIZE,', + '6" MAX. ABOVE FLOOR, NO VALVE', + "OR THREAD AT END (FBC-R P2804.6.1)", + ]); + leaderNote(d, v, o(60, rise + 1.5), o(70, 24), [ + 'PAN W/ 3/4" CPVC DRAIN, 1/8" PER', + "FT., ALONG GARAGE E. WALL AND OUT", + 'THE FRONT PIER, ENDING 6" TO 24"', + "ABOVE GRADE (FBC-R P2801.6.1;", + "ROUTE ON M-101)", + ]); + leaderNote(d, v, o(10, 6), o(-30, 50), [ + "TREATED 2X4 PLATFORM,", + "TOP AT D.F.E.", + "(FBC-R R322.1.6)", + ]); + }); +} + +const WIRE_OF = (amps: number) => + ({ 30: "#10", 40: "#8", 50: "#6" } as Record)[amps] ?? ""; + +/** The water heater schedule. */ +function heaterSchedule(d: D, at: Point): number { + return table( + d, + at, + "WATER HEATER SCHEDULE", + [ + { name: "TAG", width: 0.6 }, + { name: "TYPE", width: 2.4 }, + { name: "CAP.", width: 0.75, align: "center" }, + { name: "ELEMENTS", width: 1.3, align: "center" }, + { name: "ELECTRICAL", width: 1.9, align: "center" }, + { name: "RECOVERY", width: 1.6, align: "center" }, + { name: "UEF", width: 0.6, align: "center" }, + { name: "LOCATION", width: 2.6 }, + ], + [ + [ + "WH-1", + "ELECTRIC STORAGE, LISTED", + "50 GAL.", + "2 X 4500 W", + "240 V, 30 A/2P (E-101)", + "21 GPH AT 90 F", + "0.92", + "GARAGE NE CORNER, PLATFORM", + ], + ], + { height: H.note, rowHeight: 0.21 }, + ); +} + +function legend(d: D, at: Point, w: number): number { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "PLUMBING LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.25; + }); + const rows: [string, string, (a: Point, b: Point) => void][] = [ + ["COLD WATER (CW)", LAYERS.plmb, (a, b) => d.line(a, b)], + ["HOT WATER (HW)", HOT, (a, b) => d.line(a, b)], + ["SANITARY WASTE / SOIL", LAYERS.sanr, (a, b) => d.line(a, b)], + ["VENT", VENT, (a, b) => d.line(a, b)], + ["CLEANOUT (CO)", LAYERS.sanr, (a, b) => d.circle([(a[0] + b[0]) / 2, a[1]], 0.06)], + [ + "FIXTURE TRAP", + LAYERS.sanr, + (a, b) => d.arc([(a[0] + b[0]) / 2, a[1] + 0.04], 0.06, 180, 360), + ], + ["FIXTURE STOP / CONNECTION", LAYERS.plmb, (a, b) => d.circle([(a[0] + b[0]) / 2, a[1]], 0.03)], + ]; + for (const [label, layer, draw] of rows) { + d.layer(layer, {}, () => draw([at[0] + 0.05, y], [at[0] + 0.65, y])); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.85, y], label, H.note, { valign: "middle" })); + y -= 0.28; + } + return y; +} + +function p101(d: D): void { + d.layer(HOT, { color: 7, lineweight: 0.35, linetype: "HIDDEN" }); + d.textStyle({ name: ISO_EAST, font: "Arial", obliqueAngle: -30 }); + d.textStyle({ name: ISO_NORTH, font: "Arial", obliqueAngle: 30 }); + d.layer(VENT, { color: 7, lineweight: 0.25, linetype: "DASHED" }); + heaterDetail(d, view(d, 16)); + frame(d, "P-101", (dd, sheet) => { + const left = AREA.left + 0.2; + const diagramW = 14.2; + // The water isometric, top left. The origin is the house's southwest corner at the floor. + waterIso(dd, [left + 6.4, 13.25]); + viewTitle(dd, [left, 12.5], "1", "P-101", "WATER DISTRIBUTION ISOMETRIC", "NOT TO SCALE"); + // The DWV riser, bottom left. + dwvRiser(dd, [left, 2.2], diagramW); + viewTitle(dd, [left, 1.4], "2", "P-101", "DRAINAGE, WASTE AND VENT RISER", "NOT TO SCALE"); + // Schedules and notes, right. + const rx = left + diagramW + 0.7; + let y = fixtureSchedule(dd, [rx, AREA.top - 0.1]); + const rw = AREA.right - rx; + const sTop = y - 0.4; + y = sizing(dd, [rx, sTop], 6.4); + const legendEnd = legend(dd, [rx + 6.9, sTop], rw - 6.9); + const colW = (rw - 0.4) / 2; + const half = Math.ceil(NOTES.length / 2); + const ny = Math.min(y, legendEnd) - 0.45; + // noteColumn numbers from 1, so the second column writes its own numbers. + const n1 = noteColumn(dd, [rx, ny], colW, "PLUMBING NOTES", NOTES.slice(0, half)); + const n2 = noteColumn( + dd, + [rx + colW + 0.4, ny], + colW, + "PLUMBING NOTES (CONT.)", + NOTES.slice(half).map((n, k) => `${half + k + 1}. ${n}`), + { numbered: false }, + ); + const hs = heaterSchedule(dd, [rx, Math.min(n1, n2) - 0.4]); + // The water heater detail, through a viewport, under the schedules. + const vd = view(dd, 16); + const vpTop = hs - 0.5; + const vpH = vpTop - 2.0; + const vpW = AREA.right - rx - 0.1; + viewport(dd, sheet, { + corner: [rx, 2.0], + size: [vpW, vpH], + center: [WH_ORIGIN[0] + 28, WH_ORIGIN[1] + 58], + v: vd, + id: "vp-P-101-wh", + number: "3", + sheetNo: "P-101", + title: "WATER HEATER INSTALLATION", + titleAt: [rx + 0.1, 1.5], + }); + void width; + }); +} + +export const SHEETS_P = { "P-101": p101 }; diff --git a/examples/plan-set/sheets/mep-power.ts b/examples/plan-set/sheets/mep-power.ts new file mode 100644 index 0000000..4f53140 --- /dev/null +++ b/examples/plan-set/sheets/mep-power.ts @@ -0,0 +1,692 @@ +// The electrical design of the house, once: the circuits, every device and where it is, in +// building coordinates. E-101 draws the plans, the panel schedule and the load calculation from +// this, so a receptacle added to a room shows up in its circuit's count and load. +import type { Point } from "../../../src/index.ts"; +import { + areas, + ATTIC_ACCESS, + GARAGE_STEPS, + L1, + L2, + type Level, + type Rect, +} from "../model/house.ts"; +import { ATTIC, center, EQUIP } from "./mep-equip.ts"; + +export type Floor = "L1" | "L2"; + +/** A branch circuit, before it gets its breaker positions. */ +export interface CircuitDef { + readonly key: string; + readonly desc: string; + readonly amps: number; + readonly poles: 1 | 2; + /** Breaker type: AF (combination AFCI), GF (GFCI), DF (dual function), or plain. */ + readonly type?: "AF" | "GF" | "DF"; + /** A fixed load in VA, for an appliance or a small-appliance circuit. Else the devices add up. */ + readonly va?: number; + /** What the load calculation counts it as. */ + readonly calc?: "sabc" | "laundry" | "appliance" | "hvac" | "general"; +} + +export const WIRE: Record = { + 15: "#14", + 20: "#12", + 30: "#10", + 40: "#8", + 50: "#6", + 60: "#6", +}; + +/** The circuits, in the order they go into the panel. */ +export const CIRCUIT_DEFS: readonly CircuitDef[] = [ + { key: "AHU1", desc: "AHU-1 + 5 KW HEAT", amps: 40, poles: 2, va: 5800, calc: "hvac" }, + { key: "HP1", desc: "HP-1 CONDENSER", amps: 30, poles: 2, va: 4400, calc: "hvac" }, + { key: "AHU2", desc: "AHU-2 + 5 KW HEAT", amps: 40, poles: 2, va: 5800, calc: "hvac" }, + { key: "HP2", desc: "HP-2 CONDENSER", amps: 30, poles: 2, va: 4400, calc: "hvac" }, + { key: "RANGE", desc: "RANGE", amps: 50, poles: 2, va: 12000, calc: "appliance" }, + { key: "WH", desc: "WATER HEATER, 50 GAL.", amps: 30, poles: 2, va: 4500, calc: "appliance" }, + { key: "DRYER", desc: "DRYER", amps: 30, poles: 2, type: "GF", va: 5000, calc: "appliance" }, + { + key: "SABC1", + desc: "KIT. COUNTER RECEPT.", + amps: 20, + poles: 1, + type: "DF", + va: 1500, + calc: "sabc", + }, + { + key: "SABC2", + desc: "KIT. COUNTER RECEPT.", + amps: 20, + poles: 1, + type: "DF", + va: 1500, + calc: "sabc", + }, + { + key: "SABC3", + desc: "ISLAND + DINING RECEPT.", + amps: 20, + poles: 1, + type: "DF", + va: 1500, + calc: "sabc", + }, + { key: "REF", desc: "REFRIGERATOR", amps: 20, poles: 1, type: "AF", va: 800, calc: "appliance" }, + { key: "DW", desc: "DISHWASHER", amps: 20, poles: 1, type: "DF", va: 1200, calc: "appliance" }, + { key: "DISP", desc: "DISPOSAL", amps: 20, poles: 1, type: "AF", va: 900, calc: "appliance" }, + { + key: "MW", + desc: "MICROWAVE / HOOD", + amps: 20, + poles: 1, + type: "AF", + va: 1500, + calc: "appliance", + }, + { + key: "LAUN", + desc: "LAUNDRY RECEPT.", + amps: 20, + poles: 1, + type: "DF", + va: 1500, + calc: "laundry", + }, + { key: "GAR", desc: "GARAGE RECEPT. + GDO", amps: 20, poles: 1, type: "GF" }, + { key: "EXT", desc: "EXTERIOR RECEPT., WP", amps: 20, poles: 1, type: "GF" }, + { key: "BATH1", desc: "PWDR. + BATH 2 RECEPT.", amps: 20, poles: 1, type: "GF" }, + { key: "BATH2", desc: "OWNER'S BATH RECEPT.", amps: 20, poles: 1, type: "GF" }, + { key: "R1A", desc: "RECEPT. STUDY, FOYER", amps: 20, poles: 1, type: "AF" }, + { key: "R1B", desc: "RECEPT. GREAT ROOM", amps: 20, poles: 1, type: "AF" }, + { key: "L1A", desc: "LTG. KITCHEN, GREAT RM.", amps: 15, poles: 1, type: "AF" }, + { key: "L1B", desc: "LTG. GAR., LAUN., FOYER, EXT.", amps: 15, poles: 1, type: "AF" }, + { key: "R2A", desc: "RECEPT. BED 2, BED 3, HALL", amps: 20, poles: 1, type: "AF" }, + { key: "R2B", desc: "RECEPT. OWNER'S BED, LOFT", amps: 20, poles: 1, type: "AF" }, + { key: "L2A", desc: "LTG. BED 2, BED 3, BATH 2", amps: 15, poles: 1, type: "AF" }, + { key: "L2B", desc: "LTG. OWNER'S SUITE, LOFT", amps: 15, poles: 1, type: "AF" }, + { key: "SMK", desc: "SMOKE / CO ALARMS", amps: 15, poles: 1, type: "AF" }, + { key: "ATTIC", desc: "ATTIC SVC. RECEPT. + LTG.", amps: 20, poles: 1, type: "GF" }, + { key: "SPARE1", desc: "SPARE", amps: 20, poles: 1 }, + { key: "SPARE2", desc: "SPARE", amps: 20, poles: 1 }, +]; + +/** The panel: 40 spaces, odd on the left, even on the right, top to bottom. */ +export const PANEL = { + name: "A", + spaces: 40, + amps: 200, + main: 200, + volts: "120/240 V, 1 PH, 3 W", + aic: 22, +} as const; + +export interface Circuit extends CircuitDef { + /** Breaker positions: one, or two for a two-pole breaker. */ + readonly positions: readonly number[]; + /** "A-1,3". */ + readonly label: string; +} + +/** Places each circuit in the panel: each goes in the column whose next space is lower. */ +function placeCircuits(defs: readonly CircuitDef[]): Circuit[] { + const next = { odd: 1, even: 2 }; + return defs.map((c) => { + const side = next.odd <= next.even - 1 ? "odd" : "even"; + const first = next[side]; + const positions = c.poles === 2 ? [first, first + 2] : [first]; + next[side] = first + 2 * c.poles; + if (positions.at(-1)! > PANEL.spaces) + throw new Error(`Panel ${PANEL.name} is full at ${c.key}`); + return { ...c, positions, label: `${PANEL.name}-${positions.join(",")}` }; + }); +} + +export const CIRCUITS = placeCircuits(CIRCUIT_DEFS); +export const circuit = (key: string): Circuit => { + const c = CIRCUITS.find((x) => x.key === key); + if (!c) throw new Error(`No circuit ${key}`); + return c; +}; + +// --------------------------------------------------------------------------------------------- +// Devices. + +export type Block = + | "E-DUPLEX" + | "E-GFCI" + | "E-SWITCH" + | "E-SWITCH3" + | "E-CEILING" + | "E-RECESSED" + | "E-FAN" + | "E-WALLLIGHT" + | "E-SMOKE" + | "E-EXHAUST" + | "E-PANEL" + | "E-220" + | "E-DISC"; + +export interface Device { + readonly block: Block; + readonly floor: Floor; + readonly at: Point; + readonly rot: number; + readonly ckt: string; + /** A short label beside the symbol: WP, DW, REF, a switch's letter. */ + readonly tag?: string | undefined; + /** The switch group a switch or a fixture belongs to. */ + readonly group?: string | undefined; +} + +/** Load of each kind of device, in VA, where the circuit has no fixed load. */ +export const DEVICE_VA: Partial> = { + "E-DUPLEX": 180, + "E-GFCI": 180, + "E-CEILING": 60, + "E-RECESSED": 15, + "E-FAN": 100, + "E-WALLLIGHT": 40, + "E-SMOKE": 5, + "E-EXHAUST": 50, +}; + +export const isReceptacle = (b: Block) => b === "E-DUPLEX" || b === "E-GFCI"; +export const isSwitch = (b: Block) => b === "E-SWITCH" || b === "E-SWITCH3"; +export const isLight = (b: Block) => + b === "E-CEILING" || + b === "E-RECESSED" || + b === "E-FAN" || + b === "E-WALLLIGHT" || + b === "E-EXHAUST"; + +const devices: Device[] = []; +/** The circuit key of a device on no branch circuit: the panel itself. */ +export const NO_CIRCUIT = "PANEL"; +const add = ( + floor: Floor, + block: Block, + at: Point, + ckt: string, + o: { rot?: number; tag?: string | undefined; group?: string } = {}, +) => { + if (ckt !== NO_CIRCUIT) circuit(ckt); + devices.push({ block, floor, at, ckt, rot: o.rot ?? 0, tag: o.tag, group: o.group }); +}; + +// --- Receptacles along walls: NEC 210.52(A), no point of the wall line more than 6 ft from one. + +const inRect = (r: Rect, p: Point, grow = 0) => + p[0] >= r[0] - grow && p[0] <= r[2] + grow && p[1] >= r[1] - grow && p[1] <= r[3] + grow; + +/** + * The places along a room's wall line for receptacles, at most 12 ft apart and 6 ft from each + * end of a run of wall. The wall line runs round the corners; a door, an opening with no wall, + * or a `skip` zone (counters, appliances) ends a run. Returns each point with the rotation of its + * wall, set in from the wall's face by `inset`. + */ +export function wallReceptacles( + level: Level, + room: Rect, + inset: number, + skip: readonly Rect[] = [], +): { at: Point; rot: number }[] { + const [x0, y0, x1, y1] = room; + const w = x1 - x0; + const h = y1 - y0; + const perim = 2 * (w + h); + // A point on the perimeter at s, its outward normal and its wall's rotation. + const along = (s: number): { p: Point; n: Point; rot: number } => { + if (s < w) return { p: [x0 + s, y0], n: [0, -1], rot: 0 }; + if (s < w + h) return { p: [x1, y0 + (s - w)], n: [1, 0], rot: 90 }; + if (s < 2 * w + h) return { p: [x1 - (s - w - h), y1], n: [0, 1], rot: 0 }; + return { p: [x0, y1 - (s - 2 * w - h)], n: [-1, 0], rot: 90 }; + }; + const breaks = level.openings.filter((o) => o.kind !== "window").map((o) => o.cut); + const isWall = (s: number) => { + const { p, n } = along(s); + const out: Point = [p[0] + n[0] * 2, p[1] + n[1] * 2]; + if (!level.walls.some((r) => inRect(r, out, 0.5))) return false; + if (breaks.some((r) => inRect(r, out, 1))) return false; + if (skip.some((r) => inRect(r, p, 1))) return false; + return true; + }; + const step = 1; + const flags: boolean[] = []; + for (let s = 0; s < perim; s += step) flags.push(isWall(s + step / 2)); + // Runs of wall, joined across the start of the perimeter. + const runs: [number, number][] = []; + let start: number | undefined; + flags.forEach((f, k) => { + if (f && start === undefined) start = k; + if (!f && start !== undefined) { + runs.push([start, k]); + start = undefined; + } + }); + if (start !== undefined) { + if (runs.length && runs[0]![0] === 0) runs[0] = [start - flags.length, runs[0]![1]]; + else runs.push([start, flags.length]); + } + const corners = [0, w, w + h, 2 * w + h, perim]; + const out: { at: Point; rot: number }[] = []; + for (const [a, b] of runs) { + const len = (b - a) * step; + if (len < 24) continue; // NEC 210.52(A)(2)(1): wall space is 2 ft or more. + // Spread n receptacles along the run, kept 12" off the corners, and add one until no point + // of the run is more than 6 ft from one: NEC 210.52(A)(1). + const place = (n: number): number[] => + Array.from({ length: n }, (_, i) => { + let s = a * step + ((i + 0.5) * len) / n; + for (const c of corners) { + const cs = c + Math.floor((a * step) / perim) * perim; + for (const cc of [cs, cs + perim, cs - perim]) + if (Math.abs(s - cc) < 12) s = s < cc ? cc - 12 : cc + 12; + } + return Math.min(Math.max(s, a * step), b * step); + }); + const covered = (ss: number[]) => { + for (let t = a * step; t <= b * step; t += 2) + if (Math.min(...ss.map((x) => Math.abs(x - t))) > 72) return false; + return true; + }; + let n = Math.max(1, Math.ceil(len / 144)); + while (!covered(place(n))) n++; + for (const s0 of place(n)) { + const s = ((s0 % perim) + perim) % perim; + const { p, n: nn, rot } = along(s); + out.push({ at: [p[0] - nn[0] * inset, p[1] - nn[1] * inset], rot }); + } + } + return out; +} + +/** Receptacles on the countertops along a wall, at most 4 ft apart: NEC 210.52(C)(1). */ +function counterReceptacles( + from: Point, + to: Point, + ckts: readonly string[], + rot: number, + floor: Floor, + k0 = 0, +): number { + const len = Math.hypot(to[0] - from[0], to[1] - from[1]); + if (len < 12) return k0; + const n = Math.max(1, Math.ceil(len / 48)); + let k = k0; + for (let i = 0; i < n; i++) { + const t = (i + 0.5) / n; + add( + floor, + "E-GFCI", + [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t], + ckts[k % ckts.length]!, + { rot }, + ); + k++; + } + return k; +} + +const room = (level: Level, name: string, nth = 0): Rect => { + const r = level.rooms.filter((x) => x.name === name)[nth]; + if (!r) throw new Error(`No room ${name}`); + return r.rect; +}; + +const INSET = 5; // inches from a wall's face to a receptacle's middle +/** Panel A, on the garage's west wall north of door 109. */ +export const PANEL_AT: Point = [12, 192]; +const walls = ( + floor: Floor, + level: Level, + name: string, + ckt: string, + block: Block = "E-DUPLEX", + skip: Rect[] = [], + nth = 0, + tag?: string, +) => { + for (const r of wallReceptacles(level, room(level, name, nth), INSET, skip)) + add(floor, block, r.at, ckt, { rot: r.rot, tag }); +}; + +/** Switches and the fixtures they control: the switch legs run from the first switch. */ +const sw = ( + floor: Floor, + group: string, + ckt: string, + switches: readonly [Point, "S" | "S3"][], + fixtures: readonly [Block, Point, number?][], +) => { + for (const [at, kind] of switches) + add(floor, kind === "S3" ? "E-SWITCH3" : "E-SWITCH", at, ckt, { + group, + tag: group.length <= 2 ? group : undefined, + }); + for (const [block, at, rot] of fixtures) add(floor, block, at, ckt, { group, rot: rot ?? 0 }); +}; + +// --- First floor. ------------------------------------------------------------------------------ +{ + const F = "L1" as const; + const lv = L1; + // Garage: GFCI receptacles on the walls, clear of the panel, the steps at door 103 and the + // AHU-1 / WH-1 corner; the door opener's on the ceiling; the panel north of door 109. + const g = GARAGE_STEPS; + const stepsLow = g.top - g.landing - (g.risers - 1) * g.tread; + walls(F, lv, "GARAGE", "GAR", "E-GFCI", [ + [8, PANEL_AT[1] - 14, 20, PANEL_AT[1] + 14], + [EQUIP.ahu1[0] - 12, 232, 264, 288], + [8, stepsLow - 8, g.x1 + 4, 288], + ]); + add(F, "E-GFCI", [120, 128], "GAR", { tag: "GDO" }); + // Three-way switches at the landing by door 103 and at door 109's latch side. + sw( + F, + "a", + "L1B", + [ + [[g.x0, g.top - 10], "S3"], + [[14, 110], "S3"], + ], + [ + ["E-CEILING", [70, 70]], + ["E-CEILING", [190, 70]], + ["E-CEILING", [70, 200]], + ["E-CEILING", [160, 200]], + ], + ); + sw( + F, + "b", + "L1B", + [[[g.x0, g.top - 24], "S"]], + [ + ["E-WALLLIGHT", [12, -4], 180], + ["E-WALLLIGHT", [226, -4], 180], + ["E-WALLLIGHT", [-4, 166], 90], + ], + ); + add(F, "E-PANEL", PANEL_AT, NO_CIRCUIT, { rot: 90 }); + // WH-1 in the garage's northeast corner, on its platform; AHU-1 on its stand west of it. + add(F, "E-220", [248, 236], "WH", { tag: "WH" }); + add(F, "E-DISC", [EQUIP.ahu1[0] - 10, EQUIP.ahu1[3] - 8], "AHU1", { tag: "AHU-1" }); + // The heat pumps on their pad by the east wall, each with its disconnect on the wall, and a + // service receptacle between them. + add(F, "E-DISC", [628, center(EQUIP.hp1)[1]], "HP1", { tag: "HP-1" }); + add(F, "E-DISC", [628, center(EQUIP.hp2)[1]], "HP2", { tag: "HP-2" }); + add(F, "E-GFCI", [628, EQUIP.hp1[1] - 16], "EXT", { rot: 90, tag: "WP" }); + // Laundry. + add(F, "E-220", [55, 403], "DRYER", { tag: "DRYER" }); + add(F, "E-GFCI", [85, 403], "LAUN", { tag: "W" }); + add(F, "E-GFCI", [13, 320], "LAUN", { rot: 90 }); + sw(F, "c", "L1B", [[[126, 358], "S"]], [["E-CEILING", [70, 345]]]); + // Powder room and pantry. + add(F, "E-GFCI", [67, 497], "BATH1", { rot: 90 }); + sw( + F, + "d", + "L1B", + [[[62, 418], "S"]], + [ + ["E-CEILING", [40, 470]], + ["E-EXHAUST", [22, 496]], + ], + ); + sw(F, "e", "L1B", [[[126, 432], "S"]], [["E-CEILING", [104, 466]]]); + // Kitchen: counter receptacles on two small-appliance circuits, the island's, appliances. + let k = counterReceptacles([160.5, 515], [226, 515], ["SABC1", "SABC2"], 0, F); + k = counterReceptacles([278, 515], [379.5, 515], ["SABC1", "SABC2"], 0, F, k); + k = counterReceptacles([141.5, 362], [141.5, 412], ["SABC1", "SABC2"], 90, F, k); + counterReceptacles([141.5, 448], [141.5, 494], ["SABC1", "SABC2"], 90, F, k); + // The island: 108" x 42" is 31.5 sq ft, one for the first 9 and one per 18 more (210.52(C)(2)). + const islandSf = (108 * 42) / 144; + const islandN = 1 + Math.ceil((islandSf - 9) / 18); + for (let i = 0; i < islandN; i++) + add(F, "E-GFCI", [204 + ((i + 0.5) * 108) / islandN, 389], "SABC3"); + walls(F, lv, "KITCHEN", "SABC3", "E-DUPLEX", [ + [136.5, 292.5, 160.5, 520], + [136.5, 496, 379.5, 520], + ]); + add(F, "E-220", [141.5, 424], "RANGE", { rot: 90, tag: "R" }); + add(F, "E-DUPLEX", [141.5, 340], "REF", { rot: 90, tag: "REF" }); + // Under the sink: the dishwasher's and the disposal's outlets. The disposal's switch is on + // the backsplash beside the sink. + add(F, "E-GFCI", [272, 501], "DW", { tag: "DW" }); + add(F, "E-DUPLEX", [236, 501], "DISP", { tag: "DISP" }); + add(F, "E-SWITCH", [273, 515], "DISP", { tag: "SD" }); + add(F, "E-DUPLEX", [141.5, 444], "MW", { rot: 90, tag: "MW" }); + sw( + F, + "f", + "L1A", + [[[312, 298], "S"]], + [ + ["E-RECESSED", [190, 330]], + ["E-RECESSED", [250, 330]], + ["E-RECESSED", [310, 330]], + ["E-RECESSED", [355, 330]], + ["E-RECESSED", [190, 465]], + ["E-RECESSED", [250, 465]], + ["E-RECESSED", [310, 465]], + ["E-RECESSED", [355, 465]], + ], + ); + sw( + F, + "g", + "L1A", + [[[300, 298], "S"]], + [ + ["E-CEILING", [222, 405]], + ["E-CEILING", [258, 405]], + ["E-CEILING", [294, 405]], + ], + ); + // Foyer, stair and porch. + walls(F, lv, "FOYER", "R1A", "E-DUPLEX", [[318.5, 80, 330, 120]]); + sw(F, "h", "L1B", [[[372, 90], "S"]], [["E-CEILING", [349, 190]]]); + sw( + F, + "j", + "L1B", + [[[358, 90], "S"]], + [ + ["E-WALLLIGHT", [314, 68], 180], + ["E-WALLLIGHT", [376, 68], 180], + ], + ); + sw(F, "k", "L1B", [[[324, 104], "S3"]], [["E-WALLLIGHT", [276, 200], -90]]); + add(F, "E-GFCI", [290, 67], "EXT", { tag: "WP" }); + // Study. + walls(F, lv, "STUDY", "R1A"); + sw(F, "m", "L1A", [[[390, 168], "S"]], [["E-FAN", [500, 100]]]); + // Great room and lanai. + walls(F, lv, "GREAT ROOM", "R1B"); + sw( + F, + "n", + "L1A", + [[[390, 272], "S"]], + [ + ["E-RECESSED", [425, 250]], + ["E-RECESSED", [575, 250]], + ["E-RECESSED", [425, 360]], + ["E-RECESSED", [575, 360]], + ["E-RECESSED", [425, 470]], + ["E-RECESSED", [575, 470]], + ], + ); + sw(F, "p", "L1A", [[[402, 272], "S"]], [["E-FAN", [500, 360]]]); + sw( + F, + "q", + "L1B", + [[[424, 513], "S"]], + [ + ["E-FAN", [460, 605]], + ["E-FAN", [560, 605]], + ], + ); + sw( + F, + "r", + "L1B", + [[[412, 513], "S"]], + [ + ["E-WALLLIGHT", [420, 532], 0], + ["E-WALLLIGHT", [588, 532], 0], + ], + ); + add(F, "E-GFCI", [400, 533], "EXT", { tag: "WP" }); + add(F, "E-GFCI", [604, 533], "EXT", { tag: "WP" }); + // Smoke and CO alarms. + add(F, "E-SMOKE", [349, 245], "SMK"); + add(F, "E-SMOKE", [560, 150], "SMK"); +} + +// --- Second floor. ----------------------------------------------------------------------------- +{ + const F = "L2" as const; + const lv = L2; + walls(F, lv, "BEDROOM 2", "R2A"); + sw(F, "a", "L2A", [[[210, 194], "S"]], [["E-FAN", [111, 105]]]); + sw(F, "b", "L2A", [[[56, 198], "S"]], [["E-CEILING", [34, 262]]]); + // Bath 2. + add(F, "E-GFCI", [176, 214], "BATH1"); + sw( + F, + "c", + "L2A", + [[[210, 222], "S"]], + [ + ["E-RECESSED", [141, 245]], + ["E-RECESSED", [141, 296]], + ["E-EXHAUST", [96, 285]], + ], + ); + walls(F, lv, "BEDROOM 3", "R2A"); + sw(F, "d", "L2A", [[[210, 332], "S"]], [["E-FAN", [111, 395]]]); + sw(F, "e", "L2A", [[[88, 467], "S"]], [["E-CEILING", [36, 506]]]); + walls(F, lv, "HALL", "R2A"); + sw( + F, + "f", + "L2A", + [ + [[226, 112], "S3"], + [[262, 278], "S3"], + ], + [ + ["E-CEILING", [296, 56]], + ["E-CEILING", [350, 56]], + ["E-CEILING", [244, 190]], + ], + ); + // Loft. + walls(F, lv, "LOFT", "R2B"); + sw( + F, + "g", + "L2B", + [[[226, 280], "S"]], + [ + ["E-RECESSED", [260, 335]], + ["E-RECESSED", [340, 335]], + ["E-RECESSED", [260, 470]], + ["E-RECESSED", [340, 470]], + ], + ); + // Owner's suite. + walls(F, lv, "OWNER'S BEDROOM", "R2B"); + sw(F, "h", "L2B", [[[390, 392], "S"]], [["E-FAN", [501, 395]]]); + sw( + F, + "j", + "L2B", + [[[464, 258], "S"]], + [ + ["E-CEILING", [440, 218]], + ["E-CEILING", [560, 218]], + ], + ); + add(F, "E-GFCI", [456, 163], "BATH2"); + add(F, "E-GFCI", [546, 163], "BATH2"); + sw( + F, + "k", + "L2B", + [[[412, 160], "S"]], + [ + ["E-RECESSED", [430, 100]], + ["E-RECESSED", [501, 110]], + ["E-RECESSED", [430, 40]], + ["E-RECESSED", [570, 30]], + ["E-WALLLIGHT", [484, 164], 180], + ["E-WALLLIGHT", [518, 164], 180], + ], + ); + sw( + F, + "m", + "L2B", + [[[392, 160], "S"]], + [ + ["E-EXHAUST", [570, 75]], + ["E-EXHAUST", [596, 140]], + ], + ); + // AHU-2 in the attic over the landing (M-101): its disconnect and a service receptacle at the + // unit, and lights over the passage at the access and over the service space, switched at the + // access (FBC-R M1305.1.3.1, NEC 210.63, 210.70(A)(3)). + const svc = center(ATTIC.service); + add(F, "E-DISC", [EQUIP.ahu2[0] - 6, EQUIP.ahu2[3] - 4], "AHU2", { tag: "AHU-2" }); + add(F, "E-GFCI", [ATTIC.service[0] - 7, ATTIC.service[1] - 7], "ATTIC", { tag: "ATTIC" }); + sw( + F, + "n", + "ATTIC", + [[[ATTIC_ACCESS[2] + 6, ATTIC_ACCESS[3] - 6], "S"]], + [ + ["E-CEILING", [(ATTIC_ACCESS[0] + ATTIC_ACCESS[2]) / 2, ATTIC_ACCESS[3] + 18]], + ["E-CEILING", svc], + ], + ); + // Smoke and CO alarms: each bedroom, outside the sleeping rooms, the loft. + add(F, "E-SMOKE", [111, 160], "SMK"); + add(F, "E-SMOKE", [111, 440], "SMK"); + add(F, "E-SMOKE", [501, 470], "SMK"); + add(F, "E-SMOKE", [244, 240], "SMK"); + add(F, "E-SMOKE", [300, 300], "SMK"); +} + +export const DEVICES: readonly Device[] = devices; + +/** Each circuit's devices, its load, and its count of receptacles and fixtures. */ +export function circuitLoads() { + return CIRCUITS.map((c) => { + const ds = DEVICES.filter((x) => x.ckt === c.key); + const counted = ds.filter((x) => DEVICE_VA[x.block] !== undefined); + const va = c.va ?? counted.reduce((s, x) => s + DEVICE_VA[x.block]!, 0); + return { ...c, devices: ds, count: counted.length, va, fixed: c.va !== undefined }; + }); +} + +/** The load calculation of NEC 220.82, the optional method for a dwelling. */ +export function loadCalc() { + const loads = circuitLoads(); + const living = Math.round(areas().living); + const lighting = 3 * living; + const sabc = loads.filter((c) => c.calc === "sabc").length * 1500; + const laundry = loads.filter((c) => c.calc === "laundry").length * 1500; + const appliances = loads.filter((c) => c.calc === "appliance"); + const general = lighting + sabc + laundry + appliances.reduce((s, c) => s + c.va, 0); + const generalDemand = Math.min(general, 10000) + 0.4 * Math.max(0, general - 10000); + const hvac = loads.filter((c) => c.calc === "hvac").reduce((s, c) => s + c.va, 0); + const total = generalDemand + hvac; + const amps = total / 240; + return { living, lighting, sabc, laundry, appliances, general, generalDemand, hvac, total, amps }; +} diff --git a/examples/plan-set/sheets/mep.ts b/examples/plan-set/sheets/mep.ts new file mode 100644 index 0000000..2e4afd0 --- /dev/null +++ b/examples/plan-set/sheets/mep.ts @@ -0,0 +1,9 @@ +// M-101, E-101 and P-101, the MEP sheets. The electrical design is in `mep-power.ts`, the HVAC +// design in `mep-equip.ts` and `mep-hvac.ts`, the sheets in `mep-m101.ts`, `mep-e101.ts` and +// `mep-p101.ts`. +import type { SheetDraw } from "../lib/kit.ts"; +import { SHEETS_E } from "./mep-e101.ts"; +import { SHEETS_M } from "./mep-m101.ts"; +import { SHEETS_P } from "./mep-p101.ts"; + +export const SHEETS: Record = { ...SHEETS_M, ...SHEETS_E, ...SHEETS_P }; diff --git a/examples/plan-set/sheets/plans.ts b/examples/plan-set/sheets/plans.ts new file mode 100644 index 0000000..02f3535 --- /dev/null +++ b/examples/plan-set/sheets/plans.ts @@ -0,0 +1,265 @@ +// A-101 and A-102: the floor plans at 1/4" = 1'-0", with their tags, dimensions, notes and +// legends. The plans are drawn in model space at an origin of their own. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; +import { + areas, + ENTRY, + FLOOD_VENT_RATING, + FLOOD_VENTS, + GARAGE_STEPS, + L1, + L2, + HEIGHTS, + LANAI_SLAB, + POWDER, + STAIR, + type Level, +} from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + H, + LAYERS, + leaderNote, + noteColumn, + put, + revisionCloud, + sectionCut, + view, + viewport, + wrap, + type D, + type SheetDraw, +} from "../lib/kit.ts"; +import { ARCH_LAYERS, drawPlan, planDims, planTags } from "../lib/plan.ts"; +import { CUT_X } from "./elevations-section.ts"; + +export const ORIGIN = { L1: [20000, 0] as Point, L2: [40000, 0] as Point }; + +/** The middle of the part of model space a plan sheet shows: the house with its dimensions. */ +const center = (origin: Point): Point => [origin[0] + 312, origin[1] + 300]; + +const riser = (HEIGHTS.l2 / STAIR.risers).toFixed(2); +const PLAN_NOTES = [ + "DIMENSIONS ARE TO THE FACE OF MASONRY OR THE FACE OF STUD UNLESS NOTED. DO NOT SCALE THE DRAWINGS.", + 'EXTERIOR WALLS OF THE FIRST FLOOR ARE 8" CMU WITH A REINFORCED TIE BEAM. SEE S-101 AND S-501.', + "ALL EXTERIOR GLAZED OPENINGS ARE IMPACT RATED FOR THE DESIGN PRESSURES ON A-601, WITH FLORIDA PRODUCT APPROVAL.", + 'GARAGE SEPARATION (FBC-R R302.6): 5/8" TYPE X GYPSUM BOARD ON THE GARAGE CEILING BELOW BEDROOM 2 AND THE HALL, 1/2" GYPSUM BOARD ON THE GARAGE WALLS AND ON STRUCTURE SUPPORTING THE CEILING. DOOR 103: SOLID CORE 1 3/8" MIN., SELF-CLOSING AND SELF-LATCHING.', + `FLOOD VENTS: ${FLOOD_VENTS.length} ENGINEERED VENTS, EACH CERTIFIED (ICC-ES) TO COVER ${FLOOD_VENT_RATING} SQ. FT. OF ENCLOSED AREA: ${FLOOD_VENTS.length} X ${FLOOD_VENT_RATING} = ${FLOOD_VENTS.length * FLOOD_VENT_RATING} SQ. FT. >= ${areas().garage} SQ. FT. GROSS GARAGE AREA. BOTTOM WITHIN 12" OF GRADE, ON TWO WALLS (FBC-R R322.2.2). LOCATIONS PER S-101.`, + "SMOKE AND CARBON MONOXIDE ALARMS PER E-101, INTERCONNECTED.", + `MAIN STAIR: ${STAIR.risers} RISERS AT ${riser}" (7 3/4" MAX.), ${STAIR.treads} TREADS AT ${STAIR.tread}", 36" LANDINGS TOP AND BOTTOM, HANDRAIL 34"-38" ABOVE THE NOSINGS, GUARD 36" MIN. (FBC-R R311.7).`, + `GARAGE STEPS AT DOOR 103: 36" LANDING, ${GARAGE_STEPS.risers} RISERS AT 7 1/2", TREADS ${GARAGE_STEPS.tread}", HANDRAIL ONE SIDE. ENTRY STAIR: ${ENTRY.risers} RISERS AT 6 3/8", TREADS ${ENTRY.tread}", HANDRAILS BOTH SIDES. LANAI STEPS: ${LANAI_SLAB.steps.risers} RISERS AT 6 13/16", TREADS ${LANAI_SLAB.steps.tread}", HANDRAILS BOTH SIDES.`, + `GUARDS 36" HIGH AT THE PORCH AND LANAI EDGES, WHICH ARE 32" ABOVE FINISHED GRADE (${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD), WITH NO OPENING A 4" SPHERE PASSES (FBC-R R312.1).`, + "ROOM AREAS ON THE TAGS ARE NET, TO THE INSIDE FACE OF THE WALLS. G-001 TABULATES GROSS AREAS.", +]; + +const L2_NOTES = [ + 'EXTERIOR WALLS OF THE SECOND FLOOR ARE 2X6 STUDS AT 16" O.C. WITH 7/16" OSB SHEATHING, NAILED PER S-501 NOTE 12.', + 'EMERGENCY ESCAPE OPENINGS IN EVERY BEDROOM: 5.7 SQ. FT. NET CLEAR, 24" HIGH, 20" WIDE, SILL 44" MAX. ABOVE THE FLOOR (FBC-R R310).', + 'WINDOWS WHOSE SILL IS LESS THAN 24" ABOVE THE FLOOR AND MORE THAN 72" ABOVE GRADE GET OPENING CONTROL DEVICES (FBC-R R312.2).', + "BATH EXHAUST FANS 50 CFM MIN., DUCTED TO THE OUTSIDE.", + 'GUARDS AT THE STAIR OPENING 36" HIGH, WITH NO OPENING A 4" SPHERE PASSES.', + 'ATTIC ACCESS: 30" X 54" PULL-DOWN ATTIC STAIR IN THE HALL CEILING, LARGE ENOUGH TO REMOVE AHU-2, WITH A LIGHT AND RECEPTACLE (E-101). PASSAGEWAY TO THE UNIT 22" WIDE AND 30" HIGH MIN. WITH CONTINUOUS SOLID FLOORING 24" WIDE, 10\'-1" LONG, PER M-101 (FBC-R R807.1, M1305.1.3).', + "ROOM AREAS ON THE TAGS ARE NET, TO THE INSIDE FACE OF THE WALLS.", +]; + +function planSheet( + d: D, + number: string, + level: Level, + which: "L1" | "L2", + _notes: readonly string[], +): void { + const origin = ORIGIN[which]; + const v = view(d, 48); + drawPlan(d, origin, level, ARCH_LAYERS, which); + planTags(d, origin, level, v); + planDims(d, origin, level, v); + // Notes in the plan. + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + d.layer(LAYERS.anno, {}, () => { + if (which === "L1") { + leaderNote(d, v, o([120, 8]), o([150, -110]), [ + "16'-0\" X 8'-0\" IMPACT RATED", + "OVERHEAD DOOR, SEE A-601", + ]); + leaderNote(d, v, o([600, 640]), o([690, 720]), [ + `RAISED LANAI SLAB EL. ${LANAI_SLAB.slab.toFixed(2)}' NAVD,`, + 'ON STEM WALLS PER S-101, 36" GUARD', + ]); + leaderNote(d, v, o([300, 40]), o([60, -140]), [ + `ENTRY PORCH SLAB EL. ${ENTRY.slab.toFixed(2)}' NAVD,`, + '4" STEP DOWN FROM DOOR 101,', + '36" GUARD AT OPEN EDGE', + ]); + leaderNote(d, v, o([345, -30]), o([450, -150]), [ + `${ENTRY.risers}R DN TO WALK AT ${CRITERIA.flood.finishGrade.toFixed(2)}',`, + "HANDRAILS BOTH SIDES", + ]); + leaderNote(d, v, o([38, 240]), o([120, 230]), [ + `${GARAGE_STEPS.risers}R DN TO GARAGE SLAB,`, + 'HANDRAIL, 36" LANDING', + ]); + leaderNote(d, v, o([500, 690]), o([420, 740]), [ + `${LANAI_SLAB.steps.risers}R DN TO POOL DECK`, + `AT ${LANAI_SLAB.deck.toFixed(2)}' NAVD`, + ]); + leaderNote(d, v, o([0, 100]), o([-110, 70]), ["FLOOD VENT, TYP. OF 4,", "SEE S-101"]); + for (const fv of FLOOD_VENTS) + d.insert("FLOOD VENT", o(fv.at), { scale: v.p(1), rotation: fv.wall === "W" ? 90 : 0 }); + // Powder room: the water closet's clearances, dimensioned. + d.layer(LAYERS.dims, {}, () => { + const vanityEdge = POWDER.vanity + POWDER.vanityWidth / 2; + dimString( + d, + v, + [o([40, vanityEdge]), o([40, POWDER.wc]), o([40, 520])], + "y", + origin[0] + 62, + ); + }); + // Revision 1's changes, clouded: the stair, the garage steps, the entry stair and porch + // guards, the powder room, the lanai guards and steps. + const cloud = (r: [number, number, number, number]) => + revisionCloud( + d, + v.p, + [o([r[0], r[1]])[0], o([r[0], r[1]])[1], o([r[2], r[3]])[0], o([r[2], r[3]])[1]], + "1", + ); + cloud([268, 84, 320, 292]); + cloud([10, 214, 66, 290]); + cloud([266, -58, 382, 6]); + cloud([10, 416, 70, 518]); + cloud([380, 664, 620, 712]); + put(d, o([293, STAIR.y0 - 6]), "UP", v.p(H.note), { align: "center", valign: "top" }); + put( + d, + o([140, 56]), + `GARAGE SLAB EL. ${CRITERIA.flood.garage.toFixed(2)}' NAVD`, + v.p(H.note), + { align: "center" }, + ); + put(d, o([500, 150]), `F.F.E. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD`, v.p(H.note), { + align: "center", + }); + } else { + put(d, o([293, STAIR.y1 + 8]), "DN", v.p(H.note), { align: "center" }); + leaderNote(d, v, o([270, 200]), o([170, 360]), ['36" GUARD', "AT OPENING"]); + leaderNote(d, v, o([251, 70]), o([280, 92]), ["ATTIC ACCESS ABOVE"]); + revisionCloud( + d, + v.p, + [o([226, 14])[0], o([226, 14])[1], o([276, 88])[0], o([276, 88])[1]], + "1", + ); + put(d, o([350, 230]), "OPEN TO BELOW", v.p(H.note), { align: "center" }); + put( + d, + o([500, 330]), + `F.F. ${(CRITERIA.flood.ffe + HEIGHTS.l2 / 12).toFixed(2)}' NAVD`, + v.p(H.note), + { align: "center" }, + ); + } + }); + d.layer(LAYERS.symb, {}, () => { + d.line(o([CUT_X, -60]), o([CUT_X, 0]), { linetype: "PHANTOM" }); + d.line(o([CUT_X, which === "L1" ? 672 : 528]), o([CUT_X, which === "L1" ? 740 : 600]), { + linetype: "PHANTOM", + }); + sectionCut(d, v, o([CUT_X, -80]), "W", "1", "A-301"); + sectionCut(d, v, o([CUT_X, which === "L1" ? 760 : 620]), "W", "1", "A-301"); + sectionCut(d, v, o([745, which === "L1" ? 410 : 400]), "W", "2", "A-301"); + d.line(o([624, which === "L1" ? 410 : 400]), o([724, which === "L1" ? 410 : 400])); + }); + frame(d, number, (dd, sheet) => { + const vpW = 23.0; + const vpH = 20.5; + viewport(dd, sheet, { + corner: [AREA.left, AREA.top - vpH], + size: [vpW, vpH], + center: center(origin), + v, + id: `vp-${number}`, + number: "1", + sheetNo: number, + title: level.name + " PLAN", + titleAt: [AREA.left + 0.2, AREA.top - vpH - 0.5], + }); + dd.layer(LAYERS.text, {}, () => { + dd.insert("NORTH ARROW", [AREA.left + vpW - 1.0, AREA.top - vpH - 0.55]); + }); + const x = AREA.left + vpW + 0.5; + let y = AREA.top - 0.1; + y = noteColumn( + dd, + [x, y], + AREA.right - x, + "PLAN NOTES", + which === "L1" ? PLAN_NOTES : L2_NOTES, + ); + y -= 0.3; + y = legend(dd, [x, y], AREA.right - x, which); + void wrap; + }); +} + +/** The plan's legend, drawn with the plan's own symbols. */ +function legend(d: D, at: Point, w: number, which: "L1" | "L2"): number { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.3; + }); + const row = (label: string, draw: (c: Point) => void) => { + draw([at[0] + 0.3, y + 0.03]); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.7, y], label, H.note, { valign: "middle" })); + y -= 0.38; + }; + row("DOOR NUMBER, SEE A-601", (c) => + d.layer(LAYERS.tags, {}, () => d.insert("DOOR TAG", c, { attributes: { DOOR: "101" } })), + ); + row("WINDOW TYPE, SEE A-601", (c) => + d.layer(LAYERS.tags, {}, () => d.insert("WINDOW TAG", c, { attributes: { WINDOW: "A" } })), + ); + row("WINDOW MARK, SEE A-601", (c) => + d.layer(LAYERS.tags, {}, () => + put(d, c, "1-01", H.small, { align: "center", valign: "middle" }), + ), + ); + if (which === "L1") + row('8" CMU WALL', (c) => + d.layer(LAYERS.wall, {}, () => d.rect([c[0] - 0.25, c[1] - 0.04], 0.5, 0.08)), + ); + else + row("2X6 STUD WALL", (c) => + d.layer(LAYERS.wall, {}, () => d.rect([c[0] - 0.25, c[1] - 0.03], 0.5, 0.06)), + ); + row("2X4 STUD WALL", (c) => + d.layer(LAYERS.wall, {}, () => d.rect([c[0] - 0.25, c[1] - 0.02], 0.5, 0.04)), + ); + row('36" GUARD OR HALF WALL', (c) => + d.layer(LAYERS.wallLow, {}, () => d.rect([c[0] - 0.25, c[1] - 0.02], 0.5, 0.04)), + ); + row("OVERHEAD OR HIDDEN", (c) => + d.layer(LAYERS.ovhd, {}, () => d.line([c[0] - 0.25, c[1]], [c[0] + 0.25, c[1]])), + ); + row("SECTION CUT: NO. / SHEET", (c) => + d.layer(LAYERS.symb, {}, () => + d.insert("SECTION BUBBLE", c, { attributes: { SECT: "1", SHEET: "A-301" } }), + ), + ); + if (which === "L1") + row("FLOOD VENT", (c) => d.layer(LAYERS.anno, {}, () => d.insert("FLOOD VENT", c))); + return y; +} + +export const SHEETS: Record = { + "A-101": (d) => planSheet(d, "A-101", L1, "L1", PLAN_NOTES), + "A-102": (d) => planSheet(d, "A-102", L2, "L2", L2_NOTES), +}; diff --git a/examples/plan-set/sheets/roof.ts b/examples/plan-set/sheets/roof.ts new file mode 100644 index 0000000..100b33c --- /dev/null +++ b/examples/plan-set/sheets/roof.ts @@ -0,0 +1,226 @@ +// A-103: the roof plan at 1/4" = 1'-0": the hip roof from `ROOF`, the lanai's shed roof, slopes, +// gutters and downspouts, the walls below dashed, and the attic ventilation worked out from the +// attic's area. +import type { Point } from "../../../src/index.ts"; +import { DEPTH, HEIGHTS, LANAI, ROOF, ROOF_VENTS, W } from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + H, + LAYERS, + leaderNote, + noteColumn, + put, + revisionCloud, + slopeArrow, + table, + view, + viewport, + type D, + type SheetDraw, +} from "../lib/kit.ts"; + +export const ORIGIN: Point = [60000, 0]; + +function roof(d: D): void { + const v = view(d, 48); + const o = (p: Point): Point => [ORIGIN[0] + p[0], ORIGIN[1] + p[1]]; + const [x0, y0, x1, y1] = ROOF.eave; + const [r0, r1] = ROOF.ridge; + d.layer(LAYERS.roofOutl, {}, () => { + d.polyline([o([x0, y0]), o([x1, y0]), o([x1, y1]), o([x0, y1])], true); + }); + d.layer(LAYERS.roof, {}, () => { + // Hips from each corner to the ridge's ends, and the ridge. + d.line(o([x0, y0]), o(r0)); + d.line(o([x0, y1]), o(r0)); + d.line(o([x1, y0]), o(r1)); + d.line(o([x1, y1]), o(r1)); + d.line(o(r0), o(r1)); + // The lanai's shed roof against the north wall. + const [lx0, , lx1, ly1] = ROOF.lanai.rect; + d.polyline([o([lx0, y1]), o([lx0, ly1]), o([lx1, ly1]), o([lx1, y1])]); + // Gutters along the eaves, 6" K-style, drawn as a second line inside the eave. + d.polyline( + [o([x0 + 6, y0 + 6]), o([x1 - 6, y0 + 6]), o([x1 - 6, y1 - 6]), o([x0 + 6, y1 - 6])], + true, + ); + d.line(o([lx0 + 6, ly1 - 6]), o([lx1 - 6, ly1 - 6])); + // Downspouts, 3x4, at the corners and at the lanai. + for (const p of [ + [x0 + 6, y0 + 6], + [x1 - 6, y0 + 6], + [x0 + 6, y1 - 6], + [lx0 + 6, ly1 - 6], + [lx1 - 6, ly1 - 6], + [x1 - 6, y1 - 6], + ] as Point[]) { + d.rect(o([p[0] - 3, p[1] - 3]), 6, 6); + } + }); + d.layer(LAYERS.roof, {}, () => { + // Off-ridge vents, 4' long, two on each plane within 3' of the ridge or a hip. + // All on the north and south planes, clear of the hips, within 3' of the ridge measured + // vertically (FBC-R R806.2 exception). + for (const [cx, cy] of [ + [286, 244], + [338, 244], + [280, 214], + [344, 214], + [286, 284], + [338, 284], + [280, 314], + [344, 314], + ] as Point[]) { + d.rect(o([cx - 24, cy - 4]), 48, 8); + } + revisionCloud( + d, + v.p, + [o([240, 196])[0], o([240, 196])[1], o([384, 332])[0], o([384, 332])[1]], + "1", + ); + }); + d.layer(LAYERS.ovhd, {}, () => { + // The outside face of the walls below. + d.rect(o([0, 0]), W, DEPTH); + }); + d.layer(LAYERS.anno, {}, () => { + const t = v.p(H.note); + slopeArrow(d, v, o([312, 60]), -90, "6:12"); + slopeArrow(d, v, o([312, 470]), 90, "6:12"); + slopeArrow(d, v, o([40, 264]), 180, "6:12"); + slopeArrow(d, v, o([584, 264]), 0, "6:12"); + slopeArrow(d, v, o([(LANAI[0] + LANAI[2]) / 2, 640]), 90, "3:12"); + put(d, o([312, 286]), "RIDGE", t, { align: "center" }); + put(d, o([312, 230]), `RIDGE EL. ${(11 + ROOF.ridgeHeight / 12).toFixed(2)}' NAVD`, t, { + align: "center", + }); + leaderNote(d, v, o([150, 150]), o([200, -110]), [ + "STANDING SEAM METAL ROOF, 24 GA.,", + "ON SELF-ADHERED UNDERLAYMENT", + 'OVER 19/32" PLYWOOD SHEATHING', + ]); + leaderNote(d, v, o([x1 - 6, y0 + 20]), o([x1 + 40, -110]), [ + '6" ALUM. K-STYLE GUTTER,', + "3X4 DOWNSPOUT, TYP.", + ]); + leaderNote(d, v, o([x0, 400]), o([60, 430]), [ + '24" OVERHANG WITH VENTED', + "ALUM. SOFFIT, TYP.", + ]); + leaderNote(d, v, o([480, 600]), o([700, 680]), [ + "LANAI SHED ROOF, 3:12,", + "METAL ROOFING TO MATCH,", + "LEDGER FLASHED TO WALL", + ]); + leaderNote(d, v, o([340, 264]), o([470, 360]), [ + "CONTINUOUS RIDGE VENT,", + "SEE VENTILATION TABLE", + ]); + leaderNote(d, v, o([262, 314]), o([200, 380]), [ + "OFF-RIDGE VENT, 54 SQ. IN.", + "NET FREE AREA, TYP. OF 8", + ]); + }); + d.layer(LAYERS.dims, {}, () => { + dimString( + d, + v, + [o([x0, y0]), o([0, y0]), o([W, y0]), o([x1, y0])], + "x", + ORIGIN[1] + y0 - v.p(0.6), + ); + dimString( + d, + v, + [o([x0, y0]), o([x0, 0]), o([x0, DEPTH]), o([x0, y1])], + "y", + ORIGIN[0] + x0 - v.p(0.6), + ); + dimString(d, v, [o([x0, y1]), o(r0), o(r1), o([x1, y1])], "x", ORIGIN[1] + y1 + v.p(0.9)); + }); +} + +function sheet(d: D): void { + roof(d); + const v = view(d, 48); + const attic = (W * DEPTH) / 144; + const required = attic / 300; // 1/300 with balanced ventilation + const ridgeLength = ROOF.ridge[1][0] - ROOF.ridge[0][0]; + const ridgeVent = ((ridgeLength / 12) * ROOF_VENTS.ridgeRate) / 144; + // A hip has a short ridge, so off-ridge vents within 3' of the ridge make up the upper half. + const offRidge = ROOF_VENTS.offRidge; // sq in of net free area each + const offRidgeVent = (offRidge.count * offRidge.each) / 144; + const required150 = attic / 150; + const upper = ridgeVent + offRidgeVent; + const soffitLength = (2 * (W + DEPTH + 4 * HEIGHTS.overhang)) / 12; + const soffitVent = (soffitLength * ROOF_VENTS.soffitRate) / 144; + frame(d, "A-103", (dd, s) => { + const vpW = 23.0; + const vpH = 20.5; + viewport(dd, s, { + corner: [AREA.left, AREA.top - vpH], + size: [vpW, vpH], + center: [ORIGIN[0] + 312, ORIGIN[1] + 300], + v, + id: "vp-roof", + number: "1", + sheetNo: "A-103", + title: "ROOF PLAN", + titleAt: [AREA.left + 0.2, AREA.top - vpH - 0.5], + }); + dd.layer(LAYERS.text, {}, () => + dd.insert("NORTH ARROW", [AREA.left + vpW - 1.0, AREA.top - vpH - 0.55]), + ); + const x = AREA.left + vpW + 0.5; + const w = AREA.right - x; + let y = noteColumn(dd, [x, AREA.top - 0.1], w, "ROOF NOTES", [ + "ROOF COVERING, UNDERLAYMENT AND FASTENING SHALL HAVE FLORIDA PRODUCT APPROVAL FOR THE ROOF ZONE PRESSURES ON S-501, HVHZ NOT REQUIRED.", + "UNDERLAYMENT: SELF-ADHERED POLYMER-MODIFIED BITUMEN OVER THE WHOLE DECK (FBC-R R905.1.1, HIGH-WIND).", + 'ROOF SHEATHING: 19/32" APA RATED PLYWOOD, 8D RING SHANK NAILS AT 4" O.C. EDGES AND 6" O.C. FIELD.', + 'TRUSSES AT 24" O.C. BY THE TRUSS ENGINEER; SEE S-102 FOR THE LAYOUT AND UPLIFT CONNECTORS.', + "FLASH ALL VALLEYS, WALLS, PENETRATIONS AND THE LANAI LEDGER WITH 26 GA. GALVANIZED STEEL.", + "DOWNSPOUTS DISCHARGE TO SPLASH BLOCKS DIRECTED TO THE SIDE SWALES. SEE C-101.", + ]); + y -= 0.3; + y = table( + dd, + [x, y], + "ATTIC VENTILATION (FBC-R R806.2)", + [ + { name: "ITEM", width: 4.0 }, + { name: "SQ. FT.", width: w - 4.0, align: "right" }, + ], + [ + ["ATTIC FLOOR AREA", attic.toFixed(0)], + ["NET FREE AREA REQ. AT 1/150", required150.toFixed(2)], + ["NET FREE AREA REQ. AT 1/300 (BALANCED)", required.toFixed(2)], + [`RIDGE VENT, ${(ridgeLength / 12).toFixed(0)} LF AT 18 SQ. IN./FT.`, ridgeVent.toFixed(2)], + [ + `${offRidge.count} OFF-RIDGE VENTS AT ${offRidge.each} SQ. IN. EACH`, + offRidgeVent.toFixed(2), + ], + [ + `UPPER VENTS, ${Math.round((upper / (upper + soffitVent)) * 100)}% OF TOTAL`, + upper.toFixed(2), + ], + [`SOFFIT VENT, ${soffitLength.toFixed(0)} LF AT 9 SQ. IN./FT.`, soffitVent.toFixed(2)], + ["TOTAL PROVIDED", (upper + soffitVent).toFixed(2)], + ], + ); + noteColumn( + dd, + [x, y - 0.25], + w, + "VENTILATION NOTE", + [ + `THE TOTAL PROVIDED, ${(upper + soffitVent).toFixed(2)} SQ. FT., MEETS 1/150 (${required150.toFixed(2)} SQ. FT.) WITHOUT THE 1/300 EXCEPTION. OFF-RIDGE VENTS SIT ON THE NORTH AND SOUTH PLANES, CLEAR OF THE HIPS, NO MORE THAN 3' BELOW THE RIDGE MEASURED VERTICALLY.`, + ], + { numbered: false }, + ); + }); +} + +export const SHEETS: Record = { "A-103": sheet }; diff --git a/examples/plan-set/sheets/schedules.ts b/examples/plan-set/sheets/schedules.ts new file mode 100644 index 0000000..3eb689b --- /dev/null +++ b/examples/plan-set/sheets/schedules.ts @@ -0,0 +1,293 @@ +// A-601: the door and window schedules, read from the openings of `model/house.ts`, with each +// window's wind zone and design pressure worked out, the egress check of each bedroom window, +// and elevations of the window types drawn on the sheet. +import type { Point } from "../../../src/index.ts"; +import { + L1, + L2, + WINDOW_MARKS, + WINDOW_TYPES, + type Level, + type Opening, + type Room, +} from "../model/house.ts"; +import { alongXOf, widthOf } from "../model/geometry.ts"; +import { designPressure, endZone, meanRoofHeight, psf, qh } from "../model/wind.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + ftIn, + H, + LAYERS, + noteColumn, + put, + table, + type D, + type SheetDraw, +} from "../lib/kit.ts"; + +/** The room an opening serves: the room beside the middle of its cut, inside the house. */ +function roomOf(level: Level, o: Opening): Room | undefined { + const [x0, y0, x1, y1] = o.cut; + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const probes: Point[] = alongXOf(o) + ? [ + [cx, y1 + 12], + [cx, y0 - 12], + ] + : [ + [x1 + 12, cy], + [x0 - 12, cy], + ]; + for (const [px, py] of probes) { + const room = level.rooms.find( + (r) => px > r.rect[0] && px < r.rect[2] && py > r.rect[1] && py < r.rect[3], + ); + if (room) return room; + } + return undefined; +} + +const DOOR_TYPES: Record = { + door: { type: "A", material: "SOLID CORE WOOD", remarks: "PASSAGE SET" }, + double: { type: "B", material: "SOLID CORE WOOD", remarks: "PAIR, FLUSH BOLTS" }, + slider: { type: "C", material: "ALUM. / IMPACT GLASS", remarks: "4-PANEL SLIDER, FL APPROVAL" }, + overhead: { + type: "D", + material: "INSULATED STEEL", + remarks: "IMPACT RATED, OPENER, FL APPROVAL", + }, + bifold: { type: "E", material: "HOLLOW CORE WOOD", remarks: "BI-FOLD" }, +}; + +/** Net clear opening of a single hung window, from its frame: sash 2" all round, meeting rail 2". */ +function egress(tag: string): { area: number; w: number; h: number; ok: boolean } { + const t = WINDOW_TYPES[tag]!; + // A twin unit opens one sash at a time, so its clear opening is one sash's. + const w = (t.kind.includes("TWIN") ? t.width / 2 : t.width) - 4; + const h = t.kind.includes("CASEMENT") ? t.height - 4 : t.height / 2 - 3; + const area = (w * h) / 144; + return { area, w, h, ok: area >= 5.7 && h >= 24 && w >= 20 }; +} + +function sheet(d: D): void { + frame(d, "A-601", (dd) => { + const x0 = AREA.left + 0.2; + // The door schedule. + const doors: string[][] = []; + for (const level of [L1, L2]) { + for (const o of level.openings) { + if (o.kind === "window" || o.kind === "cased" || !o.tag) continue; + const t = DOOR_TYPES[o.kind]!; + const room = roomOf(level, o); + const fire = + o.tag === "103" + ? "20 MIN., SELF-CLOSING (R302.5.1)" + : o.exterior + ? t.remarks.replace("PASSAGE SET", "IMPACT RATED, DEADBOLT") + : t.remarks; + doors.push([ + o.tag, + room?.name ?? "-", + t.type, + ftIn(widthOf(o)), + ftIn(o.height ?? 80), + o.kind === "overhead" ? "-" : '1 3/4"', + o.exterior && o.kind === "door" ? "FIBERGLASS, IMPACT" : t.material, + o.exterior ? psf(designPressure(o)) : "-", + fire, + ]); + } + } + let y = table( + dd, + [x0, AREA.top - 0.1], + "DOOR SCHEDULE", + [ + { name: "NO.", width: 0.55, align: "center" }, + { name: "ROOM", width: 2.0 }, + { name: "TYPE", width: 0.6, align: "center" }, + { name: "WIDTH", width: 0.8, align: "center" }, + { name: "HEIGHT", width: 0.8, align: "center" }, + { name: "THICK.", width: 0.7, align: "center" }, + { name: "MATERIAL", width: 2.2 }, + { name: "DP (PSF)", width: 1.3, align: "center" }, + { name: "REMARKS", width: 3.6 }, + ], + doors, + { rowHeight: 0.24 }, + ); + + // The door types, drawn at 1/4" = 1'-0". + y -= 0.3; + dd.layer(LAYERS.text, {}, () => { + put(dd, [x0, y], "DOOR TYPE ELEVATIONS", H.label, { valign: "top" }); + put(dd, [x0 + 12.5, y], `SCALE: 1/4" = 1'-0"`, H.note, { valign: "top", align: "right" }); + }); + const ds = 0.25 / 12; + const types: [string, number, number, string][] = [ + ["A", 36, 80, "FLUSH, SINGLE"], + ["B", 60, 96, "FLUSH, PAIR"], + ["C", 144, 96, "4-PANEL SLIDER"], + ["D", 192, 96, "OVERHEAD, 4 X 8 PANELS"], + ["E", 60, 80, "BI-FOLD, 4 LEAVES"], + ]; + let dx = x0 + 0.2; + const dbase = y - 2.55; + for (const [tag, w0, h0, kind] of types) { + const w = w0 * ds; + const h = h0 * ds; + dd.layer(LAYERS.elev, {}, () => dd.rect([dx, dbase], w, h)); + dd.layer(LAYERS.elevFine, {}, () => { + if (tag === "B") dd.line([dx + w / 2, dbase], [dx + w / 2, dbase + h]); + if (tag === "C") + for (let k = 1; k < 4; k++) + dd.line([dx + (w * k) / 4, dbase], [dx + (w * k) / 4, dbase + h]); + if (tag === "D") + for (let k = 1; k < 4; k++) + dd.line([dx, dbase + (h * k) / 4], [dx + w, dbase + (h * k) / 4]); + if (tag === "E") + for (let k = 1; k < 4; k++) + dd.line([dx + (w * k) / 4, dbase], [dx + (w * k) / 4, dbase + h]); + if (tag === "A" || tag === "B") dd.circle([dx + w - 0.06, dbase + h * 0.45], 0.02); + }); + dd.layer(LAYERS.text, {}, () => { + put(dd, [dx + w / 2, dbase - 0.15], `TYPE ${tag}`, H.note, { + align: "center", + valign: "top", + }); + put(dd, [dx + w / 2, dbase - 0.33], kind, H.small, { align: "center", valign: "top" }); + }); + dx += Math.max(w, 1.2) + 0.5; + } + y = dbase - 0.6; + + // The window list: every window, with its room, zone and pressure. + const windows: string[][] = []; + const count: Record = {}; + for (const level of [L1, L2]) { + for (const o of level.openings) { + if (o.kind !== "window" || !o.tag) continue; + count[o.tag] = (count[o.tag] ?? 0) + 1; + const room = roomOf(level, o); + const p = designPressure(o); + const bedroom = room?.name.includes("BEDROOM") ?? false; + const e = egress(o.tag); + windows.push([ + WINDOW_MARKS.get(o) ?? "-", + o.tag, + room?.name ?? "-", + `ZONE ${p.zone}`, + psf(p), + bedroom ? (e.ok ? `YES, ${e.area.toFixed(1)} SF` : `NO, ${e.area.toFixed(1)} SF`) : "-", + ]); + } + } + y -= 0.35; + const listTop = y; + table( + dd, + [x0, y], + "WINDOW LIST", + [ + { name: "MARK", width: 0.7, align: "center" }, + { name: "TYPE", width: 0.6, align: "center" }, + { name: "ROOM", width: 2.2 }, + { name: "WIND ZONE", width: 1.1, align: "center" }, + { name: "DP (PSF, ASD)", width: 1.6, align: "center" }, + { name: "EGRESS (5.7 SF)", width: 1.6, align: "center" }, + ], + windows, + { rowHeight: 0.22 }, + ); + + // The window types, with an elevation of each at 1/2" = 1'-0". + const x2 = x0 + 8.4; + y = table( + dd, + [x2, listTop], + "WINDOW TYPES", + [ + { name: "TYPE", width: 0.6, align: "center" }, + { name: "QTY.", width: 0.6, align: "center" }, + { name: "SIZE (W X H)", width: 1.4, align: "center" }, + { name: "OPERATION", width: 1.7 }, + { name: "HEAD", width: 0.8, align: "center" }, + { name: "CLEAR OPENING", width: 1.3, align: "center" }, + { name: "GLAZING", width: 3.2 }, + ], + Object.entries(WINDOW_TYPES).map(([tag, t]) => { + const e = egress(tag); + return [ + tag, + String(count[tag] ?? 0), + `${ftIn(t.width)} X ${ftIn(t.height)}`, + t.kind, + `7'-2"/8'-0"`, + `${e.w}" X ${e.h}"`, + t.note, + ]; + }), + { rowHeight: 0.24 }, + ); + y -= 0.5; + dd.layer(LAYERS.text, {}, () => { + put(dd, [x2, y], "WINDOW TYPE ELEVATIONS", H.label, { valign: "top" }); + put(dd, [x2 + 8.3, y], `SCALE: 1/2" = 1'-0"`, H.note, { valign: "top", align: "right" }); + }); + let ex = x2 + 0.2; + const base = y - 3.4; + for (const [tag, t] of Object.entries(WINDOW_TYPES)) { + const s = 0.5 / 12; // paper inches per inch at 1/2" = 1'-0" + const w = t.width * s; + const h = t.height * s; + dd.layer(LAYERS.elev, {}, () => dd.rect([ex, base], w, h)); + dd.layer(LAYERS.elevFine, {}, () => { + dd.rect([ex + 0.06, base + 0.06], w - 0.12, h - 0.12); + if (t.kind.includes("HUNG")) { + const twin = t.kind.includes("TWIN"); + if (twin) dd.line([ex + w / 2, base], [ex + w / 2, base + h]); + dd.line([ex, base + h / 2], [ex + w, base + h / 2]); + } else if (t.kind === "CASEMENT") { + dd.polyline([ + [ex + 0.06, base + 0.06], + [ex + w - 0.06, base + h / 2], + [ex + 0.06, base + h - 0.06], + ]); + } else { + dd.polyline([ + [ex + 0.06, base + 0.06], + [ex + w / 2, base + h - 0.06], + [ex + w - 0.06, base + 0.06], + ]); + } + }); + dd.layer(LAYERS.tags, {}, () => + dd.insert("WINDOW TAG", [ex + w / 2, base - 0.35], { + scale: 1.3, + attributes: { WINDOW: tag }, + }), + ); + dd.layer(LAYERS.text, {}, () => + put(dd, [ex + w / 2, base - 0.65], `${ftIn(t.width)} X ${ftIn(t.height)}`, H.small, { + align: "center", + valign: "top", + }), + ); + ex += w + 0.45; + } + y = base - 1.1; + const q = qh(); + noteColumn(dd, [x2, y], 8.3, "SCHEDULE NOTES", [ + `DESIGN PRESSURES ARE ALLOWABLE STRESS (0.6 X STRENGTH) PER ASCE 7-22 CH. 30, VULT ${165} MPH, EXPOSURE D, MEAN ROOF HEIGHT ${meanRoofHeight().toFixed(1)}', QH = ${q.toFixed(1)} PSF, GCPI +/-0.18. ZONE 5 IS WITHIN ${endZone().toFixed(1)}' OF A CORNER.`, + "EVERY EXTERIOR WINDOW AND DOOR IS IMPACT RATED (LARGE MISSILE) WITH FLORIDA PRODUCT APPROVAL AT OR ABOVE THE PRESSURE LISTED. SUBMIT APPROVALS BEFORE INSTALLATION.", + 'EGRESS: NET CLEAR OPENING 5.7 SQ. FT. MIN., 24" HIGH AND 20" WIDE MIN., SILL 44" MAX. A.F.F. (FBC-R R310.2). SECOND-FLOOR SILLS 24" MIN. A.F.F. OR WITH OPENING CONTROL (R312.2).', + 'GLAZING IN DOORS, WITHIN 24" OF A DOOR, IN WET AREAS AND AT STAIRS IS SAFETY GLAZING (FBC-R R308.4).', + "ENERGY: WINDOW U-FACTOR 0.40 MAX., SHGC 0.25 MAX. (CLIMATE ZONE 2), PER FORM R405.", + 'DOOR 103 BETWEEN THE GARAGE AND THE HOUSE: SOLID WOOD OR STEEL 1 3/8" MIN., OR 20-MINUTE RATED, SELF-CLOSING AND SELF-LATCHING (FBC-R R302.5.1).', + ]); + }); +} + +export const SHEETS: Record = { "A-601": sheet }; diff --git a/examples/plan-set/sheets/site.ts b/examples/plan-set/sheets/site.ts new file mode 100644 index 0000000..e678d8e --- /dev/null +++ b/examples/plan-set/sheets/site.ts @@ -0,0 +1,544 @@ +// C-101: the site plan at 1" = 10'-0", in model space at the origin, inches, from the lot's +// front left property corner. The lot, setbacks, the house, the pool, the drive, utilities, +// grades and drainage, with site data and notes on the sheet. `INTENT` holds the setbacks the +// house and the pool keep, which every build checks. +import type { Intent, Point } from "../../../src/index.ts"; +import { CRITERIA, PROJECT } from "../project.ts"; +import { DEPTH, ENTRY, FOOTPRINT, LANAI, LANAI_SLAB, ROOF, W } from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + H, + LAYERS, + leaderNote, + noteColumn, + put, + table, + view, + viewport, + type D, + type SheetDraw, +} from "../lib/kit.ts"; +import { arrowhead } from "../../lib/drafting.ts"; + +export const LOT = { width: 80 * 12, depth: 125 * 12 }; +/** The house's southwest corner on the lot. */ +export const HOUSE_AT: Point = [14 * 12, 30 * 12]; +const STREET = { name: "SANDPIPER COVE DRIVE", row: 50 * 12, pavement: 22 * 12 }; +const CANAL = { name: "HERON SOUND CANAL (TIDAL)", width: 80 * 12 }; +const POOL = { x: 38 * 12, y: 92 * 12, w: 28 * 12, h: 14 * 12 }; +const DECK = 4 * 12; +const DRIVE = { x0: HOUSE_AT[0] + 12, x1: HOUSE_AT[0] + 228, flare: 5 * 12 }; + +const at = (p: Point): Point => [HOUSE_AT[0] + p[0], HOUSE_AT[1] + p[1]]; +const shoelace = (pts: readonly Point[]) => + Math.abs( + pts.reduce( + (s, p, i) => s + p[0] * pts[(i + 1) % pts.length]![1] - pts[(i + 1) % pts.length]![0] * p[1], + 0, + ), + ) / 2; + +export const footprintSite: Point[] = FOOTPRINT.map(at); +const poolDeck: Point[] = [ + [POOL.x - DECK, HOUSE_AT[1] + LANAI[3]], + [POOL.x + POOL.w + DECK, HOUSE_AT[1] + LANAI[3]], + [POOL.x + POOL.w + DECK, POOL.y + POOL.h + DECK], + [POOL.x - DECK, POOL.y + POOL.h + DECK], +]; + +/** The setbacks: the residence and the pool, by the ids the plan gives them. */ +export const INTENT: Intent = { + setbacks: [ + { + property: "lot", + footprints: ["residence"], + distance: [ + CRITERIA.zoning.front * 12, + CRITERIA.zoning.side * 12, + CRITERIA.zoning.rear * 12, + CRITERIA.zoning.side * 12, + ], + }, + { + property: "lot", + footprints: ["pool"], + distance: [ + CRITERIA.zoning.front * 12, + CRITERIA.zoning.poolSide * 12, + CRITERIA.zoning.poolRear * 12, + CRITERIA.zoning.poolSide * 12, + ], + }, + ], +}; + +function site(d: D): void { + const v = view(d, 120); + const t = v.p(H.note); + const left = -30 * 12; + const right = LOT.width + 30 * 12; + const cl = -STREET.row / 2; + const edgeN = cl + STREET.pavement / 2; + const edgeS = cl - STREET.pavement / 2; + + d.layer(LAYERS.prop, {}, () => d.rect([0, 0], LOT.width, LOT.depth, { id: "lot" })); + d.layer(LAYERS.setb, {}, () => { + const z = CRITERIA.zoning; + d.rect( + [z.side * 12, z.front * 12], + LOT.width - z.side * 24, + LOT.depth - (z.front + z.rear) * 12, + ); + }); + d.layer(LAYERS.bldg, {}, () => d.polyline(footprintSite, true, { id: "residence" })); + // The roof's overhang, dashed, and the pool. + d.layer(LAYERS.ovhd, {}, () => d.rect(at([-24, -24]), W + 48, DEPTH + 48)); + d.layer(LAYERS.pave, {}, () => { + d.rect([POOL.x, POOL.y], POOL.w, POOL.h, { id: "pool" }); + d.rect([POOL.x + 6, POOL.y + 6], POOL.w - 12, POOL.h - 12); + d.polyline(poolDeck, true); + // The drive from the garage to the street, flared, and the walk to the porch. + const flareY = edgeN + DRIVE.flare; + d.line([DRIVE.x0, HOUSE_AT[1]], [DRIVE.x0, flareY]); + d.line([DRIVE.x1, HOUSE_AT[1]], [DRIVE.x1, flareY]); + d.arc([DRIVE.x0 - DRIVE.flare, flareY], DRIVE.flare, 270, 360); + d.arc([DRIVE.x1 + DRIVE.flare, flareY], DRIVE.flare, 180, 270); + // The entry stair down from the porch, then the walk to the drive. + const e = ENTRY; + const foot = -(e.risers - 1) * e.tread; + for (let k = 0; k < e.risers; k++) d.line(at([e.x0, -k * e.tread]), at([e.x1, -k * e.tread])); + for (const x of [e.x0, e.x1]) d.line(at([x, 0]), at([x, foot])); + d.polyline([at([e.x0, foot]), at([e.x0, foot - 24]), [DRIVE.x1, at([0, foot - 24])[1]]]); + d.polyline([at([e.x1, foot]), at([e.x1, foot - 72]), [DRIVE.x1, at([0, foot - 72])[1]]]); + // Equipment pads on the east side. + d.rect([HOUSE_AT[0] + W + 18, HOUSE_AT[1] + 240], 36, 80); + d.rect([HOUSE_AT[0] + W + 18, HOUSE_AT[1] + 340], 30, 48); + // The street's edges of pavement, broken at the drive. + d.line([left, edgeN], [DRIVE.x0 - DRIVE.flare, edgeN]); + d.line([DRIVE.x1 + DRIVE.flare, edgeN], [right, edgeN]); + d.line([left, edgeS], [right, edgeS]); + }); + d.layer(LAYERS.road, {}, () => d.line([left, cl], [right, cl])); + d.layer(LAYERS.prop, {}, () => { + // The adjoining lot lines and the far right-of-way line. + d.line([left, -STREET.row], [right, -STREET.row]); + }); + d.layer(LAYERS.setb, {}, () => { + for (const x of [0, LOT.width]) d.line([x, 0], [x, -6]); + }); + d.layer(LAYERS.wall2, {}, () => { + // The seawall, its cap 1'-4" wide, along the rear property line. + d.rect([left, LOT.depth], right - left, 16); + }); + d.layer(LAYERS.water, {}, () => { + for (let k = 1; k <= 6; k++) + d.line([left, LOT.depth + 16 + k * 60], [right, LOT.depth + 16 + k * 60]); + }); + d.layer(LAYERS.util, {}, () => { + // Water service from the meter, sewer lateral to its cleanout, underground electric. + const meter: Point = [LOT.width - 60, -18]; + d.polyline([meter, [meter[0], HOUSE_AT[1] + 200], [HOUSE_AT[0] + W, HOUSE_AT[1] + 200]]); + d.polyline([ + [120, cl], + [120, -12], + [120, HOUSE_AT[1] + 340], + [HOUSE_AT[0], HOUSE_AT[1] + 340], + ]); + d.polyline([ + [60, cl - 60], + [60, HOUSE_AT[1] + 240], + [HOUSE_AT[0], HOUSE_AT[1] + 240], + ]); + }); + d.layer(LAYERS.util, {}, () => { + d.rect([LOT.width - 72, -30], 24, 24); + d.circle([120, -12], 6); + }); + d.layer(LAYERS.topo, {}, () => { + // Side swales. + for (const x of [3.5 * 12, LOT.width - 3.5 * 12]) d.line([x, 6 * 12], [x, LOT.depth - 2 * 12]); + }); + d.layer(LAYERS.tree, {}, () => { + for (const p of [ + [60, 120], + [900, 160], + [60, 1380], + [900, 1420], + [500, 60], + ] as Point[]) + d.insert("PALM", p, { scale: 84 }); + }); + + // Annotation of the plan. + d.layer(LAYERS.civAnno, {}, () => { + const course = (b: string, feet: number) => `${b} ${feet.toFixed(2)}'`; + put(d, [LOT.width / 2, LOT.depth - v.p(0.15)], course(`S 89°59'48" E`, 80), t, { + align: "center", + valign: "top", + }); + put(d, [LOT.width / 2, v.p(0.12)], course(`N 89°59'48" W`, 80), t, { align: "center" }); + put(d, [-v.p(0.12), LOT.depth * 0.62], course(`N 00°00'12" E`, 125), t, { + align: "center", + rotation: 90, + }); + put(d, [LOT.width + v.p(0.22), LOT.depth * 0.62], course(`S 00°00'12" W`, 125), t, { + align: "center", + rotation: 90, + }); + put(d, [LOT.width / 2, cl + v.p(0.1)], STREET.name, v.p(H.label), { align: "center" }); + put( + d, + [LOT.width / 2, cl - v.p(0.22)], + `${STREET.row / 12}' PUBLIC RIGHT-OF-WAY, ${STREET.pavement / 12}' ASPHALT`, + t, + { align: "center" }, + ); + put(d, [LOT.width / 2, LOT.depth + 16 + 150], CANAL.name, v.p(H.label), { align: "center" }); + put(d, [LOT.width / 2, LOT.depth + 16 + 120], "MEAN HIGH WATER EL. 1.20' NAVD (DEMO)", t, { + align: "center", + valign: "top", + }); + put(d, [-v.p(1.1), LOT.depth / 2], "LOT 13", v.p(H.label), { align: "center", rotation: 90 }); + put(d, [LOT.width + v.p(1.1), LOT.depth / 2], "LOT 15", v.p(H.label), { + align: "center", + rotation: 90, + }); + put(d, [LOT.width / 2, LOT.depth + 26], "EXIST. CONC. SEAWALL, CAP EL. 7.90' NAVD", t, { + align: "center", + }); + // The house and the rest. + const c = at([500, 300]); + put(d, c, "PROPOSED 2-STORY", v.p(H.label), { align: "center" }); + put(d, [c[0], c[1] - v.p(0.2)], "RESIDENCE", v.p(H.label), { align: "center" }); + put(d, [c[0], c[1] - v.p(0.4)], `F.F.E. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD`, t, { + align: "center", + }); + put(d, at([136, 140]), "GARAGE", t, { align: "center" }); + put(d, at([136, 110]), `SLAB ${CRITERIA.flood.garage.toFixed(2)}'`, t, { align: "center" }); + put(d, at([500, 600]), "COV. LANAI", t, { align: "center" }); + put(d, [POOL.x + POOL.w / 2, POOL.y + POOL.h / 2], "POOL (SEPARATE PERMIT)", t, { + align: "center", + valign: "middle", + }); + put(d, [(DRIVE.x0 + DRIVE.x1) / 2, 150], "CONC. DRIVEWAY", t, { + align: "center", + rotation: 90, + }); + put( + d, + [CRITERIA.zoning.side * 6, LOT.depth * 0.3], + `${CRITERIA.zoning.side}' SIDE SETBACK`, + t, + { rotation: 90, align: "center", valign: "middle" }, + ); + put( + d, + [LOT.width - CRITERIA.zoning.side * 6, LOT.depth * 0.3], + `${CRITERIA.zoning.side}' SIDE SETBACK`, + t, + { rotation: 90, align: "center", valign: "middle" }, + ); + put( + d, + [CRITERIA.zoning.side * 12 + v.p(0.1), CRITERIA.zoning.front * 12 + v.p(0.06)], + `${CRITERIA.zoning.front}' FRONT SETBACK`, + t, + ); + put( + d, + [CRITERIA.zoning.side * 12 + v.p(0.1), LOT.depth - CRITERIA.zoning.rear * 12 + v.p(0.06)], + `${CRITERIA.zoning.rear}' REAR SETBACK`, + t, + ); + leaderNote(d, v, [LOT.width - 60, -18], [LOT.width + 160, -150], ['WATER METER, 1" SERVICE']); + leaderNote( + d, + v, + [120, -12], + [-150, -110], + ['SANITARY CLEANOUT, 4" BUILDING SEWER', "@ 1/8\" PER FT. MIN., INV. 5.31' AT C.O."], + ); + leaderNote( + d, + v, + [HOUSE_AT[0] + W + 36, HOUSE_AT[1] + 288], + [LOT.width + 160, HOUSE_AT[1] + 380], + ["HP-1 AND HP-2 CONDENSERS ON", "PLATFORM, TOP AT 10.00' NAVD MIN."], + ); + leaderNote( + d, + v, + [HOUSE_AT[0] + W + 33, HOUSE_AT[1] + 364], + [LOT.width + 160, HOUSE_AT[1] + 470], + ["POOL EQUIPMENT PAD"], + ); + leaderNote(d, v, [42, 500], [-160, 640], ["GRASSED SWALE,", '6" DEEP, TYP.']); + leaderNote( + d, + v, + [60, HOUSE_AT[1] + 240], + [-160, HOUSE_AT[1] + 160], + ["U.G. ELECTRIC", "SERVICE"], + ); + // Flow arrows, from the house to the swales and the street. + for (const [a, b] of [ + [ + [HOUSE_AT[0] - 10, 700], + [60, 700], + ], + [ + [HOUSE_AT[0] + W + 10, 760], + [LOT.width - 60, 760], + ], + [ + [300, 300], + [300, 120], + ], + [ + [POOL.x - DECK - 20, LOT.depth - 90], + [60, LOT.depth - 90], + ], + [ + [POOL.x + POOL.w + DECK + 20, LOT.depth - 90], + [LOT.width - 60, LOT.depth - 90], + ], + ] as [Point, Point][]) { + d.line(a, b); + arrowhead(d, b, a, v.p(0.12)); + } + }); + // Spot grades: existing in parentheses, proposed plain. + d.layer(LAYERS.civAnno, {}, () => { + const fg = CRITERIA.flood.finishGrade.toFixed(2); + const spot = (p: Point, el: string) => + d.insert("SPOT", p, { scale: v.p(1), attributes: { EL: el } }); + spot([24, 24], "(7.20)"); + spot([LOT.width - 90, 24], "(7.15)"); + spot([24, LOT.depth - 30], "(7.70)"); + spot([LOT.width - 90, LOT.depth - 30], "(7.65)"); + spot(at([-30, 200]), fg); + spot(at([W + 30, 200]), fg); + spot(at([-30, 450]), fg); + spot(at([W + 30, 480]), fg); + spot([DRIVE.x0 + 30, edgeN + 30], "7.40"); + spot([POOL.x - 70, POOL.y + 40], LANAI_SLAB.deck.toFixed(2)); + spot([42, 640], "7.40"); + spot([LOT.width - 42, 700], "7.40"); + }); + // Dimensions in decimal feet, which a dimension writes natively. + d.layer(LAYERS.civDims, {}, () => { + const fp = footprintSite; + const xs = fp.map((p) => p[0]); + const ys = fp.map((p) => p[1]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + // Front setback to the garage, rear to the lanai, sides at the middle of the house. + dimString( + d, + v, + [ + [minX + 60, 0], + [minX + 60, minY], + ], + "y", + minX + 60, + ); + dimString( + d, + v, + [ + [LANAI[0] + HOUSE_AT[0], maxY], + [LANAI[0] + HOUSE_AT[0], LOT.depth], + ], + "y", + POOL.x - DECK - 40, + ); + dimString( + d, + v, + [ + [0, HOUSE_AT[1] + 420], + [minX, HOUSE_AT[1] + 420], + [maxX, HOUSE_AT[1] + 420], + [LOT.width, HOUSE_AT[1] + 420], + ], + "x", + HOUSE_AT[1] + 420, + ); + dimString( + d, + v, + [ + [POOL.x + POOL.w, POOL.y + POOL.h], + [LOT.width, POOL.y + POOL.h], + ], + "x", + POOL.y + POOL.h + 30, + ); + dimString( + d, + v, + [ + [POOL.x + POOL.w - 40, POOL.y + POOL.h], + [POOL.x + POOL.w - 40, LOT.depth], + ], + "y", + POOL.x + POOL.w + 80, + ); + }); +} + +function sheet(d: D): void { + const v = view(d, 120); + site(d); + const lotArea = (LOT.width * LOT.depth) / 144; + // One definition of height for the set: from the D.F.E. to the mean roof height. + const heightAboveDfe = + CRITERIA.flood.ffe + (ROOF.eaveHeight + ROOF.ridgeHeight) / 2 / 12 - CRITERIA.flood.dfe; + // Coverage counts the entry porch under the second floor, which the setback outline leaves out. + const porchArea = (112 * 72) / 144; + const fpArea = shoelace(footprintSite) / 144 + porchArea; + const roofArea = ((W + 48) * (DEPTH + 48)) / 144; + const driveArea = + ((DRIVE.x1 - DRIVE.x0) * (HOUSE_AT[1] + STREET.row / 2 - STREET.pavement / 2)) / 144; + const walkArea = (48 * 180) / 144; + const deckArea = shoelace(poolDeck) / 144 - (POOL.w * POOL.h) / 144; + const pool = (POOL.w * POOL.h) / 144; + const pads = (36 * 80 + 30 * 48) / 144; + const imperv = fpArea + driveArea + walkArea + deckArea + pool + pads; + const onLotDrive = ((DRIVE.x1 - DRIVE.x0) * HOUSE_AT[1]) / 144; + const impervLot = fpArea + onLotDrive + walkArea + deckArea + pool + pads; + const fpXs = footprintSite.map((p) => p[0]); + const fpYs = footprintSite.map((p) => p[1]); + const provided = { + front: Math.min(...fpYs) / 12, + rear: (LOT.depth - Math.max(...fpYs)) / 12, + left: Math.min(...fpXs) / 12, + right: (LOT.width - Math.max(...fpXs)) / 12, + }; + void roofArea; + void imperv; + frame(d, "C-101", (dd, s) => { + const vpW = 20.5; + const vpH = 20.6; + viewport(dd, s, { + corner: [AREA.left, AREA.top - vpH], + size: [vpW, vpH], + center: [LOT.width / 2, LOT.depth / 2 - 90], + v, + id: "vp-site", + number: "1", + sheetNo: "C-101", + title: "SITE PLAN", + titleAt: [AREA.left + 0.2, AREA.top - vpH - 0.55], + }); + dd.layer(LAYERS.text, {}, () => + dd.insert("NORTH ARROW", [AREA.left + 9.5, AREA.top - vpH - 0.6]), + ); + const x = AREA.left + vpW + 0.4; + const w = AREA.right - x; + let y = AREA.top - 0.1; + const sf = (n: number) => `${Math.round(n).toLocaleString("en-US")} SF`; + y = table( + dd, + [x, y], + "SITE DATA", + [ + { name: "ITEM", width: 3.6 }, + { name: "VALUE", width: w - 3.6 }, + ], + [ + ["PARCEL", PROJECT.parcel], + ["PARCEL ID", PROJECT.parcelId], + ["ZONING", CRITERIA.zoning.district], + [ + "FLOOD ZONE", + `${CRITERIA.flood.zone}, BFE ${CRITERIA.flood.bfe.toFixed(1)}' ${CRITERIA.flood.datum}`, + ], + ["FIRM PANEL", CRITERIA.flood.panel], + ["DESIGN FLOOD ELEV.", `${CRITERIA.flood.dfe.toFixed(2)}' (BFE + 1')`], + ["LOT AREA", `${sf(lotArea)} (${(lotArea / 43560).toFixed(3)} AC)`], + [ + "BUILDING COVERAGE", + `${sf(fpArea)} (${((fpArea / lotArea) * 100).toFixed(1)}%, ${CRITERIA.zoning.maxCoverage}% MAX.)`, + ], + ["COVERAGE INCLUDES", `HOUSE, GARAGE, ${Math.round(porchArea)} SF PORCH, LANAI`], + ["IMPERVIOUS ON LOT", `${sf(impervLot)} (${((impervLot / lotArea) * 100).toFixed(1)}%)`], + [ + "PERVIOUS ON LOT", + `${sf(lotArea - impervLot)} (${(((lotArea - impervLot) / lotArea) * 100).toFixed(1)}%)`, + ], + [ + "BUILDING HEIGHT", + `${heightAboveDfe.toFixed(2)}' D.F.E. TO MEAN ROOF (${CRITERIA.zoning.maxHeight}' MAX.)`, + ], + ], + ); + y -= 0.25; + const ft = (n: number) => `${n.toFixed(1)}'`; + const z = CRITERIA.zoning; + y = table( + dd, + [x, y], + "SETBACKS", + [ + { name: "SIDE", width: 1.6 }, + { name: "REQUIRED", width: 1.4, align: "center" }, + { name: "PROVIDED", width: 1.4, align: "center" }, + { name: "POOL REQ.", width: w - 4.4, align: "center" }, + ], + [ + ["FRONT (S)", ft(z.front), ft(provided.front), "-"], + ["REAR (N)", ft(z.rear), ft(provided.rear), ft(z.poolRear)], + ["SIDE (W)", ft(z.side), ft(provided.left), ft(z.poolSide)], + ["SIDE (E)", ft(z.side), ft(provided.right), ft(z.poolSide)], + ], + ); + y -= 0.25; + y = noteColumn(dd, [x, y], w, "SITE NOTES", [ + "BOUNDARY, BEARINGS AND EXISTING GRADES FROM A SURVEY BY OTHERS (FICTIONAL), NAVD 88. VERIFY BEFORE CONSTRUCTION.", + `THE LOWEST FLOOR IS AT ${CRITERIA.flood.ffe.toFixed(2)}' NAVD, ${(CRITERIA.flood.ffe - CRITERIA.flood.bfe).toFixed(1)}' ABOVE THE BFE. SUBMIT AN ELEVATION CERTIFICATE AT THE SLAB AND AT FINAL.`, + "THE GARAGE SLAB IS BELOW THE DFE: USE FLOOD-RESISTANT MATERIALS BELOW 10.00' NAVD AND FLOOD VENTS PER S-101.", + 'FILL UNDER THE HOUSE IS CLEAN SAND, COMPACTED TO 95% MODIFIED PROCTOR IN 12" LIFTS.', + `FINISHED GRADE ${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD AT THE FOUNDATION, FALLING 6" MIN. IN 10' (FBC-R R401.3) TO THE SIDE SWALES AT 7.40' AND THE STREET. THE REAR YARD DRAINS TO THE SIDE SWALES, NOT OVER THE SEAWALL. NO RUNOFF ONTO ADJOINING LOTS.`, + "INSTALL SILT FENCE ALONG THE SEAWALL AND THE SIDE LOT LINES BEFORE CLEARING. NO DISCHARGE TO THE CANAL.", + "THE DRIVEWAY APRON IN THE RIGHT-OF-WAY NEEDS ITS OWN PERMIT FROM THE COUNTY.", + "THE POOL, ITS DECK AND ITS BARRIER ARE UNDER A SEPARATE PERMIT. SHOWN FOR SETBACK AND COVERAGE ONLY.", + ]); + y -= 0.25; + legend(dd, [x, y], w); + }); +} + +function legend(d: D, at0: Point, w: number): void { + let y = at0[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at0[0], y], "LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at0[0], y], [at0[0] + w, y], { lineweight: 0.35 }); + y -= 0.22; + }); + const row = (layer: string, label: string, draw?: (c: Point) => void) => { + d.layer(layer, {}, () => + draw ? draw([at0[0] + 0.5, y + 0.04]) : d.line([at0[0], y + 0.04], [at0[0] + 1.0, y + 0.04]), + ); + d.layer(LAYERS.text, {}, () => + put(d, [at0[0] + 1.3, y], label, H.note, { valign: "baseline" }), + ); + y -= 0.27; + }; + row(LAYERS.prop, "PROPERTY LINE"); + row(LAYERS.setb, "BUILDING SETBACK LINE"); + row(LAYERS.ovhd, "ROOF OVERHANG"); + row(LAYERS.util, "UNDERGROUND UTILITY"); + row(LAYERS.topo, "SWALE"); + row(LAYERS.civAnno, "PROPOSED / (EXISTING) GRADE", (c) => + d.insert("SPOT", c, { attributes: { EL: "8.00 / (7.20)" } }), + ); +} + +export const SHEETS: Record = { "C-101": sheet }; diff --git a/examples/plan-set/sheets/structure-details.ts b/examples/plan-set/sheets/structure-details.ts new file mode 100644 index 0000000..831e740 --- /dev/null +++ b/examples/plan-set/sheets/structure-details.ts @@ -0,0 +1,528 @@ +// S-501: four structural details, each in its own viewport at its own scale, with the +// structural general notes and the design criteria table on the paper. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; +import { HEIGHTS } from "../model/house.ts"; +import { endZone, meanRoofHeight, qh, roofAngle, roofPressures } from "../model/structure-wind.ts"; +import { frame } from "../lib/frame.ts"; +import { breakLine } from "../../lib/drafting.ts"; +import { + AREA, + ftIn, + LAYERS, + leaderNote, + noteColumn, + table, + view, + viewport, + type D, + type View, +} from "../lib/kit.ts"; + +/** The first floor above finished grade at the foundation, inches. */ +export const FFE_ABOVE_GRADE = (CRITERIA.flood.ffe - CRITERIA.flood.finishGrade) * 12; +import { + barDot, + box, + cmu, + concrete, + fill, + level, + SL, + soil, + structureSetup, +} from "./structure-kit.ts"; + +const O = { + d1: [230000, 0] as Point, + d2: [232000, 0] as Point, + d3: [234000, 0] as Point, + d4: [236000, 0] as Point, +}; + +type Tx = (x: number, y: number) => Point; +const at = + (o: Point): Tx => + (x, y) => [o[0] + x, o[1] + y]; +const bx = (o: Point) => (x0: number, y0: number, x1: number, y1: number) => + box(o[0] + x0, o[1] + y0, o[0] + x1, o[1] + y1); + +/** A piece of lumber cut in section: its outline and the cross drafters draw in it. */ +export function wood(d: D, p: Tx, x0: number, y0: number, x1: number, y1: number): void { + d.layer(LAYERS.sectFine, {}, () => { + d.polyline([p(x0, y0), p(x1, y0), p(x1, y1), p(x0, y1)], true); + d.line(p(x0, y0), p(x1, y1)); + d.line(p(x0, y1), p(x1, y0)); + }); +} + +/** A linear dimension in a detail, with its feet-and-inches text. */ +export function dim( + d: D, + v: View, + a: Point, + b: Point, + line: Point, + along: "x" | "y", + suffix = "", +): void { + const len = along === "x" ? Math.abs(b[0] - a[0]) : Math.abs(b[1] - a[1]); + d.layer(LAYERS.strDims, {}, () => { + d.linearDimension(a, b, line, { + style: v.dim, + ...(along === "y" ? { along: "y" as const } : {}), + text: ftIn(len) + suffix, + }); + }); +} + +// --------------------------------------------------------------------------------------------- +// 1: the typical stem wall and footing. Finished grade at y 0, the wall's outer face at x 0. + +function stemWallDetail(d: D): void { + const v = view(d, 16); + const p = at(O.d1); + const b = bx(O.d1); + const ff = FFE_ABOVE_GRADE; + soil(d, b(-30, -30, 56, 0), [b(-6, -18, 14, -6), b(0, -6, 8, 0), b(8, -6, 56, 0)], v); + fill(d, b(8, -6, 56, ff - 4), [], v, 11); + concrete(d, b(-6, -18, 14, -6), 3, v); + concrete(d, b(8, ff - 4, 56, ff), 5, v); + cmu(d, b(0, -6, 8, ff + 24), v); + d.layer(LAYERS.sectFine, {}, () => { + breakLine(d, p(-3, ff + 24), p(11, ff + 24), v.p(0.1)); + breakLine(d, p(56, ff - 10), p(56, ff + 6), v.p(0.1)); + // The vapor retarder under the slab, the welded wire in it, and the stucco. + d.line(p(8, ff - 4.15), p(56, ff - 4.15)); + d.line(p(9, ff - 2), p(55, ff - 2), { linetype: "HIDDEN" }); + d.line(p(-0.75, 1), p(-0.75, ff + 24)); + }); + d.layer(LAYERS.grade, {}, () => d.line(p(-30, 0), p(0, 0))); + for (const x of [-2.69, 4, 10.69]) barDot(d, p(x, -14.69)); + d.layer(SL.rebar, {}, () => d.polyline([p(4, ff + 22), p(4, -13.2), p(11, -13.2)])); + level(d, v, p(62, ff), "F.F.E.", `EL. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD`); + dim(d, v, p(-6, -18), p(14, -18), p(0, -24), "x"); + dim(d, v, p(-6, -18), p(-6, -6), p(-12, 0), "y"); + dim(d, v, p(0, -18), p(0, 0), p(-20, 0), "y", " MIN."); + dim(d, v, p(0, 0), p(0, ff), p(-20, 0), "y"); + d.layer(LAYERS.strAnno, {}, () => { + leaderNote(d, v, p(30, ff - 1), p(66, ff + 18), [ + '4" CONC. SLAB, 3000 PSI, W/', + "6X6-W1.4XW1.4 W.W.F.", + ]); + leaderNote(d, v, p(46, ff - 4.15), p(66, ff - 12), [ + "10 MIL VAPOR RETARDER,", + 'LAP 6" AND TAPE', + ]); + leaderNote(d, v, p(36, 16), p(66, 14), [ + "CLEAN SAND FILL, COMPACTED", + 'TO 95% MOD. PROCTOR IN 12"', + "LIFTS, TERMITE TREATED", + ]); + leaderNote(d, v, p(7, ff + 14), p(30, ff + 34), [ + '8" CMU, CELLS AT BARS GROUTED', + "SOLID; LADDER JOINT REINF. AT", + '16" O.C.', + ]); + leaderNote(d, v, p(4, 4), p(66, -2), [ + '#5 VERT. AT 48" O.C. AND EA.', + "SIDE OF OPENINGS, STD. HOOK", + 'IN FTG., LAP 30" MIN.', + ]); + leaderNote(d, v, p(12, -10), p(66, -16), [ + '20" X 12" CONT. CONC. FTG.', + 'W/ (3) #5 CONT., 3" CLR.', + ]); + leaderNote(d, v, p(24, -26), p(66, -28), [ + "UNDISTURBED SOIL OR COMPACTED", + `FILL, ${CRITERIA.soil} PSF ALLOWABLE`, + ]); + leaderNote(d, v, p(-14, 0), p(-28, 12), [ + "FINISHED GRADE EL.", + `${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD`, + ]); + leaderNote(d, v, p(-0.75, ff + 10), p(-14, ff + 18), ["STUCCO PER ARCH."]); + }); +} + +// --------------------------------------------------------------------------------------------- +// 2: the slab step at the garage wall. The house is on the left, the garage on the right. + +function garageStepDetail(d: D): void { + const v = view(d, 16); + const p = at(O.d2); + const b = bx(O.d2); + const house = HEIGHTS.garageDrop; // the house slab above the garage slab + const g = (CRITERIA.flood.garage - CRITERIA.flood.finishGrade) * 12; // the garage slab's top above finished grade + const top = g + house; + soil(d, b(-40, -30, 48, -6), [b(-6, -18, 14, -6)], v); + fill(d, b(-40, -6, 0, top - 4), [], v, 17); + fill(d, b(8.5, -6, 48, g - 4), [], v, 19); + concrete(d, b(-6, -18, 14, -6), 7, v); + concrete(d, b(-40, top - 4, 0, top), 9, v); + concrete(d, b(8.5, g - 4, 48, g), 13, v); + cmu(d, b(0, -6, 8, top + 24), v); + d.layer(LAYERS.sectFine, {}, () => { + breakLine(d, p(-3, top + 24), p(11, top + 24), v.p(0.1)); + breakLine(d, p(-40, top - 10), p(-40, top + 6), v.p(0.1)); + breakLine(d, p(48, g - 10), p(48, g + 6), v.p(0.1)); + d.line(p(-40, top - 4.15), p(0, top - 4.15)); + d.line(p(8.5, g - 4.15), p(48, g - 4.15)); + // The expansion joint and the gypsum board on the garage face. + d.rect(p(8, g - 4), 0.5, 4); + d.line(p(8.6, g + 1), p(8.6, top + 24)); + }); + for (const x of [-2.69, 4, 10.69]) barDot(d, p(x, -14.69)); + d.layer(SL.rebar, {}, () => d.polyline([p(4, top + 22), p(4, -13.2), p(11, -13.2)])); + dim(d, v, p(-6, -18), p(14, -18), p(0, -24), "x"); + dim(d, v, p(8, g), p(8, top), p(24, 0), "y"); + d.layer(LAYERS.strAnno, {}, () => { + leaderNote(d, v, p(4, top + 12), p(26, top + 34), [ + '8" CMU WALL, GARAGE TO HOUSE, 1/2" GWB', + 'ON GARAGE WALLS; 5/8" TYPE X GWB ON', + "GARAGE CEILING (FBC-R TABLE R302.6)", + ]); + leaderNote(d, v, p(4, 26), p(52, 44), [ + `STEM WALL RETAINS ${ftIn(HEIGHTS.garageDrop)} OF`, + 'FILL: #5 VERT. AT 32" O.C.,', + "CELLS GROUTED SOLID", + ]); + leaderNote(d, v, p(8.25, g - 2), p(52, 24), ['1/2" EXPANSION JOINT']); + leaderNote(d, v, p(30, g - 1), p(52, 4), [ + '4" CONC. GARAGE SLAB W/', + "W.W.F. ON 10 MIL V.R., T.O.", + `SLAB EL. ${CRITERIA.flood.garage.toFixed(2)}' NAVD`, + ]); + leaderNote(d, v, p(12, -10), p(52, -16), ['20" X 12" CONT. FTG.', "W/ (3) #5 CONT."]); + leaderNote(d, v, p(-24, top - 1), p(-44, top + 16), [ + '4" CONC. SLAB W/ W.W.F.', + "ON 10 MIL V.R., T.O. SLAB", + `EL. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD (F.F.E.)`, + ]); + leaderNote(d, v, p(-24, 10), p(-44, 6), ["COMPACTED FILL, 95%", "MOD. PROCTOR"]); + }); +} + +// --------------------------------------------------------------------------------------------- +// 3: the tie beam and lintel over an opening, with the floor truss bearing and its straps. +// The wall's outer face at x 0, the window head (the lintel's bottom) at y 0. + +function tieBeamDetail(d: D): void { + const v = view(d, 12); + const p = at(O.d3); + const b = bx(O.d3); + const tb = 24; // top of the tie beam + const bc = tb + 1.5; // top of the sill plate, the truss's bearing + const tc = bc + 16; // top of the floor truss + const fl = tc + 0.75; // top of the sheathing + // The window head below the lintel. + d.layer(LAYERS.sectFine, {}, () => { + d.rect(p(1.5, -3), 5, 3); + d.line(p(3.5, -3), p(3.5, -14)); + d.line(p(4.5, -3), p(4.5, -14)); + breakLine(d, p(0, -14), p(8, -14), v.p(0.1)); + }); + concrete(d, b(0, 0, 8, 8), 21, v); + concrete(d, b(0, 8, 8, tb), 23, v); + barDot(d, p(4, 2.3)); + barDot(d, p(2.3, tb - 3)); + barDot(d, p(5.7, tb - 3)); + // The sill plate and its anchor bolt. + wood(d, p, 0.5, tb, 7.75, bc); + d.layer(LAYERS.sectFine, {}, () => d.polyline([p(4, bc + 0.6), p(4, tb - 7), p(5.5, tb - 7)])); + // The rim board, the floor truss and the sheathing. + wood(d, p, 0.5, bc, 1.625, tc); + d.layer(LAYERS.sectFine, {}, () => { + const x0 = 1.625; + const xEnd = 40; + d.polyline([p(xEnd, bc), p(x0, bc), p(x0, tc), p(xEnd, tc)]); + d.line(p(x0, bc + 1.5), p(xEnd, bc + 1.5)); + d.line(p(x0, tc - 1.5), p(xEnd, tc - 1.5)); + d.line(p(x0 + 1.5, bc + 1.5), p(x0 + 1.5, tc - 1.5)); + // Webs: a zigzag of 2x4s, each drawn as its two edges. + const webs: [number, number, number, number][] = [ + [3.1, bc + 1.5, 14, tc - 1.5], + [14, tc - 1.5, 26, bc + 1.5], + [26, bc + 1.5, 38, tc - 1.5], + ]; + for (const [x1, y1, x2, y2] of webs) { + d.line(p(x1, y1), p(x2, y2)); + d.line(p(x1 + 2, y1), p(x2 + 2, y2)); + } + breakLine(d, p(xEnd, bc - 2), p(xEnd, fl + 2), v.p(0.1)); + d.rect(p(0.5, tc), xEnd - 0.5, 0.75); + // The ceiling under the truss. + d.rect(p(8, bc - 0.5), xEnd - 8, 0.5); + }); + // The second floor wall: plate, stud and sheathing. + wood(d, p, 0.4375, fl, 6, fl + 1.5); + d.layer(LAYERS.sectFine, {}, () => { + d.polyline([p(0.4375, fl + 22), p(0.4375, fl + 1.5), p(6, fl + 1.5), p(6, fl + 22)]); + d.rect(p(0, bc), 0.4375, fl + 22 - bc); + breakLine(d, p(-2, fl + 22), p(8, fl + 22), v.p(0.1)); + }); + // The straps: the stud-to-tie-beam strap on the outside, the truss anchor on the inside. + d.layer(SL.rebar, {}, () => { + d.polyline([p(2, tb - 8), p(-0.3, tb - 6), p(-0.3, fl + 16)]); + d.polyline([p(7.2, tb - 6), p(7.2, tb), p(7.9, bc), p(7.9, bc + 9)]); + }); + level(d, v, p(44, tb), "T.O. TIE BEAM", `${ftIn(HEIGHTS.l1Plate)} A.F.F.`); + level(d, v, p(44, fl), "SECOND FLOOR", `${ftIn(HEIGHTS.l2)} A.F.F.`); + dim(d, v, p(0, 0), p(0, 8), p(-5, 0), "y"); + dim(d, v, p(0, 8), p(0, tb), p(-5, 0), "y"); + d.layer(LAYERS.strAnno, {}, () => { + leaderNote(d, v, p(0.2, fl + 12), p(-12, fl + 18), ['7/16" OSB SHEATHING']); + leaderNote(d, v, p(-0.3, bc + 6), p(-12, bc + 12), [ + "EMBEDDED STRAP 'C2' AT 32\" O.C.,", + "2ND FLR. STUD TO TIE BEAM,", + "SEE SCHEDULE ON S-102", + ]); + leaderNote(d, v, p(2.3, tb - 3), p(-12, 16), [ + "8\" X 16\" CONC. TIE BEAM 'TB-1',", + '(2) #5 CONT., LAP 30" MIN.', + ]); + leaderNote(d, v, p(4, 2.3), p(-12, 4), [ + "PRECAST LINTEL, (1) #5 BOT.,", + "SEE LINTEL SCHEDULE S-102", + ]); + leaderNote(d, v, p(1.5, -8), p(-12, -8), [ + "IMPACT WINDOW PER A-601,", + "ANCHORS PER PRODUCT APPROVAL", + ]); + leaderNote(d, v, p(24, tc + 0.4), p(44, fl + 14), ['3/4" T&G SHEATHING,', "GLUED AND NAILED"]); + leaderNote(d, v, p(26, bc + 8), p(44, bc + 6), [ + "16\" FLOOR TRUSS 'FT-1' AT", + '24" O.C., SEE S-102', + ]); + leaderNote(d, v, p(7.9, bc + 5), p(44, 14), [ + "EMBEDDED TRUSS ANCHOR 'C4'", + "AT EA. FLOOR TRUSS", + ]); + leaderNote(d, v, p(5, tb + 0.75), p(44, 6), [ + 'PT 2X8 SILL, 1/2" J-BOLTS', + 'AT 48" O.C., 7" EMBED.', + ]); + }); +} + +// --------------------------------------------------------------------------------------------- +// 4: a roof truss on a concrete tie beam (the lanai beam), with its uplift anchor. The wall's +// outer face at x 0, the top of the tie beam at y 12. + +function trussAnchorDetail(d: D): void { + const v = view(d, 12); + const p = at(O.d4); + const b = bx(O.d4); + const pitch = 3 / 12; + // LB-1, 8" x 20", cut between its columns, so open below. + concrete(d, b(0, -8, 8, 12), 31, v); + for (const y of [9, -5]) for (const x of [2.3, 5.7]) barDot(d, p(x, y)); + d.layer(LAYERS.sectFine, {}, () => { + // The stirrup, with 1 1/2" cover. + d.rect(p(1.5, -6.5), 5, 17); + // The bottom chord, the heel and the top chord, which runs out over the overhang. + const x1 = 44; + d.rect(p(0, 12), x1, 3.5); + const yb = (x: number) => 12 + 3.5 + 3 + pitch * x; // the top chord's underside + const t = 3.5 / Math.cos(Math.atan(pitch)); + d.polyline([p(-18, yb(-18)), p(x1, yb(x1))]); + d.polyline([p(-18, yb(-18) + t), p(x1, yb(x1) + t)]); + d.line(p(-18, yb(-18)), p(-18, yb(-18) + t)); + d.rect(p(0, 15.5), 1.5, yb(0) - 15.5 + 0.4); + d.line(p(1.5, 15.5), p(24, yb(24))); + d.line(p(3.5, 15.5), p(26, yb(26))); + breakLine(d, p(x1, 10), p(x1, yb(x1) + t + 3), v.p(0.1)); + // Sheathing and roofing, the fascia and the soffit. + d.polyline([p(-19.5, yb(-19.5) + t + 0.6), p(x1, yb(x1) + t + 0.6)]); + d.polyline([p(-20, yb(-20) + t + 1.1), p(x1, yb(x1) + t + 1.1)]); + d.rect(p(-19.5, yb(-18) - 1), 1.5, 5.5); + d.line(p(-18, yb(-18) - 0.5), p(-0.75, yb(-18) - 0.5)); + d.line(p(-0.75, -8), p(-0.75, yb(-18) - 0.5)); + d.line(p(-0.75, -8), p(8, -8)); + }); + d.layer(SL.rebar, {}, () => d.polyline([p(6.2, 8), p(6.2, 12), p(6.2, 15.5), p(6.2, 19)])); + dim(d, v, p(-18, -12), p(0, -12), p(0, -16), "x"); + dim(d, v, p(8, -8), p(8, 12), p(12, 0), "y"); + level(d, v, p(52, 12), "T.O. LB-1", `${ftIn(HEIGHTS.lanaiPlate)} A.F.F.`); + d.layer(LAYERS.strAnno, {}, () => { + leaderNote(d, v, p(30, 26), p(52, 38), [ + "ENGINEERED MONO TRUSS 'MT-1'", + 'AT 24" O.C., SEE S-102', + ]); + leaderNote(d, v, p(6.2, 17), p(52, 26), [ + 'EMBEDDED TRUSS ANCHOR, 4" MIN.', + "EMBED., FASTENED PER MFR.,", + "UPLIFT PER CONNECTOR SCHEDULE", + "ON S-102 ('C5'; GIRDERS 'C3')", + ]); + leaderNote(d, v, p(5.7, 9), p(52, 6), [ + 'LB-1: 8" X 20" CONC. LANAI BEAM,', + "(2) #5 TOP AND (2) #5 BOT.", + ]); + leaderNote(d, v, p(6.5, -2), p(52, -6), ['#3 STIRRUPS AT 8" O.C.']); + leaderNote(d, v, p(4, -8), p(52, -16), [ + "SIM. WHERE A TRUSS BEARS ON A", + "CMU TIE BEAM (TB-1): SAME ANCHOR", + "AND EMBEDMENT", + ]); + leaderNote( + d, + v, + p(-8, 18.5 + pitch * -8 + 3.5 / Math.cos(Math.atan(pitch)) + 0.6), + p(-12, 34), + ['19/32" PLY. SHEATHING,', '8D RING SHANK AT 4"/6"'], + ); + leaderNote(d, v, p(-10, 18.5 + pitch * -18 - 0.5), p(-22, 4), ["VENTED SOFFIT PER ARCH."]); + }); +} + +// --------------------------------------------------------------------------------------------- +// The sheet. + +const GENERAL = [ + `DESIGN: ${CRITERIA.code} AND ASCE 7-22. THE STRUCTURE IS STABLE ONLY WHEN COMPLETE; THE CONTRACTOR BRACES IT UNTIL THE ROOF DIAPHRAGM AND ALL CONNECTORS ARE IN PLACE.`, + "THE CONTRACTOR VERIFIES ALL DIMENSIONS AND CONDITIONS AND REPORTS ANY CONFLICT TO THE ENGINEER BEFORE PROCEEDING. DO NOT SCALE THE DRAWINGS.", + `FOUNDATIONS: ALLOWABLE SOIL BEARING ${CRITERIA.soil} PSF, ASSUMED. A GEOTECHNICAL ENGINEER VERIFIES IT AND THE COMPACTION OF FILL (95% MODIFIED PROCTOR, ASTM D1557) BEFORE FOOTINGS ARE PLACED.`, + `CONCRETE: ${CRITERIA.concrete} PSI AT 28 DAYS, ACI 318, 5" SLUMP MAX., NORMAL WEIGHT. COVER: 3" CAST AGAINST EARTH, 1 1/2" FORMED. NO WATER ADDED AT THE SITE.`, + `REINFORCING: ASTM A615 GRADE ${CRITERIA.rebar}. LAP 48 BAR DIAMETERS (30" FOR #5) UNLESS NOTED. CORNER BARS AT EVERY CORNER AND INTERSECTION OF FOOTINGS AND TIE BEAMS. WWF: ASTM A1064, IN FLAT SHEETS.`, + `MASONRY: CMU ASTM C90, f'm = ${CRITERIA.cmu} PSI. TYPE S MORTAR, ASTM C270. GROUT ASTM C476, 3000 PSI, 8" TO 11" SLUMP. GROUT EVERY CELL WITH A BAR SOLID; CLEANOUTS WHERE A LIFT IS OVER 5'-4".`, + 'TIE BEAMS: 8" X 16" (TB-1) CONTINUOUS ON EVERY CMU WALL, POURED MONOLITHICALLY WITH THE CELLS BELOW. STEPPED TIE BEAMS LAP 30" AND GET A VERTICAL #5 AT EACH STEP.', + 'LINTELS: PRECAST, WITH FLORIDA PRODUCT APPROVAL, PER THE LINTEL SCHEDULE ON S-102. 8" MIN. BEARING EACH END, ON A GROUTED CELL WITH A #5 VERTICAL.', + "WOOD: SOUTHERN PINE NO. 2 OR BETTER. LUMBER ON CONCRETE OR MASONRY IS PRESERVATIVE TREATED. NAILING PER FBC-R TABLE R602.3(1) UNLESS NOTED.", + "TRUSSES: DESIGNED BY A FLORIDA-REGISTERED ENGINEER FOR THE LOADS AND WIND CRITERIA ON THIS SHEET. SUBMIT SEALED TRUSS DRAWINGS AND THE LAYOUT BEFORE FABRICATION. BRACE PER BCSI.", + "CONNECTORS: GALVANIZED G185 OR STAINLESS, WITH FLORIDA PRODUCT APPROVAL, RATED FOR AT LEAST THE UPLIFT ON S-102. INSTALL EVERY FASTENER THE MANUFACTURER CALLS FOR.", + 'SHEATHING: ROOF 19/32" PLYWOOD, 8D RING-SHANK NAILS AT 4" O.C. EDGES AND 6" FIELD. WALLS 7/16" OSB, 8D AT 4" EDGES AND 8" FIELD. FLOOR 3/4" T&G, GLUED AND NAILED.', + `FLOOD: MATERIALS BELOW THE DESIGN FLOOD ELEVATION (${CRITERIA.flood.dfe.toFixed(2)}' NAVD) ARE FLOOD-RESISTANT (FEMA TB 2). FLOOD VENTS PER S-101.`, +]; + +/** The roof dead load's share on the top chord; the rest hangs from the bottom chord. */ +export const ROOF_TCDL = 10; + +/** The roof components-and-cladding table, ASD, for roofing and sheathing (EWA 10 sq. ft.). */ +const sgn = (p: number) => `${p > 0 ? "+" : ""}${p.toFixed(1)}`; +const ROOF_CC_ROWS: string[][] = roofPressures().map((r) => [ + r.zone, + r.where, + r.gcpPos === null ? r.gcp.toFixed(1) : `${sgn(r.gcpPos)} / ${r.gcp.toFixed(1)}`, + r.pos === null ? sgn(r.neg) : `${sgn(r.pos)} / ${sgn(r.neg)}`, +]); + +const ROOF_CC_NOTES = [ + `ASCE 7-22 CH. 30 PART 1, HIP ROOF FIG. 30.3-2F, VULT ${CRITERIA.wind.vult} MPH, EXPOSURE ${CRITERIA.wind.exposure}, KD 0.85, GCPI +/-0.18. ASD = 0.6 X STRENGTH, AS ON A-601. NEGATIVE IS UPLIFT (AWAY FROM THE SURFACE).`, + `ZONE 3 IS WITHIN a = ${endZone().toFixed(1)}' OF AN EAVE; ZONE 2 IS WITHIN a OF THE RIDGE AND HIPS; ZONE 1 IS THE REST. PRESSURES ARE AT AN EFFECTIVE WIND AREA OF 10 SQ. FT., FOR ROOFING, UNDERLAYMENT, SHEATHING AND THEIR FASTENERS.`, + "OVERHANGS: ROOF GCP PLUS THE WALL'S +1.0 UNDER THE SOFFIT (SECTION 30.7), NO GCPI. ROOFING, SOFFIT AND THEIR PRODUCT APPROVALS (G-001 NOTE 5, A-103 NOTE 1) MEET OR EXCEED THESE VALUES.", +]; + +const CRITERIA_ROWS: string[][] = [ + ["BUILDING CODE", "2023 FBC-R, 8TH EDITION"], + ["LOADS", "ASCE 7-22"], + ["ULTIMATE WIND SPEED, VULT", `${CRITERIA.wind.vult} MPH (3-SEC. GUST)`], + ["NOMINAL WIND SPEED, VASD", `${CRITERIA.wind.vasd} MPH`], + ["RISK CATEGORY", CRITERIA.wind.risk], + ["WIND EXPOSURE", CRITERIA.wind.exposure], + ["ENCLOSURE", CRITERIA.wind.enclosure], + ["INTERNAL PRESSURE COEF., GCPI", CRITERIA.wind.gcpi], + ["MEAN ROOF HEIGHT / QH", `${meanRoofHeight().toFixed(1)}' ABOVE GRADE / ${qh().toFixed(1)} PSF`], + [ + "ROOF SLOPE / END ZONE a", + `${HEIGHTS.pitch}:12 (${roofAngle().toFixed(1)} DEG.) HIP / ${endZone().toFixed(1)}'`, + ], + ["WIND-BORNE DEBRIS REGION", CRITERIA.wind.debris], + [ + "FLOOD ZONE / BFE / DFE", + `${CRITERIA.flood.zone} / ${CRITERIA.flood.bfe.toFixed(1)}' / ${CRITERIA.flood.dfe.toFixed(1)}' ${CRITERIA.flood.datum}`, + ], + ["FLOOR LIVE LOAD", `${CRITERIA.live.floor} PSF (SLEEPING ${CRITERIA.live.sleeping} PSF)`], + ["ROOF LIVE LOAD", `${CRITERIA.live.roof} PSF`], + ["DEAD LOADS", `ROOF ${CRITERIA.dead.roof} PSF TOTAL, FLOOR ${CRITERIA.dead.floor} PSF`], + [ + "ROOF DEAD LOAD, TRUSSES", + `TC ${ROOF_TCDL} PSF + BC ${CRITERIA.dead.roof - ROOF_TCDL} PSF = ${CRITERIA.dead.roof} PSF`, + ], + ["SOIL BEARING (ASSUMED)", `${CRITERIA.soil.toLocaleString("en-US")} PSF`], + ["CONCRETE, f'c", `${CRITERIA.concrete.toLocaleString("en-US")} PSI AT 28 DAYS`], + ["REINFORCING", `ASTM A615 GRADE ${CRITERIA.rebar}`], + ["MASONRY, f'm", `${CRITERIA.cmu.toLocaleString("en-US")} PSI`], + ["OCCUPANCY / CONSTRUCTION", `R-3 / ${CRITERIA.construction}`], +]; + +export function detailsSheet(d: D): void { + structureSetup(d); + stemWallDetail(d); + garageStepDetail(d); + tieBeamDetail(d); + trussAnchorDetail(d); + const v16 = view(d, 16); + const v12 = view(d, 12); + frame(d, "S-501", (dd, sheet) => { + const w = 11.0; + const h = 9.9; + const xs = [AREA.left, AREA.left + w + 0.2]; + const ys = [AREA.top - h, AREA.top - 2 * h - 1.0]; + const views = [ + { + no: "1", + title: "TYPICAL STEM WALL FOOTING", + v: v16, + c: [O.d1[0] + 28, O.d1[1] + 22] as Point, + }, + { no: "2", title: "GARAGE SLAB STEP", v: v16, c: [O.d2[0] + 4, O.d2[1] + 20] as Point }, + { + no: "3", + title: "TIE BEAM, LINTEL AND FLOOR TRUSS STRAP", + v: v12, + c: [O.d3[0] + 12, O.d3[1] + 26] as Point, + }, + { + no: "4", + title: "TRUSS TO TIE BEAM UPLIFT CONNECTION", + v: v12, + c: [O.d4[0] + 20, O.d4[1] + 10] as Point, + }, + ]; + views.forEach((x, k) => { + const corner: Point = [xs[k % 2]!, ys[Math.floor(k / 2)]!]; + viewport(dd, sheet, { + corner, + size: [w, h], + center: x.c, + v: x.v, + id: `vp-S-501-${x.no}`, + number: x.no, + sheetNo: "S-501", + title: x.title, + titleAt: [corner[0] + 0.2, corner[1] - 0.4], + }); + }); + const x = AREA.left + 2 * w + 0.6; + const cw = AREA.right - x; + let y = AREA.top - 0.1; + y = table( + dd, + [x, y], + "DESIGN CRITERIA", + [ + { name: "ITEM", width: 2.9 }, + { name: "VALUE", width: cw - 2.9 }, + ], + CRITERIA_ROWS, + ); + y -= 0.35; + y = noteColumn(dd, [x, y], cw, "STRUCTURAL GENERAL NOTES", GENERAL); + y -= 0.25; + y = table( + dd, + [x, y], + "ROOF COMPONENT AND CLADDING PRESSURES (ASD)", + [ + { name: "ZONE", width: 0.8, align: "center" }, + { name: "LOCATION", width: 2.9 }, + { name: "GCP (+ / -)", width: 1.1, align: "center" }, + { name: "PSF (+ / -)", width: cw - 4.8, align: "center" }, + ], + ROOF_CC_ROWS, + ); + y -= 0.25; + noteColumn(dd, [x, y], cw, "ROOF PRESSURE NOTES", ROOF_CC_NOTES); + }); +} diff --git a/examples/plan-set/sheets/structure-framing.ts b/examples/plan-set/sheets/structure-framing.ts new file mode 100644 index 0000000..afd3f12 --- /dev/null +++ b/examples/plan-set/sheets/structure-framing.ts @@ -0,0 +1,740 @@ +// S-102: the second floor framing plan and the roof framing plan, side by side at +// 3/16" = 1'-0", with the connector, lintel and beam schedules. +import type { Point } from "../../../src/index.ts"; +import { + ATTIC_ACCESS, + HEIGHTS, + L1, + L2, + LANAI, + ROOF, + STAIR, + W, + DEPTH, + type Opening, + type Rect, +} from "../model/house.ts"; +import { CRITERIA } from "../project.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + ftIn, + H, + LAYERS, + leaderNote, + noteColumn, + put, + slopeArrow, + table, + view, + viewport, + type D, + type View, +} from "../lib/kit.ts"; +import { alongX, openingWidth, wallOutline } from "../lib/plan.ts"; +import { breakLine } from "../../lib/drafting.ts"; +import { shift, SL, spanArrow, structureSetup } from "./structure-kit.ts"; +import { dim, FFE_ABOVE_GRADE, ROOF_TCDL, wood } from "./structure-details.ts"; + +const FLR: Point = [210000, 0]; +const RF: Point = [220000, 0]; + +/** The bearing line of the floor trusses: the garage wall and the stair wall, then FB-1. */ +const BEAR_Y = 290; +/** The roof trusses that flank the attic access, in place of the 24" layout's trusses there. */ +const ATTIC_TRUSSES = [ATTIC_ACCESS[0] - 2, ATTIC_ACCESS[2] + 4]; + +/** The lintel over each masonry opening of the first floor, by its width. */ +function lintelMark(o: Opening): string { + const w = openingWidth(o); + if (w <= 48) return "L-1"; + if (w <= 80) return "L-2"; + if (w <= 144) return "L-3"; + return "L-4"; +} +const inMasonry = (o: Opening) => + L1.exterior.some( + (r) => + o.cut[0] >= r[0] - 0.1 && + o.cut[2] <= r[2] + 0.1 && + o.cut[1] >= r[1] - 0.1 && + o.cut[3] <= r[3] + 0.1, + ); +const MASONRY_OPENINGS = L1.openings.filter(inMasonry); + +function floorFraming(d: D, v: View): void { + const o = (p: Point): Point => [FLR[0] + p[0], FLR[1] + p[1]]; + const h = v.p(H.note); + // The walls below: CMU (with the tie beam on it) and the stud walls, both dashed. + d.layer(SL.below, {}, () => { + for (const [a, b] of wallOutline( + L1.walls.map((r) => shift(r, FLR)), + L1.openings.map((x) => shift(x.cut, FLR)), + )) + d.line(a, b); + }); + // Floor trusses at 24" o.c., single lines from bearing to bearing. + d.layer(LAYERS.fram, {}, () => { + for (let x = 32; x < 616; x += 24) { + const inStair = x > STAIR.x0 && x < STAIR.x1; + if (inStair) { + d.line(o([x, 8]), o([x, STAIR.y0])); + if (BEAR_Y - STAIR.y1 > 6) d.line(o([x, STAIR.y1]), o([x, BEAR_Y])); + } else d.line(o([x, 8]), o([x, BEAR_Y])); + d.line(o([x, BEAR_Y + 4]), o([x, 520])); + } + // Doubled trimmer trusses each side of the stair opening, the girder truss FG-1 at its south + // end, and a rim at its north end, which stops at the bearing wall below. + for (const [x, s] of [ + [STAIR.x0, -1], + [STAIR.x1, 1], + ] as const) { + d.line(o([x + s * 0.75, 8]), o([x + s * 0.75, BEAR_Y])); + d.line(o([x + s * 2.25, 8]), o([x + s * 2.25, BEAR_Y])); + } + for (const dy of [-0.75, -2.25]) + d.line(o([STAIR.x0, STAIR.y0 + dy]), o([STAIR.x1, STAIR.y0 + dy]), { lineweight: 0.5 }); + d.line(o([STAIR.x0, STAIR.y1 + 0.75]), o([STAIR.x1, STAIR.y1 + 0.75])); + // The opening, crossed. + d.line(o([STAIR.x0 + 2, STAIR.y0 + 2]), o([STAIR.x1 - 2, STAIR.y1 - 2])); + d.line(o([STAIR.x0 + 2, STAIR.y1 - 2]), o([STAIR.x1 - 2, STAIR.y0 + 2])); + }); + // Beams: FB-1, the lintels and concrete beams, and the headers in the stud walls. + d.layer(LAYERS.beam, {}, () => { + d.line(o([382, BEAR_Y]), o([616, BEAR_Y])); + d.line(o([382, BEAR_Y + 4]), o([616, BEAR_Y + 4])); + for (const op of MASONRY_OPENINGS) { + const ax = alongX(op); + const c = ax ? (op.cut[1] + op.cut[3]) / 2 : (op.cut[0] + op.cut[2]) / 2; + const [a0, a1] = ax ? [op.cut[0] - 8, op.cut[2] + 8] : [op.cut[1] - 8, op.cut[3] + 8]; + d.line(o(ax ? [a0, c] : [c, a0]), o(ax ? [a1, c] : [c, a1])); + } + d.line(o([264, 4]), o([384, 4])); + for (const r of [ + [379.5, 92, 384, 168], + [379.5, 208, 384, 272], + [316, 288, 380, 292.5], + ] as Rect[]) { + const c = (r[0] + r[2]) / 2; + if (r[3] - r[1] > r[2] - r[0]) d.line(o([c, r[1]]), o([c, r[3]])); + else d.line(o([r[0], (r[1] + r[3]) / 2]), o([r[2], (r[1] + r[3]) / 2])); + } + }); + // The post under FB-1's west end. + d.layer(LAYERS.fram, {}, () => d.rect(o([379.5, BEAR_Y - 2.75]), 4.5, 8.25)); + // Tags and notes. + d.layer(LAYERS.strAnno, {}, () => { + for (const op of MASONRY_OPENINGS) { + const ax = alongX(op); + const [cx, cy] = [(op.cut[0] + op.cut[2]) / 2, (op.cut[1] + op.cut[3]) / 2]; + const out = v.p(0.16); + const inward = (ax ? (cy < 264 ? 1 : -1) : cx < 312 ? 1 : -1) * out; + const at: Point = ax ? [cx, cy + inward] : [cx + inward, cy]; + put(d, o(at), lintelMark(op), v.p(H.small * 1.2), { align: "center", valign: "middle" }); + } + spanArrow(d, v, o([150, 20]), o([150, BEAR_Y - 12]), ["FT-1", '16" FLR. TRUSSES @ 24" O.C.']); + spanArrow(d, v, o([500, 20]), o([500, BEAR_Y - 12]), ["FT-1", '16" FLR. TRUSSES @ 24" O.C.']); + spanArrow(d, v, o([150, BEAR_Y + 16]), o([150, 508]), ["FT-1", '16" FLR. TRUSSES @ 24" O.C.']); + spanArrow(d, v, o([500, BEAR_Y + 16]), o([500, 508]), ["FT-1", '16" FLR. TRUSSES @ 24" O.C.']); + const mid = (STAIR.y0 + STAIR.y1) / 2; + put(d, o([(STAIR.x0 + STAIR.x1) / 2 - v.p(0.1), mid]), "STAIR OPENING", h, { + align: "center", + rotation: 90, + }); + put( + d, + o([(STAIR.x0 + STAIR.x1) / 2 + v.p(0.1), mid]), + `${ftIn(STAIR.x1 - STAIR.x0)} X ${ftIn(STAIR.y1 - STAIR.y0)}`, + h, + { align: "center", rotation: 90, valign: "top" }, + ); + leaderNote(d, v, o([560, BEAR_Y + 2]), o([580, 380]), ["FB-1, SEE", "SCHEDULE"]); + leaderNote(d, v, o([381.75, BEAR_Y + 1]), o([330, 360]), ["(3) 2X6 POST", "AT FB-1"]); + leaderNote(d, v, o([293, STAIR.y0]), o([230, 60]), ["FG-1 FLR. GIRDER", "TRUSS"]); + leaderNote(d, v, o([STAIR.x0 + 8, STAIR.y1 + 0.75]), o([246, 335]), [ + "2X RIM ON BEARING", + "WALL BELOW", + ]); + leaderNote(d, v, o([STAIR.x1 + 1.75, 140]), o([350, 150]), ["(2) FT-1", "TRIMMERS"]); + leaderNote(d, v, o([0, 450]), o([-40, 470]), ["TB-1 ON ALL", "CMU WALLS"]); + leaderNote(d, v, o([120, 4]), o([60, -40]), ["TB-2 OVER", "O.H. DOOR"]); + leaderNote(d, v, o([320, 4]), o([400, -40]), ["TB-3 OVER PORCH"]); + leaderNote(d, v, o([381.75, 130]), o([440, 110]), ["HDR. H-1"]); + leaderNote(d, v, o([330, 290.25]), o([318, 318]), ["HDR. H-2"]); + leaderNote(d, v, o([381.75, 240]), o([440, 240]), ["HDR. H-1"]); + // Connector tags: C4 at each floor truss on the tie beam, C2 straps along the walls. + d.layer(SL.conn, {}, () => + d.insert("S-CONN TAG", o([104, 30]), { scale: v.p(1), attributes: { CONN: "C4" } }), + ); + put( + d, + o([118, 30]), + `${CONNECTORS.find((r) => r[0] === "C4")![2]} LB AT EA. TRUSS, TYP.`, + v.p(H.small * 1.2), + { valign: "middle" }, + ); + d.layer(SL.conn, {}, () => + d.insert("S-CONN TAG", o([590, 30]), { scale: v.p(1), attributes: { CONN: "C2" } }), + ); + put( + d, + o([578, 46]), + `${CONNECTORS.find((r) => r[0] === "C2")![2]} LB AT 32" O.C. ALL EXT. WALLS`, + v.p(H.small * 1.2), + { align: "right", valign: "middle" }, + ); + d.layer(SL.conn, {}, () => + d.insert("S-CONN TAG", o([616, BEAR_Y - 22]), { scale: v.p(1), attributes: { CONN: "C8" } }), + ); + }); + // The section mark to the tie beam detail, through window B in the north wall. + d.layer(LAYERS.strAnno, {}, () => { + d.line(o([238, 500]), o([238, 548]), { lineweight: 0.5 }); + d.insert("SECTION MARK", o([238, 560]), { + scale: v.p(1), + attributes: { SECT: "3", SHEET: "S-501" }, + }); + }); + d.layer(LAYERS.strDims, {}, () => { + const first = v.p(0.5); + const gap = v.p(0.3); + dimString(d, v, [o([0, 0]), o([W, 0])], "x", FLR[1] - first - gap - v.p(0.5)); + dimString(d, v, [o([0, 0]), o([0, BEAR_Y + 2]), o([0, DEPTH])], "y", FLR[0] - first - v.p(0.7)); + dimString(d, v, [o([0, 0]), o([0, DEPTH])], "y", FLR[0] - first - gap - v.p(0.7)); + }); +} + +function roofFraming(d: D, v: View): void { + const o = (p: Point): Point => [RF[0] + p[0], RF[1] + p[1]]; + const [ex0, ey0, ex1, ey1] = ROOF.eave; + const g = 84; // the hip girder's setback from the end walls + const gx = [g, W - g]; + d.layer(SL.below, {}, () => { + for (const [a, b] of wallOutline( + L2.exterior.map((r) => shift(r, RF)), + [], + )) + d.line(a, b); + }); + // Eave, hips and ridge. + d.layer(LAYERS.roof, {}, () => { + d.rect(o([ex0, ey0]), ex1 - ex0, ey1 - ey0); + const [r0, r1] = ROOF.ridge; + d.line(o(r0), o(r1)); + d.line(o([ex0, ey0]), o(r0)); + d.line(o([ex0, ey1]), o(r0)); + d.line(o([ex1, ey0]), o(r1)); + d.line(o([ex1, ey1]), o(r1)); + }); + d.layer(LAYERS.fram, {}, () => { + // Hip girders, three plies, from wall to wall. + for (const x of gx) + for (const dx of [-2.25, 2.25]) + d.line(o([x + dx, 0]), o([x + dx, DEPTH]), { lineweight: 0.5 }); + // Step-down hip trusses and commons, full span with their tails. + // The two at the attic access replace the 24" layout's trusses over the opening. + for (let x = g + 24; x < W - g - 1; x += 24) + if (x < ATTIC_TRUSSES[0]! - 6 || x > ATTIC_TRUSSES[1]! + 6) d.line(o([x, ey0]), o([x, ey1])); + for (const x of ATTIC_TRUSSES) d.line(o([x, ey0]), o([x, ey1])); + // The attic access in the ceiling, and its headers between the trusses. + const [ax0, ay0, ax1, ay1] = ATTIC_ACCESS; + d.rect(o([ax0, ay0]), ax1 - ax0, ay1 - ay0, { linetype: "HIDDEN" }); + for (const y of [ay0 - 1.5, ay1 + 1.5]) + d.line(o([ATTIC_TRUSSES[0]!, y]), o([ATTIC_TRUSSES[1]!, y]), { lineweight: 0.5 }); + // End jacks, from the eave to the girder. + for (let y = 108; y <= 420; y += 24) { + d.line(o([ex0, y]), o([g - 2.25, y])); + d.line(o([W - g + 2.25, y]), o([ex1, y])); + } + // Corner jacks, to the hips, in both directions at each corner. + for (const k of [12, 36, 60, 84]) { + d.line(o([ex0, k]), o([k, k])); + d.line(o([ex0, DEPTH - k]), o([k, DEPTH - k])); + d.line(o([W - k, k]), o([ex1, k])); + d.line(o([W - k, DEPTH - k]), o([ex1, DEPTH - k])); + if (k < g) { + d.line(o([k, ey0]), o([k, k])); + d.line(o([k, DEPTH - k]), o([k, ey1])); + d.line(o([W - k, ey0]), o([W - k, k])); + d.line(o([W - k, DEPTH - k]), o([W - k, ey1])); + } + } + // The lanai's shed: mono trusses from the ledger to the eave, on beam LB-1. + const [lx0, ly0, lx1, ly1] = ROOF.lanai.rect; + d.polyline([o([lx0, ly0]), o([lx0, ly1]), o([lx1, ly1]), o([lx1, ly0])]); + for (let x = LANAI[0] + 4; x < LANAI[2]; x += 24) d.line(o([x, ey1]), o([x, ly1])); + }); + d.layer(LAYERS.beam, {}, () => { + d.line(o([LANAI[0], LANAI[3] - 10]), o([LANAI[2], LANAI[3] - 10])); + d.line(o([LANAI[0], LANAI[3] - 2]), o([LANAI[2], LANAI[3] - 2])); + }); + d.layer(SL.below, {}, () => { + for (const x of [LANAI[0], LANAI[2] - 12]) d.rect(o([x, LANAI[3] - 12]), 12, 12); + }); + // Slopes, labels and connector tags. + const h = v.p(H.note); + d.layer(LAYERS.strAnno, {}, () => { + slopeArrow(d, v, o([312, 60]), -90, "6:12"); + slopeArrow(d, v, o([312, 470]), 90, "6:12"); + slopeArrow(d, v, o([30, 264]), 180, "6:12"); + slopeArrow(d, v, o([594, 264]), 0, "6:12"); + slopeArrow(d, v, o([450, 630]), 90, "3:12"); + leaderNote(d, v, o([g + 2.25, 200]), o([180, 200]), ["HG-1 HIP GIRDER", "TRUSS, 3-PLY"]); + leaderNote(d, v, o([W - g - 2.25, 330]), o([450, 330]), ["HG-1 HIP GIRDER", "TRUSS, 3-PLY"]); + leaderNote(d, v, o([156, 380]), o([190, 410]), [ + "H-1 TO H-7 STEP-DOWN", + 'HIP TRUSSES @ 24" O.C.', + ]); + leaderNote(d, v, o([300, 300]), o([330, 230]), ["T-1 COMMON", 'TRUSSES @ 24" O.C.']); + leaderNote( + d, + v, + o([ATTIC_ACCESS[2] - 4, (ATTIC_ACCESS[1] + ATTIC_ACCESS[3]) / 2]), + o([340, 112]), + [ + `ATTIC ACCESS ${ftIn(ATTIC_ACCESS[2] - ATTIC_ACCESS[0])} X ${ftIn(ATTIC_ACCESS[3] - ATTIC_ACCESS[1])} IN`, + `HALL CLG.: TRUSSES ${ATTIC_TRUSSES[1]! - ATTIC_TRUSSES[0]!}" APART,`, + "(2) 2X6 HDR. EA. END, NOTE 9", + ], + ); + leaderNote(d, v, o([30, 300]), o([30, 360]), ["EJ-7 END JACKS", '@ 24" O.C.']); + leaderNote(d, v, o([36, 20]), o([120, 50]), ["CJ CORNER JACKS", "AND HJ-7 HIP JACK"]); + leaderNote(d, v, o([430, LANAI[3] - 6]), o([340, 700]), [ + 'LB-1 LANAI BEAM ON 12" SQ.', + "CONC. COLUMNS", + ]); + leaderNote(d, v, o([LANAI[0] + 52, 600]), o([300, 640]), [ + "MT-1 MONO TRUSSES", + '@ 24" O.C., 3:12', + ]); + leaderNote(d, v, o([LANAI[0] + 28, 556]), o([300, 590]), [ + '2X10 PT LEDGER, 1/2"', + 'THRU-BOLTS @ 16" O.C.', + ]); + put(d, o([284, ROOF.ridgeY + v.p(0.08)]), "RIDGE", h, { align: "center" }); + const tag = (at: Point, c: string) => + d.layer(SL.conn, {}, () => + d.insert("S-CONN TAG", o(at), { scale: v.p(1), attributes: { CONN: c } }), + ); + const small = v.p(H.small * 1.2); + const uplift = (c: string) => `${CONNECTORS.find((r) => r[0] === c)![2]} LB`; + gx.forEach((x, k) => { + const side = k === 0 ? 1 : -1; + for (const y of [22, DEPTH - 22]) { + tag([x + side * 16, y], "C3"); + put(d, o([x + side * 28, y]), uplift("C3"), small, { + align: side > 0 ? "left" : "right", + valign: "middle", + }); + } + }); + tag([300, 14], "C1"); + put(d, o([314, 14]), `${uplift("C1")} AT EA. TRUSS END, TYP.`, small, { valign: "middle" }); + tag([-10, 156], "C6"); + put(d, o([-10, 140]), uplift("C6"), small, { align: "center", valign: "middle" }); + put(d, o([-10, 132]), "TYP.", small, { align: "center", valign: "middle" }); + tag([LANAI[0] + 100, LANAI[3] - 22], "C5"); + put(d, o([LANAI[0] + 112, LANAI[3] - 22]), uplift("C5"), small, { valign: "middle" }); + tag([LANAI[0] + 6, LANAI[3] - 26], "C7"); + put(d, o([LANAI[0] + 18, LANAI[3] - 26]), uplift("C7"), small, { valign: "middle" }); + }); + d.layer(LAYERS.strAnno, {}, () => { + d.line(o([500, 640]), o([500, 700]), { lineweight: 0.5 }); + d.insert("SECTION MARK", o([500, 712]), { + scale: v.p(1), + attributes: { SECT: "4", SHEET: "S-501" }, + }); + }); + d.layer(LAYERS.strDims, {}, () => { + const first = v.p(0.5); + const gap = v.p(0.3); + dimString( + d, + v, + [ + o([ex0, ey0]), + o([0, ey0]), + o([g, ey0]), + o(ROOF.ridge[0]), + o(ROOF.ridge[1]), + o([W - g, ey0]), + o([W, ey0]), + o([ex1, ey0]), + ].map((p) => [p[0], RF[1] + ey0] as Point), + "x", + RF[1] + ey0 - first, + ); + dimString(d, v, [o([ex0, ey0]), o([ex1, ey0])], "x", RF[1] + ey0 - first - gap); + dimString( + d, + v, + [o([ex0, ey0]), o([ex0, 0]), o([ex0, DEPTH]), o([ex0, ey1])], + "y", + RF[0] + ex0 - first, + ); + }); +} + +const HEEL: Point = [240000, 0]; + +/** + * 5: the roof truss heel on the second floor wall, with strap C1. The wall's outer face at x 0, + * the top of the double top plate at y 0. + */ +function heelDetail(d: D, v: View): void { + const p = (x: number, y: number): Point => [HEEL[0] + x, HEEL[1] + y]; + const pitch = HEIGHTS.pitch / 12; + const t = 3.5 / Math.cos(Math.atan(pitch)); + const yb = (x: number) => HEIGHTS.heel + pitch * x; // the top chord's underside + const o = -HEIGHTS.overhang; + const x1 = 30; + wood(d, p, 0, -1.5, 5.5, 0); + wood(d, p, 0, -3, 5.5, -1.5); + d.layer(LAYERS.sectFine, {}, () => { + // The stud, the wall sheathing and the ceiling. + d.polyline([p(0, -26), p(0, -3)]); + d.polyline([p(5.5, -26), p(5.5, -3)]); + d.rect(p(-0.4375, -26), 0.4375, 26 + yb(0) - 0.5); + breakLine(d, p(-2, -26), p(8, -26), v.p(0.1)); + d.rect(p(5.5, -0.5), x1 - 5.5, 0.5); + // The bottom chord, the heel and the top chord out to the fascia. + d.rect(p(0, 0), x1, 3.5); + d.rect(p(0, 3.5), 1.5, yb(0) - 3.5 + 0.6); + d.polyline([p(o, yb(o)), p(x1, yb(x1))]); + d.polyline([p(o, yb(o) + t), p(x1, yb(x1) + t)]); + d.line(p(o, yb(o)), p(o, yb(o) + t)); + d.line(p(1.5, 3.5), p(20, yb(20))); + d.line(p(3.5, 3.5), p(22, yb(22))); + breakLine(d, p(x1, -2), p(x1, yb(x1) + t + 3), v.p(0.1)); + d.polyline([p(o - 1.5, yb(o - 1.5) + t + 0.6), p(x1, yb(x1) + t + 0.6)]); + d.rect(p(o - 1.5, yb(o) - 1.5), 1.5, 5.5); + d.line(p(o, yb(o) - 1), p(-0.4375, yb(o) - 1)); + }); + // The strap, from the truss down the stud on the inside face. + d.layer(SL.rebar, {}, () => d.polyline([p(5.8, -20), p(5.8, 0), p(5.8, 3.5), p(5.8, 10)])); + dim(d, v, p(o, yb(o) - 6), p(0, yb(o) - 6), p(0, yb(o) - 10), "x"); + dim(d, v, p(0, 0), p(0, yb(0)), p(-6, 0), "y"); + d.layer(LAYERS.strAnno, {}, () => { + d.insert("LEVEL", p(38, 0), { + scale: v.p(1), + attributes: { NAME: "T.O. PLATE", EL: `${ftIn(HEIGHTS.l2Top)} A.F.F.` }, + }); + leaderNote(d, v, p(5.8, -12), p(38, -16), [ + "C1 TRUSS STRAP AT EA. TRUSS END,", + `RATED ${CONNECTORS[0]![2]} LB UPLIFT MIN., ALIGNED`, + "OVER A STUD", + ]); + leaderNote(d, v, p(26, yb(26) + 1), p(38, 26), ['ROOF TRUSS AT 24" O.C., SEE PLAN']); + leaderNote(d, v, p(3, -1.5), p(38, 10), ['DBL. 2X6 TOP PLATE, LAP 48" MIN.']); + leaderNote(d, v, p(-0.2, -16), p(-12, -20), ['7/16" OSB, NAILED', "TO TOP PLATE"]); + leaderNote(d, v, p(-12, yb(o) - 1), p(-20, -12), ["VENTED SOFFIT", "PER ARCH."]); + }); +} + +const OPEN6: Point = [242000, 0]; + +/** + * 6: a typical CMU wall at an opening, in elevation: the bars at each jamb and at 48" o.c., the + * lintel's bearing and the tie beam. The first floor at y 0, the wall's west end at x 0. + */ +function openingElevation(d: D, v: View): void { + const p = (x: number, y: number): Point => [OPEN6[0] + x, OPEN6[1] + y]; + const L = 168; + const grade = -FFE_ABOVE_GRADE; + const tb = HEIGHTS.l1Plate; + const op = { x0: 60, x1: 132, sill: 36, head: 96 }; + d.layer(LAYERS.elev, {}, () => { + d.polyline([p(0, grade), p(0, tb), p(L, tb), p(L, grade)]); + d.rect(p(op.x0, op.sill), op.x1 - op.x0, op.head - op.sill); + d.line(p(0, tb - 16), p(L, tb - 16)); + d.rect(p(op.x0 - 8, op.head), op.x1 - op.x0 + 16, 8); + }); + // The coursing: bed joints at 8", head joints at 16" in running bond, kept out of the opening, + // the lintel and the tie beam. + d.layer(LAYERS.patt, {}, () => { + for (let y = grade + 6, k = 0; y < tb - 16 - 0.1; y += 8, k++) { + const inOpening = y > op.sill - 0.1 && y < op.head + 8 + 0.1; + if (y > grade + 6.1) { + if (inOpening) { + d.line(p(0, y), p(op.x0 - (y > op.head ? 8 : 0), y)); + d.line(p(op.x1 + (y > op.head ? 8 : 0), y), p(L, y)); + } else d.line(p(0, y), p(L, y)); + } + for (let x = k % 2 === 0 ? 16 : 8; x < L; x += 16) { + const yTop = Math.min(y + 8, tb - 16); + const blocked = + (x > op.x0 - 8 && x < op.x1 + 8 && y + 8 > op.head - 0.1 && y < op.head + 8) || + (x > op.x0 && x < op.x1 && y + 8 > op.sill && y < op.head); + if (!blocked && y >= grade + 6) d.line(p(x, y), p(x, yTop)); + } + } + }); + // Below grade, hidden: the stem and the footing. + d.layer(LAYERS.ftng, {}, () => { + d.rect(p(-6, grade - 18), L + 12, 12); + d.line(p(0, grade), p(0, grade - 6)); + d.line(p(L, grade), p(L, grade - 6)); + }); + d.layer(LAYERS.grade, {}, () => d.line(p(-24, grade), p(L + 24, grade))); + d.layer(LAYERS.sectFine, {}, () => d.line(p(-24, 0), p(L + 24, 0), { linetype: "DASHED" })); + // The bars. + const bars = [4, op.x0 - 4, op.x1 + 4, L - 4]; + d.layer(SL.rebar, {}, () => { + for (const x of bars) + d.polyline([p(x + 8, grade - 14), p(x, grade - 14), p(x, tb - 3), p(x + 6, tb - 3)]); + d.line(p(-3, grade - 15), p(L + 3, grade - 15)); + d.line(p(2, tb - 4), p(L - 2, tb - 4)); + d.line(p(op.x0 - 6, op.head + 2), p(op.x1 + 6, op.head + 2)); + }); + dimString( + d, + v, + [p(op.x0 - 8, tb), p(op.x0, tb), p(op.x1, tb), p(op.x1 + 8, tb)], + "x", + OPEN6[1] + tb + v.p(0.45), + ); + dimString( + d, + v, + [ + p(0, grade), + p(bars[0]!, grade), + p(bars[1]!, grade), + p(bars[2]!, grade), + p(bars[3]!, grade), + p(L, grade), + ], + "x", + OPEN6[1] + grade - v.p(0.75), + ); + d.layer(LAYERS.strAnno, {}, () => { + leaderNote(d, v, p(op.x0 - 4, 70), p(-30, 80), [ + "#5 VERT. IN GROUTED", + "CELL EA. SIDE OF", + "OPENING, FULL HT.", + ]); + leaderNote(d, v, p(4, 20), p(-30, 20), ["#5 VERT. AT CORNERS", 'AND 48" O.C. MAX.']); + leaderNote(d, v, p(bars[2]! + 6, tb - 3), p(L + 30, tb + 8), [ + "STD. HOOK INTO TB-1,", + "(2) #5 CONT.", + ]); + leaderNote(d, v, p(op.x1 + 4, op.head + 4), p(L + 30, op.head - 10), [ + 'PRECAST LINTEL, 8" MIN.', + "BEARING EA. END, SEE", + "LINTEL SCHEDULE", + ]); + leaderNote(d, v, p(op.x1, 66), p(L + 30, 50), ["OPENING PER A-601"]); + leaderNote(d, v, p(L - 4 + 8, grade - 14), p(L + 30, grade - 30), [ + 'STD. HOOK IN FTG., 20" X 12"', + "FTG. W/ (3) #5 CONT.", + ]); + leaderNote(d, v, p(L + 10, grade), p(L + 30, grade + 14), [ + `FINISHED GRADE ${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD`, + ]); + leaderNote(d, v, p(L + 10, 0), p(L + 30, 22), ["FIRST FLOOR"]); + }); +} + +const CONNECTORS: string[][] = [ + ["C1", "ROOF TRUSS TO 2ND FLR. DOUBLE TOP PLATE, EA. END", "1,250", "TRUSS STRAP"], + ["C2", '2ND FLR. STUD TO TIE BEAM, AT 32" O.C., SEE 3/S-501', "1,450", "EMBEDDED STRAP"], + ["C3", "HIP GIRDER HG-1 TO STUD PACK, EA. END", "4,850", "GIRDER TIE-DOWN"], + ["C4", "FLOOR TRUSS TO TIE BEAM, SEE 3/S-501", "500", "EMBEDDED ANCHOR"], + ["C5", "LANAI MONO TRUSS TO LB-1, SEE 4/S-501", "650", "EMBEDDED ANCHOR"], + ["C6", "END AND CORNER JACK TO TOP PLATE", "450", "TRUSS STRAP"], + ["C7", "LANAI COLUMN TO LB-1 AND PAD", "3,200", "CAST-IN DOWELS"], + ["C8", "FB-1 TO TIE BEAM POCKET, EA. END", "2,100", "BEAM STRAP"], +]; + +const LINTELS: string[][] = (() => { + const by = new Map>(); + for (const op of MASONRY_OPENINGS) { + const m = lintelMark(op); + if (!by.has(m)) by.set(m, new Set()); + if (op.tag) by.get(m)!.add(op.tag); + } + const spec: Record = { + "L-1": ["TO 4'-0\"", "PRECAST 8F8-1B", "TB-1 ABOVE"], + "L-2": ["TO 6'-8\"", "PRECAST 8F16-1B/1T", "TB-1 ABOVE"], + "L-3": ["TO 12'-0\"", "PRECAST 8F24-2B/1T", "TB-1 ABOVE"], + "L-4": ["16'-0\"", "TB-2, CAST IN PLACE", "SEE BEAMS"], + }; + return [...by.keys()] + .sort() + .map((m) => [m, spec[m]![0], spec[m]![1], [...by.get(m)!].sort().join(", ")]); +})(); + +const BEAMS: string[][] = [ + ["TB-1", '8"X16" CONC. TIE BEAM, ALL CMU WALLS', "(2) #5 CONT."], + ["TB-2", '8"X24" CONC. BEAM OVER O.H. DOOR', '(3) #5 B, (2) #5 T, #3 @ 8"'], + ["TB-3", '8"X24" CONC. BEAM OVER PORCH', '(2) #5 B, (2) #5 T, #3 @ 8"'], + ["LB-1", '8"X20" CONC. LANAI BEAM', '(2) #5 B, (2) #5 T, #3 @ 8"'], + ["FB-1", '(3) 1 3/4"X16" LVL FLUSH BEAM', "HANGERS EA. SIDE"], + ["FG-1", "FLOOR GIRDER TRUSS AT STAIR", "BY TRUSS MFR."], + ["H-1", "(2) 2X12 SYP NO. 2 HEADER", "(2) JACK STUDS EA. END"], + ["H-2", "(3) 2X12 SYP NO. 2 HEADER", "(2) JACK STUDS EA. END"], +]; + +const FRAMING_NOTES = [ + 'FLOOR TRUSSES FT-1: 16" DEEP OPEN WEB AT 24" O.C., FOR 40 PSF LIVE (30 PSF SLEEPING) AND 12 PSF DEAD, L/480 LIVE LOAD DEFLECTION. 2X6 STRONGBACKS AT 10\'-0" O.C. MAX.', + `ROOF TRUSSES: AT 24" O.C., ${HEIGHTS.pitch}:12, ${ftIn(HEIGHTS.overhang)} OVERHANG, DESIGNED FOR THE WIND CRITERIA AND ROOF PRESSURES ON S-501, ${CRITERIA.live.roof} PSF LIVE, ${CRITERIA.dead.roof} PSF DEAD (${ROOF_TCDL} PSF TOP CHORD, ${CRITERIA.dead.roof - ROOF_TCDL} PSF BOTTOM CHORD).`, + "THE TRUSS MANUFACTURER SUPPLIES EVERY TRUSS-TO-GIRDER HANGER AND SHOWS IT ON THE TRUSS LAYOUT.", + `UPLIFTS ARE NET ALLOWABLE (0.6W - 0.6D) AT EACH CONNECTION. A CONNECTOR'S RATED ALLOWABLE UPLIFT, WITH ALL ITS FASTENERS, MUST MEET OR EXCEED THE SCHEDULE: C1 AT EVERY ROOF TRUSS END IS RATED ${CONNECTORS[0]![2]} LB OR MORE.`, + "LOAD PATH: TRUSS (C1) TO TOP PLATE, STUDS, STRAP (C2) TO TIE BEAM, #5 VERTICALS TO THE FOOTING. NO LINK MAY BE LEFT OUT.", + '2ND FLR. EXTERIOR WALLS: 2X6 AT 16" O.C., DOUBLE TOP PLATE, 7/16" OSB. STUD PACKS UNDER EVERY GIRDER BEARING, WITH SOLID BLOCKING THROUGH THE FLOOR TO THE TIE BEAM.', + "WALLS BELOW ARE SHOWN DASHED. LINTELS ARE TAGGED AT EACH OPENING; SEE THE LINTEL SCHEDULE.", + 'GARAGE CEILING: 5/8" TYPE X GYPSUM BOARD ON THE FLOOR TRUSSES OVER THE GARAGE, BELOW THE HABITABLE ROOMS (FBC-R TABLE R302.6), AND 1/2" GYPSUM BOARD ON THE GARAGE WALLS.', + `ATTIC ACCESS: ${ftIn(ATTIC_ACCESS[2] - ATTIC_ACCESS[0])} X ${ftIn(ATTIC_ACCESS[3] - ATTIC_ACCESS[1])} PULL-DOWN STAIR IN THE 2ND FLR. HALL CEILING (A-102). THE TRUSS MFR. SETS THE TRUSSES EACH SIDE ${ATTIC_TRUSSES[1]! - ATTIC_TRUSSES[0]!}" APART AND DESIGNS FOR IT; (2) 2X6 HEADERS EACH END ON HANGERS. NO TRUSS CHORD IS CUT.`, +]; + +export function framingSheet(d: D): void { + structureSetup(d); + const v = view(d, 64); + floorFraming(d, v); + roofFraming(d, v); + const v12 = view(d, 12); + heelDetail(d, v12); + const v48 = view(d, 48); + openingElevation(d, v48); + frame(d, "S-102", (dd, sheet) => { + const w = 12.6; + const hgt = 13.6; + const top = AREA.top; + viewport(dd, sheet, { + corner: [AREA.left, top - hgt], + size: [w, hgt], + center: [FLR[0] + 300, FLR[1] + 270], + v, + id: "vp-S-102-1", + number: "1", + sheetNo: "S-102", + title: "SECOND FLOOR FRAMING PLAN", + titleAt: [AREA.left + 0.2, top - hgt - 0.4], + }); + viewport(dd, sheet, { + corner: [AREA.left + w + 0.1, top - hgt], + size: [w, hgt], + center: [RF[0] + 312, RF[1] + 320], + v, + id: "vp-S-102-2", + number: "2", + sheetNo: "S-102", + title: "ROOF FRAMING PLAN", + titleAt: [AREA.left + w + 0.3, top - hgt - 0.4], + }); + dd.layer(LAYERS.text, {}, () => + dd.insert("NORTH ARROW", [AREA.left + 2 * w - 0.6, top - hgt - 0.5]), + ); + const x = AREA.left + 2 * w + 0.4; + let y = top - 0.1; + y = noteColumn(dd, [x, y], AREA.right - x, "FRAMING NOTES", FRAMING_NOTES); + y -= 0.3; + legend(dd, [x, y], AREA.right - x); + // The schedules along the bottom. + const ty = top - hgt - 1.1; + table( + dd, + [AREA.left, ty], + "UPLIFT CONNECTOR SCHEDULE", + [ + { name: "MARK", width: 0.55, align: "center" }, + { name: "LOCATION", width: 4.2 }, + { name: "MIN. UPLIFT (LB)", width: 1.25, align: "center" }, + { name: "TYPE", width: 1.5 }, + ], + CONNECTORS, + ); + table( + dd, + [AREA.left + 7.9, ty], + "LINTEL SCHEDULE", + [ + { name: "MARK", width: 0.55, align: "center" }, + { name: "OPENING", width: 0.95 }, + { name: "LINTEL", width: 1.9 }, + { name: "AT OPENINGS", width: 2.0 }, + ], + LINTELS, + ); + table( + dd, + [AREA.left + 13.7, ty], + "BEAM AND HEADER SCHEDULE", + [ + { name: "MARK", width: 0.65, align: "center" }, + { name: "MEMBER", width: 3.1 }, + { name: "REINF. / NOTES", width: 2.35 }, + ], + BEAMS, + ); + const dw = 11.0; + const dh = 4.9; + const dc: Point = [AREA.left, AREA.bottom + 0.65]; + viewport(dd, sheet, { + corner: dc, + size: [dw, dh], + center: [HEEL[0] + 17, HEEL[1] + 1.5], + v: v12, + id: "vp-S-102-5", + number: "5", + sheetNo: "S-102", + title: "TRUSS HEEL AT 2ND FLR. WALL", + titleAt: [dc[0] + 0.2, dc[1] - 0.4], + }); + const ec: Point = [AREA.left + 20.3, AREA.bottom + 0.65]; + viewport(dd, sheet, { + corner: ec, + size: [9.4, 6.0], + center: [OPEN6[0] + 95, OPEN6[1] + 32], + v: v48, + id: "vp-S-102-6", + number: "6", + sheetNo: "S-102", + title: "TYPICAL CMU OPENING REINFORCING", + titleAt: [ec[0] + 0.2, ec[1] - 0.4], + }); + }); +} + +function legend(d: D, at: Point, w: number): number { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.3; + }); + const row = (label: string, draw: (c: Point) => void) => { + draw([at[0] + 0.3, y + 0.03]); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.7, y], label, H.note, { valign: "middle" })); + y -= 0.36; + }; + row("TRUSS OR JOIST", (c) => + d.layer(LAYERS.fram, {}, () => d.line([c[0] - 0.25, c[1]], [c[0] + 0.25, c[1]])), + ); + row("GIRDER TRUSS", (c) => + d.layer(LAYERS.fram, {}, () => { + d.line([c[0] - 0.25, c[1] - 0.025], [c[0] + 0.25, c[1] - 0.025], { lineweight: 0.5 }); + d.line([c[0] - 0.25, c[1] + 0.025], [c[0] + 0.25, c[1] + 0.025], { lineweight: 0.5 }); + }), + ); + row("BEAM, LINTEL OR HEADER", (c) => + d.layer(LAYERS.beam, {}, () => d.line([c[0] - 0.25, c[1]], [c[0] + 0.25, c[1]])), + ); + row("WALL BELOW", (c) => + d.layer(SL.below, {}, () => d.line([c[0] - 0.25, c[1]], [c[0] + 0.25, c[1]])), + ); + row("UPLIFT CONNECTOR, SEE SCHEDULE", (c) => + d.layer(LAYERS.strAnno, {}, () => d.insert("S-CONN TAG", c, { attributes: { CONN: "C1" } })), + ); + row("SECTION, DETAIL / SHEET", (c) => + d.layer(LAYERS.strAnno, {}, () => + d.insert("SECTION MARK", [c[0] - 0.08, c[1]], { + scale: 0.7, + attributes: { SECT: "3", SHEET: "S-501" }, + }), + ), + ); + return y; +} diff --git a/examples/plan-set/sheets/structure-kit.ts b/examples/plan-set/sheets/structure-kit.ts new file mode 100644 index 0000000..d108b7a --- /dev/null +++ b/examples/plan-set/sheets/structure-kit.ts @@ -0,0 +1,158 @@ +// Helpers the structural sheets share: their own layers and blocks (defined once per drawing, +// since the kit has none for rebar or connectors), rebar in section, concrete and CMU in +// section, span arrows and level marks. +import type { Point } from "../../../src/index.ts"; +import type { Rect } from "../model/house.ts"; +import { arrowhead, earth, hatch, stipple, type Box } from "../../lib/drafting.ts"; +import { H, LAYERS, put, type D, type View } from "../lib/kit.ts"; + +/** Layers the kit does not have. Defined on first use in a drawing. */ +export const SL = { + rebar: "S-REBR", + below: "S-WALL-BLOW", + conn: "S-ANNO-CONN", +} as const; + +const ready = new WeakSet(); + +/** Defines the structural sheets' own layers and blocks, once per drawing. */ +export function structureSetup(d: D): void { + if (ready.has(d)) return; + ready.add(d); + d.layer(SL.rebar, { color: 7, lineweight: 0.5 }); + d.layer(SL.below, { color: 7, lineweight: 0.18, linetype: "HIDDEN" }); + d.layer(SL.conn, { color: 7, lineweight: 0.25 }); + // A vertical bar in a grouted cell, for plans: a dot in a square cell. + d.block("S-VERT BAR", {}, () => { + d.rect([-0.045, -0.045], 0.09, 0.09); + for (const r of [0.03, 0.02, 0.01]) d.circle([0, 0], r); + }); + // A connector tag: a mark in a diamond. + d.block("S-CONN TAG", {}, () => { + const r = 0.12; + d.polyline( + [ + [r, 0], + [0, r], + [-r, 0], + [0, -r], + ], + true, + ); + d.attribute("CONN", [0, 0], { + value: "C1", + height: 0.075, + style: "NOTES", + align: "center", + valign: "middle", + }); + }); +} + +export const grow = (r: Rect, by: number): Rect => [r[0] - by, r[1] - by, r[2] + by, r[3] + by]; +export const shift = (r: Rect, o: Point): Rect => [ + r[0] + o[0], + r[1] + o[1], + r[2] + o[0], + r[3] + o[1], +]; +export const box = (x0: number, y0: number, x1: number, y1: number): Box => [ + x0, + y0, + x1 - x0, + y1 - y0, +]; + +/** + * A bar in section. tsdraft has no solid fill, so the dot is three circles, which plot as a + * near-solid dot at the pen widths of the rebar layer. + */ +export function barDot(d: D, c: Point, dia = 0.625): void { + d.layer(SL.rebar, {}, () => { + for (const k of [0.5, 0.33, 0.16]) d.circle(c, dia * k); + }); +} + +/** Concrete cut in section: the outline, and the stipple drawn as lines (no hatch in tsdraft). */ +export function concrete(d: D, b: Box, seed: number, v: View, holes: readonly Box[] = []): void { + d.layer(LAYERS.sect, {}, () => d.rect([b[0], b[1]], b[2], b[3])); + d.layer(LAYERS.patt, {}, () => { + stipple(d, b, holes, { spacing: v.p(0.09), stroke: v.p(0.06), seed, margin: v.p(0.03) }); + // The aggregate: small triangles, as AR-CONC draws them. + let s = seed; + const rnd = () => (s = (s * 1664525 + 1013904223) % 4294967296) / 4294967296; + const step = v.p(0.32); + for (let y = b[1] + step / 2; y < b[1] + b[3] - step / 3; y += step) { + for (let x = b[0] + step / 2; x < b[0] + b[2] - step / 3; x += step) { + const c: Point = [x + (rnd() - 0.5) * step * 0.5, y + (rnd() - 0.5) * step * 0.5]; + const r = v.p(0.035); + const a = rnd() * Math.PI; + const pts: Point[] = [0, 1, 2].map((k) => [ + c[0] + r * Math.cos(a + (k * 2 * Math.PI) / 3), + c[1] + r * Math.sin(a + (k * 2 * Math.PI) / 3), + ]); + if ( + holes.some( + (h) => + c[0] > h[0] - r && + c[0] < h[0] + h[2] + r && + c[1] > h[1] - r && + c[1] < h[1] + h[3] + r, + ) + ) + continue; + d.polyline(pts, true); + } + } + }); +} + +/** A grouted CMU wall cut in section: outline, mortar joints at 8" and the 45 degree hatch. */ +export function cmu(d: D, b: Box, v: View, jointsFrom = b[1]): void { + d.layer(LAYERS.sect, {}, () => d.rect([b[0], b[1]], b[2], b[3])); + d.layer(LAYERS.patt, {}, () => { + hatch(d, b, v.p(0.06), 1); + for (let y = jointsFrom + 8; y < b[1] + b[3] - 0.5; y += 8) + if (y > b[1] + 0.5) d.line([b[0], y], [b[0] + b[2], y]); + }); +} + +/** Earth and compacted fill. Earth is the kit's line weave; fill is a sparse stipple. */ +export function soil(d: D, b: Box, holes: readonly Box[], v: View): void { + d.layer(LAYERS.patt, {}, () => earth(d, b, holes, v.p(0.22))); +} +export function fill(d: D, b: Box, holes: readonly Box[], v: View, seed: number): void { + d.layer(LAYERS.patt, {}, () => + stipple(d, b, holes, { spacing: v.p(0.16), stroke: v.p(0.08), seed }), + ); +} + +/** A double-headed span arrow with its label over it, for framing plans. */ +export function spanArrow(d: D, v: View, a: Point, b: Point, lines: readonly string[]): void { + d.layer(LAYERS.strAnno, {}, () => { + d.line(a, b); + arrowhead(d, a, b, v.p(0.14)); + arrowhead(d, b, a, v.p(0.14)); + const ang = (Math.atan2(b[1] - a[1], b[0] - a[0]) * 180) / Math.PI; + let rot = ang; + if (rot > 90) rot -= 180; + if (rot <= -90) rot += 180; + const m: Point = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + const t = (rot * Math.PI) / 180; + const h = v.p(H.note); + lines.forEach((line, k) => { + const off = v.p(0.06) + (lines.length - 1 - k) * h * 1.6; + put(d, [m[0] - Math.sin(t) * off, m[1] + Math.cos(t) * off], line, h, { + align: "center", + rotation: rot, + }); + }); + }); +} + +/** A level mark (the kit's LEVEL block) for a detail, on the structural annotation layer. */ +export function level(d: D, v: View, at: Point, name: string, el: string): void { + d.layer(LAYERS.strAnno, {}, () => { + d.insert("LEVEL", at, { scale: v.p(1), attributes: { NAME: name, EL: el } }); + }); +} diff --git a/examples/plan-set/sheets/structure.ts b/examples/plan-set/sheets/structure.ts new file mode 100644 index 0000000..5db9e47 --- /dev/null +++ b/examples/plan-set/sheets/structure.ts @@ -0,0 +1,463 @@ +// S-101, S-102 and S-501: the foundation plan, the floor and roof framing plans, and the +// structural details and notes. Model space from x 200000 to 299999. +import type { Point } from "../../../src/index.ts"; +import { CRITERIA } from "../project.ts"; +import { + areas, + ENTRY, + FLOOD_VENTS, + GARAGE_STEPS, + L1, + LANAI, + LANAI_SLAB, + type Opening, + type Rect, + FLOOD_VENT_RATING, +} from "../model/house.ts"; +import { frame } from "../lib/frame.ts"; +import { + AREA, + dimString, + H, + LAYERS, + leaderNote, + noteColumn, + put, + view, + viewport, + width, + type D, + type SheetDraw, + type View, +} from "../lib/kit.ts"; +import { alongX, wallOutline } from "../lib/plan.ts"; +import { grow, shift, SL, structureSetup } from "./structure-kit.ts"; +import { framingSheet } from "./structure-framing.ts"; +import { detailsSheet } from "./structure-details.ts"; + +export const ORIGIN = { fnd: [200500, 0] as Point }; + +/** Walls under which the foundation runs: every masonry wall of the first floor, the porch's + * open south edge, and the raised lanai's three sides. */ +const PORCH_EDGE: Rect = [272, 0, 376, 8]; +const LANAI_STEM: Rect[] = [ + [LANAI[0], LANAI[1], LANAI[0] + 8, LANAI[3]], + [LANAI[2] - 8, LANAI[1], LANAI[2], LANAI[3]], + [LANAI[0], LANAI[3] - 8, LANAI[2], LANAI[3]], +]; +const STEM: Rect[] = [...L1.exterior, PORCH_EDGE, ...LANAI_STEM]; +/** Interior bearing walls of the first floor, on thickened slab. */ +const BEARING: Rect[] = [ + [272, 288, 384, 292.5], + [379.5, 80, 384, 292.5], +]; +/** The lanai's two columns, 12" square, and their pads, 3'-0" square. */ +const COLUMNS: Point[] = [ + [LANAI[0] + 6, LANAI[3] - 6], + [LANAI[2] - 6, LANAI[3] - 6], +]; +/** The garage's flood vents, from the model, and whether the wall each sits in runs north-south. */ +const alongY = (wall: string) => wall !== "S" && wall !== "N"; +const VENTS = FLOOD_VENTS.map((x) => ({ + at: x.at, + ns: alongY(x.wall), + rot: alongY(x.wall) ? 90 : 0, +})); +/** Half a vent's width plus half a cell: a bar closer than this to a vent moves past it. */ +const VENT_CLEAR = 12; + +/** Vertical bars: at 48" o.c. along each masonry wall, and at each side of every opening. */ +function vertBars(walls: readonly Rect[], openings: readonly Opening[]): Point[] { + const out: Point[] = []; + const add = (p: Point) => { + if (!out.some((q) => Math.hypot(q[0] - p[0], q[1] - p[1]) < 10)) out.push(p); + }; + for (const r of walls) { + const ax = r[2] - r[0] > r[3] - r[1]; + const [a0, a1] = ax ? [r[0], r[2]] : [r[1], r[3]]; + const c = ax ? (r[1] + r[3]) / 2 : (r[0] + r[2]) / 2; + const at = (a: number): Point => (ax ? [a, c] : [c, a]); + const inWall = openings.filter( + (o) => + alongX(o) === ax && + (ax + ? o.cut[1] >= r[1] - 0.1 && o.cut[3] <= r[3] + 0.1 && o.cut[0] >= r[0] && o.cut[2] <= r[2] + : o.cut[0] >= r[0] - 0.1 && + o.cut[2] <= r[2] + 0.1 && + o.cut[1] >= r[1] && + o.cut[3] <= r[3]), + ); + const holes = inWall.map( + (o) => (ax ? [o.cut[0], o.cut[2]] : [o.cut[1], o.cut[3]]) as [number, number], + ); + for (const [h0, h1] of holes) { + add(at(h0 - 4)); + add(at(h1 + 4)); + } + add(at(a0 + 4)); + add(at(a1 - 4)); + for (let a = a0 + 4 + 48; a < a1 - 4; a += 48) { + if (holes.some(([h0, h1]) => a > h0 - 8 && a < h1 + 8)) continue; + add(at(a)); + } + } + // No bar cell under a flood vent: a vent needs an open cell, so the bar moves one cell past + // it, or goes where a jamb bar already stands within a cell of that spot. + const near = (p: Point, q: Point) => Math.hypot(p[0] - q[0], p[1] - q[1]) < 10; + const kept: Point[] = []; + for (const p of out) { + const k = (x: (typeof VENTS)[number]) => (x.ns ? 1 : 0); // the axis the wall runs along + const v = VENTS.find( + (x) => + Math.abs(p[1 - k(x)]! - x.at[1 - k(x)]!) < 6 && + Math.abs(p[k(x)]! - x.at[k(x)]!) < VENT_CLEAR, + ); + if (!v) { + kept.push(p); + continue; + } + const along = v.at[k(v)]! + (p[k(v)]! < v.at[k(v)]! ? -VENT_CLEAR : VENT_CLEAR); + const moved: Point = v.ns ? [p[0], along] : [along, p[1]]; + if (!out.some((q) => q !== p && near(q, moved))) kept.push(moved); + } + return kept; +} + +const WALL_NAME: Record = { W: "WEST", E: "EAST", S: "SOUTH", N: "NORTH" }; + +const FND_NOTES = [ + `ALLOWABLE SOIL BEARING PRESSURE ${CRITERIA.soil.toLocaleString("en-US")} PSF, TO BE VERIFIED BY A GEOTECHNICAL ENGINEER BEFORE FOOTINGS ARE PLACED. REMOVE ORGANICS AND DEBRIS; PROOF-ROLL THE BUILDING PAD.`, + 'FILL UNDER SLABS: CLEAN SAND, COMPACTED TO 95% OF MODIFIED PROCTOR (ASTM D1557) IN 12" LIFTS, DENSITY TESTED EVERY LIFT.', + `CONTINUOUS FOOTINGS: 20" WIDE X 12" DEEP WITH (3) #5 CONTINUOUS, BOTTOM 18" MIN. BELOW FINISHED GRADE (${CRITERIA.flood.finishGrade.toFixed(2)}' NAVD AT THE FOUNDATION). SEE 1/S-501.`, + 'STEM WALLS: 8" CMU, CELLS WITH BARS GROUTED SOLID. #5 VERTICAL AT 48" O.C., AT CORNERS AND AT EACH SIDE OF OPENINGS, HOOKED IN THE FOOTING AND RUN TO THE TIE BEAM.', + 'SLAB ON GRADE: 4" CONCRETE, 3000 PSI, WITH 6X6-W1.4XW1.4 WWF ON A 10 MIL VAPOR RETARDER, LAPPED 6" AND TAPED. SAW-CUT CONTROL JOINTS WITHIN 12 HOURS, 15\'-0" O.C. MAX.', + 'THICKENED SLAB UNDER INTERIOR BEARING WALLS: 12" WIDE X 8" BELOW THE SLAB WITH (2) #5 CONTINUOUS.', + 'LANAI COLUMN PADS: 3\'-0" X 3\'-0" X 16" WITH (4) #5 EACH WAY, (4) #5 DOWELS INTO THE COLUMN.', + `FLOOD VENTS: ${FLOOD_VENTS.length} ENGINEERED VENTS, EACH CERTIFIED (ICC-ES) TO COVER ${FLOOD_VENT_RATING} SQ. FT. OF ENCLOSED AREA (${FLOOD_VENTS.length * FLOOD_VENT_RATING} SQ. FT.), IN THE ${[...new Set(FLOOD_VENTS.map((x) => WALL_NAME[x.wall as string]))].join(" AND ")} GARAGE WALLS, IN UNGROUTED CELLS, BOTTOM WITHIN 12" OF FINISHED GRADE (FBC-R R322.2.2). THEY COVER THE GROSS GARAGE AREA, ${areas().garage} SQ. FT. TO THE OUTSIDE FACES.`, + "SOIL UNDER THE BUILDING TREATED FOR TERMITES BEFORE THE VAPOR RETARDER IS LAID (FBC-R R318).", + `ALL ELEVATIONS ARE NAVD 88. FINISHED GRADE AT THE FOUNDATION ${CRITERIA.flood.finishGrade.toFixed(2)}'; DESIGN FLOOD ELEVATION ${CRITERIA.flood.dfe.toFixed(2)}'; FIRST FLOOR ${CRITERIA.flood.ffe.toFixed(2)}'.`, + `STEPS: CONC. ON COMPACTED FILL, 4" MIN. THICK, DOWELED TO THE STEM WALL WITH #4 AT 16" O.C. HANDRAILS, GUARDS AND RISER AND TREAD SIZES PER A-101.`, +]; + +function foundationPlan(d: D): void { + structureSetup(d); + const origin = ORIGIN.fnd; + const v = view(d, 48); + const o = (p: Point): Point => [origin[0] + p[0], origin[1] + p[1]]; + const sh = (r: Rect) => shift(r, origin); + // Stem walls as one clean outline; they run unbroken under the openings. + d.layer(LAYERS.fndn, {}, () => { + for (const [a, b] of wallOutline(STEM.map(sh), [])) d.line(a, b); + }); + // Footing edges, thickened slab and column pads: hidden lines, one merged outline. + const pads = COLUMNS.map((c): Rect => [c[0] - 18, c[1] - 18, c[0] + 18, c[1] + 18]); + d.layer(LAYERS.ftng, {}, () => { + for (const [a, b] of wallOutline( + [...STEM.map((r) => grow(r, 6)), ...BEARING.map((r) => grow(r, 3.75)), ...pads].map(sh), + [], + )) + d.line(a, b); + }); + // Bearing walls above, on the thickened slab: thin outline. + d.layer(LAYERS.slab, {}, () => { + for (const [a, b] of wallOutline(BEARING.map(sh), [])) d.line(a, b); + // Steps from the model: the garage steps at door 103, the front entry and the lanai steps. + const g = GARAGE_STEPS; + const edge = g.top - g.landing; + const foot = edge - (g.risers - 1) * g.tread; + d.rect(o([g.x0, foot]), g.x1 - g.x0, g.top - foot); + for (let k = 0; k < g.risers; k++) + d.line(o([g.x0, edge - k * g.tread]), o([g.x1, edge - k * g.tread])); + for (const st of [ + { x0: ENTRY.x0, x1: ENTRY.x1, y: 0, dir: -1, n: ENTRY.risers - 1, t: ENTRY.tread }, + { + x0: LANAI_SLAB.steps.x0, + x1: LANAI_SLAB.steps.x1, + y: LANAI[3], + dir: 1, + n: LANAI_SLAB.steps.risers - 1, + t: LANAI_SLAB.steps.tread, + }, + ]) { + for (let k = 0; k <= st.n; k++) + d.line(o([st.x0, st.y + st.dir * k * st.t]), o([st.x1, st.y + st.dir * k * st.t])); + for (const x of [st.x0, st.x1]) d.line(o([x, st.y]), o([x, st.y + st.dir * st.n * st.t])); + } + }); + // The lanai columns above their pads. + d.layer(LAYERS.fndn, {}, () => { + // Drawn heavier and crossed, so the 12" column reads apart from the 8" stem wall it sits on. + for (const c of COLUMNS) { + d.rect(o([c[0] - 6, c[1] - 6]), 12, 12, { lineweight: 0.5 }); + d.line(o([c[0] - 6, c[1] - 6]), o([c[0] + 6, c[1] + 6])); + d.line(o([c[0] - 6, c[1] + 6]), o([c[0] + 6, c[1] - 6])); + } + }); + // Saw-cut control joints, 15'-0" o.c. max., between the stem walls' inside faces. + d.layer(LAYERS.slab, {}, () => { + const joints: [Point, Point][] = [ + [ + [500, 8], + [500, 520], + ], + [ + [384, 100], + [616, 100], + ], + [ + [384, 358], + [616, 358], + ], + [ + [258, 296], + [258, 520], + ], + [ + [136, 408], + [380, 408], + ], + [ + [136, 8], + [136, 288], + ], + [ + [8, 148], + [264, 148], + ], + [ + [LANAI[0] + 8, 600], + [LANAI[2] - 8, 600], + ], + ]; + for (const [a, b] of joints) d.line(o(a), o(b), { linetype: "CENTER" }); + }); + // Vertical bars. + const masonry = L1.openings.filter((x) => + STEM.some( + (r) => + x.cut[0] >= r[0] - 0.1 && + x.cut[2] <= r[2] + 0.1 && + x.cut[1] >= r[1] - 0.1 && + x.cut[3] <= r[3] + 0.1, + ), + ); + d.layer(SL.rebar, {}, () => { + for (const p of vertBars(STEM, masonry)) d.insert("S-VERT BAR", o(p), { scale: v.p(1) }); + }); + // Flood vents. + d.layer(LAYERS.strAnno, {}, () => { + for (const x of VENTS) d.insert("FLOOD VENT", o(x.at), { scale: v.p(1), rotation: x.rot }); + }); + + const h = v.p(H.note); + d.layer(LAYERS.strAnno, {}, () => { + // Slab notes and elevations. + const slab = (at: Point, lines: string[]) => + lines.forEach((t, k) => put(d, o([at[0], at[1] - k * h * 1.6]), t, h, { align: "center" })); + slab( + [442, 420], + [ + '4" CONC. SLAB ON GRADE', + "ON 10 MIL V.R. ON COMPACTED FILL", + `T.O. SLAB EL. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD (F.F.E.)`, + ], + ); + slab( + [442, 160], + ['4" CONC. SLAB ON GRADE', `T.O. SLAB EL. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD`], + ); + slab( + [196, 470], + ['4" CONC. SLAB ON GRADE', `T.O. SLAB EL. ${CRITERIA.flood.ffe.toFixed(2)}' NAVD`], + ); + slab( + [72, 118], + [ + '4" CONC. GARAGE SLAB, SLOPE', + '1/8" PER FT. TO O.H. DOOR', + `T.O. SLAB EL. ${CRITERIA.flood.garage.toFixed(2)}' NAVD`, + "(2'-6\" BELOW F.F.E.)", + ], + ); + slab( + [500, 640], + [ + '4" CONC. RAISED LANAI SLAB, SLOPE 1/8"/FT.', + `T.O. SLAB EL. ${LANAI_SLAB.slab.toFixed(2)}' NAVD`, + ], + ); + slab([324, 40], ['4" CONC. PORCH SLAB', `EL. ${ENTRY.slab.toFixed(2)}' NAVD`]); + // Leaders. + leaderNote(d, v, o([0, 400]), o([-150, 440]), [ + '8" CMU STEM WALL ON 20" X 12"', + "CONT. FTG. W/ (3) #5 CONT.,", + "TYP. AT ALL EXT. WALLS", + ]); + leaderNote(d, v, o([-6, 360]), o([-150, 330]), ["FTG. EDGE (HIDDEN), TYP."]); + leaderNote(d, v, o([4, 476]), o([-150, 520]), [ + "#5 VERT. IN GROUTED CELL AT", + '48" O.C., CORNERS AND EA.', + "SIDE OF OPENINGS, TYP.", + ]); + leaderNote(d, v, o([0, FLOOD_VENTS[2]!.at[1]]), o([-150, 230]), [ + `FLOOD VENT, TYP. OF ${FLOOD_VENTS.length},`, + "UNGROUTED CELL, NOTE 8", + ]); + leaderNote(d, v, o([382, 230]), o([450, 270]), [ + "THICKENED SLAB UNDER", + "BEARING WALL, SEE NOTE 6", + ]); + leaderNote(d, v, o([300, 290]), o([300, 330]), ["THICKENED SLAB, SEE NOTE 6"]); + leaderNote( + d, + v, + o([ + (GARAGE_STEPS.x0 + GARAGE_STEPS.x1) / 2, + GARAGE_STEPS.top - GARAGE_STEPS.landing - GARAGE_STEPS.tread * 1.5, + ]), + o([90, 200]), + [ + `CONC. STEPS: ${GARAGE_STEPS.landing}" LANDING AT DOOR 103,`, + `${GARAGE_STEPS.risers} RISERS DN. TO GARAGE SLAB, NOTE 11`, + ], + ); + leaderNote(d, v, o([ENTRY.x1 - 6, -ENTRY.tread * 1.5]), o([430, -30]), [ + `CONC. ENTRY STEPS, ${ENTRY.risers} RISERS DN.`, + "TO WALK, NOTE 11", + ]); + leaderNote( + d, + v, + o([LANAI_SLAB.steps.x0 + 6, LANAI[3] + LANAI_SLAB.steps.tread * 1.5]), + o([440, 718]), + [ + `CONC. STEPS, ${LANAI_SLAB.steps.risers} RISERS DN. TO`, + `POOL DECK ${LANAI_SLAB.deck.toFixed(2)}', NOTE 11`, + ], + ); + leaderNote(d, v, o([252, 288]), o([242, 226]), ["SLAB STEP,", "SEE 2/S-501"]); + leaderNote(d, v, o([120, 8]), o([120, 60]), [ + "SLAB EDGE AT O.H. DOOR,", + 'THICKENED 12" W/ (2) #5', + ]); + leaderNote(d, v, o(COLUMNS[1]!), o([650, 730]), [ + '12" X 12" CONC. COLUMN W/ (4) #5', + 'ON 3\'-0" SQ. X 16" PAD, SEE NOTE 7', + ]); + leaderNote(d, v, o([LANAI[0] + 4, 600]), o([300, 620]), [ + '8" CMU STEM WALL AROUND', + "RAISED LANAI SLAB, TYP.", + ]); + leaderNote(d, v, o([500, 470]), o([444, 492]), ["SAW-CUT CONTROL", "JOINT, TYP., NOTE 5"]); + }); + // Section marks to the details on S-501, each with its cut line. + const mark = (at: Point, cut: [Point, Point], no: string) => { + d.layer(LAYERS.strAnno, {}, () => { + d.line(o(cut[0]), o(cut[1]), { lineweight: 0.5 }); + d.insert("SECTION MARK", o(at), { scale: v.p(1), attributes: { SECT: no, SHEET: "S-501" } }); + }); + }; + mark( + [100, 568], + [ + [100, 557], + [100, 500], + ], + "1", + ); + mark( + [200, 252], + [ + [200, 263], + [200, 320], + ], + "2", + ); + // Dimension strings. + const first = v.p(0.75); + const gap = v.p(0.375); + d.layer(LAYERS.strDims, {}, () => { + const south = -(ENTRY.risers - 1) * ENTRY.tread; // past the entry steps + const north = LANAI[3] + (LANAI_SLAB.steps.risers - 1) * LANAI_SLAB.steps.tread; // past the lanai steps + const S = (k: number) => origin[1] + south - first - gap * k; + const Wx = (k: number) => origin[0] - first - gap * k; + const N = (k: number) => origin[1] + north + first + gap * k; + const E = (k: number) => origin[0] + 624 + first + gap * k; + const pts = (xs: number[], y: number) => xs.map((x) => o([x, y])); + const ptsY = (ys: number[], x: number) => ys.map((y) => o([x, y])); + dimString(d, v, pts([0, 24, 216, 264, 272, 376, 384, 624], 0), "x", S(0)); + dimString(d, v, pts([0, 624], 0), "x", S(1)); + dimString(d, v, ptsY([0, 288, 296, 528], 0), "y", Wx(0)); + dimString(d, v, ptsY([0, 528], 0), "y", Wx(1)); + dimString(d, v, pts([0, 384, 616, 624], 528), "x", N(0)); + dimString(d, v, ptsY([0, 192, 528, 672], 624), "y", E(0)); + dimString(d, v, ptsY([0, 672], 624), "y", E(1)); + }); + + frame(d, "S-101", (dd, sheet) => { + const vpW = 22.0; + const vpH = 20.6; + viewport(dd, sheet, { + corner: [AREA.left, AREA.top - vpH], + size: [vpW, vpH], + center: o([250, 330]), + v, + id: "vp-S-101", + number: "1", + sheetNo: "S-101", + title: "FOUNDATION PLAN", + titleAt: [AREA.left + 0.2, AREA.top - vpH - 0.5], + }); + dd.layer(LAYERS.text, {}, () => + dd.insert("NORTH ARROW", [AREA.left + vpW - 1.0, AREA.top - vpH - 0.55]), + ); + const x = AREA.left + vpW + 0.4; + let y = AREA.top - 0.1; + y = noteColumn(dd, [x, y], AREA.right - x, "FOUNDATION NOTES", FND_NOTES); + y -= 0.3; + legend(dd, [x, y], AREA.right - x); + }); +} + +function legend(d: D, at: Point, w: number): number { + let y = at[1]; + d.layer(LAYERS.text, {}, () => { + put(d, [at[0], y], "LEGEND", H.label, { valign: "top" }); + y -= H.label + 0.06; + d.line([at[0], y], [at[0] + w, y], { lineweight: 0.35 }); + y -= 0.3; + }); + const row = (label: string, draw: (c: Point) => void) => { + draw([at[0] + 0.3, y + 0.03]); + d.layer(LAYERS.text, {}, () => put(d, [at[0] + 0.7, y], label, H.note, { valign: "middle" })); + y -= 0.36; + }; + row('8" CMU STEM WALL', (c) => + d.layer(LAYERS.fndn, {}, () => d.rect([c[0] - 0.25, c[1] - 0.04], 0.5, 0.08)), + ); + row("FOOTING OR THICKENED SLAB EDGE", (c) => + d.layer(LAYERS.ftng, {}, () => d.line([c[0] - 0.25, c[1]], [c[0] + 0.25, c[1]])), + ); + row("#5 VERTICAL BAR IN GROUTED CELL", (c) => + d.layer(SL.rebar, {}, () => d.insert("S-VERT BAR", c)), + ); + row("FLOOD VENT", (c) => d.layer(LAYERS.strAnno, {}, () => d.insert("FLOOD VENT", c))); + row("SECTION, DETAIL / SHEET", (c) => + d.layer(LAYERS.strAnno, {}, () => + d.insert("SECTION MARK", [c[0] - 0.08, c[1]], { + scale: 0.7, + attributes: { SECT: "1", SHEET: "S-501" }, + }), + ), + ); + return y; +} + +export const SHEETS: Record = { + "S-101": (d) => foundationPlan(d), + "S-102": (d) => framingSheet(d), + "S-501": (d) => detailsSheet(d), +}; + +export type { View }; +void width; diff --git a/package.json b/package.json index e00df6b..dd7a34c 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "fmt:check": "oxfmt --check", "test": "pnpm build:lib && node --test test/*.test.ts cli/test/*.test.ts", "test:package": "node scripts/package-smoke.ts", - "example": "node examples/site-plan.ts && node examples/outdoor-kitchen.ts", + "example": "node examples/site-plan.ts && node examples/outdoor-kitchen.ts && node examples/plan-set/set.ts", "readme:drawing": "node scripts/readme-drawing.ts", "bench": "pnpm build:lib && node cli/scripts/bench.ts", "standard:files": "node scripts/standard-files.ts", diff --git a/scripts/readme-drawing.ts b/scripts/readme-drawing.ts index dc39153..12c2b27 100644 --- a/scripts/readme-drawing.ts +++ b/scripts/readme-drawing.ts @@ -5,6 +5,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import planSet, { xrefs as planSetXrefs } from "../examples/plan-set/set.ts"; import { sitePlan } from "../examples/site-plan.ts"; import { toSVG } from "../src/index.ts"; @@ -14,6 +15,8 @@ type Role = "line" | "muted" | "accent"; interface Picture { readonly name: string; + /** The example to run, under `examples/`, when it is not `.ts`. */ + readonly example?: string; /** Width of the picture in pixels. */ readonly width: number; /** The margin added around the drawing, in drawing units. */ @@ -38,6 +41,28 @@ interface Picture { const site = sitePlan(); const sitePicture = toSVG(site.drawing, { layout: site.drawing.layouts![0]!.name }); +// The permit set's pictures are two of its sheets, each with the border its xref draws. +const planSetSheet = (layout: string) => toSVG(planSet, { layout, xrefs: planSetXrefs }); + +/** The layers every sheet of the permit set draws: the border, its fields and the revisions. */ +const planSetFrame: Record = { + "TB-ARCHD|G-TTLB-HEVY": "line", + "TB-ARCHD|G-TTLB": "line", + "TB-ARCHD|G-TTLB-FINE": "muted", + "TB-ARCHD|G-TTLB-TEXT": "line", + "G-TTLB": "line", + Rev_Text: "line", + Rev_1: "muted", + Rev_2: "muted", + "G-ANNO-TEXT": "line", + "A-ANNO-TEXT": "line", + "A-ANNO-TAGS": "line", + "A-ANNO-SYMB": "line", + "A-ANNO-DIMS": "accent", + "A-GLAZ": "line", + "A-DOOR": "line", +}; + const themes = { light: { bg: "#f6f8fa", line: "#1f2328", muted: "#818b98", accent: "#0d7a8c" }, dark: { bg: "#131315", line: "#f2f2f3", muted: "#8b8b92", accent: "#1aafc8" }, @@ -88,6 +113,40 @@ const pictures: Picture[] = [ "A-ANNO-TITL": "line", }, }, + { + name: "plan-set-A-101", + example: "plan-set/set.ts", + width: 1100, + pad: 0, + pen: 0.8, + sheetSvg: planSetSheet("A-101"), + roundCaps: false, + roles: { + ...planSetFrame, + "A-WALL": "line", + "A-WALL-PRHT": "line", + "A-FLOR-STRS": "line", + "A-FLOR-OVHD": "muted", + "P-FIXT": "muted", + "G-ANNO-REVS": "muted", + }, + }, + { + name: "plan-set-A-201", + example: "plan-set/set.ts", + width: 1100, + pad: 0, + pen: 0.8, + sheetSvg: planSetSheet("A-201"), + roundCaps: false, + roles: { + ...planSetFrame, + "A-ELEV": "line", + "A-ELEV-FINE": "muted", + "A-ELEV-GRAD": "line", + "S-FNDN-HIDD": "muted", + }, + }, ]; // The group toSVG wraps the entities in. A default line is one pixel wide at any size, and an @@ -175,7 +234,10 @@ function sheetPicture( for (const picture of pictures) { const { name, width, pad, pen, roundCaps, roles, sheet } = picture; - execFileSync(process.execPath, [`examples/${name}.ts`], { cwd: root, stdio: "inherit" }); + execFileSync(process.execPath, [`examples/${picture.example ?? `${name}.ts`}`], { + cwd: root, + stdio: "inherit", + }); if (picture.sheetSvg !== undefined) { for (const [theme, colors] of Object.entries(themes)) { writeFileSync( diff --git a/test/plan-set.test.ts b/test/plan-set.test.ts new file mode 100644 index 0000000..a333810 --- /dev/null +++ b/test/plan-set.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { checkIntent, lint } from "../src/index.ts"; +import set, { intent, xrefs } from "../examples/plan-set/set.ts"; + +test("the permit set example draws its 15 sheets, lints clean and keeps its setbacks", () => { + assert.deepEqual( + set.layouts?.map((layout) => layout.name), + [ + "G-001", + "C-101", + "A-101", + "A-102", + "A-103", + "A-201", + "A-202", + "A-301", + "A-601", + "S-101", + "S-102", + "S-501", + "M-101", + "E-101", + "P-101", + ], + ); + for (const drawing of [set, ...Object.values(xrefs)]) { + assert.deepEqual( + lint(drawing).filter((d) => d.severity === "error"), + [], + ); + } + assert.deepEqual(checkIntent(set, intent), []); +});