Cuong2004 commited on
Commit
3bd69a8
·
1 Parent(s): 102687c

Beauti mode

Browse files
HF_DEPLOYMENT_COMPLETE.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ✅ Hugging Face Spaces Deployment Complete!
2
+
3
+ Your backend API has been successfully deployed to Hugging Face Spaces.
4
+
5
+ ## 🌐 Your Deployed API
6
+
7
+ **URL**: https://cuong2004-remb.hf.space
8
+
9
+ **API Documentation**: https://cuong2004-remb.hf.space/docs
10
+
11
+ **Health Check**: https://cuong2004-remb.hf.space/health
12
+
13
+ ## 📝 What Was Fixed
14
+
15
+ ### 1. README.md with Proper Metadata
16
+ Added Hugging Face Spaces YAML frontmatter:
17
+ ```yaml
18
+ ---
19
+ title: REMB - Land Redistribution API
20
+ emoji: 🏘️
21
+ colorFrom: blue
22
+ colorTo: green
23
+ sdk: docker
24
+ app_port: 7860
25
+ pinned: false
26
+ ---
27
+ ```
28
+
29
+ ### 2. Dockerfile for Root Directory
30
+ Created Dockerfile that:
31
+ - Uses multi-stage build for optimization
32
+ - References `algorithms/backend/` code
33
+ - Exposes port 7860 (HF Spaces standard)
34
+ - Runs as non-root user
35
+
36
+ ### 3. .dockerignore Optimization
37
+ Configured to exclude:
38
+ - Source code outside `algorithms/backend/`
39
+ - Test files and notebooks
40
+ - Virtual environments
41
+ - Documentation
42
+
43
+ ## 🔄 Build Status
44
+
45
+ Hugging Face is now building your Docker container. This typically takes **5-10 minutes**.
46
+
47
+ **Monitor build progress**:
48
+ 1. Visit https://huggingface.co/spaces/Cuong2004/REMB
49
+ 2. Click on "App" or "Logs" tab
50
+ 3. Watch the build logs
51
+
52
+ ## 🧪 Testing Your API
53
+
54
+ Once the build completes, test your API:
55
+
56
+ ### 1. Health Check
57
+ ```bash
58
+ curl https://cuong2004-remb.hf.space/health
59
+ ```
60
+
61
+ Expected response:
62
+ ```json
63
+ {
64
+ "status": "healthy",
65
+ "version": "2.0.0"
66
+ }
67
+ ```
68
+
69
+ ### 2. Run Optimization
70
+ ```bash
71
+ curl -X POST https://cuong2004-remb.hf.space/api/optimize \
72
+ -H "Content-Type: application/json" \
73
+ -d '{
74
+ "config": {
75
+ "spacing_min": 20,
76
+ "spacing_max": 30,
77
+ "population_size": 20,
78
+ "generations": 50
79
+ },
80
+ "land_plots": [{
81
+ "type": "Polygon",
82
+ "coordinates": [[[0,0],[100,0],[100,100],[0,100],[0,0]]]
83
+ }]
84
+ }'
85
+ ```
86
+
87
+ ### 3. View API Documentation
88
+ Open in browser: https://cuong2004-remb.hf.space/docs
89
+
90
+ ## 🎨 Next Step: Deploy Frontend
91
+
92
+ Now deploy your Streamlit frontend to work with this backend:
93
+
94
+ ### 1. Update Frontend API URL
95
+
96
+ In `/Volumes/WorkSpace/Project/REMB/algorithms/frontend`, create `.env`:
97
+ ```bash
98
+ API_URL=https://cuong2004-remb.hf.space
99
+ ```
100
+
101
+ ### 2. Deploy to Streamlit Cloud
102
+
103
+ ```bash
104
+ cd /Volumes/WorkSpace/Project/REMB/algorithms/frontend
105
+
106
+ # Push to GitHub
107
+ git init
108
+ git remote add origin https://github.com/<YOUR_USERNAME>/remb-frontend.git
109
+ git add .
110
+ git commit -m "Initial commit"
111
+ git push -u origin main
112
+ ```
113
+
114
+ ### 3. Configure Streamlit Cloud
115
+
116
+ 1. Go to https://streamlit.io/cloud
117
+ 2. Create new app
118
+ 3. Select your repository
119
+ 4. Set main file: `app.py`
120
+ 5. Add secret in settings:
121
+ ```toml
122
+ API_URL = "https://cuong2004-remb.hf.space"
123
+ ```
124
+
125
+ ## 📊 Architecture
126
+
127
+ ```
128
+ ┌──────────────────┐ ┌─────────────────────┐
129
+ │ Streamlit Cloud │ HTTP │ Hugging Face │
130
+ │ (Frontend) │───────▶ │ Spaces │
131
+ │ │ │ (Backend API) │
132
+ │ To be deployed │ │ ✅ DEPLOYED │
133
+ └──────────────────┘ └─────────────────────┘
134
+
135
+ https://cuong2004-remb.hf.space
136
+ ```
137
+
138
+ ## 🐛 Troubleshooting
139
+
140
+ ### If build fails:
141
+ 1. Check build logs on HF Spaces
142
+ 2. Verify Dockerfile syntax
143
+ 3. Ensure all dependencies are in `algorithms/backend/requirements.txt`
144
+
145
+ ### If API returns errors:
146
+ 1. Check application logs in HF Spaces
147
+ 2. Verify the backend code works locally
148
+ 3. Test with simple requests first
149
+
150
+ ### Common Issues:
151
+
152
+ **Port issues**: HF Spaces requires port 7860 ✅ (configured)
153
+
154
+ **Missing dependencies**: All requirements in `requirements.txt` ✅ (configured)
155
+
156
+ **CORS**: Already configured to allow all origins ✅
157
+
158
+ ## 📁 Files Modified
159
+
160
+ - ✅ `/README.md` - Added HF Spaces metadata
161
+ - ✅ `/Dockerfile` - Created for root deployment
162
+ - ✅ `/.dockerignore` - Optimized for build
163
+
164
+ ## 🎉 Congratulations!
165
+
166
+ Your backend API is now deployed and will be publicly accessible at:
167
+
168
+ **https://cuong2004-remb.hf.space**
169
+
170
+ Wait for the build to complete, then test the API!
algorithms/backend/api/routes/optimization_routes.py CHANGED
@@ -40,10 +40,11 @@ async def optimize_full(request: OptimizationRequest):
40
 
41
  # Create pipeline
42
  config = request.config.dict()
 
43
  pipeline = LandRedistributionPipeline(land_polygons, config)
44
 
45
- # Run optimization
46
- result = pipeline.run_full_pipeline()
47
 
48
  # Build stage results
49
  stages = []
 
40
 
41
  # Create pipeline
42
  config = request.config.dict()
43
+ logger.info(f"API Request Config: Spacing=[{config.get('spacing_min')}, {config.get('spacing_max')}], RoadWidth={config.get('road_width')}")
44
  pipeline = LandRedistributionPipeline(land_polygons, config)
45
 
46
+ # Run optimization with Grid layout method (orthogonal alignment for better aesthetics)
47
+ result = pipeline.run_full_pipeline(layout_method='grid')
48
 
49
  # Build stage results
50
  stages = []
algorithms/backend/api/schemas/request_schemas.py CHANGED
@@ -8,19 +8,19 @@ class AlgorithmConfig(BaseModel):
8
  """Configuration parameters for the land redistribution algorithm."""
9
 
10
  # Stage 1: Grid optimization parameters
11
- spacing_min: float = Field(default=20.0, ge=10.0, le=50.0, description="Minimum grid spacing in meters")
12
- spacing_max: float = Field(default=30.0, ge=10.0, le=50.0, description="Maximum grid spacing in meters")
13
  angle_min: float = Field(default=0.0, ge=0.0, le=90.0, description="Minimum grid angle in degrees")
14
  angle_max: float = Field(default=90.0, ge=0.0, le=90.0, description="Maximum grid angle in degrees")
15
 
16
  # Stage 2: Subdivision parameters (Industrial lots)
17
- min_lot_width: float = Field(default=20.0, ge=10.0, le=40.0, description="Minimum lot width in meters")
18
- max_lot_width: float = Field(default=80.0, ge=40.0, le=120.0, description="Maximum lot width in meters")
19
  target_lot_width: float = Field(default=40.0, ge=20.0, le=100.0, description="Target lot width in meters")
20
 
21
  # Infrastructure parameters
22
- road_width: float = Field(default=6.0, ge=3.0, le=10.0, description="Road width in meters")
23
- block_depth: float = Field(default=50.0, ge=30.0, le=100.0, description="Block depth in meters")
24
 
25
  # Optimization parameters
26
  population_size: int = Field(default=50, ge=20, le=200, description="NSGA-II population size")
 
8
  """Configuration parameters for the land redistribution algorithm."""
9
 
10
  # Stage 1: Grid optimization parameters
11
+ spacing_min: float = Field(default=20.0, ge=10.0, le=200.0, description="Minimum grid spacing in meters")
12
+ spacing_max: float = Field(default=30.0, ge=10.0, le=200.0, description="Maximum grid spacing in meters")
13
  angle_min: float = Field(default=0.0, ge=0.0, le=90.0, description="Minimum grid angle in degrees")
14
  angle_max: float = Field(default=90.0, ge=0.0, le=90.0, description="Maximum grid angle in degrees")
15
 
16
  # Stage 2: Subdivision parameters (Industrial lots)
17
+ min_lot_width: float = Field(default=20.0, ge=10.0, le=100.0, description="Minimum lot width in meters")
18
+ max_lot_width: float = Field(default=80.0, ge=40.0, le=200.0, description="Maximum lot width in meters")
19
  target_lot_width: float = Field(default=40.0, ge=20.0, le=100.0, description="Target lot width in meters")
20
 
21
  # Infrastructure parameters
22
+ road_width: float = Field(default=6.0, ge=3.0, le=30.0, description="Road width in meters")
23
+ block_depth: float = Field(default=50.0, ge=30.0, le=200.0, description="Block depth in meters")
24
 
25
  # Optimization parameters
26
  population_size: int = Field(default=50, ge=20, le=200, description="NSGA-II population size")
algorithms/backend/core/config/settings.py CHANGED
@@ -14,8 +14,8 @@ class RoadSettings:
14
  """Road and transportation infrastructure settings (TCVN standards)."""
15
 
16
  # Road widths (meters)
17
- main_width: float = 30.0 # Main road - container trucks can pass
18
- internal_width: float = 15.0 # Internal road
19
  sidewalk_width: float = 4.0 # Sidewalk each side (includes utility trench)
20
  turning_radius: float = 15.0 # Corner chamfer radius for intersections
21
 
@@ -25,8 +25,8 @@ class SubdivisionSettings:
25
  """Block and lot subdivision settings."""
26
 
27
  # Land allocation
28
- service_area_ratio: float = 0.10 # 10% for infrastructure (WWTP, parking, etc.)
29
- min_block_area: float = 5000.0 # Minimum block area to subdivide (m²)
30
 
31
  # Lot dimensions (industrial)
32
  min_lot_width: float = 20.0 # Minimum lot frontage (m)
@@ -71,7 +71,8 @@ class OptimizationSettings:
71
  eta: float = 20.0 # Distribution index for SBX crossover
72
 
73
  # Gene bounds
74
- spacing_bounds: Tuple[float, float] = (20.0, 40.0)
 
75
  angle_bounds: Tuple[float, float] = (0.0, 90.0)
76
 
77
  # Block quality thresholds
@@ -79,6 +80,28 @@ class OptimizationSettings:
79
  fragmented_block_ratio: float = 0.1 # Below this = too small
80
 
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  @dataclass
83
  class AlgorithmSettings:
