;;; ---------------------------------------------------------------------------
;;; FastenerKit.lsp
;;; code compiled by YZ August 2026
;;; ---------------------------------------------------------------------------
;;; SIDE-ELEVATION FASTENERS DRAWN TO PUBLISHED PROPORTIONS
;;;
;;; PURPOSE
;;;   Draws hexagon nuts, socket head cap screws and flat (countersunk) head cap
;;;   screws in side elevation, at any angle, at any thread diameter, as ordinary
;;;   lines and arcs. Nothing is inserted as a block, so a fastener can be
;;;   trimmed into an assembly or edited member by member.
;;;
;;; WHY THE PROPORTIONS ARE TABULATED
;;;   Fastener head sizes are NOT a constant multiple of thread diameter. The
;;;   published standards step the ratios as the diameter drops - a head on a
;;;   1/8" screw is proportionally much larger than one on a 3/4" screw. Drawing
;;;   everything from a single multiplier gives small fasteners heads that are
;;;   visibly too small.
;;;
;;;   So each dimension that steps is held here as a lookup table of
;;;   (threshold . ratio) pairs in ascending order, plus a default for anything
;;;   larger than the biggest threshold. The lookup walks up the table and takes
;;;   the first threshold the thread diameter fits inside.
;;;
;;;   Worked example, across-corners on a hexagon nut: a 0.35 diameter is not
;;;   within 0.150, 0.170, 0.200, 0.260 or 0.340, but it IS within 0.390, so the
;;;   ratio is 1.760 and the nut measures 0.35 x 1.760 across corners.
;;;
;;; HOW IT WORKS
;;;   1. You choose a fastener type, then give the thread diameter, the screw
;;;      length where it applies, and the axis angle. All three are remembered,
;;;      so the next fastener only needs a pick point.
;;;
;;;   2. Every point is built with POLAR from the insertion point along the axis
;;;      angle, which is what lets the whole fastener be drawn at any rotation
;;;      without a single transformation.
;;;
;;;   3. You keep picking insertion points until you press Enter. The original
;;;      this was rebuilt from looped a fixed hundred times with no way out but
;;;      Escape - which threw away the whole command.
;;;
;;; LINETYPES
;;;   Thread roots are drawn in HIDDEN. If that linetype is not loaded in the
;;;   drawing the routine loads it from the standard linetype file; if it cannot,
;;;   it says so once and draws those lines continuous rather than failing.
;;;
;;;   FASTENER  - draw hex nuts and cap screws, repeating until you press Enter
;;; ---------------------------------------------------------------------------

;;; ---------------------------------------------------------------------------
;;; SESSION MEMORY
;;; Kept between invocations so a run of identical fasteners is pick, pick, pick.
;;; ---------------------------------------------------------------------------

(if (null *FastenerKit:Prefs*)
    (setq *FastenerKit:Prefs*
        (list (cons "DIA" 0.25)     ; thread diameter
              (cons "LEN" 1.0)      ; screw length under the head
              (cons "ANG" 0.0)      ; axis angle in radians
        )
    )
)

(defun FastenerKit:Get ( key ) (cdr (assoc key *FastenerKit:Prefs*)))

(defun FastenerKit:Put ( key val )
    (setq *FastenerKit:Prefs*
        (cons (cons key val)
              (vl-remove-if '(lambda (p) (= (car p) key)) *FastenerKit:Prefs*)))
    val
)

;;; ---------------------------------------------------------------------------
;;; PROPORTION TABLES
;;;
;;; Ascending (threshold . ratio). Read as "a thread diameter up to and including
;;; this threshold uses this ratio". Anything above every threshold falls through
;;; to the default passed alongside the table.
;;; ---------------------------------------------------------------------------

;;; Hexagon nut, across corners as a multiple of thread diameter.
(setq FastenerKit:NutCorners
    '((0.150 . 2.654) (0.170 . 2.421) (0.200 . 2.279) (0.260 . 2.020)
      (0.340 . 1.849) (0.390 . 1.760) (0.450 . 1.816)))

;;; Hexagon nut, thickness as a multiple of thread diameter.
(setq FastenerKit:NutThick
    '((0.150 . 0.801) (0.170 . 0.762) (0.200 . 0.658) (0.260 . 0.872)
      (0.340 . 0.849) (0.390 . 0.875) (0.450 . 0.858) (0.640 . 0.873)))

;;; Socket head cap screw, head diameter as a multiple of thread diameter.
(setq FastenerKit:SocketHead '((0.240 . 1.640)))

;;; Flat head cap screw, head diameter as a multiple of thread diameter.
(setq FastenerKit:FlatHead
    '((0.120 . 2.276) (0.133 . 2.248) (0.160 . 2.224) (0.175 . 2.189)
      (0.230 . 2.163) (0.300 . 2.124) (0.350 . 2.102) (0.410 . 2.082)
      (0.480 . 1.931) (0.610 . 1.876) (0.730 . 1.901)))

;;; Flat head cap screw, head height as a multiple of thread diameter.
(setq FastenerKit:FlatHeight
    '((0.120 . 0.741) (0.133 . 0.720) (0.160 . 0.702) (0.175 . 0.682)
      (0.230 . 0.668) (0.300 . 0.644) (0.350 . 0.634) (0.410 . 0.624)
      (0.480 . 0.535) (0.610 . 0.502) (0.730 . 0.518)))

