valcore commited on
Commit
64dfdfb
·
1 Parent(s): 02b398a

feat: two-step image gen, Dockerfile builds floorplan whl at deploy time

Browse files
.gitignore CHANGED
@@ -1 +1,25 @@
1
  .worktrees/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  .worktrees/
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ .venv/
9
+ *.egg-info/
10
+
11
+ # Node / frontend
12
+ node_modules/
13
+
14
+ # macOS
15
+ .DS_Store
16
+
17
+ # Built artifacts (built by Dockerfile at deploy time)
18
+ floorplan/dist/
19
+
20
+ # Dev/test files
21
+ test_gemini.py
22
+
23
+ # Docs artifacts
24
+ docs/.DS_Store
25
+ docs/superpowers/.DS_Store
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # Install Node.js for building the frontend
4
+ RUN apt-get update && apt-get install -y curl && \
5
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
6
+ apt-get install -y nodejs && \
7
+ apt-get clean && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ # Copy floorplan source first and build the whl
12
+ COPY floorplan/ ./floorplan/
13
+ RUN pip install gradio hatchling hatch-requirements-txt hatch-fancy-pypi-readme build && \
14
+ cd floorplan && \
15
+ pip install gradio-cli && \
16
+ gradio cc build --no-generate-docs && \
17
+ pip install dist/gradio_floorplan-0.0.1-py3-none-any.whl
18
+
19
+ # Install remaining app dependencies
20
+ COPY requirements.txt ./
21
+ RUN pip install google-genai python-dotenv pillow pydantic
22
+
23
+ # Copy app
24
+ COPY app.py ./
25
+
26
+ EXPOSE 7860
27
+
28
+ CMD ["python", "app.py"]
app.py CHANGED
@@ -1,43 +1,239 @@
 
 
 
 
 
 
1
  import gradio as gr
2
  from gradio_floorplan import FloorPlan
 
 
 
 
 
 
 
 
 
 
3
 
4
  DEFAULT_FLOOR_PLAN = {
5
- "corners": [[50, 50], [550, 50], [550, 450], [50, 450]],
6
- "furnitures": [
7
- {
8
- "object": "Sofa",
9
- "localisation": [150, 100, 250, 300],
10
- "description": "3-seat sofa",
11
- },
12
- {
13
- "object": "Table",
14
- "localisation": [300, 200, 380, 400],
15
- "description": "Coffee table",
16
- },
17
- ],
18
  }
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
- def on_furniture_moved(value):
22
- return value
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  with gr.Blocks() as demo:
26
  gr.Markdown("# LaMaison\nRearrange your spaces using visual planning.")
 
 
 
 
27
 
28
- with gr.Tab("Floor Plan"):
29
- floor_plan = FloorPlan(
30
- value=DEFAULT_FLOOR_PLAN,
31
- label="Floor Plan",
32
- interactive=True,
33
- )
34
- positions_output = gr.JSON(label="Current furniture positions")
35
- floor_plan.change(on_furniture_moved, inputs=floor_plan, outputs=positions_output)
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- with gr.Tab("Image"):
38
- image_input = gr.Image(label="Upload a room image", type="numpy")
39
- image_output = gr.Image(label="Output")
40
- image_input.change(lambda x: x, inputs=image_input, outputs=image_output)
 
 
41
 
42
  if __name__ == "__main__":
43
- demo.launch()
 
1
+ import os
2
+ import json
3
+ from io import BytesIO
4
+ from typing import List
5
+ from dotenv import load_dotenv
6
+
7
  import gradio as gr
8
  from gradio_floorplan import FloorPlan
9
+ from PIL import Image
10
+ from pydantic import BaseModel, Field
11
+ from google import genai
12
+
13
+ from google.genai import types
14
+
15
+ load_dotenv()
16
+ client = genai.Client()
17
+
18
+ print("--- LA MAISON APP LOADED (VERSION 1.4 - OPTIMIZED IMAGE GEN & UI) ---")
19
 
