diff --git a/.DS_Store b/.DS_Store index db3d046..fa6d343 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..4353223 Binary files /dev/null and b/.coverage differ diff --git a/.ipynb_checkpoints/README-checkpoint.md b/.ipynb_checkpoints/README-checkpoint.md new file mode 100644 index 0000000..8206f82 --- /dev/null +++ b/.ipynb_checkpoints/README-checkpoint.md @@ -0,0 +1,3 @@ +# knitting_pattern +A project in which I attempt to create a tool to help calculate knitting patterns. +My goal is to develop an app where the user can move and scale alpha patterns (patterns on a pixel grid) on a garment, and based on selected measurement, yarn gauge and pattern placement, the app can calculate a knitting pattern to follow. diff --git a/README.md b/README.md index 8206f82..cb9d7db 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,60 @@ -# knitting_pattern -A project in which I attempt to create a tool to help calculate knitting patterns. -My goal is to develop an app where the user can move and scale alpha patterns (patterns on a pixel grid) on a garment, and based on selected measurement, yarn gauge and pattern placement, the app can calculate a knitting pattern to follow. +```markdown +# ๐Ÿงถ Knit App + +## About This Project +Knit App is a comprehensive Streamlit-based application designed for knitters and compatible with crocheters and other textile makers. It bridges the gap between digital pixel art and physical garment construction. Users can upload images to generate alpha patterns, calculate garment shaping (Front, Back, and Sleeves) based on gauge and sizing, and edit multiple graphics on a digital canvas. + + +## Key Features +* **Pattern Digitization:** Upload any PNG/JPG and use dynamic thresholding and resampling to convert it into a pixel matrix that you can layer onto a sweater canvas. +* **Multi-Panel Garment IDE:** Switch between Front Panel, Back Panel, and Sleeves canvases that make up a drop shoulder sweater. The app dynamically calculates dimensions and short-row shaping based on user measurements and gauge. +* **Layer Design Studio:** Add, duplicate, merge, and transform (rotate, flip, mirror, scale) multiple graphics on a single canvas. +* **Fine-Tune Pixel Editor:** A built-in, manual spreadsheet-style editor to fine-tune individual stitches or draw custom motifs from scratch on blank 25x25 or 50x50 canvases. +* **Alpha JSON exporter:** Export your custom design to a JSON file to reuse in future projects. +* **Master PDF Compiler:** Automatically chunks your digital canvas into a printable PDF pattern complete with cast-on counts, row instructions, and sweater specs. +* **Project Saving:** Serialize your entire workspace (including layers, settings, and math grids) into a JSON file to resume your work later. + + +## Prerequisites +Before installing, ensure you have **Python 3.9 or newer** installed on your system. We highly recommend using a virtual environment (like `venv` or Anaconda) to keep dependencies clean. + + +## Installation + +### Option A: Using GitHub Desktop +1. Download and install [GitHub Desktop](https://desktop.github.com/). +2. Open GitHub Desktop and go to **File > Clone repository**. +3. Select the **URL** tab and paste: `https://github.com/Programming-The-Next-Step-2026/knitting_pattern.git` +4. Choose a local path on your computer and click **Clone**. +5. Open your preferred terminal (or Anaconda Prompt) and navigate to the folder you just cloned. +6. Run the following command to install the package and its dependencies: + pip install -e . + + +### Option B: Command Line (Windows / Mac / Linux) +1. Open your Terminal/Command Prompt/PowerShell. +2. Clone the repository: +git clone [https://github.com/Programming-The-Next-Step-2026/knitting_pattern.git](https://github.com/Programming-The-Next-Step-2026/knitting_pattern.git) + +3. Navigate into the directory: +cd knitting_pattern + +4. Install the package: +pip install -e . + + +## Usage +Once installed, make sure your in the knitting_pattern directory, then run the application locally: +streamlit run src/knitting_pattern/app.py + +This will automatically open the Knit App dashboard in your default web browser. + +## Project Architecture + +* **`app.py`:** The Streamlit UI layer acting as the bridge for state management and user interaction. +* **`math_engine.py`:** Calculates physical measurements, gauge conversions, and garment topography (like short row geometry). +* **`image_engine.py`:** Handles digital formatting, utilizing Pillow and NumPy for image resampling, bounding box cropping, and Matplotlib for dynamic PDF generation. + +## License + +Distributed under the MIT License. Built for the course *Programming: The Next Step (2026)*. diff --git a/app_generated_chart.png b/app_generated_chart.png index 69e8575..d45f71f 100644 Binary files a/app_generated_chart.png and b/app_generated_chart.png differ diff --git a/docs/.ipynb_checkpoints/report-checkpoint.ipynb b/docs/.ipynb_checkpoints/report-checkpoint.ipynb index 9451011..72a763b 100644 --- a/docs/.ipynb_checkpoints/report-checkpoint.ipynb +++ b/docs/.ipynb_checkpoints/report-checkpoint.ipynb @@ -20,6 +20,23 @@ "source": [ "2. Software Architecture\n", "The software uses the standard Python src/ layout to isolate core business and graphical logic from the presentation layer.\n", + "\n", + "Stitch and Row Allocation: The engine converts user dimensions (in centimeters) to absolute integers. \n", + "It uses ceiling rounding (math.ceil) since a knitter cannot execute a partial stitch. The underlying formula is:\n", + "stitches = [{width (cm)} * {stitch gauge} / 10)]\n", + "\n", + "Grid Canvas Representation: The backend models the fabric as a two-dimensional grid (nested lists or arrays). Unoccupied background fabric is initialized to 0.\n", + "Alpha Pattern Integration: The system reads pixel-based assets representing the alpha design. It maps these coordinates directly onto the sweater grid, reassigning target cells from background 0 to specific integer color IDs (e.g., 1, 2, 3). This acts as the layout for the color chart." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc4d6fcd-c5f0-4b80-b409-8c4fc66b0922", + "metadata": {}, + "outputs": [], + "source": [ + "Current layout:\n", "knitting_pattern/\n", "โ”œโ”€โ”€ src/\n", "โ”‚ โ”œโ”€โ”€ knitting_pattern/\n", @@ -29,13 +46,7 @@ "โ”‚ โ”‚ โ””โ”€โ”€ image_engine.py # Visualization logic, plotting chart and recognizing alpha patterns\n", "โ”‚ โ””โ”€โ”€ tests/\n", "โ”‚ โ”œโ”€โ”€ test_image_engine.py\n", - "โ”‚ โ””โ”€โ”€ test_math_engine.py \n", - "Stitch and Row Allocation: The engine converts user dimensions (in centimeters) to absolute integers. \n", - "It uses ceiling rounding (math.ceil) since a knitter cannot execute a partial stitch. The underlying formula is:\n", - "stitches = [{width (cm)} * {stitch gauge} / 10)]\n", - "\n", - "Grid Canvas Representation: The backend models the fabric as a two-dimensional grid (nested lists or arrays). Unoccupied background fabric is initialized to 0.\n", - "Alpha Pattern Integration: The system reads pixel-based assets representing the alpha design. It maps these coordinates directly onto the sweater grid, reassigning target cells from background 0 to specific integer color IDs (e.g., 1, 2, 3). This acts as the layout for the color chart." + "โ”‚ โ””โ”€โ”€ test_math_engine.py " ] }, { diff --git a/docs/report.ipynb b/docs/report.ipynb index 9451011..5492704 100644 --- a/docs/report.ipynb +++ b/docs/report.ipynb @@ -5,8 +5,6 @@ "id": "73620923-8165-4d80-a81a-cb768294cc6f", "metadata": {}, "source": [ - "Week 3 Assignment: Knitting Pattern Calculator Project Report\n", - "\n", "1. Project Overview\n", "Knitting a custom sweater from the top down requires translating physical body measurements into a discrete grid of stitches and rows based on a individual knitting gauge. Manual conversion often leads to structural math errors. Furthermore, knitters wanting to incorporate multi-colored intarsia or stranded motifs (\"alpha patterns\") must manually map those pixel charts onto their specific garment dimensions, which is a slow and error-prone process.\n", "\n", @@ -20,6 +18,23 @@ "source": [ "2. Software Architecture\n", "The software uses the standard Python src/ layout to isolate core business and graphical logic from the presentation layer.\n", + "\n", + "Stitch and Row Allocation: The engine converts user dimensions (in centimeters) to absolute integers. \n", + "It uses ceiling rounding (math.ceil) since a knitter cannot execute a partial stitch. The underlying formula is:\n", + "stitches = [{width (cm)} * {stitch gauge} / 10)]\n", + "\n", + "Grid Canvas Representation: The backend models the fabric as a two-dimensional grid (nested lists or arrays). Unoccupied background fabric is initialized to 0.\n", + "Alpha Pattern Integration: The system reads pixel-based assets representing the alpha design. It maps these coordinates directly onto the sweater grid, reassigning target cells from background 0 to specific integer color IDs (e.g., 1, 2, 3). This acts as the layout for the color chart." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc4d6fcd-c5f0-4b80-b409-8c4fc66b0922", + "metadata": {}, + "outputs": [], + "source": [ + "Current layout:\n", "knitting_pattern/\n", "โ”œโ”€โ”€ src/\n", "โ”‚ โ”œโ”€โ”€ knitting_pattern/\n", @@ -29,13 +44,7 @@ "โ”‚ โ”‚ โ””โ”€โ”€ image_engine.py # Visualization logic, plotting chart and recognizing alpha patterns\n", "โ”‚ โ””โ”€โ”€ tests/\n", "โ”‚ โ”œโ”€โ”€ test_image_engine.py\n", - "โ”‚ โ””โ”€โ”€ test_math_engine.py \n", - "Stitch and Row Allocation: The engine converts user dimensions (in centimeters) to absolute integers. \n", - "It uses ceiling rounding (math.ceil) since a knitter cannot execute a partial stitch. The underlying formula is:\n", - "stitches = [{width (cm)} * {stitch gauge} / 10)]\n", - "\n", - "Grid Canvas Representation: The backend models the fabric as a two-dimensional grid (nested lists or arrays). Unoccupied background fabric is initialized to 0.\n", - "Alpha Pattern Integration: The system reads pixel-based assets representing the alpha design. It maps these coordinates directly onto the sweater grid, reassigning target cells from background 0 to specific integer color IDs (e.g., 1, 2, 3). This acts as the layout for the color chart." + "โ”‚ โ””โ”€โ”€ test_math_engine.py " ] }, { @@ -71,6 +80,22 @@ "\n", "Solution: Expand the mathematical backend to calculate complex raglan lines and set-in sleeve caps. This requires developing dynamic decrease formulas to scale diagonal stitch lines across the grid canvas while maintaining the integrity of the centered alpha patterns." ] + }, + { + "cell_type": "markdown", + "id": "e66e80a0-6441-43dd-b2bc-04f0bb39cc53", + "metadata": {}, + "source": [ + "Things to implement before Friday:\n", + "1. manual pixel editor in case the pixel converter isnt perfect (still in developement - prototype exists)\n", + "2. sleeves (DONE)\n", + "3. multiple alpha patterns on one thing - to edit them you click on the sidepanel on one of the alpha patterns and edit like normal. (DONE)\n", + "5. Be able to copy an alpha pattern for continuous 'stripe' or to make a repeating pattern (maybe through the multiple alpha pattern added or have like a choice to make a vertical or horizontal repeating pattern from one alpha pattern? maybe even a hexagonal grid, square grid? offset vertical stripes over some parts of the sweater? (DONE)\n", + "6. make more unit tests that have good coverage (see julians comments) (no progress)\n", + "7. have the whole pattern in one pdf; so be able to have separate 'projects' at once: front panel, back panel, and sleeves. so we can design alpha patterns and fit for each aspect of the sweater and then put everything in one pdf pattern with the title of the project that we chose and the rest of the instructions. (DONE)\n", + "8. save JSON file of current session so you don't loose progress and can import the JSON file to start back on (DONE)\n", + "9. README file update to include the instructions of download and use. (no progress)" + ] } ], "metadata": { diff --git a/pyproject.toml b/pyproject.toml index e09ae89..516ffe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,10 +15,11 @@ description = "Kitting pattern calculator tool for my programming course" readme = "README.md" requires-python = ">=3.9" dependencies = [ - "numpy", - "Pillow", - "streamlit", - "matplotlib" + "numpy>=1.24.0", + "Pillow>=9.1.0", + "streamlit>=1.30.0", + "matplotlib>=3.5.0", + "pandas>=1.5.0" ] classifiers = [ "Programming Language :: Python :: 3", diff --git a/src/.DS_Store b/src/.DS_Store index 4fe34c6..d4c9389 100644 Binary files a/src/.DS_Store and b/src/.DS_Store differ diff --git a/src/all_over_pattern.jpg b/src/all_over_pattern.jpg new file mode 100644 index 0000000..003ba90 Binary files /dev/null and b/src/all_over_pattern.jpg differ diff --git a/src/alphabet.jpg b/src/alphabet.jpg new file mode 100644 index 0000000..1389ae5 Binary files /dev/null and b/src/alphabet.jpg differ diff --git a/src/alphabet.webp b/src/alphabet.webp new file mode 100644 index 0000000..cc04301 Binary files /dev/null and b/src/alphabet.webp differ diff --git a/src/cat_nonpix.jpg b/src/cat_nonpix.jpg new file mode 100644 index 0000000..33ed9e6 Binary files /dev/null and b/src/cat_nonpix.jpg differ diff --git a/src/cow_nonpix.jpg b/src/cow_nonpix.jpg new file mode 100644 index 0000000..fca0a38 Binary files /dev/null and b/src/cow_nonpix.jpg differ diff --git a/src/diva.jpg b/src/diva.jpg new file mode 100644 index 0000000..a36cbed Binary files /dev/null and b/src/diva.jpg differ diff --git a/src/eyes.jpg b/src/eyes.jpg new file mode 100644 index 0000000..4feaabd Binary files /dev/null and b/src/eyes.jpg differ diff --git a/src/flower_repeating.jpg b/src/flower_repeating.jpg new file mode 100644 index 0000000..b7a36ed Binary files /dev/null and b/src/flower_repeating.jpg differ diff --git a/src/knitting_pattern/__init__.py b/src/knitting_pattern/__init__.py index 35ec198..fe48487 100644 --- a/src/knitting_pattern/__init__.py +++ b/src/knitting_pattern/__init__.py @@ -6,21 +6,32 @@ @author: kasteivanauskaite """ -from .example import add, calculate_mean +# src/knitting_pattern/__init__.py -# engine/__init__.py - -# This exposes these specific functions to the rest of your app from .math_engine import ( - calculate_stitches, calculate_rows, calculate_garment_dimensions, - calculate_top_down_shoulder_shaping, generate_panel_grid, apply_top_down_mountains_to_grid, - calculate_back_neck_shaping, apply_back_short_rows_to_grid + calculate_stitches, + calculate_rows, + calculate_garment_dimensions, + calculate_top_down_shoulder_shaping, + generate_panel_grid, + apply_top_down_mountains_to_grid, + calculate_back_neck_shaping, + apply_back_short_rows_to_grid ) -# Expose Image Engine Functions from .image_engine import ( get_hardcoded_heart, scale_pattern_matrix_integer, - overlay_pattern_on_grid, - generate_chart_image -) \ No newline at end of file + apply_transforms, + get_matrix_dimensions, + overlay_stamps_on_grid, + generate_multipage_pdf_figs, + process_uploaded_image, + rotate_matrix, + generate_cropped_canvas_png_bytes, + merge_stamps, + crop_matrix_to_bounding_box, + serialize_project_state, + deserialize_project_state +) + diff --git a/src/knitting_pattern/app.py b/src/knitting_pattern/app.py index 585a520..efacd75 100644 --- a/src/knitting_pattern/app.py +++ b/src/knitting_pattern/app.py @@ -10,535 +10,655 @@ import streamlit as st import numpy as np from PIL import Image, ImageDraw +import io +import copy +import json # ========================================== -# INITIALIZE SESSION STATE (For Centering Buttons) +# IMPORTING BACKEND ENGINES # ========================================== -if "x_pos" not in st.session_state: - st.session_state.x_pos = 20 -if "y_pos" not in st.session_state: - st.session_state.y_pos = 15 -if "scale" not in st.session_state: - st.session_state.scale = 2 - -# ========================================== -# IMPORTING YOUR BACKEND ENGINES -# ========================================== - from knitting_pattern.math_engine import ( calculate_stitches, calculate_rows, calculate_garment_dimensions, calculate_top_down_shoulder_shaping, generate_panel_grid, apply_top_down_mountains_to_grid, calculate_back_neck_shaping, - # (and any other functions you have) + apply_back_short_rows_to_grid, calculate_sleeve_dimensions, + generate_sleeve_grid ) from knitting_pattern.image_engine import ( - get_hardcoded_heart, scale_pattern_matrix_integer, overlay_pattern_on_grid, generate_chart_image + get_hardcoded_heart, scale_pattern_matrix_integer, overlay_stamps_on_grid, + generate_multipage_pdf_figs, process_uploaded_image, get_matrix_dimensions, + rotate_matrix, apply_transforms, generate_cropped_canvas_png_bytes, + merge_stamps, crop_matrix_to_bounding_box, serialize_project_state, + deserialize_project_state ) # ========================================== -# HELPER: PROCESS UPLOADED IMAGES +# INITIALIZE PROJECT STATE (MULTI-PANEL) # ========================================== - -def process_uploaded_image(pil_image, target_width, threshold_value): - """Converts an uploaded image into a binary matrix and a visual graph-paper preview.""" - - # 1. FIX TRANSPARENCY - if pil_image.mode in ('RGBA', 'LA') or (pil_image.mode == 'P' and 'transparency' in pil_image.info): - bg = Image.new('RGBA', pil_image.size, (255, 255, 255, 255)) - pil_image = Image.alpha_composite(bg, pil_image.convert('RGBA')) - - # 2. CALCULATE EXACT HEIGHT - aspect_ratio = pil_image.height / pil_image.width - target_height = int(target_width * aspect_ratio) - - # 3. BOX RESAMPLING - if hasattr(Image, 'Resampling'): - pil_image = pil_image.resize((target_width, target_height), Image.Resampling.BOX) - else: - pil_image = pil_image.resize((target_width, target_height), Image.BOX) - - gray = pil_image.convert('L') - arr = np.array(gray) - - # 4. THRESHOLD MATH - matrix = [[1 if val < threshold_value else 0 for val in row] for row in arr] - - # 5. BUILD THE HIGH-CONTRAST PREVIEW IMAGE - # 0 = Black (Pattern), 255 = White (Background) - preview_arr = np.array([[0 if val == 1 else 255 for val in row] for row in matrix], dtype=np.uint8) - preview_img = Image.fromarray(preview_arr) - - # Convert to RGB so we can draw colored/gray grid lines on it! - preview_img = preview_img.convert("RGB") - - # Scale it up by 10x - cell_size = 10 - preview_img = preview_img.resize((target_width * cell_size, target_height * cell_size), Image.NEAREST) - - # 6. DRAW THE GRAPH PAPER GRID - draw = ImageDraw.Draw(preview_img) - grid_color = (130, 130, 130) # Matching slate gray - - # Draw vertical grid lines - for x in range(0, preview_img.width, cell_size): - draw.line([(x, 0), (x, preview_img.height)], fill=grid_color, width=1) - # Draw horizontal grid lines - for y in range(0, preview_img.height, cell_size): - draw.line([(0, y), (preview_img.width, y)], fill=grid_color, width=1) - - # Draw a thick bounding box around the whole thing - draw.rectangle([(0, 0), (preview_img.width-1, preview_img.height-1)], outline=grid_color, width=2) - - return matrix, preview_img +if "panels" not in st.session_state: + # Our new "Folder System" for the garment! + st.session_state.panels = { + "Front Panel": {"stamps": []}, + "Back Panel": {"stamps": []}, + "Sleeve": {"stamps": []} + } +if "active_panel" not in st.session_state: st.session_state.active_panel = "Front Panel" +if "active_stamp_idx" not in st.session_state: st.session_state.active_stamp_idx = 0 +if "proj_id" not in st.session_state: st.session_state.proj_id = 0 # ========================================== # PAGE CONFIGURATION # ========================================== -st.set_page_config(page_title="Knit & Pixel", page_icon="๐Ÿงถ", layout="wide") - -st.title("๐Ÿงถ Knit & Pixel: Colorwork Generator") -st.markdown("Transform any alpha pattern into a perfectly scaled, knittable chart.") +st.set_page_config(page_title="Knit App", page_icon="๐Ÿงถ", layout="wide") +st.title("Knitting Chart Maker") +st.markdown("Transform any alpha pattern into a knittable chart with instructions for a drop-shoulder sweater") # ========================================== # SIDEBAR: SETTINGS & MEASUREMENTS # ========================================== with st.sidebar: - st.header("1. Yarn & Gauge") + st.header("1. Yarn Gauge") col1, col2 = st.columns(2) - with col1: - gauge_sts = st.number_input("Stitches / 10cm", min_value=5.0, max_value=40.0, value=18.0, step=0.5) - with col2: - gauge_rows = st.number_input("Rows / 10cm", min_value=5.0, max_value=50.0, value=24.0, step=0.5) + with col1: gauge_sts = st.number_input("Stitches / 10cm", min_value=5.0, max_value=40.0, value=18.0, step=0.5, key="gauge_sts") + with col2: gauge_rows = st.number_input("Rows / 10cm", min_value=5.0, max_value=50.0, value=24.0, step=0.5, key="gauge_rows") st.divider() - st.header("2. Sizing & Fit") - size_mode = st.radio("Sizing Method", ["Standard Sizing", "Advanced (Made-to-Measure)"]) + size_mode = st.radio("Sizing Method", ["Standard Sizing", "Advanced (Made-to-Measure)"], key="size_mode") if size_mode == "Standard Sizing": - target_size = st.selectbox("Select Size", ["XS", "S", "M", "L", "XL", "2XL"], index=2) + target_size = st.selectbox("Select Size", ["XS", "S", "M", "L", "XL", "2XL"], index=2, key="target_size") dimensions = calculate_garment_dimensions(target_size, "drop_shoulder") chest_cm = dimensions["body_chest_circ"] + dimensions["ease_added"] - # Standard sizes usually have a graded length, we'll default to 60cm if not in your dictionary garment_length_cm = dimensions.get("body_length_cm", 60.0) st.info(f"Panel Width: {dimensions['panel_width_cm']} cm\nLength: {garment_length_cm} cm") else: target_size = "M" st.markdown("**Enter your exact measurements:**") - chest_cm = st.number_input("Full Chest Circumference (cm)", min_value=50.0, max_value=160.0, value=96.0) - ease_cm = st.slider("Positive Ease (cm)", min_value=-5.0, max_value=30.0, value=15.0, help="Added to the chest circumference.") + chest_cm = st.number_input("Full Chest Circumference (cm)", min_value=50.0, max_value=160.0, value=96.0, key="chest_cm") + ease_cm = st.slider("Positive Ease (cm)", min_value=-5.0, max_value=30.0, value=15.0, key="ease_cm") chest_cm += ease_cm - - garment_length_cm = st.number_input("Desired Length (Hem to Shoulder in cm)", min_value=30.0, max_value=120.0, value=60.0, step=1.0) + garment_length_cm = st.number_input("Desired Length (Hem to Shoulder in cm)", min_value=30.0, max_value=120.0, value=60.0, step=1.0, key="garment_length_cm") st.divider() - - st.header("3. Shaping & Pattern") - panel_type = st.radio("Sweater Panel", ["Front Panel", "Back Panel"], horizontal=True) - if panel_type == "Front Panel": - use_shaping = st.toggle("Enable Front Shoulder Shaping", value=True) - else: - use_shaping = st.toggle("Enable Back German Short Rows", value=True) + # --- ACTIVE CANVAS ROUTER --- + st.header("3. Active Canvas") + active_panel = st.radio("Editing Panel:", ["Front Panel", "Back Panel", "Sleeve"], key="active_panel") + + # Reset layer selection index if we switch panels to avoid out-of-bounds errors + if "last_panel" not in st.session_state or st.session_state.last_panel != active_panel: + st.session_state.active_stamp_idx = 0 + st.session_state.last_panel = active_panel + + # Shaping rules based on the active panel + if active_panel == "Front Panel": use_shaping = st.toggle("Enable Front Shoulder Shaping", value=True, key="use_shaping_front") + elif active_panel == "Back Panel": use_shaping = st.toggle("Enable Back German Short Rows", value=True, key="use_shaping_back") + else: use_shaping = False # Sleeves have built-in taper shaping, no toggle needed st.divider() - include_pattern = st.toggle("Include Alpha Pattern", value=True) + include_pattern = st.toggle("Include Alpha Pattern", value=True, key="include_pattern") + + st.divider() + st.header("๐Ÿ’พ Project File") + st.caption("Save or restore your project.") + + st.text_input("Project Name", value="my_knit_project", key="project_name") + + def get_project_json(): + # Package the multi-panel state along with settings + project_data = { + "settings": { + "project_name": st.session_state.get("project_name", "my_knit_project"), + "gauge_sts": st.session_state.get("gauge_sts", 18.0), + "gauge_rows": st.session_state.get("gauge_rows", 24.0), + "size_mode": st.session_state.get("size_mode", "Standard Sizing"), + "target_size": st.session_state.get("target_size", "M"), + "chest_cm": st.session_state.get("chest_cm", 96.0), + "ease_cm": st.session_state.get("ease_cm", 15.0), + "garment_length_cm": st.session_state.get("garment_length_cm", 60.0), + "active_panel": st.session_state.get("active_panel", "Front Panel"), + "use_shaping_front": st.session_state.get("use_shaping_front", True), + "use_shaping_back": st.session_state.get("use_shaping_back", True), + "include_pattern": st.session_state.get("include_pattern", True) + }, + "panels": st.session_state.panels + } + # Safe JSON dump for the deep dictionary + return json.dumps(project_data) + + raw_name = st.session_state.get("project_name", "my_knit_project").strip() + safe_name = raw_name.replace(" ", "_") if raw_name else "untitled_project" + + st.download_button("๐Ÿ“ฅ Download Full Project", data=get_project_json(), file_name=f"{safe_name}.json", mime="application/json", use_container_width=True) + st.file_uploader("Upload Project", type="json", label_visibility="collapsed", key="project_file_upload") + + # --- CALLBACK FUNCTION --- + def restore_project_callback(): + if st.session_state.project_file_upload is not None: + json_string = st.session_state.project_file_upload.getvalue().decode("utf-8") + raw_data = json.loads(json_string) + + # 1. Backwards Compatibility Safety Net! + if isinstance(raw_data, list): + # If it's an old save file (just a list), wrap it in our new format + loaded_data = { + "settings": {}, + "panels": { + "Front Panel": {"stamps": raw_data}, + "Back Panel": {"stamps": []}, + "Sleeve": {"stamps": []} + } + } + else: + loaded_data = raw_data + + # 2. Inject saved settings into widgets + for k, v in loaded_data.get("settings", {}).items(): + st.session_state[k] = v + + # 3. Inject saved multi-panel layers + if "panels" in loaded_data: + st.session_state.panels = loaded_data["panels"] + elif "stamps" in loaded_data: # Intermediate save files + st.session_state.panels = {"Front Panel": {"stamps": loaded_data["stamps"]}, "Back Panel": {"stamps": []}, "Sleeve": {"stamps": []}} + + st.session_state.active_stamp_idx = 0 + st.session_state.proj_id += 1 + + st.button("โš ๏ธ Restore Project (Overwrites current)", type="primary", on_click=restore_project_callback, use_container_width=True) + # ========================================== -# CORE MATH: DYNAMIC GRID DIMENSIONS +# CORE MATH: MULTI-CANVAS ROUTER # ========================================== panel_width_cm = chest_cm / 2 -total_sts = calculate_stitches(panel_width_cm, gauge_sts) - -# This physically translates the length cm into exact matrix rows! -total_rows = calculate_rows(garment_length_cm, gauge_rows) - -sweater_grid = generate_panel_grid(total_sts, total_rows) -if panel_type == "Front Panel": - shaping = calculate_top_down_shoulder_shaping(total_sts, target_size, gauge_rows) - if use_shaping: - sweater_grid = apply_top_down_mountains_to_grid(sweater_grid, shaping) - safe_y_start = shaping["mountain_rows"] + 1 - else: - safe_y_start = 0 +# We dynamically generate the specific math grid based on what tab you are on! +if active_panel in ["Front Panel", "Back Panel"]: + total_sts = calculate_stitches(panel_width_cm, gauge_sts) + total_rows = calculate_rows(garment_length_cm, gauge_rows) + sweater_grid = generate_panel_grid(total_sts, total_rows) + + if active_panel == "Front Panel": + shaping = calculate_top_down_shoulder_shaping(total_sts, target_size, gauge_rows) + if use_shaping: + sweater_grid = apply_top_down_mountains_to_grid(sweater_grid, shaping) + safe_y_start = shaping["mountain_rows"] + 1 + else: safe_y_start = 0 + + elif active_panel == "Back Panel": + shaping = calculate_back_neck_shaping(total_sts, target_size, gauge_rows) + if use_shaping: + sweater_grid = apply_back_short_rows_to_grid(sweater_grid, shaping) + safe_y_start = shaping["mountain_rows"] + 1 + else: safe_y_start = 0 + +elif active_panel == "Sleeve": + # Hook up the new Sleeve Trapezoid math! + shaping = calculate_sleeve_dimensions(target_size, gauge_sts, gauge_rows) + sweater_grid = generate_sleeve_grid(shaping) + total_sts = shaping["bicep_sts"] + total_rows = shaping["total_rows"] + safe_y_start = 0 -elif panel_type == "Back Panel": - shaping = calculate_back_neck_shaping(total_sts, target_size, gauge_rows) - if use_shaping: - sweater_grid = apply_back_short_rows_to_grid(sweater_grid, shaping) - safe_y_start = shaping["mountain_rows"] + 1 - else: - safe_y_start = 0 +# Extract the specific layer stack for our active panel +active_stamps = st.session_state.panels[active_panel]["stamps"] # ========================================== # MAIN PAGE: PATTERN SELECTION # ========================================== st.header("4. Select Alpha Pattern") - -tab1, tab2 = st.tabs(["Upload Image", "Use Preset Pattern"]) - +tab1, tab2, tab3 = st.tabs(["Upload Image (PNG/JPG)", "Upload Alpha JSON", "Use Preset Pattern"]) base_pattern_matrix = None +current_graphic_name = "Graphic" with tab1: uploaded_file = st.file_uploader("Upload a pixel graphic (.png, .jpg)", type=['png', 'jpg', 'jpeg']) if uploaded_file is not None: + current_graphic_name = os.path.splitext(uploaded_file.name)[0] image = Image.open(uploaded_file) - - # 1. Sliders Side-by-Side (Making them smaller and neater) st.markdown("**Image Settings:**") col_w, col_t = st.columns(2) - with col_w: - target_width = st.slider("Target Width (Stitches)", min_value=5, max_value=total_sts, value=25, step=1) - with col_t: - threshold = st.slider("Darkness Threshold", min_value=1, max_value=255, value=128, step=1, - help="Slide to dial in the perfect outline.") + with col_w: target_width = st.slider("Target Width (Stitches)", min_value=5, max_value=total_sts, value=25, step=1) + with col_t: threshold = st.slider("Darkness Threshold", min_value=1, max_value=255, value=128, step=1) - # Pass the sliders into the function and get BOTH the matrix and the preview image back! base_pattern_matrix, processed_preview = process_uploaded_image(image, target_width, threshold) - # 2. Images Side-by-Side for Easy Comparison - st.markdown("**Comparison Preview:**") col_img1, col_img2 = st.columns(2) - with col_img1: - st.image(image, caption="Original Image", use_container_width=True) - with col_img2: - st.image(processed_preview, caption="Knitting Matrix Vision", use_container_width=True) + with col_img1: st.image(image, caption="Original Image", use_container_width=True) + with col_img2: st.image(processed_preview, caption="Knitting Matrix Vision", use_container_width=True) with tab2: - preset = st.selectbox("Choose a preset", ["8-Bit Heart"]) - if uploaded_file is None: - base_pattern_matrix = get_hardcoded_heart() - st.info("Using the default heart. Try uploading your own above!") - + st.markdown("Upload a pure pixel matrix generated by this app for 100% lossless quality.") + alpha_file = st.file_uploader("Upload Alpha Pattern (.json)", type=["json"]) + if alpha_file: + current_graphic_name = os.path.splitext(alpha_file.name)[0] + base_pattern_matrix = json.load(alpha_file) + st.success("Alpha pattern loaded successfully!") + preview_arr = np.array([[0 if val == 1 else 255 for val in row] for row in base_pattern_matrix], dtype=np.uint8) + st.image(Image.fromarray(preview_arr).resize((len(base_pattern_matrix[0])*10, len(base_pattern_matrix)*10), Image.NEAREST), caption="Lossless JSON Preview") + +with tab3: + preset = st.selectbox( + "Choose a preset", + ["8-Bit Heart", "Blank Canvas (25x25)", "Blank Canvas (50x50)"] + ) + + if uploaded_file is None and alpha_file is None: + current_graphic_name = preset + + if preset == "8-Bit Heart": + base_pattern_matrix = get_hardcoded_heart() + st.info("Using the default heart. Try uploading your own above!") + + elif preset == "Blank Canvas (25x25)": + # Generate a 25x25 matrix filled with 0s (empty knittable space) + base_pattern_matrix = [[0 for _ in range(25)] for _ in range(25)] + st.info("Using a blank 25x25 canvas. Add it as a layer, then use the Micro-Grid Editor to draw!") + + elif preset == "Blank Canvas (50x50)": + # Generate a 50x50 matrix filled with 0s + base_pattern_matrix = [[0 for _ in range(50)] for _ in range(50)] + st.warning("50x50 is quite wide! You will need to scroll horizontally in the Micro-Grid Editor to draw on the edges.") + # ========================================== -# NEW SECTION: REAL-TIME PLACEMENT +# DESIGN STUDIO # ========================================== st.divider() -st.header("5. Real-Time Placement") - -sweater_grid = generate_panel_grid(total_sts, total_rows) - -# THE NEW ROUTING LOGIC: Checks the sidebar toggle before doing the math! -if panel_type == "Front Panel": - shaping = calculate_top_down_shoulder_shaping(total_sts, target_size, gauge_rows, panel_type) - if use_shaping: - sweater_grid = apply_top_down_mountains_to_grid(sweater_grid, shaping) - safe_y_start = shaping["mountain_rows"] + 1 - else: - safe_y_start = 0 - -elif panel_type == "Back Panel": - shaping = calculate_back_neck_shaping(total_sts, target_size, gauge_rows) - if use_shaping: - sweater_grid = apply_back_short_rows_to_grid(sweater_grid, shaping) - # The back is a dome, so the top-center row is at 0! - safe_y_start = 0 - else: - safe_y_start = 0 - - -# Safety checks to prevent app crashes if the canvas resizes out from under the sliders! -if st.session_state.y_pos < safe_y_start: - st.session_state.y_pos = int(safe_y_start) -if st.session_state.x_pos > total_sts: - st.session_state.x_pos = total_sts // 2 - -col_controls, col_preview = st.columns([1, 1.5]) - -with col_controls: - st.markdown("Use the sliders to lock your alpha pattern to the grid.") - pattern_scale = st.slider("Scale Multiplier", min_value=1, max_value=10, key="scale") - - # Calculate physical width/height of the pattern right now - p_w = len(base_pattern_matrix[0]) * pattern_scale - p_h = len(base_pattern_matrix) * pattern_scale - - # ========================================== - # CALLBACK FUNCTIONS FOR BUTTONS - # These run *before* the UI renders! - # ========================================== - def center_x_callback(t_sts, p_width): - st.session_state.x_pos = max(0, (t_sts - p_width) // 2) - - def center_y_callback(t_rows, s_y_start, p_height): - space = t_rows - s_y_start - st.session_state.y_pos = int(s_y_start + (space - p_height) // 2) - - # X-Axis Controls - st.markdown("##### Horizontal Placement") - col_x_slider, col_x_btn = st.columns([3, 1]) - with col_x_slider: - st.slider("X Position", min_value=0, max_value=total_sts, key="x_pos", label_visibility="collapsed") - with col_x_btn: - # We attach the callback to the on_click parameter! - st.button("Center โ†”", on_click=center_x_callback, args=(total_sts, p_w), use_container_width=True) - - # Y-Axis Controls - st.markdown("##### Vertical Placement") - col_y_slider, col_y_btn = st.columns([3, 1]) - with col_y_slider: - st.slider("Y Position", min_value=int(safe_y_start), max_value=total_rows, key="y_pos", label_visibility="collapsed") - with col_y_btn: - st.button("Center โ†•", on_click=center_y_callback, args=(total_rows, safe_y_start, p_h), use_container_width=True) +st.header(f"5. Design Studio: {active_panel}") # Header dynamically updates! + +current_graphic_name = "Graphic" +if include_pattern and base_pattern_matrix is not None: + if uploaded_file is not None: current_graphic_name = os.path.splitext(uploaded_file.name)[0] + else: current_graphic_name = preset + + st.markdown("### โž• Add Current Graphic to Canvas") + if st.button(f"Add as New Layer to {active_panel}", type="primary"): + new_stamp = { + "name": f"{current_graphic_name} ({len(active_stamps) + 1})", + "matrix": base_pattern_matrix, + "x": total_sts // 2, + "y": int(safe_y_start) + 10, + "scale": 1, "symmetry": "None", "axis": "Horizontal", + "rotation": 0, "spacing_h": 0, "spacing_v": 0, "visible": True + } + active_stamps.append(new_stamp) + st.session_state.active_stamp_idx = len(active_stamps) - 1 + st.rerun() + +st.markdown("---") -with col_preview: - scaled_pattern = scale_pattern_matrix_integer(base_pattern_matrix, pattern_scale) - - # 1. Base Canvas (White for the empty space around the sweater) - preview_canvas = np.full((total_rows, total_sts, 3), 255, dtype=np.uint8) - - # 2. Solid Sweater Base (No more checkerboard!) - for r in range(total_rows): - for c in range(total_sts): - if sweater_grid[r][c] != -1: - # Just a clean, solid light gray for the sweater panel - preview_canvas[r, c] = [190, 190, 190] - - # 3. Pattern Placement - if include_pattern and base_pattern_matrix is not None: - scaled_pattern = scale_pattern_matrix_integer(base_pattern_matrix, pattern_scale) - for r in range(len(scaled_pattern)): - for c in range(len(scaled_pattern[0])): - if scaled_pattern[r][c] == 1: - y = st.session_state.y_pos + r - x = st.session_state.x_pos + c - if 0 <= y < total_rows and 0 <= x < total_sts: - preview_canvas[y, x] = [220, 50, 50] - - preview_img = Image.fromarray(preview_canvas) - - # 4. Chunkier Upscaling - cell_size = 10 - preview_img = preview_img.resize((total_sts * cell_size, total_rows * cell_size), Image.NEAREST) - - # 5. Crisp Graph Paper Lines over the solid background - draw = ImageDraw.Draw(preview_img) - grid_color = (130, 130, 130) # Dark slate gray - - # Draw vertical grid lines - for x in range(0, preview_img.width, cell_size): - draw.line([(x, 0), (x, preview_img.height)], fill=grid_color, width=1) - # Draw horizontal grid lines - for y in range(0, preview_img.height, cell_size): - draw.line([(0, y), (preview_img.width, y)], fill=grid_color, width=1) - - # Draw a thick bounding box around the whole preview - draw.rectangle([(0, 0), (preview_img.width-1, preview_img.height-1)], outline=grid_color, width=2) +if not active_stamps: + st.info(f"Your {active_panel} is currently blank. Click 'Add as New Layer' to start designing!") + col_controls, col_preview = st.columns([1, 1.5]) +else: + st.markdown("### Layer Manager") + col_manage, col_merge = st.columns([1.2, 1]) + + with col_manage: + layer_names = [stamp["name"] for stamp in active_stamps] + # Safety catch if layers were deleted + idx = st.session_state.active_stamp_idx + if idx >= len(layer_names): idx = max(0, len(layer_names) - 1) - st.image(preview_img, caption=f"Real-Time Placement ({total_sts} sts x {total_rows} rows)", use_container_width=True) - st.divider() + selected_name = st.selectbox("Select Layer to Edit:", layer_names, index=idx) + st.session_state.active_stamp_idx = layer_names.index(selected_name) + active_stamp = active_stamps[st.session_state.active_stamp_idx] + + c_ren_input, c_ren_btn, c_vis = st.columns([2.5, 1, 1.2]) + with c_ren_input: new_name = st.text_input("Rename Layer", value=active_stamp["name"], label_visibility="collapsed") + with c_ren_btn: + if st.button("โœ๏ธ Rename", use_container_width=True): + if new_name and new_name != active_stamp["name"]: + active_stamp["name"] = new_name + st.rerun() + with c_vis: + is_vis = active_stamp.get("visible", True) + if st.button("๐Ÿ‘๏ธ Visible" if is_vis else "๐Ÿšซ Hidden", use_container_width=True): + active_stamp["visible"] = not is_vis + st.rerun() + + with col_merge: + with st.expander("๐Ÿ”— Merge Layers Together"): + merge_candidates = st.multiselect("Select layers:", layer_names, label_visibility="collapsed") + if st.button("Merge Selected", type="primary", use_container_width=True): + if len(merge_candidates) > 1: + stamps_to_merge = [s for s in active_stamps if s["name"] in merge_candidates] + merged_matrix, new_x, new_y = merge_stamps(stamps_to_merge) + if merged_matrix: + new_stamp = { + "name": f"Merged Layer ({len(active_stamps) + 1})", + "matrix": merged_matrix, + "x": new_x, "y": new_y, + "scale": 1, "symmetry": "None", "axis": "Horizontal", + "rotation": 0, "spacing_h": 0, "spacing_v": 0, "visible": True + } + # Remove merged, append new + st.session_state.panels[active_panel]["stamps"] = [s for s in active_stamps if s["name"] not in merge_candidates] + st.session_state.panels[active_panel]["stamps"].append(new_stamp) + st.session_state.active_stamp_idx = len(st.session_state.panels[active_panel]["stamps"]) - 1 + st.rerun() + else: st.warning("Select at least 2 layers.") + + st.write("---") + + c_dup, c_del, c_exp = st.columns(3) + with c_dup: + if st.button("๐Ÿ“‘ Duplicate Layer", use_container_width=True): + cloned = copy.deepcopy(active_stamp) + cloned["name"] = f"{active_stamp['name']} (Copy)" + active_stamps.append(cloned) + st.session_state.active_stamp_idx = len(active_stamps) - 1 + st.rerun() + with c_del: + if st.button("๐Ÿ—‘๏ธ Delete Layer", type="secondary", use_container_width=True): + active_stamps.pop(st.session_state.active_stamp_idx) + st.session_state.active_stamp_idx = max(0, len(active_stamps) - 1) + st.rerun() + with c_exp: + with st.expander("๐Ÿ’พ Export JSON", expanded=False): + export_scaled = st.checkbox("Include Scale Multiplier?", value=False, help="Check this to download the physically enlarged matrix instead of the original size.") + + t_mat = rotate_matrix(active_stamp["matrix"], active_stamp.get("rotation", 0)) + t_mat = apply_transforms(t_mat, active_stamp.get("symmetry", "None"), active_stamp.get("axis", "Horizontal"), active_stamp.get("spacing_h", 0), active_stamp.get("spacing_v", 0)) + + # If the user checked the box, mathematically scale the matrix before saving it + if export_scaled and active_stamp.get("scale", 1) > 1: + t_mat = scale_pattern_matrix_integer(t_mat, active_stamp.get("scale", 1)) + + cropped_mat = crop_matrix_to_bounding_box(t_mat) + st.download_button( + label="๐Ÿ“ฅ Download JSON", + data=json.dumps(cropped_mat), + file_name=f"{active_stamp['name'].replace(' ', '_')}_alpha.json", + mime="application/json", + use_container_width=True + ) + st.write("---") -# ========================================== -# THE MAGIC BUTTON (Final Export) -# ========================================== -st.markdown("### Happy with your placement?") -if st.button("โœจ Generate Final Printable Chart", type="primary", use_container_width=True): - with st.spinner("Generating high-res Matplotlib chart..."): + # 3. MANUAL PIXEL EDITOR (The "Tweezers") + with st.expander("๐Ÿ–Œ๏ธ Fine-Tune Pixels", expanded=False): + st.markdown("### Grid Editor") + st.caption("Check/uncheck boxes to edit pixels. Columns are kept narrow so you can see more of your pattern at once.") - if include_pattern and base_pattern_matrix is not None: - scaled_pattern = scale_pattern_matrix_integer(base_pattern_matrix, pattern_scale) - final_chart = overlay_pattern_on_grid(sweater_grid, scaled_pattern, st.session_state.x_pos, st.session_state.y_pos) - else: - final_chart = sweater_grid - - image_filename = "app_generated_chart.png" - generate_chart_image(final_chart, shaping, image_filename) + # Grab identifiers + pk = st.session_state.proj_id + idx = st.session_state.active_stamp_idx + + # --- CSS --- + st.markdown(""" + + """, unsafe_allow_html=True) + + import pandas as pd + bool_matrix = [[bool(val) for val in row] for row in active_stamp["matrix"]] + df = pd.DataFrame(bool_matrix) - st.success("Chart generated successfully!") + # NEW: Force every column to be exactly 30 pixels wide (the smallest reliable checkbox size) + # We also use the index as a 'Row Number' + col_config = { + column: st.column_config.CheckboxColumn(label="", width=20) + for column in df.columns + } -''' -import os -import streamlit as st -import numpy as np -from PIL import Image - -# ========================================== -# IMPORTING YOUR BACKEND ENGINES -# ========================================== -from knitting_pattern.math_engine import ( - calculate_stitches, calculate_rows, calculate_garment_dimensions, - calculate_top_down_shoulder_shaping, generate_panel_grid, apply_top_down_mountains_to_grid -) -from knitting_pattern.image_engine import ( - get_hardcoded_heart, scale_pattern_matrix_integer, overlay_pattern_on_grid, generate_chart_image -) - -# ========================================== -# HELPER: PROCESS UPLOADED IMAGES -# ========================================== -def process_uploaded_image(pil_image): - """Converts a user's uploaded image into a binary 1/0 matrix for knitting.""" - # Shrink it so massive photos don't crash the grid - pil_image.thumbnail((50, 50)) - # Convert to grayscale - gray = pil_image.convert('L') - arr = np.array(gray) - # Threshold: dark pixels become 1 (the pattern), light pixels become 0 (empty) - matrix = [[1 if val < 128 else 0 for val in row] for row in arr] - return matrix - -# ========================================== -# PAGE CONFIGURATION -# ========================================== -st.set_page_config(page_title="Knit & Pixel", page_icon="๐Ÿงถ", layout="wide") - -st.title("๐Ÿงถ Knit & Pixel: Colorwork Generator") -st.markdown("Transform any alpha pattern into a perfectly scaled, knittable chart.") - -# ========================================== -# SIDEBAR: SETTINGS & MEASUREMENTS -# ========================================== -with st.sidebar: - st.header("1. Yarn & Gauge") - col1, col2 = st.columns(2) - with col1: - gauge_sts = st.number_input("Stitches / 10cm", min_value=5.0, max_value=40.0, value=18.0, step=0.5) - with col2: - gauge_rows = st.number_input("Rows / 10cm", min_value=5.0, max_value=50.0, value=24.0, step=0.5) - - st.divider() + with st.form(key=f"pixel_form_{idx}_{pk}_{active_panel}"): + # We enable the index here so you can see Row 0, Row 1, etc. to stay oriented + edited_df = st.data_editor( + df, + column_config=col_config, + hide_index=False, + use_container_width=False, + height=450, # Increased height slightly since cells are now smaller + key=f"pixel_edit_{idx}_{pk}_{active_panel}" + ) + + if st.form_submit_button("โœ… Apply Pixel Edits", type="primary", use_container_width=True): + new_matrix = [[1 if val else 0 for val in row] for row in edited_df.values.tolist()] + if new_matrix != active_stamp["matrix"]: + active_stamp["matrix"] = new_matrix + st.rerun() - st.header("2. Sizing") - size_mode = st.radio("Sizing Method", ["Standard Sizing", "Advanced (Made-to-Measure)"]) + col_controls, col_preview = st.columns([1, 1.5]) - if size_mode == "Standard Sizing": - target_size = st.selectbox("Select Size", ["XS", "S", "M", "L", "XL", "2XL"], index=2) - dimensions = calculate_garment_dimensions(target_size, "drop_shoulder") - chest_cm = dimensions["body_chest_circ"] + dimensions["ease_added"] - st.info(f"Panel Width: {dimensions['panel_width_cm']} cm") - else: - target_size = "M" - st.markdown("**Enter your exact body measurements:**") - chest_cm = st.number_input("Full Chest Circumference (cm)", min_value=50.0, max_value=160.0, value=96.0) - ease_cm = st.slider("Positive Ease (cm)", min_value=-5.0, max_value=30.0, value=15.0) - chest_cm += ease_cm - - st.divider() - - st.header("3. Shaping") - use_shoulder_shaping = st.toggle("Enable Shoulder Short Rows", value=True) - -# Calculate Core Dimensions early so the UI can use them! -panel_width_cm = chest_cm / 2 -total_sts = calculate_stitches(panel_width_cm, gauge_sts) -total_rows = 70 # The height of the preview canvas - -# ========================================== -# MAIN PAGE: PATTERN SELECTION -# ========================================== -st.header("4. Select Alpha Pattern") - -tab1, tab2 = st.tabs(["Upload Image", "Use Preset Pattern"]) - -base_pattern_matrix = None + with col_controls: + # Wrap Transform in a border + with st.container(border=True): + st.markdown("#### Transform") + pk = st.session_state.proj_id + idx = st.session_state.active_stamp_idx + + sym_opts = ["None", "Flip", "Mirror"] + curr_sym = str(active_stamp.get("symmetry", "None")).capitalize() + t_type = st.radio("Type", sym_opts, index=sym_opts.index(curr_sym) if curr_sym in sym_opts else 0, horizontal=True, key=f"sym_{idx}_{pk}_{active_panel}") + + axis_opts = ["Horizontal", "Vertical"] + if t_type == "Mirror": axis_opts.append("Quadratic") + curr_axis = str(active_stamp.get("axis", "Horizontal")).capitalize() + axis = st.radio("Axis", axis_opts, index=axis_opts.index(curr_axis) if curr_axis in axis_opts else 0, horizontal=True, key=f"ax_{idx}_{pk}_{active_panel}") + + curr_rot = active_stamp.get("rotation", 0) + rot = st.radio("Rotate", [0, 90, 180, 270], index=[0, 90, 180, 270].index(curr_rot) if curr_rot in [0,90,180,270] else 0, horizontal=True, key=f"rot_{idx}_{pk}_{active_panel}") + + active_stamp["symmetry"], active_stamp["axis"], active_stamp["rotation"] = t_type, axis, rot + + active_stamp["spacing_h"] = st.slider("Horizontal Spacing", -30, 30, int(active_stamp.get("spacing_h", 0)), key=f"sh_{idx}_{pk}_{active_panel}") + active_stamp["spacing_v"] = st.slider("Vertical Spacing", -30, 30, int(active_stamp.get("spacing_v", 0)), key=f"sv_{idx}_{pk}_{active_panel}") + # --- BAKING SCALE SLIDER --- + col_sc_slide, col_sc_bake = st.columns([2.5, 1]) + + with col_sc_slide: + active_stamp["scale"] = st.slider("Scale", 1, 10, int(active_stamp.get("scale", 1)), key=f"sc_{idx}_{pk}_{active_panel}") + + with col_sc_bake: + st.markdown("
", unsafe_allow_html=True) # Pushes the button down to align with the slider + if st.button("๐Ÿ”จ Bake", use_container_width=True, help="Permanently enlarge the pixel matrix so you can edit the scaled pixels individually.", key=f"bake_{idx}_{pk}_{active_panel}"): + if active_stamp["scale"] > 1: + # Mathematically enlarge the matrix + active_stamp["matrix"] = scale_pattern_matrix_integer(active_stamp["matrix"], active_stamp["scale"]) + # Reset the slider back to 1 + active_stamp["scale"] = 1 + st.rerun() + # Wrap Position in a border + with st.container(border=True): + st.markdown("#### Position") + p_w_raw, p_h_raw = get_matrix_dimensions(active_stamp["matrix"], t_type, axis, active_stamp["spacing_h"], active_stamp["spacing_v"]) + if active_stamp.get("rotation", 0) in [90, 270]: p_w_raw, p_h_raw = p_h_raw, p_w_raw + p_w, p_h = p_w_raw * active_stamp["scale"], p_h_raw * active_stamp["scale"] + + def center_h(key, width, tot_w): st.session_state[key] = max(0, (tot_w - width) // 2) + def center_v(key, height, tot_h, safe_y): st.session_state[key] = int(safe_y + (tot_h - safe_y - height) // 2) + + active_stamp["x"] = st.slider("X Position", -total_sts, total_sts, int(active_stamp.get("x", 0)), key=f"x_{idx}_{pk}_{active_panel}") + st.button("Center Horizontally โ†”", on_click=center_h, args=(f"x_{idx}_{pk}_{active_panel}", p_w, total_sts), use_container_width=True) + + active_stamp["y"] = st.slider("Y Position", -total_rows, total_rows, int(active_stamp.get("y", 0)), key=f"y_{idx}_{pk}_{active_panel}") + st.button("Center Vertically โ†•", on_click=center_v, args=(f"y_{idx}_{pk}_{active_panel}", p_h, total_rows, safe_y_start), use_container_width=True) + +# --- REAL-TIME CANVAS RENDERING --- +with col_preview: + if "preview_canvas" not in locals(): + preview_canvas = np.full((total_rows, total_sts, 3), 255, dtype=np.uint8) + + for r in range(total_rows): + for c in range(total_sts): + if sweater_grid[r][c] != -1: + preview_canvas[r, c] = [190, 190, 190] -with tab1: - uploaded_file = st.file_uploader("Upload a pixel graphic (.png, .jpg)", type=['png', 'jpg', 'jpeg']) - if uploaded_file is not None: - image = Image.open(uploaded_file) - st.image(image, caption="Uploaded Graphic", width=150) - base_pattern_matrix = process_uploaded_image(image) + preview_math_grid = overlay_stamps_on_grid(sweater_grid, active_stamps) + + for r in range(total_rows): + for c in range(total_sts): + if preview_math_grid[r][c] == 1: + preview_canvas[r, c] = [220, 50, 50] + + preview_img = Image.fromarray(preview_canvas) + cell_size = 10 + preview_img = preview_img.resize((total_sts * cell_size, total_rows * cell_size), Image.NEAREST) + draw = ImageDraw.Draw(preview_img) + grid_color = (130, 130, 130) + + for x in range(0, preview_img.width, cell_size): draw.line([(x, 0), (x, preview_img.height)], fill=grid_color, width=1) + for y in range(0, preview_img.height, cell_size): draw.line([(0, y), (preview_img.width, y)], fill=grid_color, width=1) + draw.rectangle([(0, 0), (preview_img.width-1, preview_img.height-1)], outline=grid_color, width=2) -with tab2: - preset = st.selectbox("Choose a preset", ["8-Bit Heart"]) - if uploaded_file is None: - base_pattern_matrix = get_hardcoded_heart() - st.info("Using the default heart. Try uploading your own above!") + st.image(preview_img, caption=f"{active_panel} Canvas ({total_sts} sts x {total_rows} rows)", use_container_width=True) + + cropped_bytes = generate_cropped_canvas_png_bytes(preview_math_grid) + st.download_button(label="Download Graphic as PNG", data=cropped_bytes, file_name=f"cropped_{active_panel.replace(' ', '_')}.png", mime="image/png", use_container_width=True) # ========================================== -# NEW SECTION: REAL-TIME PLACEMENT +# 6. EXPORT & PRINT STUDIO # ========================================== st.divider() -st.header("5. Real-Time Placement") +st.header("6. Export & Print Studio") -# 1. CALCULATE SHAPING EARLY FOR THE PREVIEW -sweater_grid = generate_panel_grid(total_sts, total_rows) -shaping = calculate_top_down_shoulder_shaping(total_sts, target_size, gauge_rows) +# The slider is brought back out to the top so it updates dynamically! +st.markdown("### ๐Ÿ“„ Print Settings") +rows_per_page = st.slider("Rows per printed page", min_value=30, max_value=120, value=60, step=10) -if use_shoulder_shaping: - sweater_grid = apply_top_down_mountains_to_grid(sweater_grid, shaping) - # The absolute highest row they are allowed to place a pattern - safe_y_start = shaping["mountain_rows"] + 1 -else: - safe_y_start = 0 +tab_preview, tab_master = st.tabs(["Preview Active Panel", "Compile Master Pattern PDF"]) -col_controls, col_preview = st.columns([1, 1.5]) -with col_controls: - st.markdown("Use the sliders to lock your alpha pattern to the grid.") - pattern_scale = st.slider("Scale Multiplier", min_value=1, max_value=10, value=2, step=1) +# --- TAB 1: REAL-TIME PREVIEW --- +with tab_preview: + st.markdown(f"Previewing pages for: **{active_panel}**") - start_x_stitch = st.slider("X Position (Left to Right)", min_value=0, max_value=total_sts, value=total_sts//2 - 5, step=1) - # 2. LOCK THE Y SLIDER! - # min_value is now safe_y_start, preventing placement in the short rows! - start_y_row = st.slider("Y Position (Top to Bottom)", - min_value=int(safe_y_start), - max_value=total_rows, - value=int(safe_y_start) + 5, - step=1, - help="Pattern placement is locked below the neckline shaping.") - -with col_preview: - scaled_pattern = scale_pattern_matrix_integer(base_pattern_matrix, pattern_scale) - - # 3. BUILD CANVAS BASED ON ACTUAL SWEATER SHAPE - # Start with a white background (empty space) - preview_canvas = np.full((total_rows, total_sts, 3), 255, dtype=np.uint8) - - # Paint only the actual sweater stitches gray! - for r in range(total_rows): - for c in range(total_sts): - if sweater_grid[r][c] != -1: # If it's a real stitch - preview_canvas[r, c] = [235, 235, 235] if r % 2 == 0 else [245, 245, 245] - - # 4. Stamp the scaled pattern - p_h = len(scaled_pattern) - p_w = len(scaled_pattern[0]) - for r in range(p_h): - for c in range(p_w): - if scaled_pattern[r][c] == 1: - y = start_y_row + r - x = start_x_stitch + c - if 0 <= y < total_rows and 0 <= x < total_sts: - preview_canvas[y, x] = [255, 75, 75] - - preview_img = Image.fromarray(preview_canvas) - preview_img = preview_img.resize((total_sts * 8, total_rows * 8), Image.NEAREST) - - st.image(preview_img, caption=f"Real-Time Grid Preview", use_container_width=True) - -st.divider() - -# ========================================== -# THE MAGIC BUTTON (Final Export) -# ========================================== -st.markdown("### Happy with your placement?") -if st.button("โœจ Generate Final Printable Chart", type="primary", use_container_width=True): - - with st.spinner("Generating high-res Matplotlib chart..."): - - # We already carved the sweater_grid above, so we just overlay! - final_chart = overlay_pattern_on_grid(sweater_grid, scaled_pattern, start_x_stitch, start_y_row) - - image_filename = "app_generated_chart.png" - generate_chart_image(final_chart, shaping, image_filename) + col_prev_btn, col_png = st.columns([1.5, 1]) + with col_prev_btn: + if st.button(f"Generate {active_panel} Preview", type="primary", use_container_width=True): + with st.spinner("Generating visual preview..."): + st.session_state.preview_grid = overlay_stamps_on_grid(sweater_grid, active_stamps) + st.session_state.preview_shaping = shaping + st.session_state.preview_panel = active_panel + + with col_png: + if "preview_img" in locals(): + buf_img = io.BytesIO() + preview_img.save(buf_img, format="PNG") + st.download_button(label=f"๐Ÿ–ผ๏ธ Download {active_panel} Graphic (PNG)", data=buf_img.getvalue(), file_name=f"{active_panel.replace(' ', '_')}_canvas.png", mime="image/png", use_container_width=True) + + # If a preview exists for the CURRENT panel, render it live! + if "preview_grid" in st.session_state and st.session_state.get("preview_panel") == active_panel: + st.success("Preview generated! Adjust the slider above to see the pages update in real-time.") + # Package settings for the legend + project_metrics = { + "gauge_sts": gauge_sts, + "gauge_rows": gauge_rows, + "target_size": target_size, + "chest_cm": chest_cm + } - st.success("Chart generated successfully!") - - col_results1, col_results2 = st.columns([1, 2]) - with col_results1: - st.subheader("Knitting Specs") - st.metric(label="Cast On Width", value=f"{total_sts} sts") - st.metric(label="Gauge (10cm)", value=f"{gauge_sts} sts x {gauge_rows} rows") - if use_shoulder_shaping: - st.metric(label="Short Row Steps", value=f"{shaping['total_short_row_steps']} steps") + figs = generate_multipage_pdf_figs( + st.session_state.preview_grid, + st.session_state.preview_shaping, + title=f"{st.session_state.get('project_name', 'My Project').replace('_', ' ')} - {active_panel}", + rows_per_page=rows_per_page, + settings=project_metrics # <--- NEW + ) + for fig in figs: + st.pyplot(fig) + + +# --- TAB 2: MASTER GARMENT COMPILER --- +with tab_master: + st.markdown("Select which panels to bundle into a single PDF document:") + export_options = st.columns(3) + with export_options[0]: export_front = st.checkbox("Front Panel", value=True) + with export_options[1]: export_back = st.checkbox("Back Panel", value=True) + with export_options[2]: export_sleeve = st.checkbox("Sleeve", value=True) + + panels_to_export = [] + if export_front: panels_to_export.append("Front Panel") + if export_back: panels_to_export.append("Back Panel") + if export_sleeve: panels_to_export.append("Sleeve") + + if st.button("Compile Master Pattern PDF", type="primary", use_container_width=True): + if not panels_to_export: + st.warning("Please select at least one panel to export.") + else: + with st.spinner("Compiling high-res master PDF... This might take a moment."): + from matplotlib.backends.backend_pdf import PdfPages + buf = io.BytesIO() - with col_results2: - st.subheader("Final Printable Chart") - if os.path.exists(image_filename): - st.image(image_filename, use_container_width=True) - else: - st.error("Failed to generate image file.") -''' + with PdfPages(buf) as pdf: + for p_name in panels_to_export: + # 1. Recalculate base math for this specific panel in the background + p_width_cm = chest_cm / 2 + if p_name in ["Front Panel", "Back Panel"]: + p_sts = calculate_stitches(p_width_cm, gauge_sts) + p_rows = calculate_rows(garment_length_cm, gauge_rows) + p_grid = generate_panel_grid(p_sts, p_rows) + + if p_name == "Front Panel": + p_shaping = calculate_top_down_shoulder_shaping(p_sts, target_size, gauge_rows) + if st.session_state.get("use_shaping_front", True): + p_grid = apply_top_down_mountains_to_grid(p_grid, p_shaping) + elif p_name == "Back Panel": + p_shaping = calculate_back_neck_shaping(p_sts, target_size, gauge_rows) + if st.session_state.get("use_shaping_back", True): + p_grid = apply_back_short_rows_to_grid(p_grid, p_shaping) + + elif p_name == "Sleeve": + p_shaping = calculate_sleeve_dimensions(target_size, gauge_sts, gauge_rows) + p_grid = generate_sleeve_grid(p_shaping) + + # 2. Overlay that panel's specific stamps + p_stamps = st.session_state.panels[p_name]["stamps"] + final_p_grid = overlay_stamps_on_grid(p_grid, p_stamps) + + # 3. Generate figures and append to the Master PDF + title = f"{st.session_state.get('project_name', 'My Project').replace('_', ' ')} - {p_name}" + # Package settings for the legend + project_metrics = { + "gauge_sts": gauge_sts, + "gauge_rows": gauge_rows, + "target_size": target_size, + "chest_cm": chest_cm + } + + # 3. Generate figures and append to the Master PDF + title = f"{st.session_state.get('project_name', 'My Project').replace('_', ' ')} - {p_name}" + figs = generate_multipage_pdf_figs( + final_p_grid, + p_shaping, + title=title, + rows_per_page=rows_per_page, + settings=project_metrics # <--- NEW + ) + for fig in figs: + pdf.savefig(fig, bbox_inches='tight') + + buf.seek(0) + st.session_state.master_pdf_buf = buf + + #celebration baloons + st.session_state.show_balloons = True + st.rerun() + + # DISPLAY MASTER DOWNLOAD + if "master_pdf_buf" in st.session_state: + st.success("Master Pattern PDF generated successfully!") + + # NEW: Check the flag, launch balloons, and immediately turn the flag off! + if st.session_state.get("show_balloons"): + st.balloons() + st.session_state.show_balloons = False + + st.download_button( + label="๐Ÿ“ฅ Download Master PDF", + data=st.session_state.master_pdf_buf, + file_name=f"{safe_name}_master_pattern.pdf", + mime="application/pdf", + type="primary", + use_container_width=True + ) \ No newline at end of file diff --git a/src/knitting_pattern/image_engine.py b/src/knitting_pattern/image_engine.py index b0d6a9e..dd475bf 100644 --- a/src/knitting_pattern/image_engine.py +++ b/src/knitting_pattern/image_engine.py @@ -5,202 +5,708 @@ @author: kasteivanauskaite """ - -# knitting_pattern/image_engine.py import copy +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.colors import ListedColormap +from PIL import Image, ImageDraw +import io +import json def get_hardcoded_heart(): """ - Returns a small 2D matrix representing a heart. - 0 = Background yarn - 1 = Contrast color yarn + Returns a predefined 2D matrix representing an 8-bit heart graphic. + + Returns: + list[list[int]]: A 2D array (matrix) of 1s and 0s. + + Example: + >>> matrix = get_hardcoded_heart() + >>> matrix[0] + [0, 1, 1, 0, 1, 1, 0] """ - return [ - [0, 1, 1, 0, 1, 1, 0], - [1, 1, 1, 1, 1, 1, 1], - [1, 1, 1, 1, 1, 1, 1], - [0, 1, 1, 1, 1, 1, 0], - [0, 0, 1, 1, 1, 0, 0], - [0, 0, 0, 1, 0, 0, 0] - ] -def scale_pattern_matrix_integer(original_matrix, multiplier): + return [[0, 1, 1, 0, 1, 1, 0], + [1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 1, 0], + [0, 0, 1, 1, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0]] + +def scale_pattern_matrix_integer(matrix, scale_factor): """ - Scales a 2D array by an exact integer multiplier to preserve crisp pixel art. - A multiplier of 2 turns every 1x1 pixel into a 2x2 block of stitches. + Scales a 2D matrix by an integer factor without altering original memory references. + Each pixel becomes a block of scale_factor x scale_factor pixels. + + Args: + matrix (list[list[int]]): The original graphic matrix. + scale_factor (int): The integer multiplier to enlarge the matrix. + + Returns: + list[list[int]]: A newly instanced, scaled-up matrix. + + Example: + >>> matrix = [[1, 0], [0, 1]] + >>> scale_pattern_matrix_integer(matrix, 2) + [[1, 1, 0, 0], [1, 1, 0, 0], [0, 0, 1, 1], [0, 0, 1, 1]] """ + if scale_factor <= 1: + # Return a safe copy of the original matrix + return [row[:] for row in matrix] + scaled_matrix = [] - - for row in original_matrix: - # Step 1: Duplicate horizontally (columns) - scaled_row = [] + for row in matrix: + # 1. Stretch the row horizontally + new_row = [] for pixel in row: - scaled_row.extend([pixel] * int(multiplier)) - - # Step 2: Duplicate vertically (rows) - for _ in range(int(multiplier)): - # We append a copy() so we don't accidentally link the memory of the rows! - scaled_matrix.append(scaled_row.copy()) + new_row.extend([pixel] * scale_factor) + + # 2. Stretch the row vertically (using .copy() to prevent memory smearing!) + for _ in range(scale_factor): + scaled_matrix.append(new_row.copy()) return scaled_matrix -def overlay_pattern_on_grid(sweater_grid, alpha_matrix, start_x, start_y): +def rotate_matrix(matrix, degrees): """ - Places the alpha pattern onto the main sweater grid. - Prevents the pattern from printing onto carved short-row areas (-1). + Rotates a 2D matrix mathematically by 90, 180, or 270 degrees. + + Args: + matrix (list[list[int]]): The matrix to rotate. + degrees (int): Rotation in degrees (0, 90, 180, 270). + + Returns: + list[list[int]]: The mathematically rotated matrix. + + Example: + >>> m = [[1, 2], [3, 4]] + >>> rotate_matrix(m, 90) + [[3, 1], [4, 2]] + """ + if degrees == 0 or not matrix: return matrix + arr = np.array(matrix) + k_map = {90: -1, 180: 2, 270: 1} + return np.rot90(arr, k=k_map.get(degrees, 0)).tolist() + +def get_matrix_dimensions(matrix, t_type, axis, spacing_h=0, spacing_v=0): + """ + Calculates the final width and height of a matrix after transforms are applied, + accounting for structural gaps or overlaps. + + Args: + matrix (list[list[int]]): The base matrix. + t_type (str): The transform type (e.g., 'Mirror', 'Flip'). + axis (str): The transformation axis ('Horizontal', 'Vertical', 'Quadratic'). + spacing_h (int, optional): Horizontal gap or overlap. Defaults to 0. + spacing_v (int, optional): Vertical gap or overlap. Defaults to 0. + + Returns: + tuple: (new_width, new_height) + + Example: + >>> m = [[1, 1]] + >>> get_matrix_dimensions(m, "Mirror", "Horizontal", spacing_h=1) + (5, 1) + """ + if t_type == "None" or not matrix: return len(matrix[0]), len(matrix) + h, w = len(matrix), len(matrix[0]) + m = 2 if t_type == "Mirror" else 1 + + new_w = w + if axis in ["Horizontal", "Quadratic"]: + overlap_w = min(abs(spacing_h), w) if spacing_h < 0 else 0 + gap_w = spacing_h if spacing_h > 0 else 0 + new_w = (w * m) + gap_w - overlap_w + + new_h = h + if axis in ["Vertical", "Quadratic"]: + overlap_h = min(abs(spacing_v), h) if spacing_v < 0 else 0 + gap_h = spacing_v if spacing_v > 0 else 0 + new_h = (h * m) + gap_h - overlap_h + + return new_w, new_h + +def apply_transforms(matrix, t_type, axis, spacing_h=0, spacing_v=0): + """ + Applies symmetry, flipping, and gap spacing to a graphical matrix. + + Args: + matrix (list[list[int]]): The base matrix to transform. + t_type (str): The transform type ('None', 'Flip', 'Mirror'). + axis (str): The axis of transformation. + spacing_h (int, optional): Horizontal pixel distance. Defaults to 0. + spacing_v (int, optional): Vertical pixel distance. Defaults to 0. + + Returns: + list[list[int]]: The transformed matrix. + + Example: + >>> m = [[1, 0]] + >>> apply_transforms(m, "Flip", "Horizontal") + [[0, 1]] + """ + t_type = t_type.capitalize() + if t_type == "None" or not matrix: return matrix + arr = np.array(matrix) + + if t_type == "Flip": + if axis == "Horizontal": return arr[:, ::-1].tolist() + if axis == "Vertical": return arr[::-1, :].tolist() + if axis == "Quadratic": return arr[::-1, ::-1].tolist() + + if t_type == "Mirror": + def add_gap(m, ax, s): + side2 = m[:, ::-1] if ax == 1 else m[::-1, :] + + if s >= 0: + gap = np.zeros((len(m), s), dtype=int) if ax == 1 else np.zeros((s, len(m[0])), dtype=int) + return np.concatenate((m, gap, side2), axis=ax) + else: + overlap = min(abs(s), m.shape[1] if ax == 1 else m.shape[0]) + if ax == 1: + w = m.shape[1] + new_w = (2 * w) - overlap + canvas = np.zeros((m.shape[0], new_w), dtype=int) + canvas[:, :w] = np.maximum(canvas[:, :w], m) + canvas[:, new_w-w:] = np.maximum(canvas[:, new_w-w:], side2) + return canvas + else: + h = m.shape[0] + new_h = (2 * h) - overlap + canvas = np.zeros((new_h, m.shape[1]), dtype=int) + canvas[:h, :] = np.maximum(canvas[:h, :], m) + canvas[new_h-h:, :] = np.maximum(canvas[new_h-h:, :], side2) + return canvas + + if axis == "Horizontal": return add_gap(arr, 1, spacing_h).tolist() + if axis == "Vertical": return add_gap(arr, 0, spacing_v).tolist() + if axis == "Quadratic": return add_gap(np.array(add_gap(arr, 1, spacing_h)), 0, spacing_v).tolist() + + return matrix.tolist() + +def overlay_stamps_on_grid(sweater_grid, stamps): """ - # Create a copy so we don't accidentally ruin our blank canvas - result_grid = copy.deepcopy(sweater_grid) + Overlays a stack of graphical stamps onto a base sweater grid using absolute coordinates. - pattern_height = len(alpha_matrix) - pattern_width = len(alpha_matrix[0]) + Args: + sweater_grid (list[list[int]]): The mathematical grid of the garment panel. + stamps (list[dict]): A list of state dictionaries containing stamp parameters. + + Returns: + list[list[int]]: A unified matrix containing the garment shaping and layered graphics. + + Example: + >>> grid = [[0, 0], [0, 0]] + >>> stamps = [{"matrix": [[1]], "x": 0, "y": 0, "visible": True}] + >>> overlay_stamps_on_grid(grid, stamps) + [[1, 0], [0, 0]] + """ + res = np.array(copy.deepcopy(sweater_grid)) + grid_h, grid_w = res.shape + + for stamp in stamps: + if not stamp.get("visible", True): continue - sweater_height = len(result_grid) - sweater_width = len(result_grid[0]) + base_matrix = stamp.get("matrix") + if not base_matrix: continue + + final_stamp = rotate_matrix(base_matrix, stamp.get("rotation", 0)) + final_stamp = apply_transforms(final_stamp, stamp.get("symmetry", "None"), stamp.get("axis", "Horizontal"), stamp.get("spacing_h", 0), stamp.get("spacing_v", 0)) + final_stamp = scale_pattern_matrix_integer(final_stamp, stamp.get("scale", 1)) + + final_arr = np.array(final_stamp) + start_x, start_y = stamp.get("x", 0), stamp.get("y", 0) + h, w = final_arr.shape + + y_min, y_max = max(0, start_y), min(grid_h, start_y + h) + x_min, x_max = max(0, start_x), min(grid_w, start_x + w) + + if y_min >= y_max or x_min >= x_max: continue + + stamp_y_min, stamp_x_min = y_min - start_y, x_min - start_x + stamp_y_max, stamp_x_max = stamp_y_min + (y_max - y_min), stamp_x_min + (x_max - x_min) + + stamp_crop = final_arr[stamp_y_min:stamp_y_max, stamp_x_min:stamp_x_max] + grid_crop = res[y_min:y_max, x_min:x_max] + + mask = (grid_crop != -1) & (stamp_crop != 0) + grid_crop[mask] = stamp_crop[mask] + + return res.tolist() - # Loop through every pixel in our little heart graphic - for p_y in range(pattern_height): - for p_x in range(pattern_width): +def get_panel_instructions(panel_type, shaping, co_sts="?"): + """ + Generates the text instruction block for the PDF based on the specific panel type. + + Args: + panel_type (str): The garment panel (e.g., 'Front Panel', 'Sleeve'). + shaping (dict): Calculations generated by the math engine. + co_sts (int, optional): Total cast-on stitches. Defaults to "?". + + Returns: + str: A formatted multi-line string with human-readable knitting instructions. + + Example: + >>> shaping = {"first_turn_stitch": 5, "total_short_row_steps": 2, "sts_per_step": 3} + >>> text = get_panel_instructions("Front Panel", shaping, 50) + >>> "FRONT PANEL SHAPING" in text + True + """ + if not shaping: shaping = {} + + if panel_type == "Front Panel": + first_turn = shaping.get("first_turn_stitch", "?") + steps = shaping.get("total_short_row_steps", 1) + sts_per_step = shaping.get("sts_per_step", "?") + next_turns = max(0, steps - 1) + + return ( + "FRONT PANEL SHAPING (German Short Rows):\n" + f"Cast on {co_sts} stitches.\n" + "Work left and right shoulder short rows simultaneously.\n\n" - # Calculate exactly where this pixel lands on the giant sweater grid - target_x = start_x + p_x - target_y = start_y + p_y + "RIGHT SHOULDER (Row 1):\n" + f"โ€ข Work {first_turn} sts, turn.\n" + f"โ€ข Next {next_turns} turns: Work {sts_per_step} sts past last turn.\n\n" - # 1. BOUNDARY CHECK: Does it fall off the right or bottom edges? - if target_x < sweater_width and target_y < sweater_height: - - # 2. CARVING CHECK: Is this stitch a physical part of the sweater? - if result_grid[target_y][target_x] != -1: - - # 3. COLOR CHECK: Only print the contrast color (1), ignore the graphic's background (0) - if alpha_matrix[p_y][p_x] != 0: - result_grid[target_y][target_x] = alpha_matrix[p_y][p_x] - - return result_grid + "LEFT SHOULDER (Row 2):\n" + f"โ€ข Work {first_turn} sts, turn.\n" + f"โ€ข Next {next_turns} turns: Work {sts_per_step} sts past last turn.\n\n" + + "BODY PANEL:\n" + "โ€ข Work even in pattern until desired length." + ) -import numpy as np -import matplotlib.pyplot as plt -from matplotlib.colors import ListedColormap + elif panel_type == "Back Panel": + sts_per_step = shaping.get("sts_per_step", "?") + first_knit = shaping.get("first_turn_stitch", "?") + purl_dist = shaping.get("purl_distance", "?") + + return ( + "BACK PANEL SHAPING (German Short Rows):\n" + f"Row 0: Cast on {co_sts} stitches.\n\n" + f"Row 1 (RS): Knit {first_knit} sts. Make a double stitch and turn.\n" + f"Row 2 (WS): Purl {purl_dist} sts. Make a double stitch and turn.\n\n" + "Continue working back and forth. Each time you reach a double stitch,\n" + f"knit (or purl) it together as one stitch, then work {sts_per_step} more\n" + "stitches past it before turning.\n\n" + "Repeat this process until you reach the outer edges of the garment." + ) + + elif panel_type == "Sleeve": + straight = shaping.get("straight_rows", "?") + dec_rate = shaping.get("dec_rate", "?") + + return ( + "SLEEVE CONSTRUCTION:\n" + f"Pick up {co_sts} stitches evenly around the armhole.\n" + "Place a stitch marker (PM) at the center underarm to signify the\n" + "Beginning of Round (BOR).\n\n" + f"Knit {straight} rounds straight.\n\n" + "Decrease Round: Knit to 3 sts before SM, ssk, k1, SM, k1, k2tog.\n" + "(If knitting flat: Apply the same decreases 3 sts from the edges).\n\n" + f"Work a decrease round every {dec_rate} rounds until desired length." + ) + + return "" -def generate_chart_image(matrix, shaping_data, filename="knitting_chart.png"): +def generate_multipage_pdf_figs(matrix, shaping_data, title="Knitting Blueprint", rows_per_page=60, settings=None): + """ + Slices a garment matrix into printable chart chunks and renders Matplotlib Figures. + + Args: + matrix (list[list[int]]): The complete garment grid. + shaping_data (dict): Logic used to draw custom arrows or text. + title (str, optional): Project Title. Defaults to "Knitting Blueprint". + rows_per_page (int, optional): Pagination break limit. Defaults to 60. + settings (dict, optional): User metrics for the legend. Defaults to None. + + Returns: + list: A list of matplotlib.figure.Figure objects ready to be saved to PDF. + + Example: + >>> matrix = [[0, 0], [0, 0]] + >>> figs = generate_multipage_pdf_figs(matrix, {}) + >>> len(figs) > 0 # Returns Title page, Instruction page, and Chart + True + """ + figs = [] + # 1. INJECT THE CAST-ON EDGE - # We change every physical stitch in Row 0 to a '2' so we can color it differently for x in range(len(matrix[0])): - if matrix[0][x] != -1: # As long as it's a real stitch + if matrix[0][x] != -1: matrix[0][x] = 2 - # 2. SET UP THE COLORS - # -1: White (Empty Space) - # 0: Light Gray (Main Yarn) - # 1: Red (Heart/Contrast Yarn) - # 2: Gold (The Cast-On Swoop!) - data = np.array(matrix) - cmap = ListedColormap(['white', '#E0E0E0', '#FF4B4B', '#FFB000']) - - fig, ax = plt.subplots(figsize=(14, 10)) - cax = ax.imshow(data, cmap=cmap, vmin=-1, vmax=2) - - # 3. DRAW THE GRID total_rows = len(matrix) total_sts = len(matrix[0]) + co_sts = sum(1 for st in matrix[0] if st != -1) # Count cast-on stitches - ax.set_xticks(np.arange(-.5, total_sts, 1), minor=True) - ax.set_yticks(np.arange(-.5, total_rows, 1), minor=True) - ax.grid(which="minor", color="black", linestyle='-', linewidth=0.5) - ax.tick_params(which="minor", size=0) - - # Hide the standard math axes - ax.set_xticks([]) - ax.set_yticks([]) - + pattern_row = next((y for y, row in enumerate(matrix) if 1 in row), None) + pattern_str = f"Row {pattern_row}" if pattern_row else "None included" + + panel_name = title.split(" - ")[-1] if " - " in title else title + project_name = title.split(" - ")[0] if " - " in title else "Knitting Project" + + if settings is None: settings = {} + gauge_sts = settings.get("gauge_sts", "?") + gauge_rows = settings.get("gauge_rows", "?") + size = settings.get("target_size", "?") + chest = settings.get("chest_cm", "?") + # ========================================== - # 4. PROFESSIONAL RS/WS CHART NUMBERING + # PAGE 1: TITLE & LEGEND (Standalone Cover) # ========================================== + fig_cover, ax_cover = plt.subplots(figsize=(11, 8.5)) + ax_cover.axis('off') + ax_cover.set_facecolor('#F9F9F9') - # We loop through every row. Odd rows get RS labels on the right. Even rows get WS labels on the left. - for y in range(total_rows): - # Only label every other row or specific intervals to avoid clutter, - # but let's do the first few specifically for the short rows! - if y == 0: - ax.text(total_sts, y, "CO (WS)", va='center', ha='left', fontsize=9, fontweight='bold', color='#FFB000') - elif y % 2 != 0: # Odd rows (Right Side) - ax.text(total_sts, y, f"Row {y} (RS)", va='center', ha='left', fontsize=8, color='black') - else: # Even rows (Wrong Side) - ax.text(-0.5, y, f"(WS) Row {y}", va='center', ha='right', fontsize=8, color='black') + cover_text = ( + f"๐Ÿงถ {project_name.upper()}\n" + f"PANEL: {panel_name.upper()}\n" + "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n\n" + "PROJECT LEGEND & METRICS:\n" + f"โ€ข Target Size: {size} (Chest: {chest} cm)\n" + f"โ€ข Gauge: {gauge_sts} sts & {gauge_rows} rows per 10cm\n" + f"โ€ข Panel Start Width: {co_sts} sts\n" + f"โ€ข Panel Total Length: {total_rows} rows\n" + f"โ€ข Colorwork Starts: {pattern_str}\n" + ) + + ax_cover.text(0.5, 0.6, cover_text, va='center', ha='center', + fontsize=16, color='#222222', linespacing=1.8, fontfamily='sans-serif') + figs.append(fig_cover) # ========================================== - # 5. ANNOTATING THE ASYMMETRIC TIMELINE + # PAGE 2: INSTRUCTIONS (Standalone Text Page) # ========================================== - mountain_rows = shaping_data["mountain_rows"] - total_sts = len(matrix[0]) - midpoint = total_sts // 2 - - # 1. REMOVED the heavy black hlines/vlines! - # Instead, we just draw a very subtle dashed line to show the physical boundary - # without making it look like a brick wall. - ax.hlines(y=mountain_rows + 0.5, xmin=midpoint, xmax=total_sts - 0.5, color='gray', linewidth=1, linestyle=':') - ax.hlines(y=mountain_rows + 1.5, xmin=-0.5, xmax=midpoint, color='gray', linewidth=1, linestyle=':') - - # 2. Add a VERTICAL ARROW to guide the eye straight up to Row 1 - # First, we draw just the arrow itself (no text attached) so it perfectly traces the midpoint - ax.annotate('', - xy=(midpoint, 1), xycoords='data', - xytext=(midpoint, mountain_rows - 0.5), textcoords='data', - arrowprops=dict(arrowstyle="->,head_length=0.8,head_width=0.4", - color="blue", lw=2.5, ls="--")) - - # Second, we place the text box slightly to the RIGHT of the arrow shaft (midpoint + 1.5) - ax.text(midpoint + 1.5, mountain_rows / 2, - 'Yarn continues\nto Row 1', - ha='left', va='center', color='blue', fontsize=10, fontweight='bold', - bbox=dict(facecolor='white', alpha=0.9, edgecolor='blue', boxstyle='round,pad=0.3')) - - # Point out the Right Shoulder - ax.text(total_sts - (shaping_data["first_turn_stitch"] / 2), mountain_rows / 2, - "Right Shoulder\n(Starts Row 1)", - ha="center", va="center", color="black", fontweight="bold", - bbox=dict(facecolor='white', alpha=0.8, edgecolor='black', boxstyle='round,pad=0.5')) - - # Point out the Left Shoulder - ax.text(shaping_data["first_turn_stitch"] / 2, (mountain_rows / 2) + 0.5, - "Left Shoulder\n(Starts Row 2)", - ha="center", va="center", color="black", fontweight="bold", - bbox=dict(facecolor='white', alpha=0.8, edgecolor='black', boxstyle='round,pad=0.5')) - - ax.set_title("Advanced Top-Down Panel: Asymmetrical Short Row Chart", pad=20, fontsize=16, fontweight='bold') - - plt.savefig(filename, dpi=300, bbox_inches='tight') - print(f"Professional chart successfully saved as {filename}!") - -#%% -# --- TESTING BLOCK --- -if __name__ == "__main__": - from knitting_pattern.math_engine import ( - calculate_stitches, calculate_rows, calculate_garment_dimensions, - calculate_top_down_shoulder_shaping, generate_panel_grid, apply_top_down_mountains_to_grid + if not shaping_data: shaping_data = {} + shaping_instructions = get_panel_instructions(panel_name, shaping_data, co_sts) + + if not shaping_instructions: + shaping_instructions = "โ€ข Shaping disabled or standard straight knitting." + + fig_inst, ax_inst = plt.subplots(figsize=(11, 8.5)) + ax_inst.axis('off') + ax_inst.set_facecolor('#FFFFFF') + + inst_text = ( + f"PATTERN INSTRUCTIONS: {panel_name.upper()}\n" + "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n\n" + f"{shaping_instructions}" ) - my_gauge_sts = 18 - my_gauge_rows = 24 + ax_inst.text(0.1, 0.85, inst_text, va='top', ha='left', + fontsize=13, color='#222222', linespacing=1.8, fontfamily='sans-serif') + figs.append(fig_inst) + + # ========================================== + # PAGES 3+: SLICING THE CHART INTO CHUNKS + # ========================================== + cmap = ListedColormap(['#C0C0C0', 'white', '#FF4B4B', '#FFB000']) - dimensions = calculate_garment_dimensions("M", "drop_shoulder") - total_sweater_sts = calculate_stitches(dimensions["panel_width_cm"], my_gauge_sts) - sweater_grid = generate_panel_grid(total_sweater_sts, 50) + for chunk_start in range(0, total_rows, rows_per_page): + chunk_end = min(chunk_start + rows_per_page, total_rows) + chunk_matrix = matrix[chunk_start:chunk_end] + chunk_height = len(chunk_matrix) + + fig, ax = plt.subplots(figsize=(11, 8.5)) + ax.imshow(chunk_matrix, cmap=cmap, vmin=-1, vmax=2) + + ax.set_xticks(np.arange(-.5, total_sts, 1), minor=True) + ax.set_yticks(np.arange(-.5, chunk_height, 1), minor=True) + ax.grid(which="minor", color="#B0B0B0", linestyle='-', linewidth=0.5) + ax.tick_params(which="minor", size=0) + ax.set_xticks([]) + ax.set_yticks([]) + + sudoku_col='#595959' + ax.axvline(-0.5, color=sudoku_col, linewidth=2) + ax.axvline(total_sts - 0.5, color=sudoku_col, linewidth=2) + ax.axhline(-0.5, color=sudoku_col, linewidth=2) + ax.axhline(chunk_height - 0.5, color=sudoku_col, linewidth=2) + + for x in range(total_sts): + if (x + 1) % 5 == 0 and (x + 1) < total_sts: + ax.axvline(x + 0.5, color=sudoku_col, linewidth=1.5) + + for local_y in range(chunk_height): + abs_y = chunk_start + local_y + if abs_y % 5 == 0 and abs_y != 0: + ax.axhline(local_y - 0.5, color=sudoku_col, linewidth=1.5) + + for x in range(total_sts): + stitch_num = x + 1 + if stitch_num == 1 or stitch_num % 5 == 0: + ax.text(x, -0.8, str(stitch_num), va='bottom', ha='center', fontsize=8, color='#444444', fontweight='bold') + ax.text(x, chunk_height - 0.2, str(stitch_num), va='top', ha='center', fontsize=8, color='#444444', fontweight='bold') + + for local_y in range(chunk_height): + abs_y = chunk_start + local_y + if abs_y == 0: + ax.text(-1.0, local_y, "CO (WS)", va='center', ha='right', fontsize=9, fontweight='bold', color='#FFB000') + elif abs_y <= 4 or abs_y % 5 == 0: + if abs_y % 2 != 0: + ax.text(total_sts + 0.5, local_y, f"Row {abs_y} (RS)", va='center', ha='left', fontsize=8, color='#333333', fontweight='bold') + else: + ax.text(-1.0, local_y, f"Row {abs_y} (WS)", va='center', ha='right', fontsize=8, color='#333333', fontweight='bold') + + if shaping_data and "mountain_rows" in shaping_data and "Front Panel" in panel_name: + m_row = shaping_data["mountain_rows"] + if chunk_start <= m_row < chunk_end: + local_m_row = m_row - chunk_start + midpoint = total_sts // 2 + ax.annotate('', + xy=(midpoint, 1 - chunk_start), xycoords='data', + xytext=(midpoint, local_m_row - 0.5), textcoords='data', + arrowprops=dict(arrowstyle="->,head_length=0.8,head_width=0.4", + color="#0055A4", lw=2, ls="--")) + + ax.set_title(f"{title} โ€” Rows {chunk_start} to {chunk_end - 1}", pad=25, fontsize=12, fontweight='bold', color='#555555') + plt.tight_layout() + figs.append(fig) + + return figs + +def process_uploaded_image(pil_image, target_width, threshold_value): + """ + Converts a high-resolution PIL Image into a boolean matrix and high-contrast preview. - shaping = calculate_top_down_shoulder_shaping(total_sweater_sts, "M", my_gauge_rows) - carved_grid = apply_top_down_mountains_to_grid(sweater_grid, shaping) + Args: + pil_image (PIL.Image): The user-uploaded image object. + target_width (int): The desired width in physical stitches. + threshold_value (int): The darkness threshold (1-255) for binarization. + + Returns: + tuple: (binary matrix list, PIL.Image high-contrast grid preview) + + Example: + >>> from PIL import Image + >>> img = Image.new('RGB', (100, 100), color='black') + >>> matrix, preview = process_uploaded_image(img, 10, 128) + >>> len(matrix[0]) == 10 + True + """ + # 1. FIX TRANSPARENCY + if pil_image.mode in ('RGBA', 'LA') or (pil_image.mode == 'P' and 'transparency' in pil_image.info): + bg = Image.new('RGBA', pil_image.size, (255, 255, 255, 255)) + pil_image = Image.alpha_composite(bg, pil_image.convert('RGBA')) - tiny_heart = get_hardcoded_heart() - heart_sts = calculate_stitches(15.0, my_gauge_sts) - heart_rows = calculate_rows(15.0, my_gauge_rows) + # 2. CALCULATE EXACT HEIGHT + aspect_ratio = pil_image.height / pil_image.width + target_height = int(target_width * aspect_ratio) + + # 3. BOX RESAMPLING + if hasattr(Image, 'Resampling'): + pil_image = pil_image.resize((target_width, target_height), Image.Resampling.BOX) + else: + pil_image = pil_image.resize((target_width, target_height), Image.BOX) + + gray = pil_image.convert('L') + arr = np.array(gray) + + # 4. THRESHOLD MATH + matrix = [[1 if val < threshold_value else 0 for val in row] for row in arr] + + # 5. BUILD THE HIGH-CONTRAST PREVIEW IMAGE + preview_arr = np.array([[0 if val == 1 else 255 for val in row] for row in matrix], dtype=np.uint8) + preview_img = Image.fromarray(preview_arr) + preview_img = preview_img.convert("RGB") + + cell_size = 10 + preview_img = preview_img.resize((target_width * cell_size, target_height * cell_size), Image.NEAREST) + + # 6. DRAW THE GRAPH PAPER GRID + draw = ImageDraw.Draw(preview_img) + grid_color = (130, 130, 130) + + for x in range(0, preview_img.width, cell_size): + draw.line([(x, 0), (x, preview_img.height)], fill=grid_color, width=1) + for y in range(0, preview_img.height, cell_size): + draw.line([(0, y), (preview_img.width, y)], fill=grid_color, width=1) + + draw.rectangle([(0, 0), (preview_img.width-1, preview_img.height-1)], outline=grid_color, width=2) + + return matrix, preview_img + +def generate_cropped_canvas_png_bytes(math_grid): + """ + Crops the final matrix tightly to its boundaries and exports an in-memory PNG. + + Args: + math_grid (list[list[int]]): The final visual representation matrix. + + Returns: + bytes: Raw image byte data for Streamlit download buttons. + + Example: + >>> grid = [[0, 0], [0, 1]] + >>> output = generate_cropped_canvas_png_bytes(grid) + >>> isinstance(output, bytes) + True + """ + arr = np.array(math_grid) + coords = np.argwhere(arr == 1) - giant_heart = scale_pattern_matrix(tiny_heart, heart_sts, heart_rows) + if coords.size == 0: + img = Image.new('RGB', (100, 100), (255, 255, 255)) + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() - start_x = (total_sweater_sts - heart_sts) // 2 - start_y = 12 + y_min, x_min = coords.min(axis=0) + y_max, x_max = coords.max(axis=0) + cropped = arr[y_min:y_max+1, x_min:x_max+1] + + img_arr = np.full((cropped.shape[0], cropped.shape[1], 3), 255, dtype=np.uint8) + img_arr[cropped == 1] = [220, 50, 50] + + img = Image.fromarray(img_arr) - final_chart = overlay_pattern_on_grid(carved_grid, giant_heart, start_x, start_y) + cell_size = 10 + img = img.resize((cropped.shape[1] * cell_size, cropped.shape[0] * cell_size), Image.NEAREST) + draw = ImageDraw.Draw(img) + grid_color = (130, 130, 130) + + for x in range(0, img.width, cell_size): + draw.line([(x, 0), (x, img.height)], fill=grid_color, width=1) + for y in range(0, img.height, cell_size): + draw.line([(0, y), (img.width, y)], fill=grid_color, width=1) + draw.rectangle([(0, 0), (img.width-1, img.height-1)], outline=grid_color, width=2) + + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() + +def merge_stamps(stamps_to_merge): + """ + Collapses multiple graphical layers into a single combined matrix. + + Args: + stamps_to_merge (list[dict]): The state dictionaries containing matrices. + + Returns: + tuple: (Combined matrix list, min_x offset, min_y offset) + + Example: + >>> stamps = [{"matrix": [[1]], "x": 1, "y": 1, "visible": True}] + >>> merge_stamps(stamps) + ([[1]], 1, 1) + """ + if not stamps_to_merge: + return None, 0, 0 + + stamp_data = [] + min_x, min_y = float('inf'), float('inf') + max_x, max_y = float('-inf'), float('-inf') + + for stamp in stamps_to_merge: + if not stamp.get("visible", True): continue + + base_matrix = stamp.get("matrix") + if not base_matrix: continue + + t_mat = rotate_matrix(base_matrix, stamp.get("rotation", 0)) + t_mat = apply_transforms(t_mat, stamp.get("symmetry", "None"), stamp.get("axis", "Horizontal"), stamp.get("spacing_h", 0), stamp.get("spacing_v", 0)) + t_mat = scale_pattern_matrix_integer(t_mat, stamp.get("scale", 1)) + + arr = np.array(t_mat) + h, w = arr.shape + sx, sy = stamp.get("x", 0), stamp.get("y", 0) + + stamp_data.append((arr, sx, sy, h, w)) + + min_x = min(min_x, sx) + min_y = min(min_y, sy) + max_x = max(max_x, sx + w) + max_y = max(max_y, sy + h) + + if not stamp_data: return None, 0, 0 + + combined_h = max_y - min_y + combined_w = max_x - min_x + combined_canvas = np.zeros((combined_h, combined_w), dtype=int) + + for arr, sx, sy, h, w in stamp_data: + local_y = sy - min_y + local_x = sx - min_x + mask = arr != 0 + combined_canvas[local_y:local_y+h, local_x:local_x+w][mask] = arr[mask] + + return combined_canvas.tolist(), min_x, min_y + +def crop_matrix_to_bounding_box(matrix): + """ + Slices away empty transparent space around a graphic before JSON serialization. + + Args: + matrix (list[list[int]]): The raw, uncropped matrix. + + Returns: + list[list[int]]: The tightly cropped matrix. + + Example: + >>> matrix = [[0, 0], [0, 1]] + >>> crop_matrix_to_bounding_box(matrix) + [[1]] + """ + if not matrix: return [] + arr = np.array(matrix) + coords = np.argwhere(arr != 0) + + if coords.size == 0: + return arr.tolist() + + y_min, x_min = coords.min(axis=0) + y_max, x_max = coords.max(axis=0) + + return arr[y_min:y_max+1, x_min:x_max+1].tolist() + +def serialize_project_state(project_data): + """ + Safely converts a complex dictionary containing NumPy arrays into a pure JSON string. + + Args: + project_data (dict): The complete active session state. + + Returns: + str: Serialized JSON payload. + + Example: + >>> state = {"settings": {}, "stamps": []} + >>> serialize_project_state(state) + '{"settings": {}, "stamps": []}' + """ + safe_data = copy.deepcopy(project_data) + safe_stamps = [] + + for s in safe_data.get("stamps", []): + if isinstance(s.get("matrix"), np.ndarray): + s["matrix"] = s["matrix"].tolist() + safe_stamps.append(s) + + safe_data["stamps"] = safe_stamps + return json.dumps(safe_data) + +def deserialize_project_state(json_string): + """ + Parses a saved JSON string back into a Python dictionary, ensuring backwards compatibility. + + Args: + json_string (str): The uploaded save file contents. + + Returns: + dict: The structured Python dictionary payload. + + Example: + >>> payload = '{"settings": {}, "stamps": []}' + >>> type(deserialize_project_state(payload)) is dict + True + """ + if not json_string: + return {"settings": {}, "stamps": []} + + data = json.loads(json_string) - # INSTEAD OF PRINTING TO TERMINAL, WE GENERATE THE IMAGE! - # Update this line at the bottom of your test block! - generate_chart_image(final_chart, shaping, "my_first_sweater_chart.png") - \ No newline at end of file + if isinstance(data, list): + return {"settings": {}, "stamps": data} + + return data \ No newline at end of file diff --git a/src/knitting_pattern/math_engine.py b/src/knitting_pattern/math_engine.py index 8e661c4..22da59a 100644 --- a/src/knitting_pattern/math_engine.py +++ b/src/knitting_pattern/math_engine.py @@ -21,6 +21,10 @@ def calculate_stitches(width_cm, gauge_sts_per_10cm): Returns: int: Total stitches needed, rounded up to the nearest whole integer. + + Example: + >>> calculate_stitches(50.0, 20.0) + 100 """ sts_per_cm = gauge_sts_per_10cm / 10.0 return math.ceil(width_cm * sts_per_cm) @@ -35,12 +39,16 @@ def calculate_rows(length_cm, gauge_rows_per_10cm): Returns: int: Total rows needed, rounded up to the nearest whole integer. + + Example: + >>> calculate_rows(40.0, 25.0) + 100 """ rows_per_cm = gauge_rows_per_10cm / 10.0 return math.ceil(length_cm * rows_per_cm) # ========================================== -# 2. SIZING & EASE ENGINE +# 2. SIZING & EASE # ========================================== def get_standard_body_measurements(size_string): @@ -51,16 +59,20 @@ def get_standard_body_measurements(size_string): size_string (str): The desired size (e.g., 'S', 'M', 'XL'). Returns: - dict: A dictionary containing 'chest_circ_cm', 'cross_back_cm', - and 'arm_length_cm' as floats. + dict: Measurements for chest, back, arm length, bicep, and wrist. + + Example: + >>> measurements = get_standard_body_measurements("S") + >>> measurements["chest_circ_cm"] + 86.0 """ body_sizes = { - "XS": {"chest_circ_cm": 76.0, "cross_back_cm": 36.0, "arm_length_cm": 43.0}, - "S": {"chest_circ_cm": 86.0, "cross_back_cm": 38.0, "arm_length_cm": 44.0}, - "M": {"chest_circ_cm": 96.0, "cross_back_cm": 40.0, "arm_length_cm": 45.0}, - "L": {"chest_circ_cm": 106.0, "cross_back_cm": 42.0, "arm_length_cm": 46.0}, - "XL": {"chest_circ_cm": 117.0, "cross_back_cm": 44.0, "arm_length_cm": 47.0}, - "2XL": {"chest_circ_cm": 127.0, "cross_back_cm": 46.0, "arm_length_cm": 48.0} + "XS": {"chest_circ_cm": 76.0, "cross_back_cm": 36.0, "arm_length_cm": 43.0, "bicep_circ_cm": 26.0, "wrist_circ_cm": 15.0}, + "S": {"chest_circ_cm": 86.0, "cross_back_cm": 38.0, "arm_length_cm": 44.0, "bicep_circ_cm": 28.0, "wrist_circ_cm": 16.0}, + "M": {"chest_circ_cm": 96.0, "cross_back_cm": 40.0, "arm_length_cm": 45.0, "bicep_circ_cm": 30.0, "wrist_circ_cm": 17.0}, + "L": {"chest_circ_cm": 106.0, "cross_back_cm": 42.0, "arm_length_cm": 46.0, "bicep_circ_cm": 34.0, "wrist_circ_cm": 18.0}, + "XL": {"chest_circ_cm": 117.0, "cross_back_cm": 44.0, "arm_length_cm": 47.0, "bicep_circ_cm": 38.0, "wrist_circ_cm": 19.0}, + "2XL": {"chest_circ_cm": 127.0, "cross_back_cm": 46.0, "arm_length_cm": 48.0, "bicep_circ_cm": 42.0, "wrist_circ_cm": 20.0} } return body_sizes.get(size_string.upper(), body_sizes["M"]) @@ -74,12 +86,16 @@ def get_style_ease(garment_type): Returns: dict: Contains 'ease_cm' (float) to be added to the body circumference, and a 'description' (str) of the fit. + + Example: + >>> get_style_ease("fitted_set_in") + {'ease_cm': 2.5, 'description': 'Tailored, close to body'} """ ease_dict = { "drop_shoulder": {"ease_cm": 15.0, "description": "Loose, oversized fit"}, "raglan": {"ease_cm": 7.5, "description": "Classic, comfortable fit"}, "fitted_set_in": {"ease_cm": 2.5, "description": "Tailored, close to body"}, - "negative_ease_ribbed": {"ease_cm": -5.0, "description": "Stretches to fit tightly"} + "negative_ease_ribbed": {"ease_cm": -5.0, "description": "Stretches to hug the body"} } return ease_dict.get(garment_type.lower(), ease_dict["drop_shoulder"]) @@ -89,11 +105,15 @@ def calculate_garment_dimensions(size_string, garment_type, custom_ease=None): Args: size_string (str): The desired body size (e.g., 'M'). - garment_type (str): The style of the garment. + garment_type (str): The style of the garment (e.g., "drop_shoulder"). custom_ease (float, optional): User-defined ease override in cm. Defaults to None. Returns: dict: Final calculated measurements for knitting the panel. + + Example: + >>> calculate_garment_dimensions("M", "drop_shoulder") + {'body_chest_circ': 96.0, 'ease_added': 15.0, 'total_garment_circ': 111.0, 'panel_width_cm': 55.5, 'arm_length_cm': 45.0, 'style_description': 'Loose, oversized fit'} """ body = get_standard_body_measurements(size_string) style = get_style_ease(garment_type) @@ -112,19 +132,22 @@ def calculate_garment_dimensions(size_string, garment_type, custom_ease=None): } # ========================================== -# 3. TOP-DOWN SHORT ROW & SHAPING ENGINE +# 3. TOP-DOWN SHORT ROW & SHAPING # ========================================== def get_shaping_guidelines(size_string): """ - Returns the graded centimeter drops for shoulder shaping. - Updated with deeper slopes for a highly tailored, less 'boxy' fit. + Returns the graded centimeter drops for shoulder and back shaping. Args: - size_string (str): The desired body size. + size_string (str): The desired standard size (e.g., "M"). Returns: dict: Contains 'shoulder_drop_cm' and 'back_neck_raise_cm'. + + Example: + >>> get_shaping_guidelines("M") + {'shoulder_drop_cm': 4.5, 'back_neck_raise_cm': 3.5} """ guidelines = { "XS": {"shoulder_drop_cm": 3.5, "back_neck_raise_cm": 2.5}, @@ -136,7 +159,7 @@ def get_shaping_guidelines(size_string): } return guidelines.get(size_string.upper(), guidelines["M"]) -def calculate_top_down_shoulder_shaping(total_chest_sts, size_string, gauge_rows_per_10cm, panel_type="Front Panel"): +def calculate_top_down_shoulder_shaping(total_chest_sts, size_string, gauge_rows_per_10cm): """ Calculates the short row intervals for top-down 'two mountain' shoulder shaping. @@ -144,14 +167,17 @@ def calculate_top_down_shoulder_shaping(total_chest_sts, size_string, gauge_rows total_chest_sts (int): The total cast-on width for the panel. size_string (str): The body size, used to pull dynamic drop guidelines. gauge_rows_per_10cm (float): The knitter's row gauge. - panel_type (str): "Front Panel" or "Back Panel". + + Returns: + dict: Calculations for shoulder short row shaping based on sizing. + + Example: + >>> calculate_top_down_shoulder_shaping(100, "M", 20.0) + {'mountain_rows': 9, 'total_short_row_steps': 4, 'first_turn_stitch': 25, 'middle_stitch': 50, 'sts_per_step': 6} """ shaping_rules = get_shaping_guidelines(size_string) - - if panel_type == "Front Panel": - slope_drop_cm = shaping_rules["shoulder_drop_cm"] - else: - slope_drop_cm = shaping_rules["back_neck_raise_cm"] + + slope_drop_cm = shaping_rules["shoulder_drop_cm"] quarter_mark = total_chest_sts // 4 middle_mark = total_chest_sts // 2 @@ -168,9 +194,25 @@ def calculate_top_down_shoulder_shaping(total_chest_sts, size_string, gauge_rows "total_short_row_steps": steps, "first_turn_stitch": quarter_mark, "middle_stitch": middle_mark, - "sts_to_knit_past_double_stitch": sts_per_step + "sts_per_step": sts_per_step } + def calculate_back_neck_shaping(total_chest_sts, size_string, gauge_rows_per_10cm): + """ + Calculates the short row intervals for top-down back short-row shaping. + + Args: + total_chest_sts (int): The total cast-on width for the panel. + size_string (str): The body size, used to pull dynamic drop guidelines. + gauge_rows_per_10cm (float): The knitter's row gauge. + + Returns: + dict: Calculations for raising the back with short-row shaping. + + Example: + >>> calculate_back_neck_shaping(100, "M", 20.0) + {'mountain_rows': 7, 'total_short_row_steps': 3, 'middle_stitch': 50, 'neck_half_width': 16, 'sts_per_step': 11, 'first_turn_stitch': 66, 'purl_distance': 32} + """ shaping_rules = get_shaping_guidelines(size_string) raise_cm = shaping_rules["back_neck_raise_cm"] @@ -184,37 +226,25 @@ def calculate_back_neck_shaping(total_chest_sts, size_string, gauge_rows_per_10c shoulder_sts = (total_chest_sts // 2) - neck_half_width sts_per_step = max(1, shoulder_sts // steps) + # Exact turn counts for the instructions + left_turn = middle_mark - neck_half_width + right_turn = middle_mark + neck_half_width + + # Row 1 (RS): Start at the edge, knit across to the left turn + first_rs_knit = total_chest_sts - left_turn + # Row 2 (WS): Purl from the left turn back to the right turn + ws_purl = right_turn - left_turn + return { "mountain_rows": mountain_rows, "total_short_row_steps": steps, "middle_stitch": middle_mark, "neck_half_width": neck_half_width, - "sts_per_step": sts_per_step + "sts_per_step": sts_per_step, + "first_turn_stitch": first_rs_knit, + "purl_distance": ws_purl } -def apply_back_short_rows_to_grid(grid, shaping_data): - steps = shaping_data["total_short_row_steps"] - middle = shaping_data["middle_stitch"] - neck_half_width = shaping_data["neck_half_width"] - sts_per_step = shaping_data["sts_per_step"] - total_sts = len(grid[0]) - - for step in range(steps): - live_left_edge = middle - neck_half_width - (step * sts_per_step) - live_right_edge = middle + neck_half_width + (step * sts_per_step) - - r1 = step * 2 - r2 = step * 2 + 1 - - if r1 < len(grid): - for col in range(0, live_left_edge): grid[r1][col] = -1 - for col in range(live_right_edge, total_sts): grid[r1][col] = -1 - - if r2 < len(grid): - for col in range(0, live_left_edge): grid[r2][col] = -1 - for col in range(live_right_edge, total_sts): grid[r2][col] = -1 - - return grid # ========================================== # 4. GRID GENERATION (THE CANVAS) # ========================================== @@ -228,77 +258,187 @@ def generate_panel_grid(width_sts, length_rows): length_rows (int): Total rows (Y-axis). Returns: - list of lists: A 2D array filled with 0s. + list[list[int]]: A 2D array filled with 0s. + + Example: + >>> generate_panel_grid(3, 2) + [[0, 0, 0], [0, 0, 0]] """ return [[0 for _ in range(width_sts)] for _ in range(length_rows)] def apply_top_down_mountains_to_grid(grid, shaping_data): """ - Modifies the digital grid with ASYMMETRICAL top-down short row shaping. + Modifies the panel grid with asymmetrical top-down short-row shaping. The left shoulder shaping is delayed by 1 row compared to the right. + + Args: + grid (list[list[int]]): Empty grid based on sizing. + shaping_data (dict): Shoulder drop calculations from the math engine. + + Returns: + list[list[int]]: Grid with -1s indicating unworked space. + + Example: + >>> grid = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] + >>> shaping = {"total_short_row_steps": 1, "first_turn_stitch": 1, "sts_per_step": 1} + >>> apply_top_down_mountains_to_grid(grid, shaping) + [[0, 0, 0, 0], [0, 0, -1, 0], [0, -1, -1, 0], [0, -1, 0, 0]] """ steps = shaping_data["total_short_row_steps"] quarter = shaping_data["first_turn_stitch"] - sts_per_step = shaping_data["sts_to_knit_past_double_stitch"] + sts_per_step = shaping_data["sts_per_step"] total_sts = len(grid[0]) midpoint = total_sts // 2 - # Row 0 is the full Cast-On sweep. Shaping starts on Row 1. - + # Row 0 is the full Cast-On. Shaping starts on Row 1. for step in range(steps): - # RIGHT SHOULDER (Starts immediately on Row 1) rs_row_1 = 1 + (step * 2) rs_row_2 = 2 + (step * 2) - # LEFT SHOULDER (Delayed by 1 row, starts Row 2) ls_row_1 = 2 + (step * 2) ls_row_2 = 3 + (step * 2) left_inner_edge = quarter + (step * sts_per_step) right_inner_edge = total_sts - left_inner_edge - # Carve RIGHT side of the neck (columns from midpoint to the right) + # Carve RIGHT side of the neck if rs_row_1 < len(grid): - for col in range(midpoint, right_inner_edge): - grid[rs_row_1][col] = -1 + for col in range(midpoint, right_inner_edge): grid[rs_row_1][col] = -1 if rs_row_2 < len(grid): - for col in range(midpoint, right_inner_edge): - grid[rs_row_2][col] = -1 + for col in range(midpoint, right_inner_edge): grid[rs_row_2][col] = -1 - # Carve LEFT side of the neck (columns from the left to midpoint) + # Carve LEFT side of the neck if ls_row_1 < len(grid): - for col in range(left_inner_edge, midpoint): - grid[ls_row_1][col] = -1 + for col in range(left_inner_edge, midpoint): grid[ls_row_1][col] = -1 if ls_row_2 < len(grid): - for col in range(left_inner_edge, midpoint): - grid[ls_row_2][col] = -1 + for col in range(left_inner_edge, midpoint): grid[ls_row_2][col] = -1 return grid -# ========================================== -# TESTING BLOCK -# ========================================== -if __name__ == "__main__": - def print_grid_visually(grid, title="Grid"): - print(f"\n--- {title} ---") - for row in grid: - row_string = "".join(["โ–ˆโ–ˆ" if stitch == 0 else " " for stitch in row]) - print(row_string) - - print("Testing Math Engine with updated Top-Down logic...\n") +def apply_back_short_rows_to_grid(grid, shaping_data): + """ + Applies back neck short-row shaping to a garment panel grid. - # 40 stitches wide, 14 rows tall (just to see the top section) - test_grid = [[0 for _ in range(40)] for _ in range(14)] + Args: + grid (list[list[int]]): A 2D garment grid representing stitches and rows. + shaping_data (dict): Dictionary containing calculated short-row shaping. + + Returns: + list[list[int]]: Updated grid containing back neck short-row shaping. + + Example: + >>> grid = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]] + >>> shaping = {"total_short_row_steps": 1, "middle_stitch": 2, "neck_half_width": 1, "sts_per_step": 1} + >>> apply_back_short_rows_to_grid(grid, shaping) + [[0, 0, 0, 0], [-1, 0, 0, -1], [-1, 0, 0, -1]] + """ + steps = shaping_data["total_short_row_steps"] + middle = shaping_data["middle_stitch"] + neck_half_width = shaping_data["neck_half_width"] + sts_per_step = shaping_data["sts_per_step"] + total_sts = len(grid[0]) - # Mock parameters for testing the new deeper slopes - test_shaping = { - "total_short_row_steps": 6, # Deeper slope requires more steps - "first_turn_stitch": 10, # 1/4 of 40 - "sts_to_knit_past_double_stitch": 1 # Slower progression towards center + for step in range(steps): + left_turn = middle - neck_half_width - (step * sts_per_step) + right_turn = middle + neck_half_width + (step * sts_per_step) + + prev_right_turn = total_sts - 1 if step == 0 else middle + neck_half_width + ((step - 1) * sts_per_step) + + rs_row = 1 + (step * 2) + ws_row = 2 + (step * 2) + + # Row 1 (RS) + if rs_row < len(grid): + for col in range(0, left_turn): grid[rs_row][col] = -1 + for col in range(prev_right_turn + 1, total_sts): grid[rs_row][col] = -1 + + # Row 2 (WS) + if ws_row < len(grid): + for col in range(0, left_turn): grid[ws_row][col] = -1 + for col in range(right_turn + 1, total_sts): grid[ws_row][col] = -1 + + return grid + +def calculate_sleeve_dimensions(size_string, gauge_sts_per_10cm, gauge_rows_per_10cm, + custom_bicep=None, custom_wrist=None, custom_length=None, + straight_length_cm=15.0): + """ + Calculates sleeve shaping dimensions for a tapered top-down sleeve. + + Args: + size_string (str): Standard body size used for baseline measurements. + gauge_sts_per_10cm (float): Stitch gauge measured over 10 cm. + gauge_rows_per_10cm (float): Row gauge measured over 10 cm. + custom_bicep (float, optional): Custom bicep circumference. Defaults to None. + custom_wrist (float, optional): Custom wrist circumference. Defaults to None. + custom_length (float, optional): Custom sleeve length. Defaults to None. + straight_length_cm (float, optional): Length of upper arm before taper. Defaults to 15.0 cm. + + Returns: + dict: Calculated sleeve shaping data. + + Example: + >>> calculate_sleeve_dimensions("M", 20.0, 20.0, custom_bicep=30.0, custom_wrist=20.0, custom_length=45.0, straight_length_cm=10.0) + {'bicep_sts': 60, 'wrist_sts': 40, 'total_rows': 90, 'straight_rows': 20, 'dec_rate': 7, 'total_dec_rounds': 10} + """ + body = get_standard_body_measurements(size_string) + + bicep_sts = calculate_stitches(custom_bicep or body["bicep_circ_cm"] + 5.0, gauge_sts_per_10cm) + wrist_sts = calculate_stitches(custom_wrist or body["wrist_circ_cm"] + 2.0, gauge_sts_per_10cm) + total_rows = calculate_rows(custom_length or body["arm_length_cm"], gauge_rows_per_10cm) + + straight_rows = calculate_rows(straight_length_cm, gauge_rows_per_10cm) + taper_rows = total_rows - straight_rows + + total_sts_to_decrease = bicep_sts - wrist_sts + decrease_rounds = total_sts_to_decrease // 2 + + dec_rate = taper_rows // decrease_rounds if decrease_rounds > 0 else 0 + + return { + "bicep_sts": bicep_sts, + "wrist_sts": wrist_sts, + "total_rows": total_rows, + "straight_rows": straight_rows, + "dec_rate": dec_rate, + "total_dec_rounds": decrease_rounds } + +def generate_sleeve_grid(sleeve_data): + """ + Generates a 2D sleeve shaping grid for a tapered sleeve. + + Args: + sleeve_data (dict): Sleeve shaping calculations containing stitch counts. + + Returns: + list[list[int]]: A 2D grid representing the sleeve layout with -1s for tapers. + + Example: + >>> sleeve_data = {'bicep_sts': 4, 'wrist_sts': 2, 'total_rows': 3, 'straight_rows': 1, 'dec_rate': 1, 'total_dec_rounds': 1} + >>> generate_sleeve_grid(sleeve_data) + [[0, 0, 0, 0], [0, 0, 0, 0], [-1, 0, 0, -1]] + """ + bicep = sleeve_data["bicep_sts"] + rows = sleeve_data["total_rows"] + straight_rows = sleeve_data["straight_rows"] + dec_rate = sleeve_data["dec_rate"] + + grid = [[0 for _ in range(bicep)] for _ in range(rows)] - carved_grid = apply_top_down_mountains_to_grid(test_grid, test_shaping) - print_grid_visually(carved_grid, "Top-Down Mountains (Row 0 is Cast-On)") + current_width = bicep - \ No newline at end of file + for r in range(rows): + if r > straight_rows and dec_rate > 0 and (r - straight_rows) % dec_rate == 0: + if current_width > sleeve_data["wrist_sts"]: + current_width -= 2 + + removed_total = bicep - current_width + empty_on_each_side = removed_total // 2 + + for c in range(empty_on_each_side): + grid[r][c] = -1 + grid[r][(bicep - 1) - c] = -1 + + return grid \ No newline at end of file diff --git a/src/saturn.jpg b/src/saturn.jpg new file mode 100644 index 0000000..83d3496 Binary files /dev/null and b/src/saturn.jpg differ diff --git a/src/stamp.jpg b/src/stamp.jpg new file mode 100644 index 0000000..b423020 Binary files /dev/null and b/src/stamp.jpg differ diff --git a/src/stamp_t.jpg b/src/stamp_t.jpg new file mode 100644 index 0000000..d58aa44 Binary files /dev/null and b/src/stamp_t.jpg differ diff --git a/src/star_2.jpg b/src/star_2.jpg new file mode 100644 index 0000000..cf99ed9 Binary files /dev/null and b/src/star_2.jpg differ diff --git a/src/stars.jpg b/src/stars.jpg new file mode 100644 index 0000000..7f27da0 Binary files /dev/null and b/src/stars.jpg differ diff --git a/src/test_nonpix_image.jpg b/src/test_nonpix_image.jpg new file mode 100644 index 0000000..541270e Binary files /dev/null and b/src/test_nonpix_image.jpg differ diff --git a/src/tests/test_example.py b/src/tests/test_example.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/tests/test_image_engine.py b/src/tests/test_image_engine.py index 3c05028..dc3c74d 100644 --- a/src/tests/test_image_engine.py +++ b/src/tests/test_image_engine.py @@ -5,46 +5,88 @@ @author: kasteivanauskaite """ -from knitting_pattern.math_engine import ( - generate_panel_grid, - apply_back_short_rows_to_grid +import pytest +import numpy as np +from PIL import Image +from knitting_pattern.image_engine import ( + scale_pattern_matrix_integer, + rotate_matrix, + apply_transforms, + merge_stamps, + crop_matrix_to_bounding_box, + serialize_project_state, + deserialize_project_state, + process_uploaded_image ) # ========================================== -# 1. GRID GENERATION TESTS +# 1. MATRIX MANIPULATION TESTS # ========================================== -def test_generate_panel_grid(): - # Create a small 10 stitch wide by 5 row tall test grid - grid = generate_panel_grid(width_sts=10, length_rows=5) - - # Check that it generated exactly 5 rows (Y-axis) - assert len(grid) == 5 - # Check that the first row is exactly 10 stitches wide (X-axis) - assert len(grid[0]) == 10 - # Check that it is initialized with zeroes - assert grid[0][0] == 0 +def test_scale_pattern_matrix_integer(): + # Test that 1s and 0s scale up correctly and maintain independence (no smearing) + matrix = [[1, 0]] + scaled = scale_pattern_matrix_integer(matrix, 2) + assert scaled == [[1, 1, 0, 0], [1, 1, 0, 0]] + +def test_rotate_matrix(): + # Test 90-degree rotation logic + matrix = [[1, 2], [3, 4]] + rotated = rotate_matrix(matrix, 90) + assert rotated == [[3, 1], [4, 2]] + +def test_apply_transforms(): + # Test Horizontal Flip + matrix = [[1, 0]] + flipped = apply_transforms(matrix, "Flip", "Horizontal") + assert flipped == [[0, 1]] + +def test_merge_stamps(): + # Test that two stamps merge correctly without overwriting each other + # Stamp 1 at 0,0 and Stamp 2 at 1,1 + stamps = [ + {"matrix": [[1]], "x": 0, "y": 0, "visible": True}, + {"matrix": [[1]], "x": 1, "y": 1, "visible": True} + ] + sweater_grid = [[0, 0], [0, 0]] + merged = merge_stamps(stamps) + # merged returns: canvas, min_x, min_y + assert merged[0] == [[1, 0], [0, 1]] # ========================================== -# 2. SHAPING MATRIX MANIPULATION TESTS +# 2. IMAGE PROCESSING & CROPPING TESTS # ========================================== -def test_apply_back_short_rows_to_grid(): - # Create a clean 20x10 test grid - grid = generate_panel_grid(width_sts=20, length_rows=10) - - # We will pass in 'mock' shaping data so we know exactly what to expect - mock_shaping = { - "total_short_row_steps": 2, - "middle_stitch": 10, - "neck_half_width": 2, - "sts_per_step": 2 + +def test_crop_matrix_to_bounding_box(): + # Slices away empty space + matrix = [[0, 0, 0], [0, 1, 0], [0, 0, 0]] + cropped = crop_matrix_to_bounding_box(matrix) + assert cropped == [[1]] + +def test_process_uploaded_image(): + # Create a simple 10x10 black image and check if it converts to a 10x10 matrix + img = Image.new('RGB', (10, 10), color='black') + matrix, preview = process_uploaded_image(img, 10, 128) + assert len(matrix) == 10 + assert len(matrix[0]) == 10 + assert matrix[0][0] == 1 # Black pixels become 1 + +# ========================================== +# 3. PROJECT STATE (JSON) TESTS +# ========================================== + +def test_serialize_deserialize(): + # Test JSON state saving with NumPy array conversion + mock_state = { + "settings": {"size": "M"}, + "stamps": [{"name": "Test", "matrix": np.array([[1, 0], [0, 1]])}] } - carved_grid = apply_back_short_rows_to_grid(grid, mock_shaping) - - # THE TEST: The outer corners should now be 'dead' canvas space (-1) - assert carved_grid[0][0] == -1 - assert carved_grid[0][-1] == -1 + # Check serialization + payload = serialize_project_state(mock_state) + assert isinstance(payload, str) - # THE TEST: The active center neck stitches should remain untouched (0) - assert carved_grid[0][10] == 0 \ No newline at end of file + # Check deserialization + recovered = deserialize_project_state(payload) + assert recovered["settings"]["size"] == "M" + assert recovered["stamps"][0]["matrix"] == [[1, 0], [0, 1]] \ No newline at end of file diff --git a/src/tests/test_math_engine.py b/src/tests/test_math_engine.py index 106a8cb..7631be9 100644 --- a/src/tests/test_math_engine.py +++ b/src/tests/test_math_engine.py @@ -5,42 +5,199 @@ @author: kasteivanauskaite """ +import pytest + from knitting_pattern.math_engine import ( - calculate_stitches, - calculate_rows, - get_standard_body_measurements + calculate_stitches, calculate_rows, get_standard_body_measurements, + get_style_ease, calculate_garment_dimensions, get_shaping_guidelines, + calculate_top_down_shoulder_shaping, calculate_back_neck_shaping, + generate_panel_grid, apply_top_down_mountains_to_grid, + apply_back_short_rows_to_grid, calculate_sleeve_dimensions, + generate_sleeve_grid ) # ========================================== -# 1. GAUGE CALCULATOR TESTS +# 1. GAUGE CALCULATORS TESTS # ========================================== + def test_calculate_stitches(): - # If a fabric is 10cm wide, and gauge is 20 sts per 10cm, we expect 20 stitches. - assert calculate_stitches(10.0, 20.0) == 20 - - # If fabric is 15cm wide, and gauge is 22 sts per 10cm (2.2 sts/cm) - # 15 * 2.2 = 33 stitches - assert calculate_stitches(15.0, 22.0) == 33 - - # Testing rounding: 10.5cm * 2.2 = 23.1. Should round up to 24. - assert calculate_stitches(10.5, 22.0) == 24 + # 20 sts per 10cm = 2.0 sts/cm. 50cm width -> 100 sts. + assert calculate_stitches(50, 20) == 100 + # Test decimal ceiling behavior: 10.5cm * 2.0 sts/cm = 21 sts. + assert calculate_stitches(10.5, 20) == 21 + # Test rounding up: 10cm * 1.8 sts/cm = 18 sts. + assert calculate_stitches(10.0, 18) == 18 def test_calculate_rows(): - # 10cm length at 30 rows/10cm should equal 30 rows - assert calculate_rows(10.0, 30.0) == 30 + # 24 rows per 10cm = 2.4 rows/cm. 20cm length -> 48 rows. + assert calculate_rows(20, 24) == 48 + # Test ceiling behavior: 10.1cm * 2.5 rows/cm = 25.25 -> 26 rows. + assert calculate_rows(10.1, 25) == 26 # ========================================== -# 2. SIZING DATA TESTS +# 2. SIZING & EASE TESTS # ========================================== + def test_get_standard_body_measurements(): - # Test that size Medium returns the correct dictionary - m_size = get_standard_body_measurements("M") - assert m_size["chest_circ_cm"] == 96.0 + # Test exact match + size_m = get_standard_body_measurements("M") + assert size_m["chest_circ_cm"] == 96.0 + assert size_m["bicep_circ_cm"] == 30.0 + + # Test case insensitivity + size_s = get_standard_body_measurements("s") + assert size_s["chest_circ_cm"] == 86.0 + + # Test fallback for invalid string (should default to M) + size_invalid = get_standard_body_measurements("INVALID_SIZE") + assert size_invalid["chest_circ_cm"] == 96.0 + +def test_get_style_ease(): + # Test known style + raglan = get_style_ease("raglan") + assert raglan["ease_cm"] == 7.5 + + # Test case insensitivity + ribbed = get_style_ease("NEGATIVE_EASE_RIBBED") + assert ribbed["ease_cm"] == -5.0 + + # Test fallback + unknown = get_style_ease("not_a_style") + assert unknown["ease_cm"] == 15.0 # Defaults to drop_shoulder + +def test_calculate_garment_dimensions(): + # Standard sizing: Size M (96cm) + Drop Shoulder (15cm) = 111cm total / 2 = 55.5cm panel + dims = calculate_garment_dimensions("M", "drop_shoulder") + assert dims["body_chest_circ"] == 96.0 + assert dims["ease_added"] == 15.0 + assert dims["total_garment_circ"] == 111.0 + assert dims["panel_width_cm"] == 55.5 + + # Custom ease override: Size S (86cm) + 10cm custom ease = 96cm total / 2 = 48cm panel + custom_dims = calculate_garment_dimensions("S", "drop_shoulder", custom_ease=10.0) + assert custom_dims["ease_added"] == 10.0 + assert custom_dims["panel_width_cm"] == 48.0 + +# ========================================== +# 3. TOP-DOWN SHORT ROW & SHAPING TESTS +# ========================================== + +def test_get_shaping_guidelines(): + guidelines = get_shaping_guidelines("L") + assert guidelines["shoulder_drop_cm"] == 5.5 + assert guidelines["back_neck_raise_cm"] == 4.0 + + fallback = get_shaping_guidelines("UNKNOWN") + assert fallback["shoulder_drop_cm"] == 4.5 # Defaults to M + +def test_calculate_top_down_shoulder_shaping(): + # Size M (drop 4.5cm). Gauge: 20 rows/10cm (2 rows/cm). + # Mountain rows = 4.5 * 2 = 9. Steps = 9 // 2 = 4. + # Total sts = 100. Quarter = 25. Middle = 50. + shaping = calculate_top_down_shoulder_shaping(100, "M", 20) + assert shaping["mountain_rows"] == 9 + assert shaping["total_short_row_steps"] == 4 + assert shaping["first_turn_stitch"] == 25 + assert shaping["middle_stitch"] == 50 + assert shaping["sts_per_step"] == 25 // 4 # 6 + +def test_calculate_back_neck_shaping(): + # Size M (raise 3.5cm). Gauge: 20 rows/10cm (2 rows/cm). + # Mountain rows = 3.5 * 2 = 7. Steps = 7 // 2 = 3. + shaping = calculate_back_neck_shaping(120, "M", 20) + assert shaping["mountain_rows"] == 7 + assert shaping["total_short_row_steps"] == 3 + assert shaping["middle_stitch"] == 60 + assert shaping["neck_half_width"] == 20 + +# ========================================== +# 4. GRID GENERATION & MODIFICATION TESTS +# ========================================== + +def test_generate_panel_grid(): + grid = generate_panel_grid(10, 5) + assert len(grid) == 5 # 5 rows + assert len(grid[0]) == 10 # 10 columns + assert grid[0][0] == 0 + assert grid[4][9] == 0 + +def test_apply_top_down_mountains_to_grid(): + grid = generate_panel_grid(20, 10) + shaping_data = { + "total_short_row_steps": 2, + "first_turn_stitch": 5, + "sts_per_step": 2 + } + carved_grid = apply_top_down_mountains_to_grid(grid, shaping_data) + + # Verify the grid retains dimensions + assert len(carved_grid) == 10 + assert len(carved_grid[0]) == 20 + + # Row 0 is the Cast-On (should remain untouched 0s) + assert all(st == 0 for st in carved_grid[0]) + + # Verify that shaping created empty space (-1) at the neck edges + # On row 1 (right shoulder step 1), stitches from midpoint (10) to right inner edge should be carved. + # right_inner_edge = 20 - (5 + 0) = 15. So cols 10 to 14 are -1. + assert carved_grid[1][12] == -1 + +def test_apply_back_short_rows_to_grid(): + grid = generate_panel_grid(20, 10) + shaping_data = { + "total_short_row_steps": 2, + "middle_stitch": 10, + "neck_half_width": 2, + "sts_per_step": 2 + } + carved_grid = apply_back_short_rows_to_grid(grid, shaping_data) + + # Verify the far left side (0) was carved + assert carved_grid[1][0] == -1 + assert carved_grid[1][10] == 0 + +# ========================================== +# 5. SLEEVE CALCULATION & GRID TESTS +# ========================================== + +def test_calculate_sleeve_dimensions(): + # Test Size M with 20 sts and 30 rows per 10cm. + # Bicep = 30+5 = 35cm -> 70 sts + # Wrist = 17+2 = 19cm -> 38 sts + # Length = 45cm -> 135 rows + # Straight length = 15cm -> 45 rows + sleeve = calculate_sleeve_dimensions("M", 20, 30, straight_length_cm=15.0) + + assert sleeve["bicep_sts"] >= sleeve["wrist_sts"] + assert sleeve["total_rows"] == 135 + assert sleeve["straight_rows"] == 45 + assert sleeve["total_dec_rounds"] > 0 + assert sleeve["dec_rate"] > 0 + +def test_generate_sleeve_grid(): + # Use a small mock sleeve for easy matrix validation + sleeve_data = { + "bicep_sts": 10, + "wrist_sts": 6, + "total_rows": 10, + "straight_rows": 2, + "dec_rate": 2, + "total_dec_rounds": 2 + } + grid = generate_sleeve_grid(sleeve_data) + + # 1. Total dimensions should match bicep x rows + assert len(grid) == 10 + assert len(grid[0]) == 10 - # Test that lowercase inputs are handled properly - s_size = get_standard_body_measurements("s") - assert s_size["chest_circ_cm"] == 86.0 + # 2. Zone 1 (Straight): Top row should be fully knittable + assert all(st == 0 for st in grid[0]) + assert all(st == 0 for st in grid[2]) - # Test fallback: an invalid size should default to Medium (96.0) - invalid_size = get_standard_body_measurements("NONEXISTENT") - assert invalid_size["chest_circ_cm"] == 96.0 \ No newline at end of file + # 3. Zone 3 (Tapered Bottom): Bottom row should have -1 on the outer edges + # Bicep is 10, wrist is 6. Difference is 4. So 2 empty stitches on each side. + assert grid[9][0] == -1 + assert grid[9][1] == -1 + assert grid[9][2] == 0 # Start of wrist + assert grid[9][8] == -1 + assert grid[9][9] == -1 \ No newline at end of file