84
  """Complete algorithm configuration."""
@@ -87,6 +110,7 @@ class AlgorithmSettings:
87
  subdivision: SubdivisionSettings = field(default_factory=SubdivisionSettings)
88
  infrastructure: InfrastructureSettings = field(default_factory=InfrastructureSettings)
89
  optimization: OptimizationSettings = field(default_factory=OptimizationSettings)
 
90
 
91
  # Random seed for reproducibility
92
  random_seed: int = 42
@@ -108,7 +132,21 @@ class AlgorithmSettings:
108
  optimization=OptimizationSettings(
109
  population_size=config.get('population_size', 30),
110
  generations=config.get('generations', 15),
 
 
 
 
 
 
 
 
111
  ),
 
 
 
 
 
 
112
  )
113
 
114
  return settings
@@ -132,3 +170,10 @@ SETBACK_DISTANCE = DEFAULT_SETTINGS.subdivision.setback_distance
132
  FIRE_SAFETY_GAP = DEFAULT_SETTINGS.subdivision.fire_safety_gap
133
  SOLVER_TIME_LIMIT = DEFAULT_SETTINGS.subdivision.solver_time_limit
134
  TRANSFORMER_RADIUS = DEFAULT_SETTINGS.infrastructure.transformer_radius
 
 
 
 
 
 
 
 
14
  """Road and transportation infrastructure settings (TCVN standards)."""
15
 
16
  # Road widths (meters)
17
+ main_width: float = 20.0 # Main road
18
+ internal_width: float = 10.0 # Internal road (reduced from 15.0 to allow better efficiency)
19
  sidewalk_width: float = 4.0 # Sidewalk each side (includes utility trench)
20
  turning_radius: float = 15.0 # Corner chamfer radius for intersections
21
 
 
25
  """Block and lot subdivision settings."""
26
 
27
  # Land allocation
28
+ service_area_ratio: float = 0.10 # 10% for infrastructure
29
+ min_block_area: float = 400.0 # Minimum block area (reduced from 5000.0 to 400.0)
30
 
31
  # Lot dimensions (industrial)
32
  min_lot_width: float = 20.0 # Minimum lot frontage (m)
 
71
  eta: float = 20.0 # Distribution index for SBX crossover
72
 
73
  # Gene bounds
74
+ # Gene bounds - Increased to create larger, more usable blocks
75
+ spacing_bounds: Tuple[float, float] = (50.0, 150.0)
76
  angle_bounds: Tuple[float, float] = (0.0, 90.0)
77
 
78
  # Block quality thresholds
 
80
  fragmented_block_ratio: float = 0.1 # Below this = too small
81
 
82
 
83
+ @dataclass(frozen=True)
84
+ class AestheticSettings:
85
+ """Shape quality thresholds for aesthetic optimization (from Beauti_mode)."""
86
+
87
+ # Rectangularity: area / OBB area (1.0 = perfect rectangle)
88
+ # Relaxed to 0.65 to accept trapezoids from Voronoi slicing
89
+ min_rectangularity: float = 0.65
90
+
91
+ # Aspect ratio: length / width (lower = more square)
92
+ max_aspect_ratio: float = 4.0
93
+
94
+ # Minimum lot area to avoid tiny fragments (m²)
95
+ # Relaxed from 1000.0 to 250.0 to accept standard industrial/residential lots
96
+ min_lot_area: float = 250.0
97
+
98
+ # OR-Tools deviation penalty weight (higher = more uniform lots)
99
+ deviation_penalty_weight: float = 50.0
100
+
101
+ # Enable leftover management (convert poor lots to green space)
102
+ enable_leftover_management: bool = True
103
+
104
+
105
  @dataclass
106
  class AlgorithmSettings:
107
  """Complete algorithm configuration."""
 
110
  subdivision: SubdivisionSettings = field(default_factory=SubdivisionSettings)
111
  infrastructure: InfrastructureSettings = field(default_factory=InfrastructureSettings)
112
  optimization: OptimizationSettings = field(default_factory=OptimizationSettings)
113
+ aesthetic: AestheticSettings = field(default_factory=AestheticSettings)
114
 
115
  # Random seed for reproducibility
116
  random_seed: int = 42
 
132
  optimization=OptimizationSettings(
133
  population_size=config.get('population_size', 30),
134
  generations=config.get('generations', 15),
135
+ spacing_bounds=(
136
+ config.get('spacing_min', 50.0),
137
+ config.get('spacing_max', 150.0)
138
+ ),
139
+ angle_bounds=(
140
+ config.get('angle_min', 0.0),
141
+ config.get('angle_max', 90.0)
142
+ ),
143
  ),
144
+ road=RoadSettings(
145
+ main_width=DEFAULT_SETTINGS.road.main_width,
146
+ internal_width=config.get('road_width', DEFAULT_SETTINGS.road.internal_width),
147
+ sidewalk_width=DEFAULT_SETTINGS.road.sidewalk_width,
148
+ turning_radius=DEFAULT_SETTINGS.road.turning_radius
149
+ )
150
  )
151
 
152
  return settings
 
170
  FIRE_SAFETY_GAP = DEFAULT_SETTINGS.subdivision.fire_safety_gap
171
  SOLVER_TIME_LIMIT = DEFAULT_SETTINGS.subdivision.solver_time_limit
172
  TRANSFORMER_RADIUS = DEFAULT_SETTINGS.infrastructure.transformer_radius
173
+
174
+ # Aesthetic thresholds (from Beauti_mode)
175
+ MIN_RECTANGULARITY = DEFAULT_SETTINGS.aesthetic.min_rectangularity
176
+ MAX_ASPECT_RATIO = DEFAULT_SETTINGS.aesthetic.max_aspect_ratio
177
+ MIN_LOT_AREA = DEFAULT_SETTINGS.aesthetic.min_lot_area
178
+ DEVIATION_PENALTY_WEIGHT = DEFAULT_SETTINGS.aesthetic.deviation_penalty_weight
179
+ ENABLE_LEFTOVER_MANAGEMENT = DEFAULT_SETTINGS.aesthetic.enable_leftover_management
algorithms/backend/core/geometry/__init__.py CHANGED
@@ -8,3 +8,12 @@ from core.geometry.voronoi import (
8
  create_voronoi_diagram,
9
  extract_voronoi_edges,
10
  )
 
 
 
 
 
 
 
 
 
 
8
  create_voronoi_diagram,
9
  extract_voronoi_edges,
10
  )