;;; Take the first ratio whose threshold the diameter fits inside; fall back to
;;; the default when the diameter is larger than every threshold in the table.
(defun FastenerKit:Ratio ( dia table default / found )
    (foreach pair table
        (if (and (null found) (<= dia (car pair)))
            (setq found (cdr pair))))
    (if found found default)
)

;;; ---------------------------------------------------------------------------
;;; SMALL HELPERS
;;; ---------------------------------------------------------------------------

;;; Point at a distance along the fastener axis and a distance across it.
;;; "along" is positive toward the far end, "across" is positive to the left of
;;; the axis looking along it. Every point in every fastener is built from this,
;;; which is why none of them need any rotation maths.
(defun FastenerKit:Pt ( base ang along across )
    (polar (polar base ang along) (+ ang (* pi 0.5)) across)
)

;;; Draw a chain of lines through the given points.
(defun FastenerKit:Chain ( pts close )
    (command "_.LINE")
    (foreach p pts (command p))
    (if close (command "_C") (command ""))
    (princ)
)

;;; Make sure a linetype is available. Returns T when it can be used.
;;; The load is wrapped because the LINETYPE command fails noisily when the
;;; linetype file is missing, and a missing HIDDEN is not worth aborting over.
(defun FastenerKit:EnsureLinetype ( name / )
    (cond
        ((tblsearch "ltype" name) t)
        ((progn
             (vl-catch-all-apply
                 '(lambda ( )
                      (command "_.-LINETYPE" "_Load" name "acad.lin" "")))
             (tblsearch "ltype" name))
         t)
        ((progn
             (vl-catch-all-apply
                 '(lambda ( )
                      (command "_.-LINETYPE" "_Load" name "acadiso.lin" "")))
             (tblsearch "ltype" name))
         t)
        (t nil)
    )
)

;;; ---------------------------------------------------------------------------
;;; HEXAGON NUT, SIDE ELEVATION
;;;
;;; Drawn as the three visible faces with their chamfer arcs. The nut is centred
;;; on the insertion point across its width and starts there along its axis, so
;;; the insertion point sits on the centreline at the near face.
;;;
;;;   hac  half the across-corners dimension - the outer edge of the nut
;;;   haf  half of that again - where the two visible flats meet
;;;   thk  nut thickness along the axis
;;; ---------------------------------------------------------------------------

(defun FastenerKit:HexNut ( ip ang dia / rac rat thk hac haf back
                            p1 p2 p3 p4 p5 p6 p7 p8 p9 p10 p11 )

    (setq rac (FastenerKit:Ratio dia FastenerKit:NutCorners 1.732)
          rat (FastenerKit:Ratio dia FastenerKit:NutThick   0.855)
          thk (* dia rat)
          hac (/ (* rac dia) 2.0)
          haf (/ (* rac dia) 4.0)
          ;; The chamfer arcs spring from 85% of the way back, leaving a short
          ;; straight run at the near face.
          back (* thk 0.85))

    (setq p1  (FastenerKit:Pt ip ang 0.0  (- hac))
          p2  (FastenerKit:Pt ip ang 0.0  (- haf))
          p3  (FastenerKit:Pt ip ang 0.0     haf)
          p4  (FastenerKit:Pt ip ang 0.0     hac)
          p5  (FastenerKit:Pt ip ang back (- hac))
          p6  (FastenerKit:Pt ip ang back (- haf))
          p7  (FastenerKit:Pt ip ang back    haf)
          p8  (FastenerKit:Pt ip ang back    hac)
          p9  (FastenerKit:Pt ip ang thk  0.0)
          p10 (FastenerKit:Pt ip ang thk  (- (+ haf (/ haf 2.0))))
          p11 (FastenerKit:Pt ip ang thk     (+ haf (/ haf 2.0))))

    ;; Three chamfer arcs across the far face, one per visible flat.
    (command "_.ARC" p5 p10 p6)
    (command "_.ARC" p6 p9  p7)
    (command "_.ARC" p7 p11 p8)

    ;; Outline back round the near face.
    (FastenerKit:Chain (list p8 p4 p1 p5) nil)

    ;; The lines where the flats meet.
    (FastenerKit:Chain (list p10 p11) nil)
    (FastenerKit:Chain (list p2  p6)  nil)
    (FastenerKit:Chain (list p3  p7)  nil)
    (princ)
)

