;;; ---------------------------------------------------------------------------
;;; FlexDuct.lsp
;;; code compiled by YZ August 2026
;;; ---------------------------------------------------------------------------
;;; FLEXIBLE DUCT
;;;
;;; PURPOSE
;;;   Draws flexible ductwork along a route you pick - two walls at the duct
;;;   width, with the corrugated coil between them that says "flexible" on an
;;;   HVAC drawing, and a plain collar at each end where it clamps to rigid.
;;;
;;;   Follows a run of straight legs, rounding each corner to a bend radius, so
;;;   a drop from a branch to a diffuser takes a couple of picks.
;;;
;;; WHY THE COIL MATTERS
;;;   Rigid duct and flexible duct are drawn the same way but for the coil, and
;;;   they are costed and installed quite differently. A run drawn without it
;;;   reads as rigid, and the difference shows up on site rather than on the
;;;   drawing.
;;;
;;; WHAT WAS FIXED
;;;   - It drew no coil at all. The name promised flexible duct and what came
;;;     out was two parallel lines - which is rigid duct.
;;;   - The collar at the end was set by two numbers written straight into the
;;;     code, 9 and 7, in drawing units. On a plan at any scale but the one it
;;;     was written for, the collar was either invisible or enormous.
;;;   - Pi was written out by hand in four places, twice as 3.1415926 and twice
;;;     as 3.14159265, and a right angle as 1.57079633.
;;;   - Corners were rounded by running the FILLET command against points picked
;;;     on the lines just drawn, with the radius set from a comparison of two
;;;     distances that got it the wrong way round on a left-hand bend.
;;;   - Every variable was global - eighteen of them, including W, Z and L.
;;;   - CMDECHO was set to 0 and never restored.
;;;
;;;   FLEXDUCT  - draw a run of flexible duct
;;; ---------------------------------------------------------------------------

(vl-load-com)
(setq *Flex:Width* nil)

(defun Flex:Layer ( name colour )
    (if (not (tblsearch "LAYER" name))
        (entmake (list '(0 . "LAYER") '(100 . "AcDbSymbolTableRecord")
                       '(100 . "AcDbLayerTableRecord") (cons 2 name)
                       '(70 . 0) (cons 62 colour) '(6 . "Continuous"))))
    name
)