11
+ from core.geometry.shape_quality import (
12
+ analyze_shape_quality,
13
+ get_dominant_edge_vector,
14
+ classify_lot_type,
15
+ )
16
+ from core.geometry.orthogonal_slicer import (
17
+ orthogonal_slice,
18
+ subdivide_with_uniform_widths,
19
+ )
algorithms/backend/core/geometry/orthogonal_slicer.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orthogonal slicing for regular lot subdivision.
3
+
4
+ Implements orthogonal alignment from Beauti_mode.md:
5
+ - Cuts perpendicular to dominant edge (usually frontage)
6
+ - Creates parallel, regular lot shapes
7
+ - Uses coordinate rotation for clean geometry
8
+
9
+ Forces 90-degree cutting angles for professional-looking layouts.
10
+ """
11
+
12
+ import numpy as np
13
+ from shapely.geometry import Polygon, LineString, box
14
+ from shapely.affinity import rotate, translate
15
+ from shapely.ops import split
16
+ from typing import List, Optional, Tuple
17
+
18
+ from core.geometry.shape_quality import (
19
+ get_dominant_edge_vector,
20
+ get_perpendicular_vector,
21
+ get_obb_dimensions
22
+ )
23
+
24
+
25
+ def orthogonal_slice(
26
+ block: Polygon,
27
+ lot_widths: List[float],
28
+ buffer_distance: float = 0.1
29
+ ) -> List[Polygon]:
30
+ """
31
+ Slice block into lots perpendicular to its dominant edge.
32
+
33
+ This creates regular, parallel lots aligned to the block's
34
+ natural orientation (typically road frontage).
35
+
36
+ Args:
37
+ block: Block polygon to subdivide
38
+ lot_widths: List of widths for each lot
39
+ buffer_distance: Small buffer to ensure clean cuts
40
+
41
+ Returns:
42
+ List of lot polygons
43
+ """
44
+ if block.is_empty or not block.is_valid:
45
+ return []
46
+
47
+ if not lot_widths:
48
+ return [block]
49
+
50
+ # Get dominant direction vector
51
+ direction_vec = get_dominant_edge_vector(block)
52
+ if direction_vec is None:
53
+ # Fallback to axis-aligned slicing
54
+ return _axis_aligned_slice(block, lot_widths)
55
+
56
+ # Get perpendicular vector for cutting
57
+ perp_vec = get_perpendicular_vector(direction_vec)
58
+
59
+ # Calculate rotation angle to align with X-axis
60
+ angle_rad = np.arctan2(direction_vec[1], direction_vec[0])
61
+ angle_deg = np.degrees(angle_rad)
62
+
63
+ # Rotate block to align dominant edge with X-axis
64
+ center = block.centroid
65
+ rotated_block = rotate(block, -angle_deg, origin=center)
66
+
67
+ # Perform axis-aligned slicing on rotated block
68
+ rotated_lots = _axis_aligned_slice(rotated_block, lot_widths)
69
+
70
+ # Rotate lots back to original orientation
71
+ lots = [rotate(lot, angle_deg, origin=center) for lot in rotated_lots]
72
+
73
+ # Clip to original block boundary (handles edge cases)
74
+ clipped_lots = []
75
+ for lot in lots:
76
+ clipped = lot.intersection(block)
77
+ if not clipped.is_empty and clipped.geom_type == 'Polygon':
78
+ clipped_lots.append(clipped)
79
+
80
+ return clipped_lots
81
+
82
+
83
+ def _axis_aligned_slice(
84
+ block: Polygon,
85
+ lot_widths: List[float]
86
+ ) -> List[Polygon]:
87
+ """
88
+ Slice block along X-axis (assumes block is axis-aligned).
89
+
90
+ Args:
91
+ block: Block polygon to subdivide
92
+ lot_widths: List of widths for each lot
93
+
94
+ Returns:
95
+ List of lot polygons
96
+ """
97
+ minx, miny, maxx, maxy = block.bounds
98
+ total_width = maxx - minx
99
+ block_height = maxy - miny
100
+
101
+ # Scale widths to fit block if necessary
102
+ sum_widths = sum(lot_widths)
103
+ if sum_widths > 0:
104
+ scale_factor = total_width / sum_widths
105
+ scaled_widths = [w * scale_factor for w in lot_widths]
106
+ else:
107
+ return [block]
108
+
109
+ lots = []
110
+ current_x = minx
111
+
112
+ for width in scaled_widths:
113
+ # Create rectangular lot
114
+ lot_poly = box(current_x, miny, current_x + width, maxy)
115
+
116
+ # Clip to block boundary
117
+ clipped = lot_poly.intersection(block)
118
+ if not clipped.is_empty and clipped.geom_type == 'Polygon':
119
+ lots.append(clipped)
120
+
121
+ current_x += width
122
+
123
+ return lots
124
+
125
+
126
+ def slice_along_direction(
127
+ block: Polygon,
128
+ direction_vec: np.ndarray,
129
+ num_slices: int,
130
+ target_width: Optional[float] = None
131
+ ) -> List[Polygon]:
132
+ """
133
+ Slice block along a specific direction vector.
134
+
135
+ Args:
136
+ block: Block polygon to subdivide
137
+ direction_vec: Unit vector for slice direction
138
+ num_slices: Number of slices to create
139
+ target_width: Optional target width (overrides num_slices)
140
+
141
+ Returns:
142
+ List of lot polygons
143
+ """
144
+ if block.is_empty or not block.is_valid:
145
+ return []
146
+
147
+ if num_slices <= 0:
148
+ return [block]
149
+
150
+ # Get OBB dimensions
151
+ width, length, _ = get_obb_dimensions(block)
152
+
153
+ # Calculate widths
154
+ if target_width and target_width > 0:
155
+ num_slices = max(1, int(length / target_width))
156
+
157
+ slice_width = length / num_slices if num_slices > 0 else length
158
+ lot_widths = [slice_width] * num_slices
159
+
160
+ return orthogonal_slice(block, lot_widths)
161
+
162
+
163
+ def create_cutting_lines(
164
+ block: Polygon,
165
+ num_cuts: int,
166
+ direction_vec: Optional[np.ndarray] = None
167
+ ) -> List[LineString]:
168
+ """
169
+ Create cutting lines for visualization or debugging.
170
+
171
+ Args:
172
+ block: Block polygon
173
+ num_cuts: Number of cutting lines
174
+ direction_vec: Optional direction vector (uses dominant if None)
175
+
176
+ Returns:
177
+ List of LineString cutting lines
178
+ """
179
+ if block.is_empty or not block.is_valid:
180
+ return []
181
+
182
+ if direction_vec is None:
183
+ direction_vec = get_dominant_edge_vector(block)
184
+ if direction_vec is None:
185
+ return []
186
+
187
+ perp_vec = get_perpendicular_vector(direction_vec)
188
+
189
+ # Get block bounds and diagonal
190
+ minx, miny, maxx, maxy = block.bounds
191
+ diagonal = np.hypot(maxx - minx, maxy - miny)
192
+
193
+ # Get OBB dimensions for spacing
194
+ width, length, _ = get_obb_dimensions(block)
195
+ spacing = length / (num_cuts + 1) if num_cuts > 0 else length
196
+
197
+ center = np.array([block.centroid.x, block.centroid.y])
198
+
199
+ cutting_lines = []
200
+
201
+ for i in range(1, num_cuts + 1):
202
+ # Calculate offset from center
203
+ offset = (i - (num_cuts + 1) / 2) * spacing
204
+
205
+ # Line center point
206
+ line_center = center + direction_vec * offset
207
+
208
+ # Create line extending perpendicular to direction
209
+ p1 = line_center - perp_vec * diagonal
210
+ p2 = line_center + perp_vec * diagonal
211
+
212
+ line = LineString([p1, p2])
213
+ cutting_lines.append(line)
214
+
215
+ return cutting_lines
216
+
217
+
218
+ def subdivide_with_uniform_widths(
219
+ block: Polygon,
220
+ target_width: float = 40.0,
221
+ min_width: float = 20.0,
222
+ max_width: float = 80.0
223
+ ) -> Tuple[List[Polygon], List[float]]:
224
+ """
225
+ Subdivide block with uniform lot widths.
226
+
227
+ Calculates optimal number of lots to match target width
228
+ while respecting min/max constraints.
229
+
230
+ Args:
231
+ block: Block polygon to subdivide
232
+ target_width: Target lot width (m)
233
+ min_width: Minimum acceptable width (m)
234
+ max_width: Maximum acceptable width (m)
235
+
236
+ Returns:
237
+ (lots, widths) tuple
238
+ """
239
+ if block.is_empty or not block.is_valid:
240
+ return [], []
241
+
242
+ # Get OBB dimensions
243
+ width, length, _ = get_obb_dimensions(block)
244
+
245
+ # Calculate optimal number of lots
246
+ num_lots = max(1, round(length / target_width))
247
+
248
+ # Calculate actual width
249
+ actual_width = length / num_lots
250
+
251
+ # Adjust if outside bounds
252
+ if actual_width < min_width:
253
+ num_lots = max(1, int(length / min_width))
254
+ actual_width = length / num_lots
255
+ elif actual_width > max_width:
256
+ num_lots = max(1, int(length / max_width) + 1)
257
+ actual_width = length / num_lots
258
+
259
+ lot_widths = [actual_width] * num_lots
260
+
261
+ lots = orthogonal_slice(block, lot_widths)
262
+
263
+ return lots, lot_widths
algorithms/backend/core/geometry/shape_quality.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shape quality analysis for aesthetic optimization.
3
+
4
+ Implements geometric quality metrics from Beauti_mode.md:
5
+ - Rectangularity (area / OBB area)
6
+ - Aspect Ratio (length / width)
7
+ - Minimum area constraints
8
+
9
+ Used to filter poor-quality lots and convert them to green spaces.
10
+ """
11
+
12
+ import numpy as np
13
+ from shapely.geometry import Polygon
14
+ from typing import Tuple, Optional
15
+
16
+
17
+ # Default thresholds from Beauti_mode.md
18
+ DEFAULT_MIN_RECTANGULARITY = 0.75
19
+ DEFAULT_MAX_ASPECT_RATIO = 4.0
20
+ DEFAULT_MIN_LOT_AREA = 1000.0 # m²
21
+
22
+
23
+ def analyze_shape_quality(
24
+ polygon: Polygon,
25
+ min_rectangularity: float = DEFAULT_MIN_RECTANGULARITY,
26
+ max_aspect_ratio: float = DEFAULT_MAX_ASPECT_RATIO,
27
+ min_area: float = DEFAULT_MIN_LOT_AREA
28
+ ) -> Tuple[float, bool]:
29
+ """
30
+ Analyze shape quality and return aesthetic score with validity status.
31
+
32
+ Uses Oriented Bounding Box (OBB) to calculate metrics:
33
+ - Rectangularity: ratio of polygon area to its OBB area
34
+ - Aspect Ratio: ratio of OBB length to width
35
+
36
+ Args:
37
+ polygon: Shapely Polygon to analyze
38
+ min_rectangularity: Minimum acceptable rectangularity (0-1)
39
+ max_aspect_ratio: Maximum acceptable length/width ratio
40
+ min_area: Minimum acceptable lot area (m²)
41
+
42
+ Returns:
43
+ (score, is_valid) tuple where:
44
+ - score: Aesthetic score between 0 and 1 (higher = better)
45
+ - is_valid: True if lot meets all quality thresholds
46
+ """
47
+ if polygon.is_empty or not polygon.is_valid:
48
+ return 0.0, False
49
+
50
+ # Calculate OBB (Oriented Bounding Box)
51
+ obb = polygon.minimum_rotated_rectangle
52
+
53
+ if obb.is_empty or obb.area <= 0:
54
+ return 0.0, False
55
+
56
+ # Rectangularity: how well the polygon fills its OBB
57
+ # Perfect rectangle = 1.0, Triangle ≈ 0.5
58
+ rectangularity = polygon.area / obb.area
59
+
60
+ # Calculate aspect ratio from OBB edges
61
+ x, y = obb.exterior.coords.xy
62
+
63
+ # Get two adjacent edges of OBB
64
+ edge_1 = np.hypot(x[1] - x[0], y[1] - y[0])
65
+ edge_2 = np.hypot(x[2] - x[1], y[2] - y[1])
66
+
67
+ if edge_1 == 0 or edge_2 == 0:
68
+ return 0.0, False
69
+
70
+ # Aspect ratio: longer edge / shorter edge
71
+ width, length = sorted([edge_1, edge_2])
72
+ aspect_ratio = length / width
73
+
74
+ # --- HARD CONSTRAINTS ---
75
+ is_valid = True
76
+
77
+ # Rule 1: Must be reasonably rectangular
78
+ if rectangularity < min_rectangularity:
79
+ is_valid = False
80
+
81
+ # Rule 2: Cannot be too elongated
82
+ if aspect_ratio > max_aspect_ratio:
83
+ is_valid = False
84
+
85
+ # Rule 3: Minimum area to avoid tiny fragments
86
+ if polygon.area < min_area:
87
+ is_valid = False
88
+
89
+ # Calculate aesthetic score (for optimization objective)
90
+ # Higher rectangularity and lower aspect ratio = higher score
91
+ score = (rectangularity * 0.7) + ((1.0 / aspect_ratio) * 0.3)
92
+
93
+ return score, is_valid
94
+
95
+
96
+ def get_dominant_edge_vector(polygon: Polygon) -> Optional[np.ndarray]:
97
+ """
98
+ Find the unit vector of the longest edge (typically the frontage).
99
+
100
+ Used for orthogonal alignment - lots should be cut perpendicular
101
+ to this dominant direction.
102
+
103
+ Args:
104
+ polygon: Shapely Polygon to analyze
105
+
106
+ Returns:
107
+ Unit vector (2D numpy array) of the longest edge,
108
+ or None if polygon is invalid
109
+ """
110
+ if polygon.is_empty or not polygon.is_valid:
111
+ return None
112
+
113
+ # Use OBB to find dominant direction
114
+ rect = polygon.minimum_rotated_rectangle
115
+ x, y = rect.exterior.coords.xy
116
+
117
+ # Get first 3 points to find 2 adjacent edges
118
+ p0 = np.array([x[0], y[0]])
119
+ p1 = np.array([x[1], y[1]])
120
+ p2 = np.array([x[2], y[2]])
121
+
122
+ edge1_len = np.linalg.norm(p1 - p0)
123
+ edge2_len = np.linalg.norm(p2 - p1)
124
+
125
+ # Select longer edge
126
+ if edge1_len > edge2_len:
127
+ vec = p1 - p0
128
+ else:
129
+ vec = p2 - p1
130
+
131
+ # Return unit vector
132
+ norm = np.linalg.norm(vec)
133
+ if norm == 0:
134
+ return None
135
+
136
+ return vec / norm
137
+
138
+
139
+ def get_perpendicular_vector(vector: np.ndarray) -> np.ndarray:
140
+ """
141
+ Get perpendicular vector (90 degree rotation).
142
+
143
+ Args:
144
+ vector: 2D numpy array
145
+
146
+ Returns:
147
+ Perpendicular unit vector
148
+ """
149
+ return np.array([-vector[1], vector[0]])
150
+
151
+
152
+ def get_obb_dimensions(polygon: Polygon) -> Tuple[float, float, float]:
153
+ """
154
+ Get oriented bounding box dimensions.
155
+
156
+ Args:
157
+ polygon: Shapely Polygon
158
+
159
+ Returns:
160
+ (width, length, angle_degrees) where width <= length
161
+ """
162
+ if polygon.is_empty or not polygon.is_valid:
163
+ return 0.0, 0.0, 0.0
164
+
165
+ obb = polygon.minimum_rotated_rectangle
166
+ x, y = obb.exterior.coords.xy
167
+
168
+ # Calculate edges
169
+ edge_1 = np.hypot(x[1] - x[0], y[1] - y[0])
170
+ edge_2 = np.hypot(x[2] - x[1], y[2] - y[1])
171
+
172
+ width, length = sorted([edge_1, edge_2])
173
+
174
+ # Calculate rotation angle of longer edge
175
+ if edge_1 > edge_2:
176
+ dx, dy = x[1] - x[0], y[1] - y[0]
177
+ else:
178
+ dx, dy = x[2] - x[1], y[2] - y[1]
179
+
180
+ angle_rad = np.arctan2(dy, dx)
181
+ angle_deg = np.degrees(angle_rad)
182
+
183
+ return width, length, angle_deg
184
+
185
+
186
+ def classify_lot_type(
187
+ polygon: Polygon,
188
+ min_rectangularity: float = DEFAULT_MIN_RECTANGULARITY,
189
+ max_aspect_ratio: float = DEFAULT_MAX_ASPECT_RATIO,
190
+ min_area: float = DEFAULT_MIN_LOT_AREA
191
+ ) -> str:
192
+ """
193
+ Classify lot into categories based on shape quality.
194
+
195
+ Args:
196
+ polygon: Lot polygon to classify
197
+ min_rectangularity: Threshold for commercial use
198
+ max_aspect_ratio: Threshold for commercial use
199
+ min_area: Minimum area for any use
200
+
201
+ Returns:
202
+ Classification string:
203
+ - 'commercial': Good shape, suitable for industrial/commercial
204
+ - 'green_space': Poor shape, should be converted to park/utility
205
+ - 'unusable': Too small or invalid
206
+ """
207
+ if polygon.is_empty or not polygon.is_valid:
208
+ return 'unusable'
209
+
210
+ if polygon.area < min_area:
211
+ return 'unusable'
212
+
213
+ score, is_valid = analyze_shape_quality(
214
+ polygon, min_rectangularity, max_aspect_ratio, min_area
215
+ )
216
+
217
+ if is_valid:
218
+ return 'commercial'
219
+ else:
220
+ return 'green_space'
algorithms/backend/core/optimization/grid_optimizer.py CHANGED
@@ -32,7 +32,8 @@ class GridOptimizer:
32
  self,