20
  DEFAULT_FLOOR_PLAN = {
21
+ "corners": [],
22
+ "furnitures": [],
 
 
 
 
 
 
 
 
 
 
 
23
  }
24
 
25
+ class FurnitureItem(BaseModel):
26
+ object: str
27
+ localisation: List[int] = Field(description="[ymin, xmin, ymax, xmax] coordinates")
28
+ description: str
29
+
30
+ class RoomLayout(BaseModel):
31
+ corners: List[List[int]] = Field(description="List of [x, y] coordinates for room corners")
32
+ furnitures: List[FurnitureItem]
33
+
34
+ def process_room_image(image_numpy):
35
+ if image_numpy is None:
36
+ return DEFAULT_FLOOR_PLAN, DEFAULT_FLOOR_PLAN
37
+
38
+ pil_image = Image.fromarray(image_numpy)
39
+
40
+ # Step 1: Generate Floor Plan image with Nano Banana 2 (Optimized)
41
+ generated_image = None
42
+ gr.Info("Step 1: Generating 2D floor plan image...")
43
+ print("\n--- Starting Gemini 3.1 Flash Image Preview (Nano Banana 2) - Optimized ---")
44
+
45
+ generate_content_config = types.GenerateContentConfig(
46
+ thinking_config=types.ThinkingConfig(
47
+ thinking_level="MINIMAL",
48
+ ),
49
+ image_config = types.ImageConfig(
50
+ image_size="1K",
51
+ ),
52
+ response_modalities=[
53
+ "IMAGE",
54
+ ],
55
+ )
56
+
57
+ try:
58
+ response_stream = client.models.generate_content_stream(
59
+ model='models/gemini-3.1-flash-image-preview',
60
+ contents=[pil_image, "Generate a clean, 2D top-down floor plan image representing this exact room. OUTPUT ONLY THE IMAGE."],
61
+ config=generate_content_config
62
+ )
63
+ for chunk in response_stream:
64
+ if chunk.parts:
65
+ for part in chunk.parts:
66
+ if part.inline_data:
67
+ print("[Image Data] Received inline image data!")
68
+ generated_image = Image.open(BytesIO(part.inline_data.data))
69
+ break
70
+
71
+ print("--- Finished generating floor plan image ---\n")
72
+ gr.Info("Step 1 Complete: Floor plan image generated!")
73
+ except Exception as e:
74
+ gr.Warning(f"Error generating image: {e}")
75
+ print(f"Error generating image: {e}")
76
+
77
+ # Step 2: Extract coordinates
78
+ layout_json = DEFAULT_FLOOR_PLAN
79
+ image_to_parse = generated_image if generated_image else pil_image
80
+
81
+ gr.Info("Step 2: Analyzing top plan image to extract coordinates...")
82
+
83
+ prompted_model = "gemini-3-flash-preview"
84
+
85
+ try:
86
+ response_json = client.models.generate_content(
87
+ model=prompted_model,
88
+ contents=[
89
+ image_to_parse,
90
+ "OUTPUT FORMAT INSTRUCTION: Return ONLY a valid JSON string. DO NOT provide any reasoning, markdown formatting, or conversational text.\n"
91
+ "Analyze this floor plan (or room image) and output the exact coordinates "
92
+ "for the room corners formatted as [[x, y], ...] and a list of furnitures with "
93
+ "their object name, description, and bounding box [ymin, xmin, ymax, xmax]."
94
+ ],
95
+ config={
96
+ 'response_mime_type': 'application/json',
97
+ 'response_schema': RoomLayout
98
+ }
99
+ )
100
+ if response_json.text:
101
+ layout_json = json.loads(response_json.text)
102
+ gr.Info("Step 2 Complete: Coordinates extracted successfully!")
103
+ print(layout_json)
104
+ except Exception as e:
105
+ gr.Warning(f"Error parsing layout JSON: {e}")
106
+ print(f"Error parsing layout JSON: {e}")
107
+
108
+ # Return: floor_plan value, initial_layout_state, topview_state, topview_before display
109
+ return layout_json, layout_json, generated_image, generated_image
110
+
111
+ def _generate_image(contents, label="image"):
112
+ """Helper: call Gemini image generation and return a PIL Image or None."""
113
+ config = types.GenerateContentConfig(
114
+ thinking_config=types.ThinkingConfig(thinking_level="MINIMAL"),
115
+ image_config=types.ImageConfig(image_size="1K"),
116
+ response_modalities=["IMAGE"],
117
+ )
118
+ try:
119
+ response_stream = client.models.generate_content_stream(
120
+ model='models/gemini-3.1-flash-image-preview',
121
+ contents=contents,
122
+ config=config,
123
+ )
124
+ for chunk in response_stream:
125
+ if chunk.parts:
126
+ for part in chunk.parts:
127
+ if part.inline_data:
128
+ print(f"[Image Data] Received {label}")
129
+ return Image.open(BytesIO(part.inline_data.data))
130
+ except Exception as e:
131
+ gr.Warning(f"Error generating {label}: {e}")
132
+ print(f"Error generating {label}: {e}")
133
+ return None
134
+
135
+
136
+ def generate_final_image(original_image_numpy, current_layout_json, initial_layout_json, original_topview):
137
+ if original_image_numpy is None:
138
+ gr.Warning("Please upload an image first.")
139
+ return None, None
140
 
