Tag: python

Threads – Changes

I had a few issues that prevented me from using Threads in its current form. I suspect my use case differs from the one they are coding to – I have resources and no need to limit myself to a small number of thread colors. Also, a hidden UI element seems to hide a few neighboring elements that I wanted to use.

Updates:

app.py
@@ -12,6 +12,7 @@ family of projects integrated:
“””
import base64
import json
+import logging
import os
import sys
import tempfile
@@ -36,8 +37,13 @@ from inkstitchlib import ai_naming
from inkstitchlib import density as density_map
from inkstitchlib import presets, sketch as sketch_mod

-JOBS = os.path.join(tempfile.gettempdir(), ‘stitchforge_jobs’)
-os.makedirs(JOBS, exist_ok=True)
+#JOBS = os.path.join(tempfile.gettempdir(), ‘stitchforge_jobs’)
+#os.makedirs(JOBS, exist_ok=True)
+from pathlib import Path
+
+JOBS = Path(__file__).resolve().parent / “stitchforge_jobs”
+JOBS.mkdir(parents=True, exist_ok=True)
+

app = FastAPI(title=’StitchForge’)

@@ -276,6 +282,8 @@ def studio():
@app.post(‘/api/analyze’)
async def analyze(image: UploadFile = File(…), colors: int = Form(3)):
“””Separate the image into colour layers and report stroke widths.”””
+ if not 1 <= colors <= segment.MAX_COLORS:
+ raise HTTPException(400, ‘colors must be between 1 and %d’ % segment.MAX_COLORS)
job = uuid.uuid4().hex[:12]
d = _job_dir(job, must_exist=False)
os.makedirs(d, exist_ok=True)
@@ -315,6 +323,8 @@ async def digitize(job: str = Form(…), colors: int = Form(3),
cap_mode: bool = Form(False),
trim_dist: float = Form(2.0),
fabric: str = Form(”), machine: str = Form(”)):
+ if not 1 <= colors <= segment.MAX_COLORS:
+ raise HTTPException(400, ‘colors must be between 1 and %d’ % segment.MAX_COLORS)
d = _job_dir(job)
src = None
for fn in os.listdir(d):
@@ -716,6 +726,8 @@ async def vectorize(image: UploadFile = File(…), colors: int = Form(4),
“””Look at the artwork and return editable vector layers — connected
regions as single objects, similar round shapes grouped, one layer per
large shape. No stitches are made until /api/stitch_layers.”””
+ if not 1 <= colors <= segment.MAX_COLORS:
+ raise HTTPException(400, ‘colors must be between 1 and %d’ % segment.MAX_COLORS)
job = uuid.uuid4().hex[:12]
d = _job_dir(job, must_exist=False)
os.makedirs(d, exist_ok=True)
@@ -723,7 +735,7 @@ async def vectorize(image: UploadFile = File(…), colors: int = Form(4),
with open(src, ‘wb’) as f:
f.write(await image.read())
try:
– lyrs, w, h = veclayers.vectorize(src, max(1, min(8, colors)),
+ lyrs, w, h = veclayers.vectorize(src, colors,
max(10.0, min(400.0, width_mm)))
except veclayers.LayerError as e:
raise HTTPException(400, str(e))
@@ -766,12 +778,20 @@ async def stitch_layers(data: dict):
“””Sew the arranged layers into a design (this is when stitches exist).”””
lyrs = data.get(‘layers’) or []
if not isinstance(lyrs, list) or not lyrs:
+ logging.getLogger(‘uvicorn.error’).warning(
+ ‘AI layer stitching rejected: no layers to stitch’)
raise HTTPException(400, ‘no layers to stitch’)
– if len(lyrs) > 80:
– raise HTTPException(400, ‘too many layers (max 80)’)
+ max_layers = 512
+ if len(lyrs) > max_layers:
+ logging.getLogger(‘uvicorn.error’).warning(
+ ‘AI layer stitching rejected: %d layers exceeds the limit of %d’,
+ len(lyrs), max_layers)
+ raise HTTPException(400, ‘too many layers (max %d)’ % max_layers)
try:
pat, block_layers = veclayers.stitch(lyrs, settings=_stitch_settings(data))
except veclayers.LayerError as e:
+ logging.getLogger(‘uvicorn.error’).warning(
+ ‘AI layer stitching rejected (%d layers): %s’, len(lyrs), e)
raise HTTPException(400, str(e))
except Exception as e:
traceback.print_exc()
@@ -796,8 +816,9 @@ def fills_list():
return [{‘id’: k, ‘name’: patterns.FILL_LABELS[k]} for k in veclayers.FILL_METHODS]

-_FILL_PREVIEW_DIR = os.path.join(tempfile.gettempdir(), ‘stitchforge_fills’)
–
+#_FILL_PREVIEW_DIR = os.path.join(tempfile.gettempdir(), ‘stitchforge_fills’)
+_FILL_PREVIEW_DIR = Path(__file__).resolve().parent / “stitchforge_fills”
+_FILL_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)

@app.get(‘/api/fill_preview/{name}.png’)
def fill_preview(name: str):

digitizer/core.py

@@ -610,9 +610,10 @@ def smooth(p, k=9):
if len(p) < k or k < 3:
return p
ker = np.ones(k) / k
+ padded = np.pad(p, ((k // 2, (k – 1) // 2), (0, 0)), mode=’edge’)
out = p.copy()
– out[:, 0] = np.convolve(p[:, 0], ker, mode=’same’)
– out[:, 1] = np.convolve(p[:, 1], ker, mode=’same’)
+ out[:, 0] = np.convolve(padded[:, 0], ker, mode=’valid’)
+ out[:, 1] = np.convolve(padded[:, 1], ker, mode=’valid’)
out[:2], out[-2:] = p[:2], p[-2:] # don’t drag the tips inward
return out

@@ -1041,7 +1042,8 @@ def sew_blob(sewer, poly, spacing, max_span, border_w, travel=None,
if not heavy_underlay and mode == ‘heavy’:
mode = ‘auto’
if _do(phase, UNDER) and mode != ‘none’:
– sew_edge_run(sewer, poly, inset=min(0.6, border_w), travel=travel)
+ inset = min(0.6, border_w) if border_w > 0 else 0.6
+ sew_edge_run(sewer, poly, inset=inset, travel=travel)
ax = principal_angle(poly)
if mode == ‘heavy’:
for ang in (ax + 45, ax – 45):
@@ -1054,7 +1056,7 @@ def sew_blob(sewer, poly, spacing, max_span, border_w, travel=None,
if not _do(phase, TOP):
return

– ring = satin_ring(poly, border_w, spacing, start=sewer.pos)
+ ring = satin_ring(poly, border_w, spacing, start=sewer.pos) if border_w > 0 else None
core = poly.buffer(-border_w * 0.72) if ring else poly
if core.geom_type == ‘MultiPolygon’:
core = max(core.geoms, key=lambda g: g.area)

digitizer/engine.py

@@ -24,7 +24,7 @@ class Params:
underlay_spacing: float = 2.5
min_fill_area: float = 0.20
min_blob_area: float = 2.0 # below this, satin-ing a blob isn’t worth it
– border_width: float = 1.0 # satin outline band around blobs
+ border_width: float = 0.0 # optional satin outline band; zero disables it
fill_method: str = ‘tatami’ # tatami | contour | circular (Ink/Stitch fills)
fill_angle: float = 65.0
heavy_underlay: bool = True
@@ -155,10 +155,7 @@ def build_pattern(layers, canvas_mm, width_px, p: Params):
if phase != core.UNDER:
fill_methods.sew_area(s, g, p.fill_method, p.fill_angle,
p.row_spacing, p.max_stitch, travel=travel)
– # too wide to satin across, but it still needs a hard edge:
– # a satin outline over the fill is what stops a torso or a
– # head reading as ragged
– if q.area >= p.min_blob_area * 2:
+ if p.border_width > 0 and q.area >= p.min_blob_area * 2:
ring = core.satin_ring(g, p.border_width, p.row_spacing)
if ring:
core.sew_ring(s, ring, travel)

digitizer/segment.py

@@ -4,6 +4,7 @@ import cv2
from PIL import Image

MAX_WORK_PX = 1400 # working raster; bigger only costs time
+MAX_COLORS = 32

def load(path):
@@ -43,7 +44,7 @@ def quantize(rgba, n_colors, fg=None):
pix = rgb[fg]
if len(pix) < 16:
raise ValueError(‘the image looks empty once the background is removed’)
– n_colors = int(max(1, min(n_colors, 8, len(np.unique(pix, axis=0)))))
+ n_colors = int(max(1, min(n_colors, MAX_COLORS, len(np.unique(pix, axis=0)))))
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
_, lbl, centers = cv2.kmeans(pix, n_colors, None, crit, 4,
cv2.KMEANS_PP_CENTERS)

inkstitchlib/layers.py

@@ -21,7 +21,7 @@ from shapely.geometry import Polygon, MultiPolygon, LineString
from digitizer import segment, core
from . import fills, patterns

-DEFAULT_PARAMS = {‘stitch’: ‘auto’, ‘fill_method’: ‘tatami’,
+DEFAULT_PARAMS = {‘stitch’: ‘fill’, ‘fill_method’: ‘tatami’,
‘angle’: ‘auto’, ‘density’: 0.4, ‘border_mm’: 1.0}

inkstitchlib/worksheet.py

@@ -444,6 +444,7 @@ def _production_page(c, pattern, report, layers, thread_matches, preview_png,
_text(c, cols[‘Name’], y, _fit(c, name, name_w, body, fs), fs)
_text(c, cols[‘Chart’], y, chart, fs, align=’right’)
y -= lh
+ next_block = min(len(blocks), i + 1) if blocks else 0
c.setStrokeColor(black)
c.setLineWidth(0.8)
c.line(col_x, y + lh – 3.5, x1, y + lh – 3.5)
@@ -455,6 +456,75 @@ def _production_page(c, pattern, report, layers, thread_matches, preview_png,
_text(c, x0 + 150, fy, ‘Design last saved : %s’ % _stamp(saved_at or now), 7.5)
_text(c, x0 + 345, fy, ‘Date printed: %s’ % _stamp(now), 7.5)
_text(c, x1 – 3, fy, ‘Page %d of %d’ % (c.getPageNumber(), pages), 7.5, align=’right’)
+ return next_block
+
+
+def _production_stop_page(c, blocks, layers, thread_matches, start, design_name,
+ client, saved_at, pages):
+ “””Draw a full-width continuation page for the production stop sequence.”””
+ W, H = letter
+ body = c._body
+ m = 0.22 * 72
+ x0, x1, y0, y1 = m, W – m, m, H – m
+ c.setStrokeColor(black)
+ c.setLineWidth(0.8)
+ c.rect(x0, y0, x1 – x0, y1 – y0)
+
+ _text(c, x0 + 8, y1 – 20, ‘Production Worksheet’, 12.5, bold=True, color=c._accent)
+ _text(c, x0 + 8, y1 – 35, design_name, 9)
+ _text(c, x0 + 8, y1 – 55, ‘Stop Sequence (continued)’, 10, bold=True)
+ c.line(x0, y1 – 64, x1, y1 – 64)
+
+ cols = {‘#’: x0 + 8, ‘N#’: x0 + 35, ‘Color’: x0 + 66, ‘St.’: x0 + 151,
+ ‘Code’: x0 + 158, ‘Name’: x0 + 245, ‘Chart’: x1 – 8}
+ y = y1 – 82
+ for key, align in ((‘#’, ‘l’), (‘N#’, ‘l’), (‘Color’, ‘l’), (‘St.’, ‘r’),
+ (‘Code’, ‘l’), (‘Name’, ‘l’), (‘Chart’, ‘r’)):
+ _text(c, cols[key], y, key, 8, bold=True,
+ align=’right’ if align == ‘r’ else ‘left’)
+ c.line(x0 + 5, y – 4, x1 – 5, y – 4)
+ y -= 16
+
+ unique = []
+ for layer in layers:
+ if layer[‘hex’].upper() not in unique:
+ unique.append(layer[‘hex’].upper())
+ index = start
+ while index < len(blocks) and y >= y0 + 30:
+ block = blocks[index]
+ layer = layers[index] if index < len(layers) else {
+ ‘hex’: ‘#888888’, ‘name’: ‘Colour %d’ % (index + 1)}
+ needle = unique.index(layer[‘hex’].upper()) + 1 \
+ if layer[‘hex’].upper() in unique else index + 1
+ match = thread_matches[index] if thread_matches and index < len(thread_matches) else None
+ code = (match.get(‘thread_number’) or ”) if match else ”
+ name = (match.get(‘thread_name’) if match else None) or layer.get(‘name’, ”)
+ chart = match.get(‘palette’, ‘Default’) if match else ‘Default’
+ fs = 7.5
+ if c.stringWidth(chart, body, fs) > 70:
+ chart = chart.split(‘ ‘)[0]
+ chart = _fit(c, chart, 70, body, fs)
+ name_w = cols[‘Chart’] – c.stringWidth(chart, body, fs) – cols[‘Name’] – 8
+
+ _text(c, cols[‘#’], y, ‘%d.’ % (index + 1), 8)
+ _text(c, cols[‘N#’], y, str(needle), 8)
+ c.setFillColor(HexColor(layer[‘hex’]))
+ c.setStrokeColor(black)
+ c.rect(cols[‘Color’], y – 2, 72, 9, fill=1)
+ _text(c, cols[‘St.’], y, ‘{:,}’.format(block[‘stitches’]), fs, align=’right’)
+ _text(c, cols[‘Code’], y, _fit(c, code, 80, body, fs), fs)
+ _text(c, cols[‘Name’], y, _fit(c, name, name_w, body, fs), fs)
+ _text(c, cols[‘Chart’], y, chart, fs, align=’right’)
+ y -= 14
+ index += 1
+
+ c.line(x0 + 5, y + 10, x1 – 5, y + 10)
+ now = datetime.datetime.now()
+ _text(c, x0 + 3, y0 + 4, ‘Authors:%s’ % ((‘ ‘ + client) if client else ”), 7.5)
+ _text(c, x0 + 150, y0 + 4, ‘Design last saved : %s’ % _stamp(saved_at or now), 7.5)
+ _text(c, x1 – 3, y0 + 4, ‘Page %d of %d’ % (c.getPageNumber(), pages),
+ 7.5, align=’right’)
+ return index

def _quote_page(c, stitches, quote_params, design_name, client=”, notes=”):
@@ -497,10 +567,17 @@ def build_production(path, pattern, report, layers, thread_matches=None,
c = Canvas(path, pagesize=letter)
c.setTitle(‘%s — production worksheet’ % design_name)
_apply_theme(c, theme, logo_path)
– _production_page(c, pattern, report, layers, thread_matches, preview_png,
– design_name, client, saved_at, title=title,
– pages=2 if quote_params else 1)
+ blocks = block_stats(pattern)
+ continuation_pages = int(math.ceil(max(0, len(blocks) – 32) / 48.0))
+ pages = 1 + continuation_pages + (1 if quote_params else 0)
+ next_block = _production_page(c, pattern, report, layers, thread_matches, preview_png,
+ design_name, client, saved_at, title=title, pages=pages)
c.showPage()
+ while next_block < len(blocks):
+ _apply_theme(c, theme, logo_path)
+ next_block = _production_stop_page(c, blocks, layers, thread_matches, next_block,
+ design_name, client, saved_at, pages)
+ c.showPage()
if quote_params:
_apply_theme(c, theme, logo_path)
_quote_page(c, report.get(‘stitches’, 0), quote_params, design_name)
@@ -582,6 +659,12 @@ def build(path, pattern, report, layers, thread_matches=None, preview_png=None,
c.line(MARGIN, y, PAGE_W – MARGIN, y)
y -= 16
for i, L in enumerate(layers):
+ if y < MARGIN + 24:
+ _footer(c)
+ c.showPage()
+ _apply_theme(c, theme, logo_path)
+ _header(c, design_name, ‘Thread sequence (continued)’)
+ y = PAGE_H – MARGIN – 48
c.setFillColor(HexColor(L[‘hex’]))
c.setStrokeColor(LINE)
c.rect(MARGIN, y – 3, 8 * mm, 5 * mm, fill=1)

static/clients.html

@@ -11,7 +11,7 @@
–bg:#f6f7f9; –surface:#ffffff; –ink:#1a1d23; –muted:#6b7280;
–line:#e9eaee; –line2:#e2e4e9;
–accent:#2563eb; –accent-press:#1d4ed8; –accent-soft:#eef4ff;
– –stage:#181c26; –ok:#188652; –warn:#a8620a; –warnbg:#fdf5e7; –danger:#c2410c;
+ –stage:#cccccc; –ok:#188652; –warn:#a8620a; –warnbg:#fdf5e7; –danger:#c2410c;
–r:16px; –shadow:0 1px 2px rgba(16,24,40,.05),0 8px 28px rgba(16,24,40,.07);
}
*{box-sizing:border-box}

static/index.html

@@ -294,7 +294,7 @@
details.sec[open]>summary::after{transform:rotate(-135deg)}
details.sec>*:not(summary){margin-bottom:6px}
details.sec>div:last-child,details.sec>p:last-child{margin-bottom:14px}
– details.sec:has(> .hide){display:none}
+ details.sec:has(> .hide):not(:has(> :not(summary):not(.hide))){display:none}
details.sec label{margin-top:8px}
details:not(.sec){background:var(–panel2);box-shadow:none;margin-top:10px}
details:not(.sec) summary{color:var(–ink)}
@@ -644,7 +644,7 @@
<div class=”unit”><input type=”number” id=”objGradTo” value=”1.5″ step=”0.1″ min=”0.3″ max=”4″><span>mm</span></div>
<div class=”tglrow”><span>Reverse direction</span><label class=”tog”><input type=”checkbox” id=”objGradFlip”><i></i></label></div>
</div>
– <div class=”tglrow”><span>Satin border</span><label class=”tog”><input type=”checkbox” id=”objBorder” checked><i></i></label></div>
+ <div class=”tglrow”><span>Satin border</span><label class=”tog”><input type=”checkbox” id=”objBorder”><i></i></label></div>
</details>
</div>
<details class=”sec” open><summary>Density Control<i class=”qhelp” title=”On the inside of a bend satin needle points crowd together. Row shortening pulls every other one back once the inner step drops below the trigger × the outer step.”>?</i></summary>
@@ -819,10 +819,7 @@
<option value=”sketch”>Sketch — one-colour outline</option>
</select></div>
<div id=”colorsWrap”><label>Thread colours</label>
– <select id=”colors”>
– <option>2</option><option selected>3</option><option>4</option>
– <option>5</option><option>6</option>
– </select></div>
+ <input id=”colors” type=”number” min=”2″ max=”32″ value=”3″></div>
</div>
<details class=”sec” open><summary>Fill Settings</summary>
<div id=”setDigitize”>
@@ -1009,8 +1006,8 @@
<div class=”row”>
<div><label>Stitch</label>
<select id=”vlStitch”>
– <option value=”auto” selected>Auto (satin/fill + border)</option>
– <option value=”fill”>Fill only</option>
+ <option value=”auto”>Auto (satin/fill + border)</option>
+ <option value=”fill” selected>Fill only</option>
<option value=”outline”>Satin outline</option>
<option value=”run”>Running outline</option>
<option value=”bean”>Bean-stitch outline</option>
@@ -3747,7 +3744,7 @@ function mmPos(el, e) { const p = worldPos(el, e); return [p[0] / PXMM, p[1] / P
const RUNS = [‘run’, ‘bean’, ‘satin’, ‘estitch’, ‘triangle’, ‘cross’, ‘motif’];
const DECOR = [‘estitch’, ‘triangle’, ‘cross’, ‘motif’];
const objDef = { color: ‘#1A3B69’, filled: true, run: ‘run’, pattern: ‘tatami’, angle: ‘auto’, density: 0.4, satinDensity: 0.32,
– border: true, width: 3, runLen: 2.5, stitchLen: 3.5, pull: 0.25, hand: 0, underpath: true, rowShort: true, trigger: 0.5,
+ border: false, width: 3, runLen: 2.5, stitchLen: 3.5, pull: 0.25, hand: 0, underpath: true, rowShort: true, trigger: 0.5,
underlay: ‘auto’, split: true, splitMax: 7, stagger: true, cycles: 4, amount: 0.3, gradient: false, gradTo: 1.5, gradFlip: false,
shortFrac: 0.45, underlays: null };
const UL_DEFAULT = { auto: [{ type: ‘contour’, len_mm: 1.5 }, { type: ‘center’, len_mm: 2.5 }], light: [{ type: ‘contour’, len_mm: 1.5 }], heavy: [{ type: ‘contour’, len_mm: 1.5 }, { type: ‘zigzag’, len_mm: 3 }], none: [] };
@@ -3835,7 +3832,7 @@ function syncObjBox() {
d.pattern = p.fill_method || ‘tatami’;
d.angle = p.angle === undefined ? ‘auto’ : p.angle;
if (!d.filled || isLine) d.satinDensity = p.density || 0.32; else d.density = p.density || 0.4;
– d.border = p.stitch !== ‘fill’;
+ d.border = p.stitch === ‘auto’ || p.stitch === ‘satin’;
d.width = p.width_mm || 3; d.runLen = p.run_len_mm || 2.5; d.stitchLen = p.stitch_len_mm || 3.5;
d.pull = p.pull_comp_mm === undefined ? +$(‘pullComp’).value || 0.25 : p.pull_comp_mm;
d.hand = p.hand || 0;

tests/test_app.py

@@ -1,9 +1,11 @@
“””End-to-end tests over the HTTP API (run with: pytest).”””
import io
import os
+import re
import sys
import tempfile

+import numpy as np
import pytest
from PIL import Image, ImageDraw

@@ -12,6 +14,7 @@ os.environ.setdefault(‘STITCHFORGE_DATA’,
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from fastapi.testclient import TestClient
+import pystitch
from app import app

client = TestClient(app)
@@ -51,6 +54,38 @@ def test_digitize_report(image_job):
assert d[‘threads’][0][‘thread_name’]

+def test_image_quantizer_supports_32_colors():
+ from digitizer import segment
+ colors = np.array([[i * 7 % 256, i * 13 % 256, i * 23 % 256, 255]
+ for i in range(32)], dtype=np.int16)
+ rgba = np.repeat(colors[:, None, :], 20, axis=1)
+ layers = segment.quantize(rgba, 32, np.ones(rgba.shape[:2], dtype=bool))
+ assert len(layers) == 32
+
+
+def test_image_color_limit_is_validated():
+ r = client.post(‘/api/analyze’,
+ files={‘image’: (‘t.png’, _test_png(), ‘image/png’)},
+ data={‘colors’: 33})
+ assert r.status_code == 400
+ assert r.json()[‘detail’] == ‘colors must be between 1 and 32’
+
+
+def test_vectorize_uses_requested_colors():
+ im = Image.new(‘RGBA’, (240, 80), (0, 0, 0, 0))
+ d = ImageDraw.Draw(im)
+ for i in range(12):
+ d.rectangle([i * 20 + 2, 10, i * 20 + 17, 69],
+ fill=(i * 19, i * 37 % 256, i * 61 % 256, 255))
+ buf = io.BytesIO()
+ im.save(buf, ‘PNG’)
+ r = client.post(‘/api/vectorize’,
+ files={‘image’: (‘colors.png’, buf.getvalue(), ‘image/png’)},
+ data={‘colors’: 12})
+ assert r.status_code == 200, r.text
+ assert len({layer[‘color’] for layer in r.json()[‘layers’]}) == 12
+
+
def test_exports(image_job):
job, _ = image_job
for fmt in (‘dst’, ‘pes’, ‘jef’, ‘exp’, ‘vp3’, ‘csv’):
@@ -429,6 +464,44 @@ def test_production_worksheet():
assert ps[‘max_stitch_mm’] > 0 and ps[‘thread_ft’] > ps[‘bobbin_ft’] > 0

+def test_worksheet_thread_lists_paginate(tmp_path, monkeypatch):
+ from inkstitchlib import worksheet
+ pattern = pystitch.EmbPattern()
+ layers = []
+ for index in range(40):
+ color = ((index * 47) % 256 << 16) | ((index * 83) % 256 << 8) | ((index * 131) % 256)
+ thread = pystitch.EmbThread()
+ thread.color = color
+ pattern.add_thread(thread)
+ if index:
+ pattern.color_change()
+ pattern.stitch_abs(index * 10, 0)
+ pattern.stitch_abs(index * 10 + 5, 5)
+ layers.append({‘name’: ‘Colour %d’ % (index + 1), ‘hex’: ‘#%06X’ % color})
+
+ drawn = []
+ original_text = worksheet._text
+
+ def capture_text(canvas, x, y, text, *args, **kwargs):
+ drawn.append(str(text))
+ return original_text(canvas, x, y, text, *args, **kwargs)
+
+ monkeypatch.setattr(worksheet, ‘_text’, capture_text)
+ report = {‘stitches’: 80, ‘width_mm’: 40, ‘height_mm’: 1,
+ ‘colour_changes’: 39, ‘travels’: 0, ‘trimmed’: 0}
+
+ production = tmp_path / ‘production.pdf’
+ worksheet.build(str(production), pattern, report, layers, layout=’production’)
+ assert ’40.’ in drawn
+ assert len(re.findall(rb’/Type\s*/Page\b’, production.read_bytes())) == 2
+
+ drawn.clear()
+ classic = tmp_path / ‘classic.pdf’
+ worksheet.build(str(classic), pattern, report, layers, layout=’classic’)
+ assert any(text.startswith(‘#40 ‘) for text in drawn)
+ assert len(re.findall(rb’/Type\s*/Page\b’, classic.read_bytes())) >= 3
+
+
def test_client_design_library():
# main page + studio routes
assert b’clients’ in client.get(‘/’).content.lower()

Blender Script: Moving Object

This script moves the selected object to be centered on the world origin but it sits on the z plane rather than half of it being below the z plane.

 

import bpy
from mathutils import Vector

# Put the origin at the center of volume
bpy.ops.object.origin_set(
type='ORIGIN_CENTER_OF_VOLUME',
center='MEDIAN',
)

for obj in bpy.context.selected_objects:
# The bounding-box corners are stored in object-local coordinates.
# Transform them to world coordinates before finding the lowest point.
lowest_z = min(
(obj.matrix_world @ Vector(corner)).z
for corner in obj.bound_box
)

world_matrix = obj.matrix_world.copy()
world_matrix.translation.x = 0.0
world_matrix.translation.y = 0.0
world_matrix.translation.z -= lowest_z
obj.matrix_world = world_matrix

Blender Script: Distance Between Two Points

We were having a lot of trouble using the measure tool in Blender — after discovering that you can hold the Ctrl key and “snap” your selection to a vertex, it actually worked in the way we wanted it to. But, before that discovery, my thought was to manually select two vertices and use a Python script to measure the distance between those points. That is “blender units” … and the metric or imperial units would be more meaningful. So I converted based on the scene configuration.

import bpy
import bmesh


def format_metric(meters: float) -> str:
    a = abs(meters)

    if a >= 1.0:
        return f"{meters:.6f} m"
    elif a >= 0.01:
        return f"{meters * 100.0:.3f} cm"
    else:
        return f"{meters * 1000.0:.3f} mm"


def format_imperial(meters: float) -> str:
    total_inches = meters / 0.0254
    sign = "-" if total_inches < 0 else ""
    total_inches = abs(total_inches)

    feet = int(total_inches // 12)
    inches = total_inches - (feet * 12)

    if feet > 0:
        return f"{sign}{feet} ft {inches:.3f} in"
    else:
        return f"{sign}{total_inches:.3f} in"


def measure_selected_vertices():
    ctx = bpy.context
    obj = ctx.edit_object

    if obj is None or obj.type != 'MESH':
        raise RuntimeError("Go into Edit Mode on a mesh and select exactly 2 vertices.")

    bm = bmesh.from_edit_mesh(obj.data)
    selected_verts = [v for v in bm.verts if v.select]

    if len(selected_verts) != 2:
        raise RuntimeError(f"Expected exactly 2 selected vertices, found {len(selected_verts)}.")

    v1, v2 = selected_verts

    # Convert local vertex coordinates to world-space coordinates
    p1 = obj.matrix_world @ v1.co
    p2 = obj.matrix_world @ v2.co

    # Raw world-space distance in Blender units
    raw_distance = (p2 - p1).length

    scene = ctx.scene
    units = scene.unit_settings
    scale_length = units.scale_length if units.scale_length != 0 else 1.0

    # Convert Blender units to meters according to the scene unit scale
    distance_meters = raw_distance * scale_length

    print("\n----- Vertex Distance -----")
    print(f"Object: {obj.name}")
    print(f"Raw distance (Blender units): {raw_distance:.6f}")
    print(f"Scene unit system: {units.system}")
    print(f"Scene unit scale: {scale_length}")

    if units.system == 'METRIC':
        print(f"Formatted distance: {format_metric(distance_meters)}")

    elif units.system == 'IMPERIAL':
        print(f"Formatted distance: {format_imperial(distance_meters)}")

    else:
        print("Formatted distance: Scene unit system is 'NONE'")
        print(f"Interpreted using current unit scale: {format_metric(distance_meters)}")


measure_selected_vertices()

Blender API: List All Items in a Collection

Instead of iterating through all objects, you can iterate through the items in a specific collection:

import bpy

# Name of the collection to inspect
collection_name = "TestCollection"

collection = bpy.data.collections.get(collection_name)

if collection is None:
    print(f"Collection '{collection_name}' not found.")
else:
    print(f"Objects in collection '{collection_name}':")
    for obj in collection.objects:
        print(f"- {obj.name}")

Printing to the console:

For this sample workspace that contains a torus and sphere with the default names

Blender API: Finding The Orange Dot

A quick script to get each object and the location of the “orange dot” … the origin of the object

# Get location of orange dot for each object in Blender
import bpy

scene = bpy.context.scene
us = scene.unit_settings

unit_system = getattr(us, "system", "NONE")  # 'NONE', 'METRIC', 'IMPERIAL'

meters_per_bu = us.scale_length if unit_system != 'NONE' else 1.0
mm_per_bu = meters_per_bu * 1000.0

for obj in bpy.data.objects:
    if obj.type != 'MESH':
        continue

    origin_world = obj.matrix_world.translation          # in BU
    origin_world_mm = origin_world * mm_per_bu           # in mm

    print(f"Object: {obj.name}")
    print(f"  origin_world (BU): {origin_world.x:.6f}, {origin_world.y:.6f}, {origin_world.z:.6f}")
    print(f"  origin_world (mm): {origin_world_mm.x:.3f}, {origin_world_mm.y:.3f}, {origin_world_mm.z:.3f}")
    print("-" * 30)

Blender API: Bending a 2D Rectangle

Another attempt to create a t-post bracket using a script. This creates a 2D rectangle, bends it, and then solidifies it into a 3d object.

import bpy
import bmesh
import math
from mathutils import Vector, Matrix

# -----------------------------
# Reset / clear scene
# -----------------------------
for obj in list(bpy.data.objects):
    bpy.data.objects.remove(obj, do_unlink=True)

# -----------------------------
# Scene units: mm (1 BU = 1 mm)
# -----------------------------
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 0.001

INCH_TO_MM = 25.4
def inch(x):  # returns mm (Blender units)
    return x * INCH_TO_MM

# -----------------------------
# Parameters
# -----------------------------
size_x_in = 3.0
size_y_in = 7.0
thickness_in = 0.25  # SOLIDIFY thickness

fold1_offset_in = 0.5   # from MIN-Y end
fold2_offset_in = 2.0   # from MIN-Y end

fold1_rad = math.radians(-80.0)
fold2_rad = math.radians(80.0)

subdivide_cuts = 60
EPS_Y = 1e-5  # mm tolerance for "on the fold line"

# -----------------------------
# Create flat sheet (plane)
# -----------------------------
bpy.ops.mesh.primitive_plane_add(size=1.0, location=(0.0, 0.0, 0.0))
obj = bpy.context.active_object
obj.name = "Bracket"
obj.dimensions = (inch(size_x_in), inch(size_y_in), 0.0)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)

# Subdivide for clean fold lines
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.subdivide(number_cuts=subdivide_cuts)
bpy.ops.object.mode_set(mode='OBJECT')

# Compute fold Y positions
half_y = inch(size_y_in) / 2.0
min_y = -half_y
y_fold1 = min_y + inch(fold1_offset_in)
y_fold2 = min_y + inch(fold2_offset_in)

# Add both fold lines
bm = bmesh.new()
bm.from_mesh(obj.data)

for y_fold in (y_fold1, y_fold2):
    geom = bm.verts[:] + bm.edges[:] + bm.faces[:]
    bmesh.ops.bisect_plane(
        bm,
        geom=geom,
        plane_co=Vector((0.0, y_fold, 0.0)),
        plane_no=Vector((0.0, 1.0, 0.0)),
        clear_inner=False,
        clear_outer=False
    )

bm.normal_update()
bm.to_mesh(obj.data)
bm.free()

# -----------------------------
# Re-open bmesh, store ORIGINAL Y per vertex
# -----------------------------
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()

orig_y_layer = bm.verts.layers.float.new("orig_y")
for v in bm.verts:
    v[orig_y_layer] = v.co.y

# ============================================================
# FOLD 1
# ============================================================
hinge_verts_1 = [v for v in bm.verts if abs(v[orig_y_layer] - y_fold1) < EPS_Y]
if not hinge_verts_1:
    raise RuntimeError("No hinge vertices found for fold 1. Increase subdivide_cuts or EPS_Y.")

hinge_point_1 = Vector((0.0, 0.0, 0.0))
for v in hinge_verts_1:
    hinge_point_1 += v.co
hinge_point_1 /= len(hinge_verts_1)

verts_to_rotate_1 = [v for v in bm.verts if v[orig_y_layer] > (y_fold1 + EPS_Y)]
rot1 = Matrix.Rotation(fold1_rad, 4, 'X')
bmesh.ops.rotate(bm, verts=verts_to_rotate_1, cent=hinge_point_1, matrix=rot1)

# ============================================================
# FOLD 2
# ============================================================
hinge_verts_2 = [v for v in bm.verts if abs(v[orig_y_layer] - y_fold2) < EPS_Y]
if not hinge_verts_2:
    raise RuntimeError("No hinge vertices found for fold 2. Increase subdivide_cuts or EPS_Y.")

hinge_point_2 = Vector((0.0, 0.0, 0.0))
for v in hinge_verts_2:
    hinge_point_2 += v.co
hinge_point_2 /= len(hinge_verts_2)

verts_to_rotate_2 = [v for v in bm.verts if v[orig_y_layer] > (y_fold2 + EPS_Y)]
rot2 = Matrix.Rotation(fold2_rad, 4, 'X')
bmesh.ops.rotate(bm, verts=verts_to_rotate_2, cent=hinge_point_2, matrix=rot2)

# Write back mesh
bm.normal_update()
bm.to_mesh(obj.data)
bm.free()

# -----------------------------
# Solidify AFTER folding
# -----------------------------
solid = obj.modifiers.new(name="Solidify_0p5in", type='SOLIDIFY')
solid.thickness = inch(thickness_in)  # 0.5"
solid.offset = 0.0                    # centered thickness (equal on both sides)
solid.use_even_offset = True
solid.use_rim = True

# Optional: keep object active
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj

Blender API: Playing with Cylinders

This script was mostly made to play around with rotation on cylinders.

import bpy
import math

# Delete all existing objects
for obj in list(bpy.data.objects):
    bpy.data.objects.remove(obj, do_unlink=True)

for i in range(4):
    bpy.ops.mesh.primitive_cylinder_add(
        radius=0.5,
        depth=10.0,
        location=(0, 0.0, 0.0),
        rotation=((i * 5.5), 0.0, 0.0)
    )

    cyl = bpy.context.active_object
    cyl.name = f"DemoCylinderX{i}"

for i in range(4):
    bpy.ops.mesh.primitive_cylinder_add(
        radius=0.5,
        depth=10.0,
        location=(0, 0.0, 0.0),
        rotation=(0.0, (i * 5.5), 0.0)
    )

    cyl = bpy.context.active_object
    cyl.name = f"DemoCylinderY{i}"

# cyl.rotation_euler = (15.0,13.0,12.0)

# Or single-axis rotation
# Rotate 45 degrees about X axis
#cyl.rotation_euler[0] = math.radians(45.0)


Blender Scripting – T-Post Sign Holder

We spent a lot of the day trying to modify 3D models that we found online to work as a sign holder. Something like the bent metal plates you can buy at the tractor store. Since these are simple polygons, I thought it might be easier to script the build (plus making changes to the dimensions would just require tweaking variables).

Voila – hopefully it’s a T-post sign holder! It at least looks like one.

import bpy
import bmesh
import math
from mathutils import Vector

# Clear all existing objects
for obj in list(bpy.data.objects):
    bpy.data.objects.remove(obj, do_unlink=True)

# -----------------------------
# Scene units (mm)
# -----------------------------
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 0.001  # 1 Blender unit = 1 mm

INCH = 25.4
def inch(x): return x * INCH

# -----------------------------
# PARAMETERS (mm)
# -----------------------------
bracket_thickness = inch(0.25)   # sheet thickness
bracket_width = inch(3)   # bracket width (across the post)

# Leg lengths (side profile)
bracket_top_length = inch(1)        # bracket segment 1 length
bracket_middle_length = inch(2)     # bracket segment 2 length
bracket_bottom_length = inch(4.5)   # bracket segment 3 length

# Bend included angles
bend1_angle_included = 105.0   # top flange
bend2_angle_included = 255.0   # web -> long leg

# If the long leg goes the wrong direction, flip this
flip_second_bend = True

# -----------------------------
# Punch hole
# -----------------------------
do_punch = True

# T-post size references
tpost_horizontal_hole_height = inch(0.25)
tpost_horizontal_hole_width = inch(1.5)
tpost_vertical_hole_height = inch(2)
tpost_vertical_hole_width = inch(0.25)
punch_clearance = 1.0 # clearance added around each rectangle (mm)

# Position of t-post before rotation (Z from p0 end, and X across width)
punch_center_z = inch(1)
punch_center_x = bracket_width / 2

# Vertical placement on top flange (Y=0 plane)
punch_center_y = -inch(0.5)

# -----------------------------
# Optional bevel to make edges25ook more formed
# -----------------------------
do_bevel = True

bevel_width = inch(0.05)
bevel_segments = 25

# -----------------------------
# Cleanup
# -----------------------------
#for n in ["BracketShape", "PunchBar", "PunchStem", "HoleRight1", "HoleRight2", "HoleRight3", "HoleRight4", "HoleLeft1", "HoleRLeft2", "HoleLeft3", "HoleLeft4"]:
#    o = bpy.data.objects.get(n)
#    if o:
#        bpy.data.objects.remove(o, do_unlink=True)

# -----------------------------
# Helpers (YZ plane directions)
# Define 0° as +Z. +90° is +Y. -90° is -Y.
# -----------------------------
def unit_from_angle(deg_from_posZ):
    a = math.radians(deg_from_posZ)
    return Vector((0.0, math.sin(a), math.cos(a)))

def boolean_diff(target, cutter):
    mod = target.modifiers.new(name=f"BOOL_{cutter.name}", type="BOOLEAN")
    mod.operation = 'DIFFERENCE'
    mod.solver = 'EXACT'
    mod.object = cutter
    bpy.context.view_layer.objects.active = target
    bpy.ops.object.modifier_apply(modifier=mod.name)
    cutter.hide_set(True)

def add_cube(name, size_xyz, location_xyz, rotation_xyz):
    bpy.ops.mesh.primitive_cube_add(size=1, location=location_xyz, rotation=rotation_xyz)
    obj = bpy.context.active_object
    obj.name = name
    obj.scale = (size_xyz[0], size_xyz[1], size_xyz[2])
    bpy.ops.object.transform_apply()
    return obj

def add_cylinder(name, radius, length, location_xyz, rotation_xyz):
    bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=length, location=location_xyz, rotation=rotation_xyz)
    obj = bpy.context.active_object
    obj.name = name
    bpy.ops.object.transform_apply()
    return obj

# Convert included bend angles to turn angles
angle_top = 180.0 - bend1_angle_included
angle_bottom = 180.0 - bend2_angle_included

# Start along +Z (top flange)
theta0 = 0.0
d0 = unit_from_angle(theta0)

# After bend1, go "down" (toward -Y) by turning negative
theta1 = theta0 - angle_top
d1 = unit_from_angle(theta1)

# After bend2, go toward +Z again (or flip if needed)
theta2 = theta1 + (angle_bottom if not flip_second_bend else - angle_bottom)
d2 = unit_from_angle(theta2)

# Profile points (center surface)
p0 = Vector((0.0, 0.0, 0.0))      # free end of top flange
p1 = p0 + d0 * bracket_top_length       # bend1 line
p2 = p1 + d1 * bracket_middle_length    # bend2 line
p3 = p2 + d2 * bracket_bottom_length    # end of long leg

# -----------------------------
# Build a single connected sheet surface:
# Create two polylines separated in X, then make quads between them.
# -----------------------------
mesh = bpy.data.meshes.new("BracketShapeMesh")
bracket = bpy.data.objects.new("BracketShape", mesh)
bpy.context.collection.objects.link(bracket)
bpy.context.view_layer.objects.active = bracket
bracket.select_set(True)

bm = bmesh.new()

x0, x1 = 0.0, bracket_width

# Left side (x0)
v0a = bm.verts.new((x0, p0.y, p0.z))
v1a = bm.verts.new((x0, p1.y, p1.z))
v2a = bm.verts.new((x0, p2.y, p2.z))
v3a = bm.verts.new((x0, p3.y, p3.z))

# Right side (x1)
v0b = bm.verts.new((x1, p0.y, p0.z))
v1b = bm.verts.new((x1, p1.y, p1.z))
v2b = bm.verts.new((x1, p2.y, p2.z))
v3b = bm.verts.new((x1, p3.y, p3.z))

# Faces (one per segment)
bm.faces.new((v0a, v0b, v1b, v1a))  # top flange
bm.faces.new((v1a, v1b, v2b, v2a))  # web
bm.faces.new((v2a, v2b, v3b, v3a))  # long leg

bm.normal_update()
bm.to_mesh(mesh)
bm.free()

# -----------------------------
# Solidify to thickness (sheet metal look)
# -----------------------------
solid = bracket.modifiers.new("Solidify", type="SOLIDIFY")
solid.thickness = bracket_thickness
solid.offset = 0.0
bpy.ops.object.modifier_apply(modifier=solid.name)

# -----------------------------
# Punch the lowercase "t" on the top flange
# (Top flange is flat at Y=0; punch straight through Y)
# -----------------------------
if do_punch:
    tpost_length_y = bracket_thickness * 5  # ensure it fully cuts through

    # Crossbar rectangle
    horizontal_hole = add_cube(
        "PunchBar",
        size_xyz=(tpost_horizontal_hole_width + 2 * punch_clearance, tpost_length_y, tpost_horizontal_hole_height + 2 * punch_clearance),
        location_xyz=(punch_center_x, 13 + punch_center_y, punch_center_z),
        rotation_xyz=(math.radians(90 - bend1_angle_included / 2), math.radians(0), math.radians(0))
    )

    # Stem rectangle (placed under the bar like a lowercase "t")
    vertical_hole = add_cube(
        "PunchStem",
        size_xyz=(tpost_vertical_hole_width + 2 * punch_clearance, tpost_length_y, tpost_vertical_hole_height + 2 * punch_clearance),
        location_xyz=(punch_center_x, punch_center_y, punch_center_z),
        rotation_xyz=(math.radians(90), math.radians(0), math.radians(0))
        #rotation_xyz=(math.radians(90 - bend1_angle_included / 2), math.radians(0), math.radians(0))
    )
    boolean_diff(bracket, vertical_hole)
    boolean_diff(bracket, horizontal_hole)


for hole in range(4):
    right_hole = add_cylinder(
        "HoleRight{}".format(hole),
        radius=inch(0.125),
        length=100,
        location_xyz=(inch(0.5), -inch(2), inch(2) + inch(1.175) * hole),
        rotation_xyz=(math.radians(90), 0, 0)
    )
    left_hole = add_cylinder(
        "HoleLeft{}".format(hole),
        radius=inch(0.125),
        length=100,
        location_xyz=(inch(2.5), -inch(2), inch(2) + inch(1.175) * hole),
        rotation_xyz=(math.radians(90), 0, 0)
    )
    boolean_diff(bracket, right_hole)
    boolean_diff(bracket, left_hole)

# -----------------------------
# Optional bevel
# -----------------------------
if do_bevel:
    bev = bracket.modifiers.new("Bevel", type="BEVEL")
    bev.width = bevel_width
    bev.segments = bevel_segments
    bev.limit_method = 'ANGLE'
    #bev.angle_limit = math.radians(35)
    bev.use_clamp_overlap = False
    bpy.context.view_layer.objects.active = bracket
    bpy.ops.object.modifier_apply(modifier=bev.name)

API Documentation Links:

https://docs.blender.org/api/current/bpy.ops.mesh.html
https://docs.blender.org/api/current/bmesh.ops.html

Blender Scripting Lesson of the Week: Cylinders

Quick script for creating a cylinder using bpy

import bpy

# Clear all existing objects
for obj in list(bpy.data.objects):
    bpy.data.objects.remove(obj, do_unlink=True)

# Set Units
scene = bpy.context.scene
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 0.001  # 1 BU = 1 mm

# Create cylinder
bpy.ops.mesh.primitive_cylinder_add(
    vertices=32, radius=10.0, depth=20.0,
    end_fill_type='NGON', calc_uvs=True,
    enter_editmode=False, align='WORLD',
    location=(0.0, 0.0, -2.0), rotation=(0.0, 0.0, 0.0),
    scale=(1, 1, 1)
)

# Name cylinder
obj = bpy.context.active_object
obj.name = "MyCylinder"

# Frame Selected 
for area in bpy.context.window.screen.areas:
    if area.type == 'VIEW_3D':
        for region in area.regions:
            if region.type == 'WINDOW':
                with bpy.context.temp_override(area=area, region=region):
                    bpy.ops.view3d.view_selected(use_all_regions=False)
                break
        break

Viewing *Real* Certificate Chain

Browsers implement AIA which “helps” by repairing the certificate chain and forming a trust even without proper server configuration. Which is great for user experience, but causes a lot of challenges to people troubleshooting SSL connection failures from devices, old equipment, etc. It’s fine when I try it from my laptop!

This python script reports on the real certificate chain being served from an endpoint. Self-signed certificates will show as untrusted

And public certs will show the chain and show as trusted

Code:

import ssl
import socket
import datetime
import select

# Third-party modules (install: pip install pyopenssl cryptography)
try:
    from OpenSSL import SSL, crypto
    from cryptography import x509
    from cryptography.hazmat.primitives import hashes
except ImportError as e:
    raise SystemExit(
        "Missing required modules. Please install:\n"
        "  pip install pyopenssl cryptography\n"
        f"Import error: {e}"
    )

def prompt(text, default=None):
    s = input(text).strip()
    return s if s else default

def check_trust(hostname: str, port: int, timeout=6.0):
    """
    Attempt a TLS connection using system trust store and hostname verification.
    Returns (trusted: bool, message: str).
    """
    try:
        ctx = ssl.create_default_context()
        with socket.create_connection((hostname, port), timeout=timeout) as sock:
            with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
                # Minimal HTTP GET to ensure we fully complete the handshake
                req = f"GET / HTTP/1.1\r\nHost: {hostname}\r\nConnection: close\r\n\r\n"
                ssock.sendall(req.encode("utf-8"))
                _ = ssock.recv(1)
        return True, "TRUSTED (system trust store)"
    except ssl.SSLCertVerificationError as e:
        return False, f"NOT TRUSTED (certificate verification error): {e}"
    except ssl.SSLError as e:
        return False, f"NOT TRUSTED (SSL error): {e}"
    except Exception as e:
        return False, f"Error connecting: {e}"

def _aware_utc(dt: datetime.datetime) -> datetime.datetime:
    """
    Ensure a datetime is timezone-aware in UTC. cryptography returns naive UTC datetimes.
    """
    if dt.tzinfo is None:
        return dt.replace(tzinfo=datetime.timezone.utc)
    return dt.astimezone(datetime.timezone.utc)

def _cert_to_info(cert: x509.Certificate):
    subj = cert.subject.rfc4514_string()
    issr = cert.issuer.rfc4514_string()
    fp_sha1 = cert.fingerprint(hashes.SHA1()).hex().upper()
    nb = _aware_utc(cert.not_valid_before_utc)
    na = _aware_utc(cert.not_valid_after_utc)
    now = datetime.datetime.now(datetime.timezone.utc)
    delta_days = max(0, (na - now).days)
    return {
        "subject": subj,
        "issuer": issr,
        "sha1": fp_sha1,
        "not_before": nb,
        "not_after": na,
        "days_to_expiry": delta_days
    }

def _do_handshake_blocking(conn: SSL.Connection, sock: socket.socket, timeout: float):
    """
    Drive the TLS handshake, handling WantRead/WantWrite by waiting with select.
    """
    deadline = datetime.datetime.now() + datetime.timedelta(seconds=timeout)
    while True:
        try:
            conn.do_handshake()
            return
        except SSL.WantReadError:
            remaining = (deadline - datetime.datetime.now()).total_seconds()
            if remaining <= 0:
                raise TimeoutError("TLS handshake timed out (WantRead)")
            r, _, _ = select.select([sock], [], [], remaining)
            if not r:
                raise TimeoutError("TLS handshake timed out (WantRead)")
            continue
        except SSL.WantWriteError:
            remaining = (deadline - datetime.datetime.now()).total_seconds()
            if remaining <= 0:
                raise TimeoutError("TLS handshake timed out (WantWrite)")
            _, w, _ = select.select([], [sock], [], remaining)
            if not w:
                raise TimeoutError("TLS handshake timed out (WantWrite)")
            continue

def fetch_presented_chain(hostname: str, port: int, timeout: float = 12.0):
    """
    Capture the presented certificate chain using pyOpenSSL.
    Returns (chain: list of {subject, issuer, sha1, not_before, not_after, days_to_expiry}, error: str or None).
    """
    # TCP connect
    try:
        sock = socket.create_connection((hostname, port), timeout=timeout)
        sock.settimeout(timeout)
    except Exception as e:
        return [], f"Error connecting: {e}"

    try:
        # TLS client context
        ctx = SSL.Context(SSL.TLS_CLIENT_METHOD)

        # Compatibility tweaks:
        # - Lower OpenSSL security level
        try:
            ctx.set_cipher_list(b"DEFAULT:@SECLEVEL=1")
        except Exception:
            pass

        # - Disable TLS 1.3 and set minimum TLS 1.2
        try:
            ctx.set_options(SSL.OP_NO_TLSv1_3)
        except Exception:
            pass
        try:
            # Ensure TLSv1.2+ (pyOpenSSL exposes set_min_proto_version on some builds)
            if hasattr(ctx, "set_min_proto_version"):
                ctx.set_min_proto_version(SSL.TLS1_2_VERSION)
        except Exception:
            pass

        # - Set ALPN to http/1.1 (some paths work better when ALPN is present)
        try:
            ctx.set_alpn_protos([b"http/1.1"])
        except Exception:
            pass

        conn = SSL.Connection(ctx, sock)
        conn.set_tlsext_host_name(hostname.encode("utf-8"))
        conn.set_connect_state()

        # Blocking mode (best effort)
        try:
            conn.setblocking(True)
        except Exception:
            pass

        # Drive handshake
        _do_handshake_blocking(conn, sock, timeout=timeout)

        # Retrieve chain (some servers only expose leaf)
        chain = conn.get_peer_cert_chain()
        infos = []
        if chain:
            for c in chain:
                der = crypto.dump_certificate(crypto.FILETYPE_ASN1, c)
                cert = x509.load_der_x509_certificate(der)
                infos.append(_cert_to_info(cert))
        else:
            peer = conn.get_peer_certificate()
            if peer is not None:
                der = crypto.dump_certificate(crypto.FILETYPE_ASN1, peer)
                cert = x509.load_der_x509_certificate(der)
                infos.append(_cert_to_info(cert))

        # Cleanup
        try:
            conn.shutdown()
        except Exception:
            pass
        finally:
            try:
                conn.close()
            except Exception:
                pass
            try:
                sock.close()
            except Exception:
                pass

        if not infos:
            return [], "No certificates captured (server did not present a chain and peer cert unavailable)"
        return infos, None

    except Exception as e:
        try:
            sock.close()
        except Exception:
            pass
        etype = type(e).__name__
        emsg = str(e) or "no message"
        return [], f"TLS handshake or chain retrieval error: {etype}: {emsg}"

def main():
    hostname = prompt("Enter hostname to test (e.g., example.domain.com): ")
    if not hostname:
        print("Hostname is required.")
        return
    port_str = prompt("Enter port [default 443]: ", "443")
    try:
        port = int(port_str)
    except ValueError:
        print("Invalid port.")
        return

    print(f"\nTesting TLS chain for {hostname}:{port} ...")
    chain, err = fetch_presented_chain(hostname, port)
    print("\nPresented chain:")
    if err:
        print(f"  [ERROR] {err}")
    elif not chain:
        print("  [No certificates captured]")
    else:
        for i, ci in enumerate(chain, 1):
            print(f"  [{i}] Subject: {ci['subject']}")
            print(f"       Issuer:  {ci['issuer']}")
            print(f"       SHA1:    {ci['sha1']}")
            nb_val = ci.get("not_before")
            na_val = ci.get("not_after")
            nb_str = nb_val.isoformat() if isinstance(nb_val, datetime.datetime) else str(nb_val)
            na_str = na_val.isoformat() if isinstance(na_val, datetime.datetime) else str(na_val)
            print(f"       Not Before: {nb_str}")
            print(f"       Not After:  {na_str}")
            dte = ci.get("days_to_expiry")
            if dte is not None:
                print(f"       Expires In: {dte} days")

    trusted, msg = check_trust(hostname, port)
    print(f"\nTrust result: {'TRUSTED' if trusted else 'NOT TRUSTED'} - {msg}")

if __name__ == "__main__":
    main()