diff --git a/TESTING_404R.md b/TESTING_404R.md new file mode 100644 index 0000000..9340586 --- /dev/null +++ b/TESTING_404R.md @@ -0,0 +1,105 @@ +# Testing Layer Transforms - Product 404R + +## Quick Test Guide + +I've created **product 404R** as a demonstration of the layer transform feature. It uses the same images as product 404 but flips the door and hardware layers horizontally to show a right-hinge configuration. + +## Test URLs + +**Left-Hinge (Original):** +``` +http://localhost:8080/quiz/product/404 +``` + +**Right-Hinge (Flipped):** +``` +http://localhost:8080/quiz/product/404R +``` + +## What to Look For + +When you compare both products: + +1. **Base layer** - Should be identical (house/background doesn't flip) +2. **Door layer** - Should be mirrored left-to-right +3. **Hardware layer** - Should be mirrored to match door position +4. **Foreground layer** - Should be identical (plants don't flip) + +## Image Files Used + +Both products use the same image files from: +``` +/static/images/window/storm-window/404/ + ├── door-white.png + ├── door-black.png + └── (other color variants) + +/static/images/window/storm-window/layers/ + ├── base.jpg + ├── hardware.png + └── foreground.png +``` + +**Zero additional images needed!** The flip is pure CSS. + +## Configuration Difference + +### Product 404 (Left-Hinge) +```json +"imageConfig": { + "useCanvasAPI": true +} +``` + +### Product 404R (Right-Hinge) +```json +"imageConfig": { + "useCanvasAPI": true, + "layerTransforms": { + "door": "flip-horizontal", + "hardware": "flip-horizontal" + } +} +``` + +## Color Variations + +Both products support all color options: +- Black +- White +- Bronze +- Sandstone + +Select different colors in the dropdown to see the door layer change while maintaining the flip. + +## Technical Notes + +- **Performance**: Instant rendering, GPU-accelerated CSS transform +- **Storage**: No duplicate images required +- **Bandwidth**: Same image files downloaded once and reused +- **Compatibility**: Works in all modern browsers + +## Use Cases + +This same technique can be applied to: +- Storm doors with different hinge sides +- Windows with operable panels on left vs right +- Sliding doors/windows that open from different sides +- Any product where a mirror variant is needed + +## Next Steps + +1. Restart your Flask server to load the new product data +2. Visit both URLs to compare +3. Try different color selections +4. Open browser DevTools to see the CSS classes applied: + - `.layer-flip-horizontal` on door and hardware layers for 404R + - No transform classes on 404 + +## Production Usage + +When ready for production: +1. Design all "left-hinge" (or default orientation) images +2. Add product entries for "right-hinge" variants with `layerTransforms` +3. No need to create separate images! +4. Consider naming convention: 404, 404R, 404L, etc. diff --git a/VISUAL_LAYER_REFERENCE.md b/VISUAL_LAYER_REFERENCE.md new file mode 100644 index 0000000..eb3c4f5 --- /dev/null +++ b/VISUAL_LAYER_REFERENCE.md @@ -0,0 +1,115 @@ +# Visual Test Images - Layer Reference + +## Image Overview + +All images created with **800x600px** canvas dimensions for perfect alignment. + +## Layers Created + +### 1. **Base Layer** (`/layers/base.jpg`) +- **Sky blue background** (#87CEEB) +- **Green grass** at bottom +- **Tan house** with dark brown roof +- **Two side windows** with dividers +- **Door opening** in center (lighter tan rectangle) +- **Opaque JPG** - the foundation layer + +### 2. **Door Layer** (`/404/door-{color}.png`) +Created in 4 colors: +- `door-white.png` - White door (#FFFFFF) +- `door-black.png` - Dark gray/black door (#2C2C2C) +- `door-bronze.png` - Bronze door (#8B6914) +- `door-tan.png` - Tan door (#D2B48C) + +Each door has: +- **Rectangular shape** positioned in the door opening +- **Two decorative panels** (outlined rectangles) +- **Small black circle** on right side marking handle position +- **Transparent PNG** - shows house through transparent areas + +### 3. **Hardware Layer** (`/layers/hardware.png`) +- **Gold circle** (#FFD700) for door handle +- **Bronze backplate** (#B8860B) behind handle +- **Light highlight** for 3D effect +- **Positioned on right side** (for left-hinge door) +- **Transparent PNG** - only handle visible + +### 4. **Foreground Layer** (`/layers/foreground.png`) +- **Green bushes** at bottom corners (#228B22) +- **Pink flowers** with gold centers +- **Plants overlap bottom of door** realistically +- **Transparent PNG** - plants appear in front + +## How They Stack (z-index order) + +``` +Layer 5: Foreground (plants) - On top, in front of everything +Layer 3: Hardware (handle) - Above door +Layer 2: Door (colored) - In door opening +Layer 1: Base (house) - Background, always visible +``` + +## Left-Hinge vs Right-Hinge + +### Product 404 (Left-Hinge) +- Door handle on **right side** of door +- Door opens to the right +- Uses images as created + +### Product 404R (Right-Hinge) +- Door and hardware layers **flipped horizontally** +- Door handle appears on **left side** of door +- Door opens to the left +- Same image files, just CSS transformed! + +## Visual Features to Notice + +1. **Door positioning**: The door rectangle fits perfectly in the lighter opening on the house +2. **Handle alignment**: The small black dot on the door matches the gold handle position +3. **Plants overlap**: The foreground plants appear in front of the door bottom +4. **Color changes**: Select different colors to see door change while everything else stays the same +5. **Flip effect**: Compare 404 vs 404R to see the door and handle swap sides + +## Technical Details + +- **Canvas size**: 800x600 pixels (all layers) +- **Format**: JPG for base (opaque), PNG for others (transparency) +- **Color depth**: RGB/RGBA +- **Positioning**: Centered with `object-fit: contain` + +## Test Checklist + +When viewing the products, verify: +- ✅ House background always visible +- ✅ Door fits in opening +- ✅ Door color changes when selected +- ✅ Gold handle appears on door +- ✅ Plants appear in front of door +- ✅ 404R has flipped door and handle +- ✅ No white gaps or misalignment + +## File Locations + +``` +app/static/images/ +└── window/ + └── storm-window/ + ├── 404/ + │ ├── door-white.png + │ ├── door-black.png + │ ├── door-bronze.png + │ └── door-tan.png + └── layers/ + ├── base.jpg + ├── hardware.png + └── foreground.png +``` + +## Creating Similar Images + +Use the script `create_visual_test_images.py` as a template: +1. Set canvas dimensions (CANVAS_WIDTH, CANVAS_HEIGHT) +2. Design each layer with proper positioning +3. Use transparent PNGs for layered elements +4. Export base layer as JPG, others as PNG +5. Keep all layers at same canvas size diff --git a/app/app.py b/app/app.py index 35377ba..1ed1ab1 100644 --- a/app/app.py +++ b/app/app.py @@ -19,10 +19,12 @@ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' from blueprints.auth import auth_bp from blueprints.users import users_bp from blueprints.products import products_bp +from blueprints.canvas import canvas_bp app.register_blueprint(auth_bp) app.register_blueprint(users_bp) app.register_blueprint(products_bp) +app.register_blueprint(canvas_bp) # Comment Change # Home route @@ -44,6 +46,7 @@ def home():
Current Location: {session.get('currentLocation', 'Not selected')}
/-.
+ 2. /images////.
+ 3. /images///layers/-.
+ 4. /images///layers/.
+ 5. /images//layers/.
+ 6. /images/layers/.
+
+ Returns: (file_path, mime_type) or (None, None)
+ """
+ product_info = get_product_info(product_code)
+ if not product_info:
+ current_app.logger.warning(f"Product not found: {product_code}")
+ return None, None
+
+ images_dir = os.path.join(current_app.root_path, 'static', 'images')
+ base_type = product_info['type']
+ sub_type = product_info['subtype']
+
+ # Supported extensions
+ extensions = ['png', 'jpg', 'jpeg', 'webp']
+
+ # Build search paths in priority order
+ search_paths = []
+
+ # 1. Product-specific with color
+ if color and base_type and sub_type:
+ for ext in extensions:
+ search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}-{color.lower()}.{ext}"))
+
+ # 2. Product-specific without color
+ if base_type and sub_type:
+ for ext in extensions:
+ search_paths.append(os.path.join(images_dir, base_type, sub_type, product_code, f"{layer}.{ext}"))
+
+ # 3. Subtype fallback with color
+ if color and base_type and sub_type:
+ for ext in extensions:
+ search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}-{color.lower()}.{ext}"))
+
+ # 4. Subtype fallback without color
+ if base_type and sub_type:
+ for ext in extensions:
+ search_paths.append(os.path.join(images_dir, base_type, sub_type, 'layers', f"{layer}.{ext}"))
+
+ # 5. Type fallback
+ if base_type:
+ for ext in extensions:
+ search_paths.append(os.path.join(images_dir, base_type, 'layers', f"{layer}.{ext}"))
+
+ # 6. Global fallback
+ for ext in extensions:
+ search_paths.append(os.path.join(images_dir, 'layers', f"{layer}.{ext}"))
+
+ # Search for first existing file
+ for path in search_paths:
+ if os.path.exists(path):
+ # Determine MIME type
+ ext = path.split('.')[-1].lower()
+ mime_types = {
+ 'png': 'image/png',
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'webp': 'image/webp'
+ }
+ mime_type = mime_types.get(ext, 'image/png')
+
+ current_app.logger.info(f"Found image: {path}")
+ return path, mime_type
+
+ current_app.logger.warning(f"No image found for {product_code}/{layer} (color: {color})")
+ return None, None
+
+@canvas_bp.route('/api/canvas//')
+def get_layer_image(product_code, layer):
+ """
+ Serve a specific layer image for a product with hierarchical fallback.
+
+ Query parameters:
+ - color: Optional color variant
+ - material: Optional material (for future use)
+
+ Returns: Image file or 404
+ """
+ color = request.args.get('color')
+ material = request.args.get('material')
+
+ # Validate layer name (prevent directory traversal)
+ valid_layers = ['base', 'door', 'hardware', 'overlay', 'foreground']
+ if layer not in valid_layers:
+ return jsonify({'error': 'Invalid layer name'}), 400
+
+ # Find image with fallback
+ image_path, mime_type = find_image_with_fallback(product_code, layer, color, material)
+
+ if image_path:
+ return send_file(image_path, mimetype=mime_type)
+ else:
+ # Return placeholder or 404
+ return jsonify({
+ 'error': 'Image not found',
+ 'product': product_code,
+ 'layer': layer,
+ 'color': color
+ }), 404
+
+@canvas_bp.route('/api/canvas//info')
+def get_canvas_info(product_code):
+ """
+ Get information about available layers for a product.
+ Useful for frontend to know which layers exist.
+ """
+ product_info = get_product_info(product_code)
+
+ if not product_info:
+ return jsonify({'error': 'Product not found'}), 404
+
+ # Check which layers are available
+ available_layers = {}
+ layers_to_check = ['base', 'door', 'hardware', 'overlay', 'foreground']
+
+ for layer in layers_to_check:
+ # Check if layer exists (with or without color)
+ path, _ = find_image_with_fallback(product_code, layer)
+ if path:
+ available_layers[layer] = True
+
+ # For door layer, check which colors are available
+ if layer == 'door' and product_info['colors']:
+ available_colors = []
+ for color in product_info['colors']:
+ color_path, _ = find_image_with_fallback(product_code, layer, color)
+ if color_path:
+ available_colors.append(color)
+ available_layers['door_colors'] = available_colors
+
+ return jsonify({
+ 'product': product_code,
+ 'type': product_info['type'],
+ 'subtype': product_info['subtype'],
+ 'availableLayers': available_layers,
+ 'colors': product_info['colors'],
+ 'materials': product_info['materials']
+ })
+
+@canvas_bp.route('/api/canvas/test')
+def test_canvas():
+ """Test endpoint to verify canvas API is working"""
+ return jsonify({
+ 'status': 'ok',
+ 'message': 'Canvas API is running',
+ 'endpoints': {
+ 'get_layer': '/api/canvas//?color=',
+ 'get_info': '/api/canvas//info',
+ 'test': '/api/canvas/test'
+ }
+ })
diff --git a/app/blueprints/products.py b/app/blueprints/products.py
index bff17b5..abbcd98 100644
--- a/app/blueprints/products.py
+++ b/app/blueprints/products.py
@@ -1,9 +1,9 @@
-"""
+"""
Product Finder Blueprint
Handles the product quiz and recommendations
"""
-from flask import Blueprint, render_template, send_from_directory
-from blueprints.auth import login_required
+from flask import Blueprint, render_template, send_from_directory, session, jsonify, redirect, url_for
+from blueprints.auth import login_required, get_current_user, can_user
import os
products_bp = Blueprint('products', __name__, url_prefix='/quiz')
@@ -29,6 +29,31 @@ def advanced_search():
"""Serve the advanced search page"""
return render_template('advanced_search.html')
+@products_bp.route('/manage')
+@login_required
+def product_manager():
+ """Serve the product management page"""
+ # Check if user has permission to manage products
+ if not can_user('manage_products'):
+ return redirect(url_for('products.product_finder'))
+ return render_template('product_manager.html')
+
+@products_bp.route('/api/user-session')
+@login_required
+def get_user_session():
+ """Return current user's session data including accessible locations"""
+ user = get_current_user()
+ permissions = user.get('permissions', {}) if user else {}
+
+ return jsonify({
+ 'username': session.get('username'),
+ 'userId': session.get('user_id'),
+ 'currentLocation': session.get('currentLocation'),
+ 'accessibleLocations': session.get('accessibleLocations', []),
+ 'role': session.get('role', 'user'),
+ 'permissions': permissions
+ })
+
@products_bp.route('/css/')
def serve_css(filename):
"""Serve CSS files"""
@@ -48,3 +73,12 @@ def serve_images(filename):
def serve_data(filename):
"""Serve data files (JSON)"""
return send_from_directory(os.path.join(basedir, 'data'), filename)
+
+@products_bp.route('/product/')
+@login_required
+def product_detail(product_code):
+ """
+ Serve product detail page with URL-based navigation
+ This allows direct linking to products via /quiz/product/404
+ """
+ return render_template('product_finder.html', initial_product=product_code)
diff --git a/app/create_test_images.py b/app/create_test_images.py
new file mode 100644
index 0000000..d11f20f
--- /dev/null
+++ b/app/create_test_images.py
@@ -0,0 +1,90 @@
+"""
+Create simple test placeholder images for Canvas API testing
+"""
+from PIL import Image, ImageDraw, ImageFont
+import os
+
+def create_placeholder_image(path, text, color, size=(800, 600)):
+ """Create a simple colored placeholder image with text"""
+ # Create image with solid color
+ img = Image.new('RGB', size, color)
+ draw = ImageDraw.Draw(img)
+
+ # Add text
+ try:
+ # Try to use a default font
+ font = ImageFont.truetype("arial.ttf", 48)
+ except:
+ # Fallback to default font
+ font = ImageFont.load_default()
+
+ # Get text bounding box for centering
+ bbox = draw.textbbox((0, 0), text, font=font)
+ text_width = bbox[2] - bbox[0]
+ text_height = bbox[3] - bbox[1]
+
+ position = ((size[0] - text_width) // 2, (size[1] - text_height) // 2)
+
+ # Draw text with outline for visibility
+ outline_color = 'white' if sum(color) < 400 else 'black'
+ for offset_x in [-2, 0, 2]:
+ for offset_y in [-2, 0, 2]:
+ draw.text((position[0] + offset_x, position[1] + offset_y),
+ text, font=font, fill=outline_color)
+
+ text_color = 'black' if sum(color) > 400 else 'white'
+ draw.text(position, text, font=font, fill=text_color)
+
+ # Save image
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ img.save(path)
+ print(f"✓ Created: {path}")
+
+# Base directory
+base_dir = 'static/images'
+
+# Create product-specific images for 404
+create_placeholder_image(
+ f'{base_dir}/window/storm-window/404/door-white.png',
+ '404\nDoor - White',
+ (240, 240, 240) # Light gray
+)
+
+create_placeholder_image(
+ f'{base_dir}/window/storm-window/404/door-black.png',
+ '404\nDoor - Black',
+ (40, 40, 40) # Dark gray
+)
+
+# Create shared images for storm windows
+create_placeholder_image(
+ f'{base_dir}/window/storm-window/layers/base.jpg',
+ 'Storm Window\nBase Layer\n(Shared)',
+ (200, 220, 240) # Light blue
+)
+
+create_placeholder_image(
+ f'{base_dir}/window/storm-window/layers/hardware.png',
+ 'Storm Window\nHardware\n(Shared)',
+ (180, 180, 180) # Silver
+)
+
+create_placeholder_image(
+ f'{base_dir}/window/storm-window/layers/foreground.png',
+ 'Storm Window\nForeground\n(Shared Plants)',
+ (100, 180, 100) # Green
+)
+
+# Create global fallback images
+create_placeholder_image(
+ f'{base_dir}/layers/base.jpg',
+ 'Global\nBase Layer\n(Fallback)',
+ (220, 200, 180) # Beige
+)
+
+print("\n✅ All test images created successfully!")
+print("\nNow test the Canvas API:")
+print(" http://localhost:8080/api/canvas/404/base")
+print(" http://localhost:8080/api/canvas/404/door?color=white")
+print(" http://localhost:8080/api/canvas/404/hardware")
+print(" http://localhost:8080/api/canvas/404/foreground")
diff --git a/app/create_visual_test_images.py b/app/create_visual_test_images.py
new file mode 100644
index 0000000..1ec94e3
--- /dev/null
+++ b/app/create_visual_test_images.py
@@ -0,0 +1,240 @@
+"""
+Create visual test images for the Canvas API layered system.
+Demonstrates base layer, door layer (with colors), hardware layer, and foreground layer.
+"""
+
+from PIL import Image, ImageDraw, ImageFont
+import os
+
+# Canvas dimensions - all layers use the same size
+CANVAS_WIDTH = 800
+CANVAS_HEIGHT = 600
+
+# Output directory
+OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'static', 'images')
+
+def create_base_layer():
+ """Create base layer - a simple house background"""
+ img = Image.new('RGB', (CANVAS_WIDTH, CANVAS_HEIGHT), color='#87CEEB') # Sky blue
+ draw = ImageDraw.Draw(img)
+
+ # Draw grass at bottom
+ draw.rectangle([(0, 450), (CANVAS_WIDTH, CANVAS_HEIGHT)], fill='#90EE90') # Light green
+
+ # Draw house body (tan/beige)
+ house_left = 150
+ house_right = 650
+ house_top = 200
+ house_bottom = 500
+ draw.rectangle([(house_left, house_top), (house_right, house_bottom)], fill='#D2B48C') # Tan
+
+ # Draw roof (dark brown triangle)
+ roof_points = [
+ (400, 100), # Top point
+ (house_left - 30, house_top), # Left bottom
+ (house_right + 30, house_top) # Right bottom
+ ]
+ draw.polygon(roof_points, fill='#654321') # Dark brown
+
+ # Draw window openings (lighter rectangles where door/window will go)
+ # Door opening in center
+ door_left = 325
+ door_right = 475
+ door_top = 280
+ door_bottom = 480
+ draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill='#C9B896') # Lighter tan
+
+ # Add some house details (window on left side)
+ draw.rectangle([(200, 280), (280, 360)], fill='#87CEEB') # Window
+ draw.rectangle([(238, 280), (242, 360)], fill='#654321') # Window divider vertical
+ draw.rectangle([(200, 318), (280, 322)], fill='#654321') # Window divider horizontal
+
+ # Window on right side
+ draw.rectangle([(520, 280), (600, 360)], fill='#87CEEB') # Window
+ draw.rectangle([(558, 280), (562, 360)], fill='#654321') # Window divider vertical
+ draw.rectangle([(520, 318), (600, 322)], fill='#654321') # Window divider horizontal
+
+ return img
+
+def create_door_layer(color_name, color_hex):
+ """Create door layer with transparency - colored rectangle positioned in door opening"""
+ img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
+ draw = ImageDraw.Draw(img)
+
+ # Door dimensions - positioned where the door opening is on the house
+ door_left = 330
+ door_right = 470
+ door_top = 285
+ door_bottom = 475
+
+ # Draw door
+ draw.rectangle([(door_left, door_top), (door_right, door_bottom)], fill=color_hex)
+
+ # Draw door panels (decorative)
+ panel_margin = 15
+ panel_gap = 10
+
+ # Top panel
+ draw.rectangle([
+ (door_left + panel_margin, door_top + panel_margin),
+ (door_right - panel_margin, door_top + panel_margin + 80)
+ ], outline='#000000', width=3)
+
+ # Bottom panel
+ draw.rectangle([
+ (door_left + panel_margin, door_top + panel_margin + 80 + panel_gap),
+ (door_right - panel_margin, door_bottom - panel_margin)
+ ], outline='#000000', width=3)
+
+ # Door handle position marker (small circle to show where handle will go)
+ # On the right side for left-hinge door
+ handle_x = door_right - 40
+ handle_y = door_top + (door_bottom - door_top) // 2
+ draw.ellipse([
+ (handle_x - 8, handle_y - 8),
+ (handle_x + 8, handle_y + 8)
+ ], fill='#000000')
+
+ return img
+
+def create_hardware_layer():
+ """Create hardware layer with transparency - gold circle for door handle"""
+ img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
+ draw = ImageDraw.Draw(img)
+
+ # Door handle position - matches the marker on the door
+ # Right side for left-hinge door
+ handle_x = 430 # door_right (470) - 40
+ handle_y = 380 # middle of door
+
+ # Draw decorative backplate (bronze/gold rectangle)
+ plate_width = 30
+ plate_height = 80
+ draw.rectangle([
+ (handle_x - plate_width//2, handle_y - plate_height//2),
+ (handle_x + plate_width//2, handle_y + plate_height//2)
+ ], fill='#B8860B') # Dark goldenrod
+
+ # Draw handle (gold circle)
+ handle_radius = 20
+ draw.ellipse([
+ (handle_x - handle_radius, handle_y - handle_radius),
+ (handle_x + handle_radius, handle_y + handle_radius)
+ ], fill='#FFD700') # Gold
+
+ # Add shine/highlight
+ highlight_offset = 6
+ draw.ellipse([
+ (handle_x - highlight_offset, handle_y - highlight_offset),
+ (handle_x - highlight_offset + 8, handle_y - highlight_offset + 8)
+ ], fill='#FFFFE0') # Light yellow highlight
+
+ return img
+
+def create_foreground_layer():
+ """Create foreground layer with transparency - plants at bottom"""
+ img = Image.new('RGBA', (CANVAS_WIDTH, CANVAS_HEIGHT), color=(0, 0, 0, 0)) # Transparent
+ draw = ImageDraw.Draw(img)
+
+ # Draw plants/bushes at the bottom corners
+ # Left plant
+ left_plant_x = 120
+ plant_y = 480
+ for i in range(3):
+ offset_x = i * 25
+ offset_y = i * 10
+ # Draw leaves (green circles)
+ draw.ellipse([
+ (left_plant_x + offset_x - 30, plant_y + offset_y - 30),
+ (left_plant_x + offset_x + 30, plant_y + offset_y + 30)
+ ], fill='#228B22') # Forest green
+
+ # Right plant
+ right_plant_x = 680
+ for i in range(3):
+ offset_x = -i * 25
+ offset_y = i * 10
+ # Draw leaves (green circles)
+ draw.ellipse([
+ (right_plant_x + offset_x - 30, plant_y + offset_y - 30),
+ (right_plant_x + offset_x + 30, plant_y + offset_y + 30)
+ ], fill='#228B22') # Forest green
+
+ # Add some decorative flowers
+ flower_positions = [
+ (100, 520), (140, 510), (160, 530), # Left side
+ (640, 530), (660, 510), (700, 520) # Right side
+ ]
+
+ for fx, fy in flower_positions:
+ # Flower petals (pink circles)
+ petal_radius = 8
+ for angle in range(0, 360, 72): # 5 petals
+ import math
+ px = fx + int(12 * math.cos(math.radians(angle)))
+ py = fy + int(12 * math.sin(math.radians(angle)))
+ draw.ellipse([
+ (px - petal_radius, py - petal_radius),
+ (px + petal_radius, py + petal_radius)
+ ], fill='#FFB6C1') # Light pink
+
+ # Flower center (yellow circle)
+ center_radius = 6
+ draw.ellipse([
+ (fx - center_radius, fy - center_radius),
+ (fx + center_radius, fy + center_radius)
+ ], fill='#FFD700') # Gold
+
+ return img
+
+def main():
+ print("Creating visual test images for Canvas API...")
+
+ # Define paths
+ base_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', '404')
+ layers_path = os.path.join(OUTPUT_DIR, 'window', 'storm-window', 'layers')
+
+ # Create directories
+ os.makedirs(base_path, exist_ok=True)
+ os.makedirs(layers_path, exist_ok=True)
+
+ # Create base layer
+ print("Creating base layer (house)...")
+ base_img = create_base_layer()
+ base_img.save(os.path.join(layers_path, 'base.jpg'), quality=90)
+ print(f" ✓ Saved: {layers_path}/base.jpg")
+
+ # Create door layers in various colors
+ door_colors = {
+ 'white': '#FFFFFF',
+ 'black': '#2C2C2C',
+ 'bronze': '#8B6914',
+ 'tan': '#D2B48C'
+ }
+
+ print("\nCreating door layers...")
+ for color_name, color_hex in door_colors.items():
+ door_img = create_door_layer(color_name, color_hex)
+ door_img.save(os.path.join(base_path, f'door-{color_name}.png'))
+ print(f" ✓ Saved: {base_path}/door-{color_name}.png")
+
+ # Create hardware layer
+ print("\nCreating hardware layer (gold handle)...")
+ hardware_img = create_hardware_layer()
+ hardware_img.save(os.path.join(layers_path, 'hardware.png'))
+ print(f" ✓ Saved: {layers_path}/hardware.png")
+
+ # Create foreground layer
+ print("\nCreating foreground layer (plants)...")
+ foreground_img = create_foreground_layer()
+ foreground_img.save(os.path.join(layers_path, 'foreground.png'))
+ print(f" ✓ Saved: {layers_path}/foreground.png")
+
+ print("\n✅ All images created successfully!")
+ print("\nTest URLs:")
+ print(" Left-hinge: http://localhost:8080/quiz/product/404")
+ print(" Right-hinge: http://localhost:8080/quiz/product/404R")
+ print("\nThe right-hinge version will flip the door and hardware layers horizontally.")
+
+if __name__ == '__main__':
+ main()
diff --git a/app/data/product_attributes.json b/app/data/product_attributes.json
new file mode 100644
index 0000000..ef5f1d6
--- /dev/null
+++ b/app/data/product_attributes.json
@@ -0,0 +1,215 @@
+{
+ "locations": [
+ {
+ "code": "IOLA",
+ "name": "Iola"
+ },
+ {
+ "code": "LINDS",
+ "name": "Lindsborg"
+ },
+ {
+ "code": "KC",
+ "name": "Kansas City"
+ },
+ {
+ "code": "BMD",
+ "name": "BMD"
+ }
+ ],
+ "statuses": [
+ {
+ "value": "default",
+ "label": "Default"
+ },
+ {
+ "value": "discontinued",
+ "label": "Discontinued"
+ },
+ {
+ "value": "hidden",
+ "label": "Hide"
+ }
+ ],
+ "productTypes": [
+ {
+ "id": "storm_door",
+ "type": "Storm Door",
+ "category": "STORMS",
+ "baseType": "Door",
+ "subType": "Storm Door",
+ "availableMaterials": ["Aluminum", "Steel"],
+ "availableColors": ["White", "Bronze", "Black", "Sandstone"],
+ "additionalOptions": []
+ },
+ {
+ "id": "storm_window",
+ "type": "Storm Window",
+ "category": "STORMS",
+ "baseType": "Window",
+ "subType": "Storm Window",
+ "availableMaterials": ["Aluminum"],
+ "availableColors": ["White", "Bronze", "Black", "Sandstone"],
+ "additionalOptions": []
+ },
+ {
+ "id": "primary_window",
+ "type": "Primary Window",
+ "category": "SHPT",
+ "baseType": "Window",
+ "subType": "Primary Window",
+ "availableMaterials": ["Vinyl", "Aluminum"],
+ "availableColors": ["White", "Bronze", "Mill"],
+ "additionalOptions": []
+ },
+ {
+ "id": "sliding_window",
+ "type": "Sliding Window",
+ "category": "SHPW",
+ "baseType": "Window",
+ "subType": "Sliding Window",
+ "availableMaterials": ["Vinyl", "Aluminum"],
+ "availableColors": ["White", "Bronze", "Mill"],
+ "additionalOptions": []
+ },
+ {
+ "id": "fixed_lite",
+ "type": "Fixed Lite",
+ "category": "FPPW",
+ "baseType": "Window",
+ "subType": "Fixed Lite",
+ "availableMaterials": ["Vinyl", "Aluminum"],
+ "availableColors": ["White", "Bronze", "Mill"],
+ "additionalOptions": []
+ },
+ {
+ "id": "casement",
+ "type": "Casement",
+ "category": "CASEMNT",
+ "baseType": "Window",
+ "subType": "Casement",
+ "availableMaterials": ["Aluminum"],
+ "availableColors": ["White", "Bronze"],
+ "additionalOptions": ["1-Panel", "2-Panel", "3-Panel"]
+ },
+ {
+ "id": "double_hung",
+ "type": "Double Hung",
+ "category": "3000DHP",
+ "baseType": "Window",
+ "subType": "Double Hung",
+ "availableMaterials": ["Aluminum"],
+ "availableColors": ["White", "Bronze"],
+ "additionalOptions": ["Thermal Break", "Standard"]
+ },
+ {
+ "id": "screen_door",
+ "type": "Screen Door",
+ "category": "SCREENS",
+ "baseType": "Door",
+ "subType": "Screen Door",
+ "availableMaterials": ["Aluminum"],
+ "availableColors": ["White", "Bronze", "Black"],
+ "additionalOptions": ["Self-Storing", "Fixed"]
+ }
+ ],
+ "allMaterials": [
+ "Aluminum",
+ "Vinyl",
+ "Steel",
+ "Wood",
+ "Fiberglass"
+ ],
+ "allColors": [
+ "White",
+ "Black",
+ "Bronze",
+ "Sandstone",
+ "Mill",
+ "Almond",
+ "Clay",
+ "Gray"
+ ],
+ "categories": [
+ {
+ "code": "STORMS",
+ "label": "Storm Products"
+ },
+ {
+ "code": "SHPW",
+ "label": "Single Hung Primary Windows"
+ },
+ {
+ "code": "SHPT",
+ "label": "Single Hung Primary Tilt"
+ },
+ {
+ "code": "FPPW",
+ "label": "Fixed Picture Primary Windows"
+ },
+ {
+ "code": "CASEMNT",
+ "label": "Casement Windows"
+ },
+ {
+ "code": "3000DHP",
+ "label": "3000 Series Double Hung Primary"
+ },
+ {
+ "code": "3000FPP",
+ "label": "3000 Series Fixed Picture Primary"
+ },
+ {
+ "code": "SCREENS",
+ "label": "Screen Doors"
+ }
+ ],
+ "codeModifiers": {
+ "prefixes": [
+ {
+ "id": "replacement",
+ "label": "Replacement (R-)",
+ "value": "R",
+ "appliesTo": ["Window"],
+ "description": "For replacement windows"
+ },
+ {
+ "id": "new_construction",
+ "label": "New Construction (N-)",
+ "value": "N",
+ "appliesTo": ["Window"],
+ "description": "For new construction windows"
+ }
+ ],
+ "suffixes": [
+ {
+ "id": "kickplate",
+ "label": "Kick Plate (KP)",
+ "value": "KP",
+ "appliesTo": ["Door"],
+ "description": "Add kick plate to door"
+ },
+ {
+ "id": "vent",
+ "label": "Vent (V)",
+ "value": "V",
+ "appliesTo": ["Door"],
+ "description": "Add vent to door"
+ },
+ {
+ "id": "screen",
+ "label": "Screen (SCR)",
+ "value": "SCR",
+ "appliesTo": ["Door", "Window"],
+ "description": "Include screen"
+ },
+ {
+ "id": "tempered",
+ "label": "Tempered Glass (T)",
+ "value": "T",
+ "appliesTo": ["Door", "Window"],
+ "description": "Tempered glass option"
+ }
+ ]
+ }
+}
diff --git a/app/data/products.json b/app/data/products.json
index fc37759..ba2eb86 100644
--- a/app/data/products.json
+++ b/app/data/products.json
@@ -40,7 +40,41 @@
"Sandstone"
],
"isAccessory": false,
- "compatibleAccessories": []
+ "compatibleAccessories": [],
+ "imageConfig": {
+ "useCanvasAPI": true
+ }
+ },
+ {
+ "id": "404R",
+ "productCode": "404R",
+ "category": "STORMS",
+ "description": "#404 FALCON STORM WINDOWS (RIGHT-HINGE)",
+ "discontinued": false,
+ "location": "Iola",
+ "baseType": "Window",
+ "subType": {
+ "door": null,
+ "window": "Storm Window"
+ },
+ "materials": [
+ "Aluminum"
+ ],
+ "colors": [
+ "Black",
+ "White",
+ "Bronze",
+ "Sandstone"
+ ],
+ "isAccessory": false,
+ "compatibleAccessories": [],
+ "imageConfig": {
+ "useCanvasAPI": true,
+ "layerTransforms": {
+ "door": "flip-horizontal",
+ "hardware": "flip-horizontal"
+ }
+ }
},
{
"id": "450",
diff --git a/app/data/users.json b/app/data/users.json
index a35b3c6..1455c21 100644
--- a/app/data/users.json
+++ b/app/data/users.json
@@ -4,7 +4,8 @@
"password": "pbkdf2:sha256:1000000$tPRmseRGKLveXTxS$87eda9a20db29d12838fcb607f1f3536141f743ec9879702dc2a0dd893b4078d",
"defaultLocation": "KC",
"permissions": {
- "manage_users": true
+ "manage_users": true,
+ "manage_products": true
},
"locationSettings": {
"LINDS": {
@@ -39,6 +40,7 @@
},
"permissions": {
"manage_users": true,
+ "manage_products": true,
"create_quotes": true
},
"active": true
diff --git a/app/static/css/styles.css b/app/static/css/styles.css
index 9fba4f9..a2f86a3 100644
--- a/app/static/css/styles.css
+++ b/app/static/css/styles.css
@@ -300,6 +300,24 @@ body {
z-index: 4;
}
+.layer-foreground {
+ z-index: 5;
+ pointer-events: none; /* Allow clicks to pass through to controls below */
+}
+
+/* Layer transform modifiers */
+.layer-flip-horizontal {
+ transform: scaleX(-1);
+}
+
+.layer-flip-vertical {
+ transform: scaleY(-1);
+}
+
+.layer-flip-both {
+ transform: scale(-1, -1);
+}
+
.config-preview-notice {
position: absolute;
bottom: 10px;
diff --git a/app/static/images/layers/base.jpg b/app/static/images/layers/base.jpg
new file mode 100644
index 0000000..8af324a
Binary files /dev/null and b/app/static/images/layers/base.jpg differ
diff --git a/app/static/images/window/storm-window/404/door-black.png b/app/static/images/window/storm-window/404/door-black.png
new file mode 100644
index 0000000..6d3d6e5
Binary files /dev/null and b/app/static/images/window/storm-window/404/door-black.png differ
diff --git a/app/static/images/window/storm-window/404/door-bronze.png b/app/static/images/window/storm-window/404/door-bronze.png
new file mode 100644
index 0000000..47d1b60
Binary files /dev/null and b/app/static/images/window/storm-window/404/door-bronze.png differ
diff --git a/app/static/images/window/storm-window/404/door-tan.png b/app/static/images/window/storm-window/404/door-tan.png
new file mode 100644
index 0000000..4710073
Binary files /dev/null and b/app/static/images/window/storm-window/404/door-tan.png differ
diff --git a/app/static/images/window/storm-window/404/door-white.png b/app/static/images/window/storm-window/404/door-white.png
new file mode 100644
index 0000000..6beb8c4
Binary files /dev/null and b/app/static/images/window/storm-window/404/door-white.png differ
diff --git a/app/static/images/window/storm-window/layers/base.jpg b/app/static/images/window/storm-window/layers/base.jpg
new file mode 100644
index 0000000..3b363ce
Binary files /dev/null and b/app/static/images/window/storm-window/layers/base.jpg differ
diff --git a/app/static/images/window/storm-window/layers/foreground.png b/app/static/images/window/storm-window/layers/foreground.png
new file mode 100644
index 0000000..f0c2e0b
Binary files /dev/null and b/app/static/images/window/storm-window/layers/foreground.png differ
diff --git a/app/static/images/window/storm-window/layers/hardware.png b/app/static/images/window/storm-window/layers/hardware.png
new file mode 100644
index 0000000..c82e5cd
Binary files /dev/null and b/app/static/images/window/storm-window/layers/hardware.png differ
diff --git a/app/static/js/script.js b/app/static/js/script.js
index 0296302..0e8704a 100644
--- a/app/static/js/script.js
+++ b/app/static/js/script.js
@@ -106,7 +106,13 @@ let history = ['start'];
function initFromURL() {
const params = new URLSearchParams(window.location.search);
- // Priority 1: Direct product view
+ // Priority 0: Direct product from Flask route (e.g., /quiz/product/404)
+ if (window.INITIAL_PRODUCT && window.INITIAL_PRODUCT !== '') {
+ showProductByCode(window.INITIAL_PRODUCT);
+ return;
+ }
+
+ // Priority 1: Direct product view from query param
const productCode = params.get('p');
if (productCode) {
accumulatedBitValue = parseInt(params.get('b') || '0', 10);
@@ -705,6 +711,7 @@ function showProductDetail(product) {
// Get product image configuration
const productImage = product.image || `https://via.placeholder.com/400x400/e0e0e0/666?text=${encodeURIComponent(prodCode)}`;
+ const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
const hasLayeredImages = product.imageConfig && product.imageConfig.layered === true;
const hasFlatImages = product.flatImages === true || product.imagePattern;
const useDynamicAPI = product.useDynamicAPI === true;
@@ -795,20 +802,21 @@ function showProductDetail(product) {
const contentDiv = document.getElementById('content');
- // Build the image display (layered, flat, or dynamic API)
+ // Build the image display (Canvas API, layered, flat, or dynamic API)
let imageDisplayHtml = '';
- if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
- // Use layered image system
- const basePath = product.imageConfig.basePath || '';
- const layers = product.imageConfig.layers || {};
+ if (useCanvasAPI || (hasLayeredImages && !hasFlatImages && !useDynamicAPI)) {
+ // Use layered image system (Canvas API or static paths)
+ const basePath = product.imageConfig?.basePath || '';
+ const layers = product.imageConfig?.layers || {};
imageDisplayHtml = `
- ${layers.base ? `
` : ''}
+
- ${layers.hardware ? `
` : ''}
+
+
@@ -958,7 +966,11 @@ function showProductDetail(product) {
updateBreadcrumb();
// Initialize images based on type
- if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
+ if (useCanvasAPI) {
+ // Use Canvas API with hierarchical fallback
+ updateCanvasAPIPreview(prodCode);
+ } else if (hasLayeredImages && !hasFlatImages && !useDynamicAPI) {
+ // Use static layered images
updateProductPreview(prodCode);
} else if (hasFlatImages || useDynamicAPI) {
// Flat images or dynamic API - will update on dropdown change
@@ -969,18 +981,138 @@ function showProductDetail(product) {
}
}
+// ============================================================================
+// Canvas API Functions - Hierarchical Image Fallback System
+// ============================================================================
+
+/**
+ * Build Canvas API URL for a specific layer
+ * @param {string} productCode - Product code
+ * @param {string} layer - Layer name (base, door, hardware, overlay, foreground)
+ * @param {string} color - Optional color
+ * @param {string} material - Optional material
+ * @returns {string} Canvas API URL
+ */
+function buildCanvasAPIUrl(productCode, layer, color = null, material = null) {
+ let url = `${BASE_URL}/api/canvas/${productCode}/${layer}`;
+ const params = new URLSearchParams();
+
+ if (color) params.append('color', color.toLowerCase());
+ if (material) params.append('material', material.toLowerCase());
+
+ const queryString = params.toString();
+ return queryString ? `${url}?${queryString}` : url;
+}
+
+/**
+ * Update layered image preview using Canvas API
+ * @param {string} productCode - Product code
+ */
+function updateCanvasAPIPreview(productCode) {
+ const product = productData.find(p => p.productCode === productCode || p.id === productCode);
+ if (!product) return;
+
+ // Get selected values
+ const selectedColor = document.getElementById('config-color')?.value || '';
+ const selectedMaterial = document.getElementById('config-material')?.value || '';
+
+ // Get layer transform configuration (for flipping, etc.)
+ const layerTransforms = product.imageConfig?.layerTransforms || {};
+
+ // Helper function to apply transform classes
+ function applyTransform(element, layerName) {
+ if (!element) return;
+ // Remove any existing transform classes
+ element.classList.remove('layer-flip-horizontal', 'layer-flip-vertical', 'layer-flip-both');
+ // Apply new transform if configured
+ const transform = layerTransforms[layerName];
+ if (transform) {
+ element.classList.add(`layer-${transform}`);
+ }
+ }
+
+ // Update each layer
+ const baseLayer = document.querySelector('.layer-base');
+ const doorLayer = document.getElementById('door-layer');
+ const hardwareLayer = document.getElementById('hardware-layer');
+ const overlayLayer = document.getElementById('overlay-layer');
+ const foregroundLayer = document.querySelector('.layer-foreground');
+
+ // Base layer (no color variation)
+ if (baseLayer && baseLayer.tagName === 'IMG') {
+ baseLayer.src = buildCanvasAPIUrl(productCode, 'base');
+ applyTransform(baseLayer, 'base');
+ baseLayer.onerror = function() {
+ console.warn('Base layer not found via Canvas API');
+ };
+ }
+
+ // Door layer (with color)
+ if (doorLayer) {
+ applyTransform(doorLayer, 'door');
+ if (selectedColor) {
+ doorLayer.src = buildCanvasAPIUrl(productCode, 'door', selectedColor);
+ doorLayer.style.display = 'block';
+ doorLayer.onerror = function() {
+ console.warn(`Door layer not found for color: ${selectedColor}`);
+ this.style.display = 'none';
+ showConfigNotice(true);
+ };
+ doorLayer.onload = function() {
+ showConfigNotice(false);
+ };
+ } else {
+ doorLayer.style.display = 'none';
+ }
+ }
+
+ // Hardware layer
+ if (hardwareLayer) {
+ hardwareLayer.src = buildCanvasAPIUrl(productCode, 'hardware');
+ applyTransform(hardwareLayer, 'hardware');
+ hardwareLayer.onerror = function() {
+ console.warn('Hardware layer not found via Canvas API');
+ this.style.display = 'none';
+ };
+ hardwareLayer.onload = function() {
+ this.style.display = 'block';
+ };
+ }
+
+ // Foreground layer
+ if (foregroundLayer && foregroundLayer.tagName === 'IMG') {
+ foregroundLayer.src = buildCanvasAPIUrl(productCode, 'foreground');
+ applyTransform(foregroundLayer, 'foreground');
+ foregroundLayer.onerror = function() {
+ console.warn('Foreground layer not found via Canvas API');
+ // This is fine - foreground is optional
+ };
+ }
+}
+
+// ============================================================================
// Update product preview based on configuration
+// ============================================================================
+
function updateProductPreview(productCode) {
console.log('updateProductPreview called with:', productCode);
const product = productData.find(p => p.productCode === productCode || p.id === productCode);
if (!product) return;
- // Check which system to use
+ // Check which system to use - Canvas API has priority
+ const useCanvasAPI = product.imageConfig && product.imageConfig.useCanvasAPI === true;
const hasFlatImages = product.flatImages === true || product.imagePattern;
const useDynamicAPI = product.useDynamicAPI === true;
- console.log('Product flags - hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
+ console.log('Product flags - useCanvasAPI:', useCanvasAPI, 'hasFlatImages:', hasFlatImages, 'useDynamicAPI:', useDynamicAPI);
+
+ // Priority: Canvas API > Flat Images > Dynamic API > Layered Static
+ if (useCanvasAPI) {
+ // Use Canvas API with hierarchical fallback
+ updateCanvasAPIPreview(productCode);
+ return;
+ }
if (hasFlatImages) {
// Use pre-rendered flat images
@@ -1810,6 +1942,7 @@ startOver = function() {
// Initialize search bar when page loads
window.addEventListener('DOMContentLoaded', function() {
+ init(); // Load quiz data and initialize
initSearchBar();
toggleSearchBar();
});
diff --git a/app/templates/product_finder.html b/app/templates/product_finder.html
index 29bab7d..5f2d20f 100644
--- a/app/templates/product_finder.html
+++ b/app/templates/product_finder.html
@@ -77,8 +77,16 @@
locationDisplay = `📍 ${currentLoc}`;
}
+ let manageProductsLink = '';
+ if (data.permissions && data.permissions.manage_products) {
+ manageProductsLink = ` | Manage Products`;
+ }
+
document.getElementById('userLocationInfo').innerHTML =
- `👤 ${data.username} | ${locationDisplay}`;
+ `👤 ${data.username} | ${locationDisplay}${manageProductsLink}`;
}
} catch (error) {
console.error('Error loading user info:', error);
@@ -93,6 +101,8 @@
window.APP_BASE_URL = '';
// Set data path for the quiz
window.DATA_PATH = '{{ url_for("products.serve_data", filename="") }}';
+ // Set initial product if provided via URL
+ window.INITIAL_PRODUCT = '{{ initial_product or "" }}';
Add or edit product information
+ + +diff --git a/app/templates/product_manager.html b/app/templates/product_manager.html new file mode 100644 index 0000000..1a8f8ef --- /dev/null +++ b/app/templates/product_manager.html @@ -0,0 +1,762 @@ + + +
+ + +
+ + +
+