33
  land_polygon: Polygon,
34
  lake_polygon: Optional[Polygon] = None,
35
- settings: Optional[OptimizationSettings] = None
 
36
  ):
37
  """
38
  Initialize grid optimizer.
@@ -41,10 +42,12 @@ class GridOptimizer:
41
  land_polygon: Main land boundary
42
  lake_polygon: Water body to exclude (optional)
43
  settings: Optimization settings (uses defaults if None)
 
44
  """
45
  self.land_poly = land_polygon
46
  self.lake_poly = lake_polygon or Polygon()
47
  self.settings = settings or DEFAULT_SETTINGS.optimization
 
48
 
49
  self._setup_deap()
50
 
@@ -60,37 +63,73 @@ class GridOptimizer:
60
 
61
  # Gene definitions
62
  spacing_min, spacing_max = self.settings.spacing_bounds
63
- angle_min, angle_max = self.settings.angle_bounds
64
 
65
  self.toolbox.register("attr_spacing", random.uniform, spacing_min, spacing_max)
66
- self.toolbox.register("attr_angle", random.uniform, angle_min, angle_max)
67
-
68
- self.toolbox.register(
69
- "individual",
70
- tools.initCycle,
71
- creator.Individual,
72
- (self.toolbox.attr_spacing, self.toolbox.attr_angle),
73
- n=1
74
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  self.toolbox.register("population", tools.initRepeat, list, self.toolbox.individual)
76
 
 
77
  # Genetic operators
78
  self.toolbox.register("evaluate", self._evaluate_layout)
79
- self.toolbox.register(
80
- "mate",
81
- tools.cxSimulatedBinaryBounded,
82
- low=[spacing_min, angle_min],
83
- up=[spacing_max, angle_max],
84
- eta=self.settings.eta
85
- )
86
- self.toolbox.register(
87
- "mutate",
88
- tools.mutPolynomialBounded,
89
- low=[spacing_min, angle_min],
90
- up=[spacing_max, angle_max],
91
- eta=self.settings.eta,
92
- indpb=0.2
93
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  self.toolbox.register("select", tools.selNSGA2)
95
 
96
  def generate_grid_candidates(
@@ -153,7 +192,12 @@ class GridOptimizer:
153
  Returns:
154
  (total_residential_area, fragmented_blocks)
155
  """
156
- spacing, angle = individual
 
 
 
 
 
157
  blocks = self.generate_grid_candidates(spacing, angle)
158
 
159
  total_residential_area = 0.0
@@ -235,6 +279,12 @@ class GridOptimizer:
235
  history.append(list(best_ind))
236
 
237
  final_best = tools.selBest(pop, 1)[0]
238
- logger.info(f"Optimization complete: spacing={final_best[0]:.2f}, angle={final_best[1]:.2f}")
239
 
240
- return list(final_best), history
 
 
 
 
 
 
 
 
32
  self,
33
  land_polygon: Polygon,
34
  lake_polygon: Optional[Polygon] = None,
35
+ settings: Optional[OptimizationSettings] = None,
36
+ fixed_angle: Optional[float] = None
37
  ):
38
  """
39
  Initialize grid optimizer.
 
42
  land_polygon: Main land boundary
43
  lake_polygon: Water body to exclude (optional)
44
  settings: Optimization settings (uses defaults if None)
45
+ fixed_angle: Force grid rotation to specific angle (degrees). If None, optimizes angle.
46
  """
47
  self.land_poly = land_polygon
48
  self.lake_poly = lake_polygon or Polygon()
49
  self.settings = settings or DEFAULT_SETTINGS.optimization
50
+ self.fixed_angle = fixed_angle
51
 
52
  self._setup_deap()
53
 
 
63
 
64
  # Gene definitions
65
  spacing_min, spacing_max = self.settings.spacing_bounds
 
66
 
67
  self.toolbox.register("attr_spacing", random.uniform, spacing_min, spacing_max)
68
+
69
+ if self.fixed_angle is not None:
70
+ # Fixed angle optimization - only spacing varies
71
+ self.toolbox.register(
72
+ "individual",
73
+ tools.initCycle,
74
+ creator.Individual,
75
+ (self.toolbox.attr_spacing,),
76
+ n=1
77
+ )
78
+ else:
79
+ # Full optimization - spacing and angle
80
+ angle_min, angle_max = self.settings.angle_bounds
81
+ self.toolbox.register("attr_angle", random.uniform, angle_min, angle_max)
82
+
83
+ self.toolbox.register(
84
+ "individual",
85
+ tools.initCycle,
86
+ creator.Individual,
87
+ (self.toolbox.attr_spacing, self.toolbox.attr_angle),
88
+ n=1
89
+ )
90
+
91
  self.toolbox.register("population", tools.initRepeat, list, self.toolbox.individual)
92
 
93
+ # Genetic operators
94
  # Genetic operators
95
  self.toolbox.register("evaluate", self._evaluate_layout)
96
+
97
+ if self.fixed_angle is not None:
98
+ # 1D mutation/mating
99
+ self.toolbox.register(
100
+ "mate",
101
+ tools.cxSimulatedBinaryBounded,
102
+ low=[spacing_min],
103
+ up=[spacing_max],
104
+ eta=self.settings.eta
105
+ )
106
+ self.toolbox.register(
107
+ "mutate",
108
+ tools.mutPolynomialBounded,
109
+ low=[spacing_min],
110
+ up=[spacing_max],
111
+ eta=self.settings.eta,
112
+ indpb=0.2
113
+ )
114
+ else:
115
+ # 2D mutation/mating
116
+ angle_min, angle_max = self.settings.angle_bounds
117
+ self.toolbox.register(
118
+ "mate",
119
+ tools.cxSimulatedBinaryBounded,
120
+ low=[spacing_min, angle_min],
121
+ up=[spacing_max, angle_max],
122
+ eta=self.settings.eta
123
+ )
124
+ self.toolbox.register(
125
+ "mutate",
126
+ tools.mutPolynomialBounded,
127
+ low=[spacing_min, angle_min],
128
+ up=[spacing_max, angle_max],
129
+ eta=self.settings.eta,
130
+ indpb=0.2
131
+ )
132
+
133
  self.toolbox.register("select", tools.selNSGA2)
134
 
135
  def generate_grid_candidates(
 
192
  Returns:
193
  (total_residential_area, fragmented_blocks)
194
  """
195
+ if self.fixed_angle is not None:
196
+ spacing = individual[0]
197
+ angle = self.fixed_angle
198
+ else:
199
+ spacing, angle = individual
200
+
201
  blocks = self.generate_grid_candidates(spacing, angle)
202
 
203
  total_residential_area = 0.0
 
279
  history.append(list(best_ind))
280
 
281
  final_best = tools.selBest(pop, 1)[0]
 
282
 
283
+ if self.fixed_angle is not None:
284
+ spacing = final_best[0]
285
+ angle = self.fixed_angle
286
+ logger.info(f"Optimization complete (Fixed Angle): spacing={spacing:.2f}, angle={angle:.2f}")
287
+ return [spacing, angle], history
288
+ else:
289
+ logger.info(f"Optimization complete: spacing={final_best[0]:.2f}, angle={final_best[1]:.2f}")
290
+ return list(final_best), history
algorithms/backend/core/optimization/subdivision_solver.py CHANGED
@@ -12,7 +12,13 @@ import numpy as np
12
  from shapely.geometry import Polygon
13
  from ortools.sat.python import cp_model
14
 
15
- from core.config.settings import SubdivisionSettings, DEFAULT_SETTINGS
 
 
 
 
 
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
@@ -33,17 +39,22 @@ class SubdivisionSolver:
33
  min_width: float,
34
  max_width: float,
35
  target_width: float,
36
- time_limit: float = 5.0
 
37
  ) -> List[float]:
38
  """
39
  Solve optimal lot widths using constraint programming.
40
 
 
 
 
41
  Args:
42
  total_length: Total length to subdivide
43
  min_width: Minimum lot width
44
  max_width: Maximum lot width
45
  target_width: Target lot width
46
  time_limit: Solver time limit in seconds
 
47
 
48
  Returns:
49
  List of lot widths
@@ -70,11 +81,13 @@ class SubdivisionSolver:
70
  # Estimate number of lots
71
  max_lots = int(total_length / min_width) + 1
72
 
 
 
73
  # Decision variables: lot widths (scaled to integers for CP)
74
  scale = 100 # 1cm precision
75
  lot_vars = [
76
  model.NewIntVar(
77
- int(min_width * scale),
78
  int(max_width * scale),
79
  f'lot_{i}'
80
  )
@@ -98,9 +111,11 @@ class SubdivisionSolver:
98
  model.Add(lot_vars[i] >= int(min_width * scale)).OnlyEnforceIf(used[i])
99
  model.Add(lot_vars[i] == 0).OnlyEnforceIf(used[i].Not())
100
 
101
- # Objective: Minimize deviation from target
 
 
102
  deviations = [
103
- model.NewIntVar(0, int((max_width - min_width) * scale), f'dev_{i}')
104
  for i in range(max_lots)
105
  ]
106
 
@@ -108,7 +123,10 @@ class SubdivisionSolver:
108
  for i in range(max_lots):
109
  model.AddAbsEquality(deviations[i], lot_vars[i] - target_scaled)
110
 
111
- model.Minimize(sum(deviations))
 
 
 
112
 
113
  # Solve
114
  solver = cp_model.CpSolver()
@@ -125,7 +143,7 @@ class SubdivisionSolver:
125
  return widths
126
  else:
127
  # Fallback: uniform division
128
- logger.warning("CP solver failed, using uniform fallback")
129
  num_lots = max(1, int(total_length / target_width))
130
  return [total_length / num_lots] * num_lots
131
 
@@ -181,42 +199,33 @@ class SubdivisionSolver:
181
  # Good blocks become residential/commercial
182
  result['type'] = 'residential'
183
 
184
- # Solve subdivision
185
- minx, miny, maxx, maxy = block_geom.bounds
186
- total_width = maxx - minx
 
187
 
188
  # Adaptive time limit based on block size
189
- adaptive_time = min(time_limit, max(0.5, total_width / 100))
190
 
191
  lot_widths = SubdivisionSolver.solve_subdivision(
192
- total_width, min_width, max_width, target_width, adaptive_time
193
  )
194
 
195
- # Create lot geometries
196
- current_x = minx
197
-
198
- for width in lot_widths:
199
- lot_poly = Polygon([
200
- (current_x, miny),
201
- (current_x + width, miny),
202
- (current_x + width, maxy),
203
- (current_x, maxy)
204
- ])
205
-
206
- # Clip to block boundary
207
- clipped = lot_poly.intersection(block_geom)
208
- if not clipped.is_empty and clipped.geom_type == 'Polygon':
209
- # Calculate setback (buildable area)
210
- buildable = clipped.buffer(-setback_dist)
211
- if buildable.is_empty or not buildable.is_valid:
212
- buildable = None
213
-
214
- result['lots'].append({
215
- 'geometry': clipped,
216
- 'width': width,
217
- 'buildable': buildable
218
- })
219
-
220
- current_x += width
221
 
222
  return result
 
12
  from shapely.geometry import Polygon
13
  from ortools.sat.python import cp_model
14
 
15
+ from core.config.settings import (
16
+ SubdivisionSettings,
17
+ DEFAULT_SETTINGS,
18
+ DEVIATION_PENALTY_WEIGHT,
19
+ )
20
+ from core.geometry.orthogonal_slicer import orthogonal_slice
21
+ from core.geometry.shape_quality import get_obb_dimensions
22
 
23
  logger = logging.getLogger(__name__)
24
 
 
39
  min_width: float,
40
  max_width: float,
41
  target_width: float,
42
+ time_limit: float = 5.0,
43
+ deviation_penalty_weight: float = DEVIATION_PENALTY_WEIGHT
44
  ) -> List[float]:
45
  """
46
  Solve optimal lot widths using constraint programming.
47
 
48
+ Uses weighted objective from Beauti_mode.md:
49
+ Maximize(sum(widths) * 100 - sum(deviations) * penalty_weight)
50
+
51
  Args:
52
  total_length: Total length to subdivide
53
  min_width: Minimum lot width
54
  max_width: Maximum lot width
55
  target_width: Target lot width
56
  time_limit: Solver time limit in seconds
57
+ deviation_penalty_weight: Weight for deviation penalty (higher = more uniform)
58
 
59
  Returns:
60
  List of lot widths
 
81
  # Estimate number of lots
82
  max_lots = int(total_length / min_width) + 1
83
 
84
+ logger.debug(f"Solving subdivision: Length={total_length:.2f}, Min={min_width}, Max={max_width}, Target={target_width}")
85
+
86
  # Decision variables: lot widths (scaled to integers for CP)
87
  scale = 100 # 1cm precision
88
  lot_vars = [
89
  model.NewIntVar(
90
+ 0,
91
  int(max_width * scale),
92
  f'lot_{i}'
93
  )
 
111
  model.Add(lot_vars[i] >= int(min_width * scale)).OnlyEnforceIf(used[i])
112
  model.Add(lot_vars[i] == 0).OnlyEnforceIf(used[i].Not())
113
 
114
+ # Objective: Minimize deviation from target (weighted approach from Beauti_mode)
115
+ # Deviation bound must accommodate unused lots (0 width -> deviation = target_width)
116
+ dev_upper_bound = int(max(max_width, target_width) * 2 * scale)
117
  deviations = [
118
+ model.NewIntVar(0, dev_upper_bound, f'dev_{i}')
119
  for i in range(max_lots)
120
  ]
121
 
 
123
  for i in range(max_lots):
124
  model.AddAbsEquality(deviations[i], lot_vars[i] - target_scaled)
125
 
126
+ # Enhanced objective: Maximize area while penalizing deviation (Beauti_mode Section 4)
127
+ # Higher deviation_penalty_weight = more uniform lot sizes
128
+ penalty_weight = int(deviation_penalty_weight)
129
+ model.Maximize(sum(lot_vars) * 100 - sum(deviations) * penalty_weight)
130
 
131
  # Solve
132
  solver = cp_model.CpSolver()
 
143
  return widths
144
  else:
145
  # Fallback: uniform division
146
+ logger.warning(f"CP solver failed (Status: {solver.StatusName(status)}), using uniform fallback")
147
  num_lots = max(1, int(total_length / target_width))
148
  return [total_length / num_lots] * num_lots
149
 
 
199
  # Good blocks become residential/commercial
200
  result['type'] = 'residential'
201
 
202
+ # Use OBB length for subdivision (handles rotated blocks)
203
+ # width is shorter dim, length is longer dim.
204
+ # We assume we cut along the dominant edge (length).
205
+ _, total_length, _ = get_obb_dimensions(block_geom)
206
 
207
  # Adaptive time limit based on block size
208
+ adaptive_time = min(time_limit, max(0.5, total_length / 100))
209
 
210
  lot_widths = SubdivisionSolver.solve_subdivision(
211
+ total_length, min_width, max_width, target_width, adaptive_time
212
  )
213
 
214
+ # Use Orthogonal Slicer to generate lot geometries
215
+ raw_lots = orthogonal_slice(block_geom, lot_widths)
216
+
217
+ for lot_poly in raw_lots:
218
+ # Calculate setback (buildable area)
219
+ # simplify(0.1) handles minor artifacts from rotation/buffering
220
+ buildable = lot_poly.buffer(-setback_dist).simplify(0.1)
221
+
222
+ if buildable.is_empty or not buildable.is_valid:
223
+ buildable = None
224
+
225
+ result['lots'].append({
226
+ 'geometry': lot_poly,
227
+ 'width': lot_poly.area / total_length * len(lot_widths), # Approx width
228
+ 'buildable': buildable
229
+ })
 
 
 
 
 
 
 
 
 
 
230
 
231
  return result
algorithms/backend/pipeline/land_redistribution.py CHANGED
@@ -11,6 +11,7 @@ import logging
11
  import random
12
  from typing import List, Dict, Any, Tuple, Optional
13
 
 
14
  import numpy as np
15
  from shapely.geometry import Polygon, Point, mapping
16
  from shapely.ops import unary_union
@@ -19,11 +20,15 @@ from core.config.settings import (
19
  AlgorithmSettings,
20
  DEFAULT_SETTINGS,
21
  ROAD_MAIN_WIDTH,
22
- ROAD_INTERNAL_WIDTH,
23
  SIDEWALK_WIDTH,
24
  TURNING_RADIUS,
25
  SERVICE_AREA_RATIO,
26
  MIN_BLOCK_AREA,
 
 
 
 
27
  )
28
  from core.geometry.polygon_utils import (
29
  get_elevation,
@@ -31,6 +36,11 @@ from core.geometry.polygon_utils import (
31
  filter_by_min_area,
32
  sort_by_elevation,
33
  )
 
 
 
 
 
34
  from core.geometry.voronoi import (
35
  generate_voronoi_seeds,
36
  create_voronoi_diagram,
@@ -168,8 +178,22 @@ class LandRedistributionPipeline:
168
  return smooth_network, service_blocks, commercial_blocks
169
 
170
  def run_stage1(self) -> Dict[str, Any]:
171
- """Run grid optimization stage (NSGA-II)."""
172
- optimizer = GridOptimizer(self.land_poly, self.lake_poly)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
  best_solution, history = optimizer.optimize(
175
  population_size=self.config.get('population_size', 30),
@@ -179,12 +203,28 @@ class LandRedistributionPipeline:
179
  spacing, angle = best_solution
180
  blocks = optimizer.generate_grid_candidates(spacing, angle)
181
 
182
- # Filter to usable blocks
183
  usable_blocks = []
 
 
 
184
  for blk in blocks:
 
185
  intersection = blk.intersection(self.land_poly).difference(self.lake_poly)
186
- if not intersection.is_empty:
187
- usable_blocks.append(intersection)
 
 
 
 
 
 
 
 
 
 
 
 
188
 
189
  return {
190
  'spacing': spacing,
@@ -203,9 +243,10 @@ class LandRedistributionPipeline:
203
  blocks: List[Polygon],
204
  spacing: float
205
  ) -> Dict[str, Any]:
206
- """Run subdivision stage (OR-Tools)."""
207
  all_lots = []
208
  parks = []
 
209
 
210
  for block in blocks:
211
  result = SubdivisionSolver.subdivide_block(
@@ -220,16 +261,46 @@ class LandRedistributionPipeline:
220
  if result['type'] == 'park':
221
  parks.append(result['geometry'])
222
  else:
223
- all_lots.extend(result['lots'])
224
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  avg_width = np.mean([lot['width'] for lot in all_lots]) if all_lots else 0
226
 
227
  return {
228
  'lots': all_lots,
229
  'parks': parks,
 
230
  'metrics': {
231
  'total_lots': len(all_lots),
232
  'total_parks': len(parks),
 
233
  'avg_lot_width': avg_width
234
  }
235
  }
@@ -259,12 +330,40 @@ class LandRedistributionPipeline:
259
  accumulated += xlnt.area
260
 
261
  # Fill remaining service quota
262
- for b in sorted_blocks:
263
- if accumulated < service_target:
264
- service_blocks.append(b)
265
- accumulated += b.area
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  else:
267
- commercial_blocks.append(b)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  return {
270
  'xlnt': xlnt_block,
@@ -272,17 +371,52 @@ class LandRedistributionPipeline:
272
  'commercial': commercial_blocks
273
  }
274
 
275
- def run_full_pipeline(self) -> Dict[str, Any]:
276
- """Run complete optimization pipeline with Voronoi road generation."""
277
- logger.info("Starting full pipeline...")
278
-
279
- # Stage 0: Voronoi Road Network
280
- road_network, service_blocks_voronoi, commercial_blocks_voronoi = \
281
- self.generate_road_network(num_seeds=15)
 
 
 
 
 
 
 
 
 
 
 
282
 
283
- # Fallback to grid-based if Voronoi fails
284
- if not commercial_blocks_voronoi:
285
- logger.info("Voronoi failed, using grid-based approach")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  stage1_result = self.run_stage1()
287
  classification = self.classify_blocks(stage1_result['blocks'])
288
  commercial_blocks_voronoi = classification['commercial']
@@ -292,15 +426,14 @@ class LandRedistributionPipeline:
292
  road_network = self.land_poly.difference(unary_union(all_blocks))
293
  spacing_for_subdivision = stage1_result['spacing']
294
  else:
295
- # Separate XLNT from service blocks
296
  if service_blocks_voronoi:
297
  xlnt_blocks = [service_blocks_voronoi[0]]
298
  service_blocks_voronoi = service_blocks_voronoi[1:]
299
- else:
300
- xlnt_blocks = []
301
 
302
  # Estimate spacing for subdivision
303
  if commercial_blocks_voronoi:
 
304
  avg_area = sum(b.area for b in commercial_blocks_voronoi) / len(commercial_blocks_voronoi)
305
  spacing_for_subdivision = max(20.0, (avg_area ** 0.5) * 0.7)
306
  else:
@@ -353,6 +486,7 @@ class LandRedistributionPipeline:
353
  'road_network': mapping(road_network)
354
  },
355
  'total_lots': stage2_result['metrics']['total_lots'],
356
- 'service_blocks': [list(b.exterior.coords) for b in service_blocks_voronoi],
357
- 'xlnt_blocks': [list(b.exterior.coords) for b in xlnt_blocks]
 
358
  }
 
11
  import random
12
  from typing import List, Dict, Any, Tuple, Optional
13
 
14
+ import math
15
  import numpy as np
16
  from shapely.geometry import Polygon, Point, mapping
17
  from shapely.ops import unary_union
 
20
  AlgorithmSettings,
21
  DEFAULT_SETTINGS,
22
  ROAD_MAIN_WIDTH,
23
+ ROAD_INTERNAL_WIDTH, # This is usually the road width between blocks
24
  SIDEWALK_WIDTH,
25
  TURNING_RADIUS,
26
  SERVICE_AREA_RATIO,
27
  MIN_BLOCK_AREA,
28
+ ENABLE_LEFTOVER_MANAGEMENT,
29
+ MIN_RECTANGULARITY,
30
+ MAX_ASPECT_RATIO,
31
+ MIN_LOT_AREA,
32
  )
33
  from core.geometry.polygon_utils import (
34
  get_elevation,
 
36
  filter_by_min_area,
37
  sort_by_elevation,
38
  )
39
+ from core.geometry.shape_quality import (
40
+ analyze_shape_quality,
41
+ classify_lot_type,
42
+ get_dominant_edge_vector,
43
+ )
44
  from core.geometry.voronoi import (
45
  generate_voronoi_seeds,
46
  create_voronoi_diagram,
 
178
  return smooth_network, service_blocks, commercial_blocks
179
 
180
  def run_stage1(self) -> Dict[str, Any]:
181
+ """Run grid optimization stage (NSGA-II) with orthogonal alignment."""
182
+
183
+ # Calculate dominant edge angle for orthogonal alignment
184
+ # This addresses User feedback about "uneven blocks" in Stage 1
185
+ dom_vec = get_dominant_edge_vector(self.land_poly)
186
+ # atan2 returns radians between -pi and pi
187
+ fixed_angle = math.degrees(math.atan2(dom_vec[1], dom_vec[0]))
188
+
189
+ logger.info(f"Enforcing orthogonal alignment: {fixed_angle:.2f} degrees (Vector: {dom_vec})")
190
+
191
+ optimizer = GridOptimizer(
192
+ self.land_poly,
193
+ self.lake_poly,
194
+ fixed_angle=fixed_angle,
195
+ settings=self.settings.optimization
196
+ )
197
 
198
  best_solution, history = optimizer.optimize(
199
  population_size=self.config.get('population_size', 30),
 
203
  spacing, angle = best_solution
204
  blocks = optimizer.generate_grid_candidates(spacing, angle)
205
 
206
+ # Filter to usable blocks and apply road buffer
207
  usable_blocks = []
208
+ road_width = self.config.get('road_width', ROAD_INTERNAL_WIDTH)
209
+ buffer_amount = -road_width / 2.0
210
+
211
  for blk in blocks:
212
+ # Intersect with land
213
  intersection = blk.intersection(self.land_poly).difference(self.lake_poly)
214
+
215
+ if not intersection.is_empty and intersection.area > MIN_BLOCK_AREA:
216
+ # Apply negative buffer to create road gaps
217
+ # simplify(0.1) helps clean up artifacts after buffering
218
+ buffered_blk = intersection.buffer(buffer_amount, join_style=2).simplify(0.1)
219
+
220
+ if not buffered_blk.is_empty:
221
+ if buffered_blk.geom_type == 'Polygon':
222
+ if buffered_blk.area > MIN_BLOCK_AREA:
223
+ usable_blocks.append(buffered_blk)
224
+ elif buffered_blk.geom_type == 'MultiPolygon':
225
+ for part in buffered_blk.geoms:
226
+ if part.area > MIN_BLOCK_AREA:
227
+ usable_blocks.append(part)
228
 
229
  return {
230
  'spacing': spacing,
 
243
  blocks: List[Polygon],
244
  spacing: float
245
  ) -> Dict[str, Any]:
246
+ """Run subdivision stage (OR-Tools) with leftover management."""
247
  all_lots = []
248
  parks = []
249
+ green_spaces = [] # NEW: collect poor-quality lots (Beauti_mode Section 3)
250
 
251
  for block in blocks:
252
  result = SubdivisionSolver.subdivide_block(
 
261
  if result['type'] == 'park':
262
  parks.append(result['geometry'])
263
  else:
264
+ # Apply leftover management (Beauti_mode Section 3)
265
+ block_total = len(result['lots'])
266
+ kept_count = 0
267
+ green_count = 0
268
+
269
+ for lot_info in result['lots']:
270
+ lot_geom = lot_info['geometry']
271
+
272
+ if ENABLE_LEFTOVER_MANAGEMENT:
273
+ lot_type = classify_lot_type(
274
+ lot_geom,
275
+ min_rectangularity=MIN_RECTANGULARITY,
276
+ max_aspect_ratio=MAX_ASPECT_RATIO,
277
+ min_area=MIN_LOT_AREA
278
+ )
279
+
280
+ if lot_type == 'commercial':
281
+ all_lots.append(lot_info)
282
+ kept_count += 1
283
+ elif lot_type == 'green_space':
284
+ green_spaces.append(lot_geom)
285
+ green_count += 1
286
+ # 'unusable' lots are discarded
287
+ else:
288
+ all_lots.append(lot_info)
289
+ kept_count += 1
290
+
291
+ if block_total > 0:
292
+ logger.info(f"Block Subdivision: Generated {block_total} lots -> Kept {kept_count} Commercial, {green_count} Green Space")
293
+
294
  avg_width = np.mean([lot['width'] for lot in all_lots]) if all_lots else 0
295
 
296
  return {
297
  'lots': all_lots,
298
  'parks': parks,
299
+ 'green_spaces': green_spaces, # NEW field
300
  'metrics': {
301
  'total_lots': len(all_lots),
302
  'total_parks': len(parks),
303
+ 'total_green_spaces': len(green_spaces),
304
  'avg_lot_width': avg_width
305
  }
306
  }
 
330
  accumulated += xlnt.area
331
 
332
  # Fill remaining service quota
333
+ # Distribute service blocks (Interleave)
334
+ # Instead of taking the first N blocks, we distribute them evenly
335
+ # to avoid "clumping" of service areas.
336
+
337
+ remaining_blocks = sorted_blocks # These are already sorted by elevation (low -> high)
338
+ num_remaining = len(remaining_blocks)
339
+
340
+ if num_remaining > 0:
341
+ # Calculate how many service blocks we need
342
+ # We use checks against area, but let's approximate by count for mixing
343
+ avg_area = sum(b.area for b in remaining_blocks) / num_remaining
344
+ service_count = int(service_target / avg_area)
345
+ service_count = max(1, min(service_count, int(num_remaining * 0.3))) # Cap at 30%
346
+
347
+ if service_count >= num_remaining:
348
+ service_blocks.extend(remaining_blocks)
349
+ logger.warning(f"Classification: All {num_remaining} blocks assigned to Service (Count={service_count})")
350
  else:
351
+ # Step size for distribution
352
+ step = num_remaining / service_count
353
+ indices = [int(i * step) for i in range(service_count)]
354
+
355
+ logger.info(f"Classification: Total={num_remaining}, ServiceTarget={service_count}, Step={step:.2f}")
356
+
357
+ for i, block in enumerate(remaining_blocks):
358
+ if i in indices:
359
+ service_blocks.append(block)
360
+ else:
361
+ commercial_blocks.append(block)
362
+ else:
363
+ # Should not happen if blocks exist
364
+ pass
365
+
366
+ logger.info(f"Classification Result: XLNT={len(xlnt_block)}, Service={len(service_blocks)}, Commercial={len(commercial_blocks)}")
367
 
368
  return {
369
  'xlnt': xlnt_block,
 
371
  'commercial': commercial_blocks
372
  }
373
 
374
+ @staticmethod
375
+ def _safe_coords(geom):
376
+ """Helper to safely extract coordinates for JSON serialization."""
377
+ if geom.geom_type == 'Polygon':
378
+ return list(geom.exterior.coords)
379
+ elif geom.geom_type == 'MultiPolygon':
380
+ # Return exterior of the largest part
381
+ largest = max(geom.geoms, key=lambda p: p.area)
382
+ return list(largest.exterior.coords)
383
+ return []
384
+
385
+ def run_full_pipeline(
386
+ self,
387
+ layout_method: str = 'auto', # 'auto', 'voronoi', 'grid'
388
+ num_seeds: int = 15
389
+ ) -> Dict[str, Any]:
390
+ """
391
+ Run complete optimization pipeline.
392
 
393
+ Args:
394
+ layout_method: Strategy for road network ('voronoi' or 'grid')
395
+ num_seeds: Number of seeds for Voronoi generation
396
+ """
397
+ logger.info(f"Starting full pipeline with method: {layout_method}")
398
+
399
+ road_network = Polygon()
400
+ service_blocks_voronoi = []
401
+ commercial_blocks_voronoi = []
402
+ xlnt_blocks = []
403
+ spacing_for_subdivision = 25.0
404
+
405
+ # Stage 0: Voronoi Road Network (if selected)
406
+ if layout_method in ['auto', 'voronoi']:
407
+ road_network, service_blocks_voronoi, commercial_blocks_voronoi = \
408
+ self.generate_road_network(num_seeds=num_seeds)
409
+
410
+ # Determine if we should use Grid (fallback or forced)
411
+ use_grid = False
412
+ if layout_method == 'grid':
413
+ use_grid = True
414
+ elif layout_method == 'auto' and not commercial_blocks_voronoi:
415
+ logger.info("Voronoi failed or produced no blocks, switching to grid-based")
416
+ use_grid = True
417
+
418
+ if use_grid:
419
+ logger.info("Using Grid-based generation (Stage 1)")
420
  stage1_result = self.run_stage1()
421
  classification = self.classify_blocks(stage1_result['blocks'])
422
  commercial_blocks_voronoi = classification['commercial']
 
426
  road_network = self.land_poly.difference(unary_union(all_blocks))
427
  spacing_for_subdivision = stage1_result['spacing']
428
  else:
429
+ # Separate XLNT from service blocks for Voronoi path
430
  if service_blocks_voronoi:
431
  xlnt_blocks = [service_blocks_voronoi[0]]
432
  service_blocks_voronoi = service_blocks_voronoi[1:]
 
 
433
 
434
  # Estimate spacing for subdivision
435
  if commercial_blocks_voronoi:
436
+ # Use a heuristic for Voronoi block spacing
437
  avg_area = sum(b.area for b in commercial_blocks_voronoi) / len(commercial_blocks_voronoi)
438
  spacing_for_subdivision = max(20.0, (avg_area ** 0.5) * 0.7)
439
  else:
 
486
  'road_network': mapping(road_network)
487
  },
488
  'total_lots': stage2_result['metrics']['total_lots'],
489
+ 'total_lots': stage2_result['metrics']['total_lots'],
490
+ 'service_blocks': [self._safe_coords(b) for b in service_blocks_voronoi],
491
+ 'xlnt_blocks': [self._safe_coords(b) for b in xlnt_blocks]
492
  }
algorithms/backend/test_aesthetic.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for aesthetic optimization functions."""
2
+
3
+ import sys
4
+ import traceback
5
+
6
+ def test_shape_quality():
7
+ """Test shape quality analysis functions."""
8
+ print("Testing shape quality functions...")
9
+ try:
10
+ from shapely.geometry import Polygon
11
+ from core.geometry.shape_quality import (
12
+ analyze_shape_quality,
13
+ get_dominant_edge_vector,
14
+ classify_lot_type,
15
+ get_obb_dimensions
16
+ )
17
+
18
+ # Test 1: Perfect rectangle should have high score and be valid
19
+ # Must be > 1000 m² to pass min area check
20
+ rect = Polygon([(0, 0), (50, 0), (50, 40), (0, 40)]) # 2000 m²
21
+ score, valid = analyze_shape_quality(rect)
22
+ print(f" Rectangle (2000m²): score={score:.3f}, valid={valid}")
23
+ assert valid, "Rectangle should be valid"
24
+ assert score > 0.8, "Rectangle should have high score"
25
+
26
+ # Test 2: Triangle should have low rectangularity and be invalid
27
+ tri = Polygon([(0, 0), (40, 0), (20, 30)])
28
+ score, valid = analyze_shape_quality(tri)
29
+ print(f" Triangle: score={score:.3f}, valid={valid}")
30
+ assert not valid, "Triangle should be invalid (low rectangularity)"
31
+
32
+ # Test 3: Very elongated shape should be invalid
33
+ elongated = Polygon([(0, 0), (200, 0), (200, 10), (0, 10)])
34
+ score, valid = analyze_shape_quality(elongated)
35
+ print(f" Elongated: score={score:.3f}, valid={valid}")
36
+ assert not valid, "Elongated shape should be invalid"
37
+
38
+ # Test 4: Small lot should be invalid
39
+ small = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)]) # 100 m²
40
+ score, valid = analyze_shape_quality(small)
41
+ print(f" Small lot: score={score:.3f}, valid={valid}")
42
+ assert not valid, "Small lot should be invalid"
43
+
44
+ # Test 5: Dominant edge vector
45
+ vec = get_dominant_edge_vector(rect)
46
+ print(f" Dominant edge vector: {vec}")
47
+ assert vec is not None, "Should return vector"
48
+
49
+ # Test 6: OBB dimensions
50
+ w, l, angle = get_obb_dimensions(rect)
51
+ print(f" OBB dimensions: width={w:.1f}, length={l:.1f}, angle={angle:.1f}°")
52
+ assert abs(w - 40) < 1, "Width should be ~40"
53
+ assert abs(l - 50) < 1, "Length should be ~50"
54
+
55
+ # Test 7: Lot classification
56
+ lot_type = classify_lot_type(rect)
57
+ print(f" Rectangle type: {lot_type}")
58
+ assert lot_type == 'commercial', "Good lot should be commercial"
59
+
60
+ # Triangle must be large enough (>1000m²) to be green_space, otherwise it's unusable
61
+ large_tri = Polygon([(0, 0), (60, 0), (30, 50)]) # 1500 m²
62
+ lot_type = classify_lot_type(large_tri)
63
+ print(f" Triangle type: {lot_type}")
64
+ assert lot_type == 'green_space', "Bad lot (large enough) should be green_space"
65
+
66
+ print("✅ Shape quality tests passed")
67
+ return True
68
+ except Exception as e:
69
+ print(f"❌ Shape quality test failed: {e}")
70
+ traceback.print_exc()
71
+ return False
72
+
73
+
74
+ def test_orthogonal_slicer():
75
+ """Test orthogonal slicing functions."""
76
+ print("\nTesting orthogonal slicer functions...")
77
+ try:
78
+ from shapely.geometry import Polygon
79
+ from core.geometry.orthogonal_slicer import (
80
+ orthogonal_slice,
81
+ subdivide_with_uniform_widths
82
+ )
83
+
84
+ # Test 1: Subdivide a rectangular block
85
+ block = Polygon([(0, 0), (100, 0), (100, 50), (0, 50)])
86
+ widths = [25, 25, 25, 25]
87
+
88
+ lots = orthogonal_slice(block, widths)
89
+ print(f" Orthogonal slice: {len(lots)} lots from 4 widths")
90
+ assert len(lots) == 4, "Should create 4 lots"
91
+
92
+ # Test 2: Uniform width subdivision
93
+ lots2, actual_widths = subdivide_with_uniform_widths(block, target_width=20)
94
+ print(f" Uniform subdivision: {len(lots2)} lots, widths={[round(w,1) for w in actual_widths]}")
95
+ assert len(lots2) > 0, "Should create lots"
96
+
97
+ print("✅ Orthogonal slicer tests passed")
98
+ return True
99
+ except Exception as e:
100
+ print(f"❌ Orthogonal slicer test failed: {e}")
101
+ traceback.print_exc()
102
+ return False
103
+
104
+
105
+ def test_settings():
106
+ """Test aesthetic settings in config."""
107
+ print("\nTesting aesthetic settings...")
108
+ try:
109
+ from core.config.settings import (
110
+ DEFAULT_SETTINGS,
111
+ MIN_RECTANGULARITY,
112
+ MAX_ASPECT_RATIO,
113
+ MIN_LOT_AREA,
114
+ DEVIATION_PENALTY_WEIGHT,
115
+ ENABLE_LEFTOVER_MANAGEMENT
116
+ )
117
+
118
+ print(f" MIN_RECTANGULARITY = {MIN_RECTANGULARITY}")
119
+ print(f" MAX_ASPECT_RATIO = {MAX_ASPECT_RATIO}")
120
+ print(f" MIN_LOT_AREA = {MIN_LOT_AREA}")
121
+ print(f" DEVIATION_PENALTY_WEIGHT = {DEVIATION_PENALTY_WEIGHT}")
122
+ print(f" ENABLE_LEFTOVER_MANAGEMENT = {ENABLE_LEFTOVER_MANAGEMENT}")
123
+
124
+ assert MIN_RECTANGULARITY == 0.75, "Should match Beauti_mode spec"
125
+ assert MAX_ASPECT_RATIO == 4.0, "Should match Beauti_mode spec"
126
+ assert MIN_LOT_AREA == 1000.0, "Should match Beauti_mode spec"
127
+
128
+ print("✅ Settings tests passed")
129
+ return True
130
+ except Exception as e:
131
+ print(f"❌ Settings test failed: {e}")
132
+ traceback.print_exc()
133
+ return False
134
+
135
+
136
+ if __name__ == "__main__":
137
+ print("=" * 50)
138
+ print("Aesthetic Optimization Test Suite")
139
+ print("=" * 50)
140
+
141
+ results = []
142
+ results.append(("Shape Quality", test_shape_quality()))
143
+ results.append(("Orthogonal Slicer", test_orthogonal_slicer()))
144
+ results.append(("Settings", test_settings()))
145
+
146
+ print("\n" + "=" * 50)
147
+ print("Test Results:")
148
+ print("=" * 50)
149
+
150
+ for name, passed in results:
151
+ status = "✅ PASS" if passed else "❌ FAIL"
152
+ print(f"{status} - {name}")
153
+
154
+ all_passed = all(r[1] for r in results)
155
+
156
+ print("\n" + "=" * 50)
157
+ if all_passed:
158
+ print("✅ ALL AESTHETIC TESTS PASSED")
159
+ sys.exit(0)
160
+ else:
161
+ print("❌ SOME TESTS FAILED")
162
+ sys.exit(1)
algorithms/backend/test_real_dxf.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script to run aesthetic optimization on real DXF example.
3
+ Target: examples/663409.dxf
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import logging
9
+
10
+ # Add current directory to path
11
+ sys.path.append(os.getcwd())
12
+
13
+ from utils.dxf_utils import load_boundary_from_dxf
14
+ from pipeline.land_redistribution import LandRedistributionPipeline
15
+ from core.geometry.shape_quality import analyze_shape_quality
16
+
17
+ logging.basicConfig(level=logging.INFO)
18
+ logger = logging.getLogger(__name__)
19
+
20
+ def test_dxf_optimization():
21
+ dxf_path = "../../examples/663409.dxf"
22
+
23
+ if not os.path.exists(dxf_path):
24
+ logger.error(f"DXF file not found: {dxf_path}")
25
+ return
26
+
27
+ logger.info(f"Loading DXF: {dxf_path}")
28
+ with open(dxf_path, 'rb') as f:
29
+ dxf_content = f.read()
30
+
31
+ land_poly = load_boundary_from_dxf(dxf_content)
32
+
33
+ if not land_poly:
34
+ logger.error("Failed to load polygon from DXF")
35
+ return
36
+
37
+ logger.info(f"Loaded land polygon: {land_poly.area:.2f} m²")
38
+
39
+ # Simplify geometry to speed up testing
40
+ land_poly = land_poly.simplify(1.0, preserve_topology=True)
41
+
42
+ # Initialize pipeline
43
+ config = {
44
+ 'min_lot_width': 20.0,
45
+ 'target_lot_width': 40.0,
46
+ 'population_size': 10, # Speed up
47
+ 'generations': 5, # Speed up
48
+ 'ortools_time_limit': 5.0
49
+ }
50
+
51
+ pipeline = LandRedistributionPipeline([land_poly], config)
52
+
53
+ # Run full pipeline with GRID method (Orthogonal Alignment)
54
+ logger.info("Running optimization pipeline with GRID method...")
55
+ result = pipeline.run_full_pipeline(layout_method='grid')
56
+
57
+ # Analyze Aesthetic Metrics
58
+ stage2 = result.get('stage2', {})
59
+ lots = stage2.get('lots', [])
60
+ green_spaces = stage2.get('green_spaces', [])
61
+ parks = stage2.get('parks', [])
62
+
63
+ print("\n" + "="*50)
64
+ print("AESTHETIC OPTIMIZATION RESULTS (GRID MODE)")
65
+ print("="*50)
66
+
67
+ print(f"Total Commercial Lots: {len(lots)}")
68
+ print(f"Total Green Spaces (Leftovers): {len(green_spaces)}")
69
+
70
+ # Calculate quality metrics
71
+ rectangularities = []
72
+
73
+ for lot_info in lots:
74
+ geom = lot_info['geometry']
75
+ obb = geom.minimum_rotated_rectangle
76
+ rectangularity = geom.area / obb.area
77
+ rectangularities.append(rectangularity)
78
+
79
+ if rectangularities:
80
+ avg_rect = sum(rectangularities) / len(rectangularities)
81
+ print(f" Average Rectangularity: {avg_rect:.3f} (target > 0.75)")
82
+
83
+ perfect_rects = sum(1 for r in rectangularities if r > 0.95)
84
+ print(f" Perfect Rectangles (>0.95): {perfect_rects} ({perfect_rects/len(lots)*100:.1f}%)")
85
+ else:
86
+ print("\nNo commercial lots generated!")
87
+
88
+ if __name__ == "__main__":
89
+ test_dxf_optimization()
algorithms/frontend/app.py CHANGED
@@ -158,19 +158,19 @@ with col_config:
158
  with c1:
159
  spacing_min = st.number_input(
160
  "Min",
161
- min_value=10.0,
162
- max_value=50.0,
163
- value=20.0,
164
- step=0.5,
165
  help="Minimum grid spacing"
166
  )
167
  with c2:
168
  spacing_max = st.number_input(
169
  "Max",
170
- min_value=10.0,
171
- max_value=50.0,
172
- value=30.0,
173
- step=0.5,
174
  help="Maximum grid spacing"
175
  )
176
 
 
158
  with c1:
159
  spacing_min = st.number_input(
160
  "Min",
161
+ min_value=30.0,
162
+ max_value=150.0,
163
+ value=50.0,
164
+ step=5.0,
165
  help="Minimum grid spacing"
166
  )
167
  with c2:
168
  spacing_max = st.number_input(
169
  "Max",
170
+ min_value=30.0,
171
+ max_value=200.0,
172
+ value=100.0,
173
+ step=5.0,
174
  help="Maximum grid spacing"
175
  )
176
 
docs/Beauti_mode.md ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Chào bạn, đây là phần đi sâu chi tiết vào các kỹ thuật **tối ưu hóa thẩm mỹ (Aesthetic Optimization)** kèm theo các đoạn code Python thực tế sử dụng thư viện `Shapely` và `NumPy` mà bạn có thể tích hợp ngay vào dự án.
2
+
3
+ Chúng ta sẽ tập trung vào 3 giải pháp cốt lõi: **Kiểm soát hình học**, **Cắt gọt trực giao (Orthogonal Slicing)**, và **Xử lý phần dư thông minh**.
4
+
5
+ -----
6
+
7
+ ### 1\. Phương pháp 1: Kiểm soát Hình học (Geometric Regularization)
8
+
9
+ **Mục tiêu:** Loại bỏ các lô đất có hình dạng "kỳ dị" (quá dẹt, quá méo) ngay từ bước sinh phương án hoặc dùng làm hàm mục tiêu để phạt điểm.
10
+
11
+ **Chỉ số kỹ thuật:**
12
+
13
+ 1. **Rectangularity (Độ đầy đặn):** Tỷ lệ diện tích lô đất so với hình chữ nhật bao quanh nó (Oriented Bounding Box - OBB). Giá trị càng gần 1.0 càng vuông.
14
+ 2. **Aspect Ratio (Tỷ lệ cạnh):** Tỷ lệ giữa chiều dài và chiều rộng.
15
+
16
+ **Đoạn Code Tối ưu:**
17
+
18
+ ```python
19
+ import numpy as np
20
+ from shapely.geometry import Polygon
21
+
22
+ def analyze_shape_quality(polygon):
23
+ """
24
+ Trả về điểm số thẩm mỹ và trạng thái hợp lệ của lô đất.
25
+ """
26
+ if polygon.is_empty or not polygon.is_valid:
27
+ return 0.0, False
28
+
29
+ # 1. Tính OBB (Oriented Bounding Box) - Hình chữ nhật bao quanh nhỏ nhất
30
+ obb = polygon.minimum_rotated_rectangle
31
+
32
+ # Tính độ vuông vắn (Rectangularity)
33
+ # Nếu polygon là hình chữ nhật, tỷ số = 1.0. Nếu là tam giác, tỷ số ~ 0.5
34
+ rectangularity = polygon.area / obb.area
35
+
36
+ # 2. Tính Tỷ lệ cạnh (Aspect Ratio) từ OBB
37
+ x, y = obb.exterior.coords.xy
38
+ # Tính độ dài 2 cạnh kề nhau của OBB
39
+ edge_1 = np.hypot(x[1] - x[0], y[1] - y[0])
40
+ edge_2 = np.hypot(x[2] - x[1], y[2] - y[1])
41
+
42
+ if edge_1 == 0 or edge_2 == 0: return 0.0, False
43
+
44
+ width, length = sorted([edge_1, edge_2])
45
+ aspect_ratio = length / width
46
+
47
+ # --- CÁC LUẬT RÀNG BUỘC (HARD CONSTRAINTS) ---
48
+ is_valid = True
49
+
50
+ # Luật 1: Phải tương đối vuông (chấp nhận méo nhẹ do vạt góc)
51
+ if rectangularity < 0.75:
52
+ is_valid = False
53
+
54
+ # Luật 2: Không được quá dẹt (ví dụ: dài gấp 4 lần rộng là xấu)
55
+ if aspect_ratio > 4.0:
56
+ is_valid = False
57
+
58
+ # Luật 3: Diện tích tối thiểu (để tránh lô vụn)
59
+ if polygon.area < 1000: # m2
60
+ is_valid = False
61
+
62
+ # Điểm thưởng cho lô đất đẹp (để dùng cho hàm mục tiêu)
63
+ score = (rectangularity * 0.7) + ((1.0 / aspect_ratio) * 0.3)
64
+
65
+ return score, is_valid
66
+
67
+ # Ví dụ sử dụng trong vòng lặp chia lô
68
+ # for lot in generated_lots:
69
+ # score, valid = analyze_shape_quality(lot)
70
+ # if not valid:
71
+ # # Đưa vào danh sách "Đất cây xanh/Kỹ thuật" thay vì "Đất thương phẩm"
72
+ # move_to_leftover(lot)
73
+ ```
74
+
75
+ -----
76
+
77
+ ### 2\. Phương pháp 2: Cắt gọt Trực giao (Orthogonal Alignment)
78
+
79
+ **Mục tiêu:** Thay vì dùng Voronoi ngẫu nhiên (tạo ra các cạnh xiên xẹo), chúng ta ép buộc các đường cắt phải **vuông góc** với trục đường chính hoặc cạnh dài nhất của Block mẹ.
80
+
81
+ **Thuật toán:**
82
+
83
+ 1. Xác định "Cạnh chủ" (Dominant Edge) của Block mẹ (thường là cạnh giáp đường).
84
+ 2. Tạo vector vuông góc với Cạnh chủ.
85
+ 3. Thực hiện chia lô theo vector này.
86
+
87
+ **Đoạn Code Tối ưu:**
88
+
89
+ ```python
90
+ from shapely.affinity import rotate, translate
91
+
92
+ def get_dominant_edge_vector(polygon):
93
+ """Tìm vector chỉ phương của cạnh dài nhất (thường là mặt tiền)"""
94
+ rect = polygon.minimum_rotated_rectangle
95
+ x, y = rect.exterior.coords.xy
96
+
97
+ # Lấy 3 điểm đầu để xác định 2 cạnh kề nhau
98
+ p0 = np.array([x[0], y[0]])
99
+ p1 = np.array([x[1], y[1]])
100
+ p2 = np.array([x[2], y[2]])
101
+
102
+ edge1_len = np.linalg.norm(p1 - p0)
103
+ edge2_len = np.linalg.norm(p2 - p1)
104
+
105
+ # Trả về vector đơn vị của cạnh dài nhất
106
+ if edge1_len > edge2_len:
107
+ vec = p1 - p0
108
+ else:
109
+ vec = p2 - p1
110
+
111
+ return vec / np.linalg.norm(vec)
112
+
113
+ def orthogonal_slice(block, num_lots):
114
+ """
115
+ Chia block thành các lô song song, vuông góc với cạnh chính.
116
+ Đây là kỹ thuật quan trọng nhất để tạo ra sự ngăn nắp.
117
+ """
118
+ # 1. Lấy hướng trục chính
119
+ direction_vec = get_dominant_edge_vector(block)
120
+
121
+ # Vector vuông góc (dùng để quét cắt)
122
+ perp_vec = np.array([-direction_vec[1], direction_vec[0]])
123
+
124
+ # 2. Xoay block về trục ngang để dễ tính toán (Optional nhưng recommended)
125
+ # Ở đây ta dùng cách tạo đường cắt trực tiếp
126
+
127
+ minx, miny, maxx, maxy = block.bounds
128
+ # Tạo một đường thẳng dài bao trùm block
129
+ diagonal = np.hypot(maxx-minx, maxy-miny)
130
+
131
+ lots = []
132
+ # Giả sử chia đều (trong thực tế bạn sẽ dùng OR-Tools để tính widths)
133
+ # Ta cần tìm điểm bắt đầu và kết thúc dọc theo cạnh chính
134
+ # (Phần này cần logic chiếu hình học phức tạp hơn một chút,
135
+ # dưới đây là mô phỏng cách dao cắt hoạt động)
136
+
137
+ # ... Logic cắt (simplified) ...
138
+ # Thay vì code cắt phức tạp, hãy áp dụng nguyên tắc:
139
+ # "Luôn xoay vector cắt lệch 90 độ so với vector đường giao thông tiếp giáp"
140
+
141
+ return lots # Trả về danh sách lô đã cắt vuông
142
+ ```
143
+
144
+ -----
145
+
146
+ ### 3\. Phương pháp 3: Xử lý phần dư (Leftover Management) - "Biến rác thành hoa"
147
+
148
+ **Mục tiêu:** Sau khi chia các lô vuông vắn, sẽ luôn còn lại các mẩu đất hình tam giác hoặc hình thang méo ở các góc cua. Thay vì cố ép nó thành đất ở (làm xấu bản vẽ và giảm giá trị), hãy tự động chuyển đổi nó thành **Tiện ích**.
149
+
150
+ **Chiến lược:**
151
+
152
+ * Chạy thuật toán chia lô vuông vắn tối đa.
153
+ * Phần diện tích còn lại (Difference) $\rightarrow$ Kiểm tra hình dáng.
154
+ * Nếu `Rectangularity < 0.6` $\rightarrow$ Gán label `Cây xanh` hoặc `Bãi đỗ xe`.
155
+
156
+ **Đoạn Code Tối ưu quy trình:**
157
+
158
+ ```python
159
+ def optimize_layout_w_leftovers(site_polygon, road_network):
160
+ # 1. Tạo các Block lớn từ mạng lưới đường
161
+ blocks = site_polygon.difference(road_network)
162
+
163
+ final_commercial_lots = []
164
+ final_green_spaces = []
165
+
166
+ for block in blocks.geoms:
167
+ # Bước A: Làm sạch hình học block (Simplify)
168
+ clean_block = block.simplify(0.5, preserve_topology=True)
169
+
170
+ # Bước B: Chia lô (Sử dụng thuật toán cắt trực giao ở trên)
171
+ # Giả sử hàm subdivide_block trả về danh sách các lô
172
+ raw_lots = subdivide_block_orthogonally(clean_block)
173
+
174
+ for lot in raw_lots:
175
+ # Bước C: Đánh giá thẩm mỹ
176
+ score, is_valid = analyze_shape_quality(lot)
177
+
178
+ if is_valid:
179
+ # Nếu lô đẹp -> Đất thương phẩm
180
+ final_commercial_lots.append(lot)
181
+ else:
182
+ # Nếu lô quá méo/nhỏ -> Chuyển thành đất cây xanh
183
+ # Đây là bước "Làm đẹp" bản vẽ
184
+ final_green_spaces.append(lot)
185
+
186
+ return final_commercial_lots, final_green_spaces
187
+ ```
188
+
189
+ ### 4\. Tích hợp vào OR-Tools (Hàm mục tiêu nâng cao)
190
+
191
+ Nếu bạn đang dùng `CP-SAT` solver của Google OR-Tools (như trong context cũ), bạn có thể sửa hàm mục tiêu để tối ưu sự đồng đều (Symmetry).
192
+
193
+ **Code OR-Tools (Logic):**
194
+
195
+ ```python
196
+ # Thay vì chỉ Maximize(TotalArea), hãy thêm phạt sự chênh lệch
197
+ # Mục tiêu: Các lô nên có kích thước bằng nhau (Target Width)
198
+
199
+ # ... khai báo biến widths[i] ...
200
+
201
+ target_width = 15.0 # mét (Mặt tiền mong muốn)
202
+ deviations = []
203
+
204
+ for w in widths:
205
+ # Tạo biến phụ để tính trị tuyệt đối: abs(w - target)
206
+ diff = model.NewIntVar(0, 100, 'diff')
207
+
208
+ # Mẹo OR-Tools để tính abs:
209
+ # diff >= w - target VÀ diff >= target - w
210
+ model.Add(diff >= w - int(target_width))
211
+ model.Add(diff >= int(target_width) - w)
212
+
213
+ deviations.append(diff)
214
+
215
+ # Hàm mục tiêu mới:
216
+ # Tối đa hóa diện tích NHƯNG Trừ điểm nặng nếu kích thước không đều
217
+ model.Maximize(sum(widths) * 100 - sum(deviations) * 50)
218
+ ```
219
+
220
+ ### Tổng kết chiến lược
221
+
222
+ Để bản vẽ từ "Tối ưu tài chính (xấu)" trở thành "Tối ưu tổng thể (đẹp + hiệu quả)", bạn chỉ cần thực hiện thay đổi nhỏ trong luồng xử lý:
223
+
224
+ 1. **Input:** Xác định cạnh đường chính.
225
+ 2. **Process:** Chỉ cắt vuông góc với cạnh đường (Force 90 deg).
226
+ 3. **Filter:** Dùng hàm `analyze_shape_quality` để lọc các mảnh vỡ.
227
+ 4. **Post-Process:** Tô màu xanh (công viên) cho các mảnh vỡ đó thay vì cố gắng bán chúng.
228
+
229
+ Bản vẽ sẽ trông chuyên nghiệp hơn ngay lập tức vì sự ngăn nắp và có nhiều không gian xanh ở các góc ngã tư.