141
+ pil_image = Image.fromarray(original_image_numpy)
 
142
 
143
+ # Compute changed furnitures
144
+ old_furnitures = {f["object"]: f for f in (initial_layout_json.get("furnitures") or [])}
145
+ new_furnitures = {f["object"]: f for f in (current_layout_json.get("furnitures") or [])}
146
+ moved = [
147
+ {"object": name, "from": old_furnitures[name]["localisation"], "to": new_furnitures[name]["localisation"]}
148
+ for name in new_furnitures
149
+ if name in old_furnitures and old_furnitures[name]["localisation"] != new_furnitures[name]["localisation"]
150
+ ]
151
+ added = [f for name, f in new_furnitures.items() if name not in old_furnitures]
152
+ removed = [f for name, f in old_furnitures.items() if name not in new_furnitures]
153
+ changes = {"moved": moved, "added": added, "removed": removed}
154
+ changes_str = json.dumps(changes, indent=2)
155
+ new_layout_str = json.dumps(current_layout_json, indent=2)
156
+ print(f"Furniture changes: {changes_str}")
157
+
158
+ # Step A: original top-view + new layout → new top-view
159
+ gr.Info("Step A: Generating updated top-view floor plan image...")
160
+ print("\n--- Step A: new top-view from floor plan ---")
161
+ step_a_input = original_topview if original_topview is not None else pil_image
162
+ prompt_a = f"""This is a 2D top-down floor plan image of a room.
163
+ Redraw it as a clean 2D top-down floor plan image applying the following furniture changes.
164
+ Coordinates are bounding boxes [ymin, xmin, ymax, xmax].
165
+
166
+ Changes to apply:
167
+ {changes_str}
168
+
169
+ Full new target layout for reference:
170
+ {new_layout_str}
171
+
172
+ OUTPUT ONLY THE IMAGE. Keep the same room boundaries and style."""
173
+
174
+ new_topview = _generate_image([step_a_input, prompt_a], label="new top-view")
175
+ if new_topview is None:
176
+ gr.Warning("Step A failed: could not generate updated top-view.")
177
+ return None, None
178
+
179
+ gr.Info("Step A complete. Generating final photorealistic image...")
180
+
181
+ # Step B: new top-view + original photo → final photorealistic image
182
+ print("\n--- Step B: photorealistic synthesis ---")
183
+ prompt_b = """You are given two images:
184
+ 1. A 2D top-down floor plan showing the NEW furniture layout.
185
+ 2. The original room photo.
186
+
187
+ Generate a high-quality photorealistic image of the room from the EXACT SAME camera angle as the original photo, but with the furniture repositioned to match the new floor plan.
188
+
189
+ STRICT INSTRUCTIONS:
190
+ 1. Maintain the exact camera point-of-view, lighting, colors, and architectural features of the original photo.
191
+ 2. Place furniture according to the new floor plan layout.
192
+ 3. The room must look realistic and clean."""
193
+
194
+ final_image = _generate_image([new_topview, pil_image, prompt_b], label="final photorealistic")
195
+
196
+ if final_image is None:
197
+ gr.Warning("Step B failed: could not generate final image.")
198
+ return new_topview, None
199
+
200
+ gr.Info("Final image generated successfully!")
201
+ return new_topview, final_image
202
 