;;; ---------------------------------------------------------------------------
;;; SOCKET HEAD CAP SCREW
;;;
;;; A plain cylindrical head with a chamfered edge, then the shank with its
;;; thread roots shown as hidden lines. The head length equals the thread
;;; diameter, which is the standard proportion for this fastener.
;;;
;;; The insertion point is on the centreline at the face of the head, with the
;;; head behind it and the shank running forward along the axis.
;;; ---------------------------------------------------------------------------

(defun FastenerKit:SocketCap ( ip ang dia len hidden / hd ch headBack prevLt
                               h1 h2 h3 h4 h5 h6
                               s1 s2 s3 s4 s5 s6 s7 s8 )

    (setq hd  (* dia (FastenerKit:Ratio dia FastenerKit:SocketHead 1.50))
          ch  (* dia 0.12)                 ; chamfer size on the head edge
          headBack (- dia))                ; head extends one diameter backwards

    ;; --- head ------------------------------------------------------------
    (setq h1 (FastenerKit:Pt ip ang 0.0 (/ hd 2.0))
          h2 (FastenerKit:Pt ip ang 0.0 (- (/ hd 2.0)))
          h3 (FastenerKit:Pt ip ang (- (- dia ch)) (/ hd 2.0))
          h4 (FastenerKit:Pt ip ang (- (- dia ch)) (- (/ hd 2.0)))
          h5 (FastenerKit:Pt ip ang headBack (- (/ hd 2.0) ch))
          h6 (FastenerKit:Pt ip ang headBack (- (- (/ hd 2.0) ch))))

    (FastenerKit:Chain (list h4 h3 h5 h6 h4 h2 h1 h3) nil)

    ;; --- shank -----------------------------------------------------------
    (setq s1 (FastenerKit:Pt ip ang 0.0 (/ dia 2.0))
          s2 (FastenerKit:Pt ip ang 0.0 (- (/ dia 2.0)))
          s7 (FastenerKit:Pt ip ang 0.0 (- (/ dia 2.0) ch))
          s8 (FastenerKit:Pt ip ang 0.0 (- (- (/ dia 2.0) ch)))
          s3 (FastenerKit:Pt ip ang (- len ch) (/ dia 2.0))
          s4 (FastenerKit:Pt ip ang (- len ch) (- (/ dia 2.0)))
          s5 (FastenerKit:Pt ip ang len (- (/ dia 2.0) ch))
          s6 (FastenerKit:Pt ip ang len (- (- (/ dia 2.0) ch))))

    ;; Outside of the shank, with the lead chamfer at the far end.
    (FastenerKit:Chain (list s2 s4 s6 s5 s3 s1) nil)
    (FastenerKit:Chain (list s4 s3) nil)

    ;; Thread roots, hidden. Falls back to the current linetype when HIDDEN is
    ;; unavailable rather than refusing to draw them at all. Whatever the current
    ;; linetype was is put straight back, so a user working with a deliberate
    ;; CELTYPE keeps it.
    (setq prevLt (getvar "CELTYPE"))
    (if hidden (setvar "CELTYPE" "HIDDEN"))
    (FastenerKit:Chain (list s5 s7) nil)
    (FastenerKit:Chain (list s6 s8) nil)
    (setvar "CELTYPE" prevLt)
    (princ)
)

;;; ---------------------------------------------------------------------------
;;; FLAT (COUNTERSUNK) HEAD CAP SCREW
;;;
;;; A conical head tapering from the full head diameter at the insertion point
;;; down to the shank diameter at the head height, then the shank.
;;;
;;; NOTE ON A CORRECTED DEFECT
;;;   The routine this was rebuilt from drew the head cone with the HIDDEN
;;;   linetype, which is wrong - the cone is the visible outline of the fastener
;;;   and only the thread roots inside the shank are hidden. The cone is drawn
;;;   continuous here.
;;; ---------------------------------------------------------------------------

