diff --git a/.github/workflows/docs-images.yml b/.github/workflows/docs-images.yml new file mode 100644 index 0000000..5b5db7d --- /dev/null +++ b/.github/workflows/docs-images.yml @@ -0,0 +1,59 @@ +name: Verify docs images + +on: + pull_request: + branches: + - main + workflow_dispatch: {} + +jobs: + verify-docs-images: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: true + ref: ${{ github.head_ref || github.ref }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Setup uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y just libfuse2 libegl1 libxcb-cursor0 libglu1-mesa + uv sync --frozen + python -m pip install --user scadm==0.6.3 + tmpdir="$(mktemp -d)" + printf '{"dependencies":[]}\n' > "${tmpdir}/scadm.json" + ( + cd "${tmpdir}" + ~/.local/bin/scadm install --openscad-only + ) + echo "${tmpdir}/bin/openscad" >> "$GITHUB_PATH" + "${tmpdir}/bin/openscad/openscad" --version + + - name: Regenerate docs images + run: | + just clean-docs paths docs + env: + LIBGL_ALWAYS_SOFTWARE: "1" + GALLIUM_DRIVER: softpipe + + - name: Commit regenerated docs images + run: | + if [ -n "$(git status --porcelain -- docs/images)" ]; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/images/ + git commit -m "Regenerate docs images [skip ci]" + git push + fi diff --git a/docs/images/align-center.png b/docs/images/align-center.png index f8ac2bc..1278bf0 100644 Binary files a/docs/images/align-center.png and b/docs/images/align-center.png differ diff --git a/docs/images/align-east.png b/docs/images/align-east.png index 350b104..3f03093 100644 Binary files a/docs/images/align-east.png and b/docs/images/align-east.png differ diff --git a/docs/images/align-west.png b/docs/images/align-west.png index 8bc60db..3fb8120 100644 Binary files a/docs/images/align-west.png and b/docs/images/align-west.png differ diff --git a/docs/images/bottom-chamfer.png b/docs/images/bottom-chamfer.png index 7201dba..401fbf9 100644 Binary files a/docs/images/bottom-chamfer.png and b/docs/images/bottom-chamfer.png differ diff --git a/docs/images/click1-distance-0.png b/docs/images/click1-distance-0.png index ca76402..b9bdd14 100644 Binary files a/docs/images/click1-distance-0.png and b/docs/images/click1-distance-0.png differ diff --git a/docs/images/click1-distance-5.png b/docs/images/click1-distance-5.png index 7c28ba9..e23f5ea 100644 Binary files a/docs/images/click1-distance-5.png and b/docs/images/click1-distance-5.png differ diff --git a/docs/images/click1-height-0.5.png b/docs/images/click1-height-0.5.png index 47040dc..7722d6b 100644 Binary files a/docs/images/click1-height-0.5.png and b/docs/images/click1-height-0.5.png differ diff --git a/docs/images/click1-inner-length-20.png b/docs/images/click1-inner-length-20.png index 552b741..430ae45 100644 Binary files a/docs/images/click1-inner-length-20.png and b/docs/images/click1-inner-length-20.png differ diff --git a/docs/images/click1-outer-length-20.png b/docs/images/click1-outer-length-20.png index d01188d..44362b5 100644 Binary files a/docs/images/click1-outer-length-20.png and b/docs/images/click1-outer-length-20.png differ diff --git a/docs/images/click1-steepness-0.1.png b/docs/images/click1-steepness-0.1.png index 1e12cb3..b7a024f 100644 Binary files a/docs/images/click1-steepness-0.1.png and b/docs/images/click1-steepness-0.1.png differ diff --git a/docs/images/click1-steepness-5.png b/docs/images/click1-steepness-5.png index 7ec0e0a..d4198a9 100644 Binary files a/docs/images/click1-steepness-5.png and b/docs/images/click1-steepness-5.png differ diff --git a/docs/images/click1-strength-2.5.png b/docs/images/click1-strength-2.5.png index bf58dae..a160c41 100644 Binary files a/docs/images/click1-strength-2.5.png and b/docs/images/click1-strength-2.5.png differ diff --git a/docs/images/click1-wall-strength-0.png b/docs/images/click1-wall-strength-0.png index 853ada9..3074eeb 100644 Binary files a/docs/images/click1-wall-strength-0.png and b/docs/images/click1-wall-strength-0.png differ diff --git a/docs/images/click1-wall-strength-2.png b/docs/images/click1-wall-strength-2.png index d509ec7..a0330ff 100644 Binary files a/docs/images/click1-wall-strength-2.png and b/docs/images/click1-wall-strength-2.png differ diff --git a/docs/images/click1.png b/docs/images/click1.png index 6ae0d2f..d5ca557 100644 Binary files a/docs/images/click1.png and b/docs/images/click1.png differ diff --git a/docs/images/click2-bin.png b/docs/images/click2-bin.png index 520d859..6266d32 100644 Binary files a/docs/images/click2-bin.png and b/docs/images/click2-bin.png differ diff --git a/docs/images/click2-bottom-chamfer-1.png b/docs/images/click2-bottom-chamfer-1.png index 5414509..e72e584 100644 Binary files a/docs/images/click2-bottom-chamfer-1.png and b/docs/images/click2-bottom-chamfer-1.png differ diff --git a/docs/images/click2-bottom-chamfer-2.png b/docs/images/click2-bottom-chamfer-2.png index 751fe59..af18287 100644 Binary files a/docs/images/click2-bottom-chamfer-2.png and b/docs/images/click2-bottom-chamfer-2.png differ diff --git a/docs/images/click2-bottom-chamfer-3.png b/docs/images/click2-bottom-chamfer-3.png index 2eda04d..0f47f22 100644 Binary files a/docs/images/click2-bottom-chamfer-3.png and b/docs/images/click2-bottom-chamfer-3.png differ diff --git a/docs/images/click2-bottom-chamfer-4.png b/docs/images/click2-bottom-chamfer-4.png index b8271b3..6f7dbb7 100644 Binary files a/docs/images/click2-bottom-chamfer-4.png and b/docs/images/click2-bottom-chamfer-4.png differ diff --git a/docs/images/click2-depth.png b/docs/images/click2-depth.png index e676ca1..bc946b9 100644 Binary files a/docs/images/click2-depth.png and b/docs/images/click2-depth.png differ diff --git a/docs/images/click2-gap-length.png b/docs/images/click2-gap-length.png index 958a4ab..1e074c5 100644 Binary files a/docs/images/click2-gap-length.png and b/docs/images/click2-gap-length.png differ diff --git a/docs/images/click2-tab-length.png b/docs/images/click2-tab-length.png index e6b7d4e..8ee7265 100644 Binary files a/docs/images/click2-tab-length.png and b/docs/images/click2-tab-length.png differ diff --git a/docs/images/click2.png b/docs/images/click2.png index 2ae5e9a..b1cc0fd 100644 Binary files a/docs/images/click2.png and b/docs/images/click2.png differ diff --git a/docs/images/closeup.png b/docs/images/closeup.png index 3e09bdd..71194e2 100644 Binary files a/docs/images/closeup.png and b/docs/images/closeup.png differ diff --git a/docs/images/corner-radius-1.png b/docs/images/corner-radius-1.png index d3aeb59..c1c08a2 100644 Binary files a/docs/images/corner-radius-1.png and b/docs/images/corner-radius-1.png differ diff --git a/docs/images/corner-radius.png b/docs/images/corner-radius.png index 03bfd2c..a160c41 100644 Binary files a/docs/images/corner-radius.png and b/docs/images/corner-radius.png differ diff --git a/docs/images/custom-cell.png b/docs/images/custom-cell.png index b4de301..7ee11c6 100644 Binary files a/docs/images/custom-cell.png and b/docs/images/custom-cell.png differ diff --git a/docs/images/edge-adjustment-cut.png b/docs/images/edge-adjustment-cut.png index 418241a..7d34b41 100644 Binary files a/docs/images/edge-adjustment-cut.png and b/docs/images/edge-adjustment-cut.png differ diff --git a/docs/images/edge-adjustment-default.png b/docs/images/edge-adjustment-default.png index be59733..d8208de 100644 Binary files a/docs/images/edge-adjustment-default.png and b/docs/images/edge-adjustment-default.png differ diff --git a/docs/images/edge-adjustment-pad.png b/docs/images/edge-adjustment-pad.png index 93daf13..b9003a5 100644 Binary files a/docs/images/edge-adjustment-pad.png and b/docs/images/edge-adjustment-pad.png differ diff --git a/docs/images/edge-adjustment-shift.png b/docs/images/edge-adjustment-shift.png index cce4467..8140cc9 100644 Binary files a/docs/images/edge-adjustment-shift.png and b/docs/images/edge-adjustment-shift.png differ diff --git a/docs/images/edge-puzzle-clickgroove.png b/docs/images/edge-puzzle-clickgroove.png index cefab57..440968d 100644 Binary files a/docs/images/edge-puzzle-clickgroove.png and b/docs/images/edge-puzzle-clickgroove.png differ diff --git a/docs/images/edge-puzzle-full-height.png b/docs/images/edge-puzzle-full-height.png index 110f78a..4cf8b5a 100644 Binary files a/docs/images/edge-puzzle-full-height.png and b/docs/images/edge-puzzle-full-height.png differ diff --git a/docs/images/edge-puzzle-multi.png b/docs/images/edge-puzzle-multi.png index b1becde..be77734 100644 Binary files a/docs/images/edge-puzzle-multi.png and b/docs/images/edge-puzzle-multi.png differ diff --git a/docs/images/edge-puzzle-unconnected.png b/docs/images/edge-puzzle-unconnected.png index 99895cd..5d030c8 100644 Binary files a/docs/images/edge-puzzle-unconnected.png and b/docs/images/edge-puzzle-unconnected.png differ diff --git a/docs/images/edge-puzzle.png b/docs/images/edge-puzzle.png index 4495a90..bea87e7 100644 Binary files a/docs/images/edge-puzzle.png and b/docs/images/edge-puzzle.png differ diff --git a/docs/images/filler-dynamic-expand-always.png b/docs/images/filler-dynamic-expand-always.png index 67822d5..b54c167 100644 Binary files a/docs/images/filler-dynamic-expand-always.png and b/docs/images/filler-dynamic-expand-always.png differ diff --git a/docs/images/filler-dynamic-expand.png b/docs/images/filler-dynamic-expand.png index 3f64492..bc7da50 100644 Binary files a/docs/images/filler-dynamic-expand.png and b/docs/images/filler-dynamic-expand.png differ diff --git a/docs/images/filler-dynamic.png b/docs/images/filler-dynamic.png index ced6cde..0b08321 100644 Binary files a/docs/images/filler-dynamic.png and b/docs/images/filler-dynamic.png differ diff --git a/docs/images/filler-half.png b/docs/images/filler-half.png index f67bb82..4a740f0 100644 Binary files a/docs/images/filler-half.png and b/docs/images/filler-half.png differ diff --git a/docs/images/filler-none.png b/docs/images/filler-none.png index 52be7cb..ece0626 100644 Binary files a/docs/images/filler-none.png and b/docs/images/filler-none.png differ diff --git a/docs/images/filler-third.png b/docs/images/filler-third.png index 0b2b3ae..94ff355 100644 Binary files a/docs/images/filler-third.png and b/docs/images/filler-third.png differ diff --git a/docs/images/intersection-puzzle-loose.png b/docs/images/intersection-puzzle-loose.png index e8db7a8..9f4f50c 100644 Binary files a/docs/images/intersection-puzzle-loose.png and b/docs/images/intersection-puzzle-loose.png differ diff --git a/docs/images/intersection-puzzle-tight.png b/docs/images/intersection-puzzle-tight.png index 8881bf3..c283350 100644 Binary files a/docs/images/intersection-puzzle-tight.png and b/docs/images/intersection-puzzle-tight.png differ diff --git a/docs/images/intersection-puzzle.png b/docs/images/intersection-puzzle.png index 3e09bdd..71194e2 100644 Binary files a/docs/images/intersection-puzzle.png and b/docs/images/intersection-puzzle.png differ diff --git a/docs/images/irregular-base-shape.png b/docs/images/irregular-base-shape.png index 962dafa..adc636d 100644 Binary files a/docs/images/irregular-base-shape.png and b/docs/images/irregular-base-shape.png differ diff --git a/docs/images/irregular-no-override.png b/docs/images/irregular-no-override.png index 26e5a55..cdde14d 100644 Binary files a/docs/images/irregular-no-override.png and b/docs/images/irregular-no-override.png differ diff --git a/docs/images/irregular-override.png b/docs/images/irregular-override.png index fafdebc..a1f2b27 100644 Binary files a/docs/images/irregular-override.png and b/docs/images/irregular-override.png differ diff --git a/docs/images/jig-main-below.png b/docs/images/jig-main-below.png index cae60cb..514c289 100644 Binary files a/docs/images/jig-main-below.png and b/docs/images/jig-main-below.png differ diff --git a/docs/images/jig-main.png b/docs/images/jig-main.png index bc19f79..28e2467 100644 Binary files a/docs/images/jig-main.png and b/docs/images/jig-main.png differ diff --git a/docs/images/jig-pusher-below.png b/docs/images/jig-pusher-below.png index b79765d..900bf22 100644 Binary files a/docs/images/jig-pusher-below.png and b/docs/images/jig-pusher-below.png differ diff --git a/docs/images/jig-pusher.png b/docs/images/jig-pusher.png index 98a7a87..9c1ed59 100644 Binary files a/docs/images/jig-pusher.png and b/docs/images/jig-pusher.png differ diff --git a/docs/images/magnet-border-1.png b/docs/images/magnet-border-1.png index 0eb1043..421a132 100644 Binary files a/docs/images/magnet-border-1.png and b/docs/images/magnet-border-1.png differ diff --git a/docs/images/magnets-glue-in-bottom.png b/docs/images/magnets-glue-in-bottom.png index 2fbaa94..54a0a37 100644 Binary files a/docs/images/magnets-glue-in-bottom.png and b/docs/images/magnets-glue-in-bottom.png differ diff --git a/docs/images/magnets-glue-in.png b/docs/images/magnets-glue-in.png index a54e905..4f96a20 100644 Binary files a/docs/images/magnets-glue-in.png and b/docs/images/magnets-glue-in.png differ diff --git a/docs/images/magnets-none.png b/docs/images/magnets-none.png index 36110dd..38546c3 100644 Binary files a/docs/images/magnets-none.png and b/docs/images/magnets-none.png differ diff --git a/docs/images/magnets-press-fit-below.png b/docs/images/magnets-press-fit-below.png index 81e0093..8876ee3 100644 Binary files a/docs/images/magnets-press-fit-below.png and b/docs/images/magnets-press-fit-below.png differ diff --git a/docs/images/magnets-press-fit.png b/docs/images/magnets-press-fit.png index b5d3852..4de461a 100644 Binary files a/docs/images/magnets-press-fit.png and b/docs/images/magnets-press-fit.png differ diff --git a/docs/images/magnets-solid.png b/docs/images/magnets-solid.png index ba2ebee..0bc76cf 100644 Binary files a/docs/images/magnets-solid.png and b/docs/images/magnets-solid.png differ diff --git a/docs/images/numbering-squeeze.png b/docs/images/numbering-squeeze.png index ab4abdd..0d7fecc 100644 Binary files a/docs/images/numbering-squeeze.png and b/docs/images/numbering-squeeze.png differ diff --git a/docs/images/numbering.png b/docs/images/numbering.png index 19ff74d..f1a0d31 100644 Binary files a/docs/images/numbering.png and b/docs/images/numbering.png differ diff --git a/docs/images/override-empty.png b/docs/images/override-empty.png index ddec6e5..cd28164 100644 Binary files a/docs/images/override-empty.png and b/docs/images/override-empty.png differ diff --git a/docs/images/override-normal.png b/docs/images/override-normal.png index 081c889..ba11674 100644 Binary files a/docs/images/override-normal.png and b/docs/images/override-normal.png differ diff --git a/docs/images/override-solid.png b/docs/images/override-solid.png index 08857ac..6829b05 100644 Binary files a/docs/images/override-solid.png and b/docs/images/override-solid.png differ diff --git a/docs/images/segment-x-ideal.png b/docs/images/segment-x-ideal.png index 36dba58..ff5de03 100644 Binary files a/docs/images/segment-x-ideal.png and b/docs/images/segment-x-ideal.png differ diff --git a/docs/images/segment-x-incremental-override.png b/docs/images/segment-x-incremental-override.png index ca111b5..9e8bfdd 100644 Binary files a/docs/images/segment-x-incremental-override.png and b/docs/images/segment-x-incremental-override.png differ diff --git a/docs/images/segment-x-incremental.png b/docs/images/segment-x-incremental.png index 05dee8c..43dcebd 100644 Binary files a/docs/images/segment-x-incremental.png and b/docs/images/segment-x-incremental.png differ diff --git a/docs/images/segment-y-override.png b/docs/images/segment-y-override.png index 6f854af..30b2497 100644 Binary files a/docs/images/segment-y-override.png and b/docs/images/segment-y-override.png differ diff --git a/docs/images/segment-y.png b/docs/images/segment-y.png index e57ffba..0d93cf9 100644 Binary files a/docs/images/segment-y.png and b/docs/images/segment-y.png differ diff --git a/docs/images/solid_base.png b/docs/images/solid_base.png index a083569..8a2e446 100644 Binary files a/docs/images/solid_base.png and b/docs/images/solid_base.png differ diff --git a/docs/images/stacked-print-duplicate.png b/docs/images/stacked-print-duplicate.png index e36c065..fa764fd 100644 Binary files a/docs/images/stacked-print-duplicate.png and b/docs/images/stacked-print-duplicate.png differ diff --git a/docs/images/stacked-print-gap-exaggerated.png b/docs/images/stacked-print-gap-exaggerated.png index 0f967ab..87d5e33 100644 Binary files a/docs/images/stacked-print-gap-exaggerated.png and b/docs/images/stacked-print-gap-exaggerated.png differ diff --git a/docs/images/stacked-print-gap-normal.png b/docs/images/stacked-print-gap-normal.png index 1eef732..bae7c36 100644 Binary files a/docs/images/stacked-print-gap-normal.png and b/docs/images/stacked-print-gap-normal.png differ diff --git a/docs/images/stacked-print.png b/docs/images/stacked-print.png index a4f372b..9c5dec4 100644 Binary files a/docs/images/stacked-print.png and b/docs/images/stacked-print.png differ diff --git a/docs/images/thumb-screw-base-magnet.png b/docs/images/thumb-screw-base-magnet.png index 4a2d3a7..5009c75 100644 Binary files a/docs/images/thumb-screw-base-magnet.png and b/docs/images/thumb-screw-base-magnet.png differ diff --git a/docs/images/thumb-screw-base.png b/docs/images/thumb-screw-base.png index 287ddb5..55809f3 100644 Binary files a/docs/images/thumb-screw-base.png and b/docs/images/thumb-screw-base.png differ diff --git a/docs/images/thumb-screw-magnet.png b/docs/images/thumb-screw-magnet.png index 941bbde..d89c536 100644 Binary files a/docs/images/thumb-screw-magnet.png and b/docs/images/thumb-screw-magnet.png differ diff --git a/docs/images/top-chamfer.png b/docs/images/top-chamfer.png index c43294a..9884e66 100644 Binary files a/docs/images/top-chamfer.png and b/docs/images/top-chamfer.png differ diff --git a/docs/images/top-slice.png b/docs/images/top-slice.png index 8f3b1bd..11d4851 100644 Binary files a/docs/images/top-slice.png and b/docs/images/top-slice.png differ diff --git a/docs/images/vscrews-counterbore.png b/docs/images/vscrews-counterbore.png index 7ff2628..5e9c8f6 100644 Binary files a/docs/images/vscrews-counterbore.png and b/docs/images/vscrews-counterbore.png differ diff --git a/docs/images/vscrews-counterboth.png b/docs/images/vscrews-counterboth.png index 830d626..baa19d9 100644 Binary files a/docs/images/vscrews-counterboth.png and b/docs/images/vscrews-counterboth.png differ diff --git a/docs/images/vscrews-countersink.png b/docs/images/vscrews-countersink.png index d8c9656..4a549a1 100644 Binary files a/docs/images/vscrews-countersink.png and b/docs/images/vscrews-countersink.png differ diff --git a/docs/images/vscrews-diameter.png b/docs/images/vscrews-diameter.png index 272febd..5ed8a50 100644 Binary files a/docs/images/vscrews-diameter.png and b/docs/images/vscrews-diameter.png differ diff --git a/docs/images/vscrews-else.png b/docs/images/vscrews-else.png index 6e7b535..50590c0 100644 Binary files a/docs/images/vscrews-else.png and b/docs/images/vscrews-else.png differ diff --git a/docs/images/vscrews-most.png b/docs/images/vscrews-most.png index 2fdcc75..2db93d1 100644 Binary files a/docs/images/vscrews-most.png and b/docs/images/vscrews-most.png differ diff --git a/docs/images/vscrews-plate-corners-mod.png b/docs/images/vscrews-plate-corners-mod.png index 2def9fb..a5030f7 100644 Binary files a/docs/images/vscrews-plate-corners-mod.png and b/docs/images/vscrews-plate-corners-mod.png differ diff --git a/docs/images/vscrews-plate-corners.png b/docs/images/vscrews-plate-corners.png index 8fd795e..6c84d99 100644 Binary files a/docs/images/vscrews-plate-corners.png and b/docs/images/vscrews-plate-corners.png differ diff --git a/docs/images/vscrews-plate-edges.png b/docs/images/vscrews-plate-edges.png index 04527c4..829704d 100644 Binary files a/docs/images/vscrews-plate-edges.png and b/docs/images/vscrews-plate-edges.png differ diff --git a/docs/images/vscrews-segment-corners.png b/docs/images/vscrews-segment-corners.png index 788024b..7ed8880 100644 Binary files a/docs/images/vscrews-segment-corners.png and b/docs/images/vscrews-segment-corners.png differ diff --git a/docs/images/vscrews-segment-edges.png b/docs/images/vscrews-segment-edges.png index 66a0762..e051f99 100644 Binary files a/docs/images/vscrews-segment-edges.png and b/docs/images/vscrews-segment-edges.png differ diff --git a/docs/images/vscrews.png b/docs/images/vscrews.png index 2fdcc75..2db93d1 100644 Binary files a/docs/images/vscrews.png and b/docs/images/vscrews.png differ diff --git a/docs/images/wall-bottom.png b/docs/images/wall-bottom.png index bf1a424..e60312f 100644 Binary files a/docs/images/wall-bottom.png and b/docs/images/wall-bottom.png differ diff --git a/docs/images/wall-top.png b/docs/images/wall-top.png index 4b0d763..2dfc5ba 100644 Binary files a/docs/images/wall-top.png and b/docs/images/wall-top.png differ diff --git a/docs/images/whole.png b/docs/images/whole.png index 3c58f99..8be7122 100644 Binary files a/docs/images/whole.png and b/docs/images/whole.png differ diff --git a/generate_docs_images.py b/generate_docs_images.py new file mode 100644 index 0000000..5615328 --- /dev/null +++ b/generate_docs_images.py @@ -0,0 +1,212 @@ +import asyncio +import os +import re +import shlex +import struct +import zlib + +OPENSCAD_PATTERN = re.compile(r"^\s*\s*$") +CONCURRENCY = asyncio.Semaphore(8) +# PNG color types: 0=grayscale, 2=RGB, 3=indexed, 4=grayscale+alpha, 6=RGBA. +CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} +# OpenSCAD intermittently writes a broken placeholder PNG of this exact size. +OPENSCAD_BROKEN_PNG_SIZE_BYTES = 7763 +MAX_RENDER_RETRIES = 5 +MAX_DEFLATE_BLOCK_SIZE = 0xFFFF +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def chunk(typ, data): + return ( + struct.pack(">I", len(data)) + + typ + + data + + struct.pack(">I", zlib.crc32(typ + data) & 0xFFFFFFFF) + ) + + +def bpp_for_filter(bit_depth, color_type): + """Return bytes-per-pixel for PNG filter reconstruction.""" + channels = CHANNELS_BY_COLOR_TYPE[color_type] + return max(1, (channels * bit_depth + 7) // 8) + + +def undo_filter(filter_type, scanline, prev, bpp): + if filter_type == 0: + return scanline + if filter_type == 1: + out = bytearray(scanline) + for i in range(len(out)): + out[i] = (out[i] + (out[i - bpp] if i >= bpp else 0)) & 0xFF + return bytes(out) + if filter_type == 2: + return bytes((scanline[i] + prev[i]) & 0xFF for i in range(len(scanline))) + if filter_type == 3: + out = bytearray(scanline) + for i in range(len(out)): + left = out[i - bpp] if i >= bpp else 0 + up = prev[i] + out[i] = (out[i] + ((left + up) // 2)) & 0xFF + return bytes(out) + if filter_type == 4: + out = bytearray(scanline) + for i in range(len(out)): + a = out[i - bpp] if i >= bpp else 0 + b = prev[i] + c = prev[i - bpp] if i >= bpp else 0 + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + predictor = a if pa <= pb and pa <= pc else (b if pb <= pc else c) + out[i] = (out[i] + predictor) & 0xFF + return bytes(out) + raise ValueError(f"Unsupported PNG filter type: {filter_type}") + + +def zlib_store(data): + # Write deterministic store-only DEFLATE blocks to avoid version-dependent + # compression heuristics from normal DEFLATE encoders. + out = bytearray(b"\x78\x01") + pos = 0 + while pos < len(data): + block = data[pos : pos + MAX_DEFLATE_BLOCK_SIZE] + pos += len(block) + final = 1 if pos == len(data) else 0 + out.append(final) + out.extend(struct.pack("I", zlib.adler32(data) & 0xFFFFFFFF)) + return bytes(out) + + +def canonicalize_png(path): + """Re-encode PNGs with filter type 0 to make equivalent pixels byte-identical.""" + with open(path, "rb") as f: + data = f.read() + if not data.startswith(PNG_SIGNATURE): + raise ValueError(f"{path} is not a PNG") + + idat = bytearray() + ihdr = None + plte = None + trns = None + pos = len(PNG_SIGNATURE) + while pos < len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + typ = data[pos + 4 : pos + 8] + chunk_data = data[pos + 8 : pos + 8 + length] + pos += 12 + length + + if typ == b"IHDR": + ihdr = chunk_data + elif typ == b"PLTE": + plte = chunk_data + elif typ == b"tRNS": + trns = chunk_data + elif typ == b"IDAT": + idat.extend(chunk_data) + elif typ == b"IEND": + break + + ihdr_fields = struct.unpack(">IIBBBBB", ihdr) + width, height, bit_depth, color_type, compression, filter_method, interlace = ihdr_fields + if compression != 0 or filter_method != 0 or interlace != 0: + raise ValueError(f"Unsupported PNG encoding for {path}") + row_bytes = (width * CHANNELS_BY_COLOR_TYPE[color_type] * bit_depth + 7) // 8 + bpp = bpp_for_filter(bit_depth, color_type) + + raw = zlib.decompress(bytes(idat)) + expected_size = height * (1 + row_bytes) + if len(raw) != expected_size: + raise ValueError(f"Unexpected decompressed size for {path}") + + canonical_scanlines = bytearray() + prev = bytes(row_bytes) + pos = 0 + for _ in range(height): + filter_type = raw[pos] + pos += 1 + scanline = raw[pos : pos + row_bytes] + pos += row_bytes + unfiltered = undo_filter(filter_type, scanline, prev, bpp) + canonical_scanlines.append(0) + canonical_scanlines.extend(unfiltered) + prev = unfiltered + + output = bytearray(PNG_SIGNATURE) + output.extend(chunk(b"IHDR", ihdr)) + if plte is not None: + output.extend(chunk(b"PLTE", plte)) + if trns is not None: + output.extend(chunk(b"tRNS", trns)) + output.extend(chunk(b"IDAT", zlib_store(bytes(canonical_scanlines)))) + output.extend(chunk(b"IEND", b"")) + + with open(path, "wb") as f: + f.write(output) + + +async def run(cmd, output): + retries = 0 + while True: + async with CONCURRENCY: + print("Running: " + shlex.join(cmd)) + proc = await asyncio.create_subprocess_exec(*cmd) + await proc.wait() + assert proc.returncode == 0 + if os.path.getsize(output) == OPENSCAD_BROKEN_PNG_SIZE_BYTES: + # OpenSCAD occasionally writes a fixed-size broken PNG; retry the render. + retries += 1 + if retries >= MAX_RENDER_RETRIES: + raise RuntimeError(f"Render failure for `{shlex.join(cmd)}` after {retries} retries") + print(f"Render failure for `{shlex.join(cmd)}`, retrying") + continue + canonicalize_png(output) + return + + +async def main(): + tasks = [] + written = [] + with open("README.md") as f: + for line in f: + match = OPENSCAD_PATTERN.match(line) + if match: + cmd = [ + "openscad", + "--hardwarnings", + "--projection=ortho", + "--colorscheme=Starnight", + "--render", + "--imgsize=2500,1000", + *shlex.split(match.group(1)), + ] + # use gridflock.scad if no other file specified + for arg in cmd: + if ".scad" in arg: + break + else: + cmd.append("gridflock.scad") + try: + output_index = cmd.index("-o") + 1 + except ValueError as original_error: + raise ValueError( + f"OpenSCAD command in README.md is missing -o output argument: {match.group(1)}" + ) from original_error + if output_index >= len(cmd): + raise ValueError( + f"OpenSCAD command in README.md has -o without an output path: {match.group(1)}" + ) + output = cmd[output_index] + tasks.append(run(cmd, output)) + written.append(output) + for f in os.listdir("docs/images"): + if os.path.join("docs/images", f) not in written: + os.unlink(os.path.join("docs/images", f)) + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/justfile b/justfile index c56e846..7af86af 100644 --- a/justfile +++ b/justfile @@ -15,49 +15,7 @@ banner name: banners: (banner "banner-generator-yawkat") (banner "banner-generator-perplexinglabs") docs: - #!/usr/bin/env -S uv run --script - import re - import shlex - import subprocess - import os - import asyncio - - openscad_pattern = re.compile(r"^\s*\s*$") - concurrency = asyncio.Semaphore(8) - - async def run(cmd, output): - async with concurrency: - print("Running: " + shlex.join(cmd)) - proc = await asyncio.create_subprocess_exec(*cmd) - await proc.wait() - assert proc.returncode == 0 - if os.path.getsize(output) == 7763: - # render failure, retry - print(f"Render failure for `{shlex.join(cmd)}`, retrying") - await run(cmd, output) - - async def main(): - tasks = [] - written = [] - for line in open("README.md"): - match = openscad_pattern.match(line) - if match: - cmd = ["openscad", "--hardwarnings", "--projection=ortho", "--colorscheme=Starnight", "--render", "--imgsize=2500,1000", *shlex.split(match.group(1))] - # use gridflock.scad if no other file specified - for c in cmd: - if ".scad" in c: - break - else: - cmd.append("gridflock.scad") - output = cmd[cmd.index("-o") + 1] - tasks.append(run(cmd, output)) - written.append(output) - for f in os.listdir("docs/images"): - if os.path.join("docs/images", f) not in written: - os.unlink(os.path.join("docs/images", f)) - await asyncio.gather(*tasks) - - asyncio.run(main()) + uv run generate_docs_images.py overlay-png name: inkscape -w 1600 -h 1200 docs/{{name}}.svg -o build/{{name}}.png