203
  with gr.Blocks() as demo:
204
  gr.Markdown("# LaMaison\nRearrange your spaces using visual planning.")
205
+
206
+ # State to store the initial layout snapshot and the original top-view image
207
+ initial_layout_state = gr.State(DEFAULT_FLOOR_PLAN)
208
+ topview_state = gr.State(None)
209
 
210
+ with gr.Row():
211
+ with gr.Column(scale=1):
212
+ image_input = gr.Image(label="Upload a room image", type="numpy")
213
+ floor_plan = FloorPlan(
214
+ value=DEFAULT_FLOOR_PLAN,
215
+ label="Floor Plan",
216
+ interactive=True,
217
+ )
218
+ generate_button = gr.Button("Generate New Image based on Floor plan", variant="primary")
219
+ with gr.Column(scale=1):
220
+ topview_before = gr.Image(label="Top View — Original", interactive=False)
221
+ topview_after = gr.Image(label="Top View — New Layout (Step A)", interactive=False)
222
+ image_output = gr.Image(label="Final Photorealistic Output (Step B)", interactive=False)
223
+
224
+ # Wire it: input -> [floor_plan, initial_layout_state, topview_state, topview_before]
225
+ image_input.change(
226
+ process_room_image,
227
+ inputs=image_input,
228
+ outputs=[floor_plan, initial_layout_state, topview_state, topview_before]
229
+ )
230
 
231
+ # Phase 2: User clicks button -> generates new top-view then final photorealistic image
232
+ generate_button.click(
233
+ generate_final_image,
234
+ inputs=[image_input, floor_plan, initial_layout_state, topview_state],
235
+ outputs=[topview_after, image_output]
236
+ )
237
 
238
  if __name__ == "__main__":
239
+ demo.launch(server_name="0.0.0.0", server_port=7860)
floorplan/.gitignore CHANGED
@@ -1,5 +1,5 @@
1
  .eggs/
2
- dist/
3
  *.pyc
4
  __pycache__/
5
  *.py[cod]
 
1
  .eggs/