(defun FastenerKit:FlatCap ( ip ang dia len hidden / hd hh ch prevLt
                             p4 p5 p6 p7 p8 p9 p10 p11 p12 p13 )

    (setq hd (* dia (FastenerKit:Ratio dia FastenerKit:FlatHead   1.916))
          hh (* dia (FastenerKit:Ratio dia FastenerKit:FlatHeight 0.528))
          ch (* dia 0.12))

    (setq p4  (FastenerKit:Pt ip ang 0.0 (/ hd 2.0))
          p5  (FastenerKit:Pt ip ang 0.0 (- (/ hd 2.0)))
          p6  (FastenerKit:Pt ip ang hh  (/ dia 2.0))
          p7  (FastenerKit:Pt ip ang hh  (- (/ dia 2.0)))
          p8  (FastenerKit:Pt ip ang (- len ch) (/ dia 2.0))
          p9  (FastenerKit:Pt ip ang (- len ch) (- (/ dia 2.0)))
          p10 (FastenerKit:Pt ip ang len (- (/ dia 2.0) ch))
          p11 (FastenerKit:Pt ip ang len (- (- (/ dia 2.0) ch)))
          ;; Thread roots stop short of the head, at 90% of the shank length.
          p12 (FastenerKit:Pt ip ang (- len (* (- len hh) 0.9)) (- (/ dia 2.0) ch))
          p13 (FastenerKit:Pt ip ang (- len (* (- len hh) 0.9)) (- (- (/ dia 2.0) ch))))

    ;; Shank outline and its end face.
    (FastenerKit:Chain (list p7 p9 p11 p10 p8 p6) nil)
    (FastenerKit:Chain (list p9 p8) nil)

    ;; The countersunk head cone - visible outline, drawn continuous.
    (FastenerKit:Chain (list p5 p7 p6 p4) t)

    ;; Thread roots, hidden, with the caller's linetype restored afterwards.
    (setq prevLt (getvar "CELTYPE"))
    (if hidden (setvar "CELTYPE" "HIDDEN"))
    (FastenerKit:Chain (list p10 p12) nil)
    (FastenerKit:Chain (list p11 p13) nil)
    (setvar "CELTYPE" prevLt)
    (princ)
)

;;; ---------------------------------------------------------------------------
;;; MAIN COMMAND
;;; ---------------------------------------------------------------------------

(defun c:FASTENER ( / *error* vars vals kind dia len ang ip hidden )

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

    (defun FastenerKit: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 )
        (FastenerKit:Restore)
        (if (and msg (not (wcmatch (strcase msg t) "*break*,*cancel*,*exit*")))
            (princ (strcat "\n** FASTENER error: " msg " **")))
        (princ)
    )

    (setvar "CMDECHO" 0)
    ;; AutoCAD 2015 and later refuse (command) inside an *error* handler
    ;; unless the routine says up front that it will use one. Restore does,
    ;; to close this undo group. The declaring call is absent on older
    ;; releases, so it is wrapped rather than tested for.
    (vl-catch-all-apply '(lambda ( ) (*push-error-using-command*)) '())
    (command "_.UNDO" "_Begin")

    (initget 1 "Hexnut Socketcap Flatcap")
    (setq kind (getkword "\nFastener [Hexnut/Socketcap/Flatcap] <Hexnut>: "))
    (if (null kind) (setq kind "Hexnut"))

    ;; Thread diameter, defaulting to whatever was used last.
    (initget 6)   ; reject zero and negative, but allow Enter to take the default
    (setq dia (getdist (strcat "\nThread diameter <"
                               (rtos (FastenerKit:Get "DIA") 2 4) ">: ")))
    (if dia (FastenerKit:Put "DIA" dia) (setq dia (FastenerKit:Get "DIA")))

    ;; Screw length, for the two screws only - a nut has no length.
    (if (/= kind "Hexnut")
        (progn
            (initget 6)
            (setq len (getdist (strcat "\nScrew length under the head <"
                                       (rtos (FastenerKit:Get "LEN") 2 4) ">: ")))
            (if len (FastenerKit:Put "LEN" len) (setq len (FastenerKit:Get "LEN")))))

    ;; Axis angle. GETANGLE returns radians and honours a two-point pick.
    (setq ang (getangle (strcat "\nAxis angle <"
                                (angtos (FastenerKit:Get "ANG") 0 2) ">: ")))
    (if ang (FastenerKit:Put "ANG" ang) (setq ang (FastenerKit:Get "ANG")))

    ;; Resolved once per command rather than once per fastener, so a drawing
    ;; without HIDDEN produces one message instead of one per insertion.
    (setq hidden (FastenerKit:EnsureLinetype "HIDDEN"))
    (if (not hidden)
        (princ "\nFastenerKit: HIDDEN linetype unavailable - thread roots drawn continuous."))

    (setvar "BLIPMODE" 0)
    ;; Osnaps would snap the calculated points onto nearby geometry and distort
    ;; the fastener; the insertion pick itself is taken before this takes effect
    ;; on each pass, so snapping the fastener TO something still works.
    (while (setq ip (getpoint "\nInsertion point <Enter to finish>: "))
        (setvar "OSMODE" 0)
        (cond
            ((= kind "Hexnut")    (FastenerKit:HexNut    ip ang dia))
            ((= kind "Socketcap") (FastenerKit:SocketCap ip ang dia len hidden))
            ((= kind "Flatcap")   (FastenerKit:FlatCap   ip ang dia len hidden))
        )
        (setvar "OSMODE" (nth (vl-position "OSMODE" vars) vals))
    )

    (FastenerKit:Restore)
    (princ)
)

(princ)