(defun Flex:Line ( p1 p2 layer )
    (entmake (list '(0 . "LINE") (cons 8 layer) (cons 10 p1) (cons 11 p2)))
)

;;; A polyline from (point . bulge) pairs.
(defun Flex:Poly ( pairs layer )
    (entmake (append
        (list '(0 . "LWPOLYLINE") '(100 . "AcDbEntity") (cons 8 layer)
              '(100 . "AcDbPolyline") (cons 90 (length pairs)) '(70 . 0))
        (apply 'append
            (mapcar '(lambda ( pr )
                        (list (cons 10 (list (car (car pr)) (cadr (car pr))))
                              (cons 42 (cdr pr))))
                    pairs))))
)

;;; The corrugation: a run of half-circle arcs across the duct, stepped along
;;; the centreline. Alternating the bulge sign is what gives the coil its
;;; characteristic lean rather than a row of rings.
(defun Flex:Coil ( obj total width spacing layer / d pairs pt tan ang half n side )
    (setq d (/ spacing 2.0) pairs nil n 0 half (* width 0.42) side 1.0)
    (while (< d total)
        (setq pt  (vlax-curve-getPointAtDist obj d)
              tan (vlax-curve-getFirstDeriv obj
                      (vlax-curve-getParamAtDist obj d))
              ang (+ (angle '(0 0 0) tan) (/ pi 2.0)))
        (Flex:Poly
            (list (cons (polar pt ang half) (* side 0.55))
                  (cons (polar pt (+ ang pi) half) 0.0))
            layer)
        (setq side (- side) n (1+ n) d (+ d spacing)))
    n
)

(defun c:FLEXDUCT ( / *error* vars vals pts p width bend lay obj cl total
                      n v spacing collar ent )

    (setq vars '("CMDECHO" "BLIPMODE" "OSMODE" "CLAYER")
          vals (mapcar 'getvar vars))

    (defun Flex:Restore ( )
        (mapcar 'setvar vars vals)
        (while (= 8 (logand 8 (getvar 'undoctl))) (command "_.UNDO" "_End"))
        (vl-catch-all-apply '(lambda ( ) (*pop-error-mode*)) '())
        (princ)
    )

    (defun *error* ( msg )
        (Flex:Restore)
        (if (and msg (not (wcmatch (strcase msg t) "*break*,*cancel*,*exit*")))
            (princ (strcat "\n** FLEXDUCT error: " msg " **")))
        (princ)
    )

    (setvar "CMDECHO" 0)
    (setvar "BLIPMODE" 0)
    ;; AutoCAD 2015 and later refuse (command) inside an *error* handler unless
    ;; the routine says up front that it will use one.
    (vl-catch-all-apply '(lambda ( ) (*push-error-using-command*)) '())
    (command "_.UNDO" "_Begin")

    (initget 6)
    (setq width (getdist (strcat "\nDuct width"
                                 (if *Flex:Width*
                                     (strcat " <" (rtos *Flex:Width* 2 3) ">") "")
                                 ": ")))
    (if (null width) (setq width *Flex:Width*))

    (if (null width)
        (princ "\nNo width given.")
        (progn
            (setq *Flex:Width* width)
            (initget 4)
            (setq bend (getdist (strcat "\nBend radius at corners <"
                                        (rtos width 2 3) ">: ")))
            (if (null bend) (setq bend width))

            (princ "\nPick the route along the duct centreline.")
            (setq pts nil)
            (while (setq p (getpoint (if pts (car pts) nil)
                                     (strcat "\nPoint " (itoa (1+ (length pts)))
                                             (if (> (length pts) 1)
                                                 " <Enter when done>" "") ": ")))
                (setq pts (cons (list (car p) (cadr p) 0.0) pts)))
            (setq pts (reverse pts))

            (if (< (length pts) 2)
                (princ "\nTwo points at least are needed.")
                (progn
                    (setvar "OSMODE" 0)
                    (setq lay (Flex:Layer "Duct-Flexible" 4))
                    (setvar "CLAYER" lay)

                    ;; The centreline, as a polyline so it can be filleted and
                    ;; then measured along.
                    (Flex:Poly (mapcar '(lambda ( q ) (cons q 0.0)) pts) lay)
                    (setq cl (entlast))

                    ;; Round every corner in one pass.
                    (if (and (> bend 0.0) (> (length pts) 2))
                        (progn
                            (setvar "CMDECHO" 0)
                            (command "_.FILLET" "_Radius" bend)
                            (command "_.FILLET" "_Polyline" cl)))

                    (setq obj   (vlax-ename->vla-object cl)
                          total (vlax-curve-getDistAtParam obj
                                    (vlax-curve-getEndParam obj)))

                    ;; The two walls.
                    (command "_.OFFSET" (/ width 2.0) cl
                             (polar (vlax-curve-getPointAtDist obj (/ total 2.0))
                                    (+ (angle '(0 0 0)
                                        (vlax-curve-getFirstDeriv obj
                                            (vlax-curve-getParamAtDist obj (/ total 2.0))))
                                       (/ pi 2.0))
                                    (max (/ width 2.0) 1e-6)) "")
                    (command "_.OFFSET" (/ width 2.0) cl
                             (polar (vlax-curve-getPointAtDist obj (/ total 2.0))
                                    (- (angle '(0 0 0)
                                        (vlax-curve-getFirstDeriv obj
                                            (vlax-curve-getParamAtDist obj (/ total 2.0))))
                                       (/ pi 2.0))
                                    (max (/ width 2.0) 1e-6)) "")

                    ;; Collars at each end, sized from the duct rather than from
                    ;; a number typed into the source.
                    (setq collar (* width 0.14))
                    (foreach d (list 0.0 total)
                        (setq p (vlax-curve-getPointAtDist obj d)
                              v (+ (angle '(0 0 0)
                                    (vlax-curve-getFirstDeriv obj
                                        (vlax-curve-getParamAtDist obj d)))
                                   (/ pi 2.0)))
                        (Flex:Line (polar p v (+ (/ width 2.0) collar))
                                   (polar p (+ v pi) (+ (/ width 2.0) collar))
                                   lay))

                    ;; And the coil that makes it flexible.
                    (setq spacing (* width 0.55)
                          n (Flex:Coil obj total width spacing lay))

                    ;; The centreline has done its job.
                    (initget "Yes No")
                    (if (= "No" (getkword "\nKeep the centreline [Yes/No] <Yes>: "))
                        (entdel cl))

                    (princ (strcat "\nDuct " (rtos total 2 3) " long, "
                                   (rtos width 2 3) " wide, "
                                   (itoa n) " corrugations."))))))

    (Flex:Restore)
    (princ)
)

(princ)