2
+ dist/*.tar.gz
3
  *.pyc
4
  __pycache__/
5
  *.py[cod]
floorplan/README.md CHANGED
@@ -1,10 +1,262 @@
1
 
2
- # gradio_floorplan
3
- A Custom Gradio component.
4
 
5
- ## Example usage
 
 
 
 
 
 
 
 
6
 
7
  ```python
8
  import gradio as gr
9
  from gradio_floorplan import FloorPlan
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
+ # `gradio_floorplan`
3
+ <img alt="Static Badge" src="https://img.shields.io/badge/version%20-%200.0.1%20-%20orange">
4
 
5
+ A Gradio custom component for interactive SVG floor plan editing
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install gradio_floorplan
11
+ ```
12
+
13
+ ## Usage
14
 
15
  ```python
16
  import gradio as gr
17
  from gradio_floorplan import FloorPlan
18
+
19
+ DEFAULT_VALUE = {
20
+ "corners": [[50, 50], [550, 50], [550, 450], [50, 450]],
21
+ "furnitures": [
22
+ {
23
+ "object": "Sofa",
24
+ "localisation": [150, 100, 250, 300],
25
+ "description": "3-seat sofa",
26
+ },
27
+ {
28
+ "object": "Table",
29
+ "localisation": [300, 200, 380, 400],
30
+ "description": "Coffee table",
31
+ },
32
+ ],
33
+ }
34
+
35
+
36
+ def on_furniture_moved(value):
37
+ """Receives updated floor plan after user moves a furniture item."""
38
+ return value
39
+
40
+
41
+ with gr.Blocks() as demo:
42
+ gr.Markdown("## LaMaison — Floor Plan")
43
+ floor_plan = FloorPlan(value=DEFAULT_VALUE, label="Floor Plan", interactive=True)
44
+ output = gr.JSON(label="Updated positions")
45
+ floor_plan.change(on_furniture_moved, inputs=floor_plan, outputs=output)
46
+
47
+ if __name__ == "__main__":
48
+ demo.launch()
49
+
50
+ ```
51
+
52
+ ## `FloorPlan`
53
+
54
+ ### Initialization
55
+
56
+ <table>
57
+ <thead>
58
+ <tr>
59
+ <th align="left">name</th>
60
+ <th align="left" style="width: 25%;">type</th>
61
+ <th align="left">default</th>
62
+ <th align="left">description</th>
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+ <tr>
67
+ <td align="left"><code>value</code></td>
68
+ <td align="left" style="width: 25%;">
69
+
70
+ ```python
71
+ dict | None
72
+ ```
73
+
74
+ </td>
75
+ <td align="left"><code>value = None</code></td>
76
+ <td align="left">None</td>
77
+ </tr>
78
+
79
+ <tr>
80
+ <td align="left"><code>label</code></td>
81
+ <td align="left" style="width: 25%;">
82
+
83
+ ```python
84
+ str | None
85
+ ```
86
+
87
+ </td>
88
+ <td align="left"><code>value = None</code></td>
89
+ <td align="left">None</td>
90
+ </tr>
91
+
92
+ <tr>
93
+ <td align="left"><code>info</code></td>
94
+ <td align="left" style="width: 25%;">
95
+
96
+ ```python
97
+ str | None
98
+ ```
99
+
100
+ </td>
101
+ <td align="left"><code>value = None</code></td>
102
+ <td align="left">None</td>
103
+ </tr>
104
+
105
+ <tr>
106
+ <td align="left"><code>every</code></td>
107
+ <td align="left" style="width: 25%;">
108
+
109
+ ```python
110
+ 'Timer | float | None'
111
+ ```
112
+
113
+ </td>
114
+ <td align="left"><code>value = None</code></td>
115
+ <td align="left">None</td>
116
+ </tr>
117
+
118
+ <tr>
119
+ <td align="left"><code>show_label</code></td>
120
+ <td align="left" style="width: 25%;">
121
+
122
+ ```python
123
+ bool | None
124
+ ```
125
+
126
+ </td>
127
+ <td align="left"><code>value = None</code></td>
128
+ <td align="left">None</td>
129
+ </tr>
130
+
131
+ <tr>
132
+ <td align="left"><code>container</code></td>
133
+ <td align="left" style="width: 25%;">
134
+
135
+ ```python
136
+ bool
137
+ ```
138
+
139
+ </td>
140
+ <td align="left"><code>value = True</code></td>
141
+ <td align="left">None</td>
142
+ </tr>
143
+
144
+ <tr>
145
+ <td align="left"><code>scale</code></td>
146
+ <td align="left" style="width: 25%;">
147
+
148
+ ```python
149
+ int | None
150
+ ```
151
+
152
+ </td>
153
+ <td align="left"><code>value = None</code></td>
154
+ <td align="left">None</td>
155
+ </tr>
156
+
157
+ <tr>
158
+ <td align="left"><code>min_width</code></td>
159
+ <td align="left" style="width: 25%;">
160
+
161
+ ```python
162
+ int
163
+ ```
164
+
165
+ </td>
166
+ <td align="left"><code>value = 160</code></td>
167
+ <td align="left">None</td>
168
+ </tr>
169
+
170
+ <tr>
171
+ <td align="left"><code>interactive</code></td>
172
+ <td align="left" style="width: 25%;">
173
+
174
+ ```python
175
+ bool | None
176
  ```
177
+
178
+ </td>
179
+ <td align="left"><code>value = None</code></td>
180
+ <td align="left">None</td>
181
+ </tr>
182
+
183
+ <tr>
184
+ <td align="left"><code>visible</code></td>
185
+ <td align="left" style="width: 25%;">
186
+
187
+ ```python
188
+ bool
189
+ ```
190
+
191
+ </td>
192
+ <td align="left"><code>value = True</code></td>
193
+ <td align="left">None</td>
194
+ </tr>
195
+
196
+ <tr>
197
+ <td align="left"><code>elem_id</code></td>
198
+ <td align="left" style="width: 25%;">
199
+
200
+ ```python
201
+ str | None
202
+ ```
203
+
204
+ </td>
205
+ <td align="left"><code>value = None</code></td>
206
+ <td align="left">None</td>
207
+ </tr>
208
+
209
+ <tr>
210
+ <td align="left"><code>elem_classes</code></td>
211
+ <td align="left" style="width: 25%;">
212
+
213
+ ```python
214
+ list[str] | str | None
215
+ ```
216
+
217
+ </td>
218
+ <td align="left"><code>value = None</code></td>
219
+ <td align="left">None</td>
220
+ </tr>
221
+
222
+ <tr>
223
+ <td align="left"><code>render</code></td>
224
+ <td align="left" style="width: 25%;">
225
+
226
+ ```python
227
+ bool
228
+ ```
229
+
230
+ </td>
231
+ <td align="left"><code>value = True</code></td>
232
+ <td align="left">None</td>
233
+ </tr>
234
+ </tbody></table>
235
+
236
+
237
+ ### Events
238
+
239
+ | name | description |
240
+ |:-----|:------------|
241
+ | `change` | |
242
+
243
+
244
+
245
+ ### User function
246
+
247
+ The impact on the users predict function varies depending on whether the component is used as an input or output for an event (or both).
248
+
249
+ - When used as an Input, the component only impacts the input signature of the user function.
250
+ - When used as an output, the component only impacts the return signature of the user function.
251
+
252
+ The code snippet below is accurate in cases where the component is used as both an input and an output.
253
+
254
+
255
+
256
+ ```python
257
+ def predict(
258
+ value: dict| None
259
+ ) -> dict| None:
260
+ return value
261
+ ```
262
+
floorplan/frontend/FloorPlan.svelte CHANGED
@@ -44,6 +44,16 @@
44
  let startDy = 0;
45
  let savedOffsets: [number, number][] = [];
46
 
 
 
 
 
 
 
 
 
 
 
47
  function clampOffset(
48
  dx: number,
49
  dy: number,
@@ -58,15 +68,93 @@
58
  }
59
 
60
  function clientToSvg(clientX: number, clientY: number): [number, number] {
 
 
61
  const rect = svgEl.getBoundingClientRect();
 
 
62
  const scaleX = SVG_WIDTH / rect.width;
63
  const scaleY = SVG_HEIGHT / rect.height;
64
- return [
65
- (clientX - rect.left) * scaleX,
66
- (clientY - rect.top) * scaleY,
67
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  function onPointerDown(e: PointerEvent, i: number) {
71
  if (!interactive) return;
72
  e.preventDefault();
@@ -125,12 +213,15 @@
125
  <!-- svelte-ignore a11y-no-static-element-interactions -->
126
  <svg
127
  bind:this={svgEl}
128
- width={SVG_WIDTH}
129
- height={SVG_HEIGHT}
130
- style="border: 1px solid #ccc; background: #fafafa; display: block;"
131
- on:pointermove={onPointerMove}
132
- on:pointerup={onPointerUp}
133
- on:pointercancel={onPointerCancel}
 
 
 
134
  >
135
  <!-- Room outline -->
136
  <polygon points={polygonPoints} fill="none" stroke="#333" stroke-width="2" />
 
44
  let startDy = 0;
45
  let savedOffsets: [number, number][] = [];
46
 
47
+ // View state
48
+ let zoom = 1;
49
+ let viewBoxPanX = 0;
50
+ let viewBoxPanY = 0;
51
+ let isPanning = false;
52
+ let panStartX = 0;
53
+ let panStartY = 0;
54
+ let panStartViewBoxX = 0;
55
+ let panStartViewBoxY = 0;
56
+
57
  function clampOffset(
58
  dx: number,
59
  dy: number,
 
68
  }
69
 
70
  function clientToSvg(clientX: number, clientY: number): [number, number] {
71
+ // Return coordinates mapped precisely taking zoom and pan into account
72
+ // (bounding box returns layout size in CSS pixels)
73
  const rect = svgEl.getBoundingClientRect();
74
+
75
+ // Scale from CSS pixels to base SVG pixels
76
  const scaleX = SVG_WIDTH / rect.width;
77
  const scaleY = SVG_HEIGHT / rect.height;
78
+
79
+ // Apply viewport scale and translation (zoom & pan)
80
+ const baseSvgX = (clientX - rect.left) * scaleX;
81
+ const baseSvgY = (clientY - rect.top) * scaleY;
82
+
83
+ // Apply the inverse of the viewbox transform
84
+ const sx = viewBoxPanX + (baseSvgX / zoom);
85
+ const sy = viewBoxPanY + (baseSvgY / zoom);
86
+
87
+ return [sx, sy];
88
+ }
89
+
90
+ // --- Background Panning / Zooming ---
91
+
92
+ function onSvgPointerDown(e: PointerEvent) {
93
+ // If we clicked directly on the background (svgEl) or polygon
94
+ if (e.target === svgEl || (e.target as Element).tagName.toLowerCase() === 'polygon') {
95
+ isPanning = true;
96
+ panStartX = e.clientX;
97
+ panStartY = e.clientY;
98
+ panStartViewBoxX = viewBoxPanX;
99
+ panStartViewBoxY = viewBoxPanY;
100
+ svgEl.setPointerCapture(e.pointerId);
101
+ }
102
+ }
103
+
104
+ function onSvgPointerMove(e: PointerEvent) {
105
+ if (isPanning) {
106
+ const rect = svgEl.getBoundingClientRect();
107
+ const scaleX = SVG_WIDTH / rect.width;
108
+ const scaleY = SVG_HEIGHT / rect.height;
109
+
110
+ const dxStr = (e.clientX - panStartX) * scaleX;
111
+ const dyStr = (e.clientY - panStartY) * scaleY;
112
+
113
+ viewBoxPanX = panStartViewBoxX - (dxStr / zoom);
114
+ viewBoxPanY = panStartViewBoxY - (dyStr / zoom);
115
+ } else {
116
+ onPointerMove(e);
117
+ }
118
+ }
119
+
120
+ function onSvgPointerUp(e: PointerEvent) {
121
+ if (isPanning) {
122
+ isPanning = false;
123
+ svgEl.releasePointerCapture(e.pointerId);
124
+ } else {
125
+ if (e.type === 'pointercancel') {
126
+ onPointerCancel(e);
127
+ } else {
128
+ onPointerUp(e);
129
+ }
130
+ }
131
  }
132
 
133
+ function onWheel(e: WheelEvent) {
134
+ // Zoom centered at pointer
135
+ const zoomFactor = 1.1;
136
+ const direction = e.deltaY > 0 ? -1 : 1;
137
+
138
+ const [pointerSvgX, pointerSvgY] = clientToSvg(e.clientX, e.clientY);
139
+
140
+ if (direction === 1) {
141
+ zoom *= zoomFactor;
142
+ } else {
143
+ zoom /= zoomFactor;
144
+ }
145
+
146
+ // Constrain zoom to reasonable bounds
147
+ zoom = Math.max(0.2, Math.min(zoom, 10));
148
+
149
+ // Re-center around pointer position
150
+ const [newPointerSvgX, newPointerSvgY] = clientToSvg(e.clientX, e.clientY);
151
+ viewBoxPanX -= (newPointerSvgX - pointerSvgX);
152
+ viewBoxPanY -= (newPointerSvgY - pointerSvgY);
153
+ }
154
+
155
+ // --- Object Dragging ---
156
+
157
+
158
  function onPointerDown(e: PointerEvent, i: number) {
159
  if (!interactive) return;
160
  e.preventDefault();
 
213
  <!-- svelte-ignore a11y-no-static-element-interactions -->
214
  <svg
215
  bind:this={svgEl}
216
+ width="100%"
217
+ height="100%"
218
+ viewBox="{viewBoxPanX} {viewBoxPanY} {SVG_WIDTH / zoom} {SVG_HEIGHT / zoom}"
219
+ style="border: 1px solid #ccc; background: #fafafa; display: block; min-height: 400px; cursor: {isPanning ? 'grabbing' : 'grab'}; touch-action: none;"
220
+ on:pointerdown={onSvgPointerDown}
221
+ on:pointermove={onSvgPointerMove}
222
+ on:pointerup={onSvgPointerUp}
223
+ on:pointercancel={onSvgPointerUp}
224
+ on:wheel|preventDefault={onWheel}
225
  >
226
  <!-- Room outline -->
227
  <polygon points={polygonPoints} fill="none" stroke="#333" stroke-width="2" />
floorplan/frontend/package-lock.json CHANGED
@@ -16,7 +16,7 @@
16
  "svelte": "^5.48.0"
17
  },
18
  "devDependencies": {
19
- "@gradio/preview": "0.16.0",
20
  "vitest": "^4.1.0"
21
  },
22
  "peerDependencies": {
 
16
  "svelte": "^5.48.0"
17
  },
18
  "devDependencies": {
19
+ "@gradio/preview": "^0.16.0",
20
  "vitest": "^4.1.0"
21
  },
22
  "peerDependencies": {
floorplan/frontend/package.json CHANGED
@@ -28,7 +28,7 @@
28
  "svelte": "^5.48.0"
29
  },
30
  "devDependencies": {
31
- "@gradio/preview": "0.16.0",
32
  "vitest": "^4.1.0"
33
  },
34
  "peerDependencies": {
 
28
  "svelte": "^5.48.0"
29
  },
30
  "devDependencies": {
31
+ "@gradio/preview": "^0.16.0",
32
  "vitest": "^4.1.0"
33
  },
34
  "peerDependencies": {
floorplan/pyproject.toml CHANGED
@@ -45,7 +45,7 @@ classifiers = [
45
  dev = ["build", "twine"]
46
 
47
  [tool.hatch.build]
48
- artifacts = ["/backend/gradio_floorplan/templates", "*.pyi"]
49
 
50
  [tool.hatch.build.targets.wheel]
51
  packages = ["/backend/gradio_floorplan"]
 
45
  dev = ["build", "twine"]
46
 
47
  [tool.hatch.build]
48
+ artifacts = ["/backend/gradio_floorplan/templates", "*.pyi", "/opt/homebrew/lib/python3.14/site-packages/gradio_floorplan/templates"]
49
 
50
  [tool.hatch.build.targets.wheel]
51
  packages = ["/backend/gradio_floorplan"]
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ google-genai
2
+ python-dotenv
3
+ pillow
4
+ pydantic