feat: Overlay the mathematical Sun Path over the live camera feed or 3D model view (#8)
#7 Task completed. Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -1,157 +1,147 @@
|
|||||||
# Dream Weaver — iOS ↔ Backend Integration Guide
|
# Dream Weaver API v2 — iOS Integration Guide (Dynamic Keywords)
|
||||||
**Version:** 2.0 | **Updated:** 2026-03-09 | **Server:** `54.172.172.2` | **Port:** `8080`
|
**Version:** 2.0-FINAL | **Updated:** 2026-03-09 | **Server:** `54.172.172.2` | **Port:** `8082`
|
||||||
|
|
||||||
> This document is for **Sayan** (iOS / Swift) and **Sourik** (backend review).
|
> This document is for **Sayan** (iOS / Swift).
|
||||||
> It describes exactly how the iPad app should talk to the Dream Weaver AI and how keywords from a user tap become a full ComfyUI generation.
|
> Dream Weaver API v2 introduces a **Dynamic Keyword to Local LLM Prompt Expansion** system.
|
||||||
|
> The app no longer relies on 5 hardcoded styles. Users can pick ANY keywords, and a local LLM (Qwen 3.5 27B via Ollama) will generate a photorealistic interior design prompt based on the room type without sending data to the cloud.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **PORT 8080 IS DEAD.** Do not use port 8080 anymore. The old gateway process has been completely killed. If you try to send `POST /dream-weaver` or `/docs` to port 8080 you will get a 404. You MUST change your `AppConfig.baseURL` parameter to use port **`8082`**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Architecture Overview
|
## 1. Architecture Overview (API v2)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────┐ HTTP/S ┌──────────────────────────────┐
|
┌────────────────────┐ HTTP/S ┌──────────────────────────────┐
|
||||||
│ │ ─── POST image ───► │ Dream Weaver Gateway │
|
│ │ ── keywords ────► │ Dream Weaver Gateway v2 │
|
||||||
│ iPad App (Swift) │ │ FastAPI port 8080 │
|
│ iPad App (Swift) │ │ FastAPI port 8082 │
|
||||||
│ │ ◄── PNG result ─── │ dw_gateway.py │
|
│ │ ◄── PNG result ── │ dw_gateway_v2.py │
|
||||||
└────────────────────┘ └─────────────┬────────────────┘
|
└────────────────────┘ └─────────────┬────────────────┘
|
||||||
│ internal HTTP
|
│ LLM Prompt Expansion
|
||||||
|
│ (Local Ollama: Qwen 3.5 27B)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────┐
|
┌─────────────────────────┐
|
||||||
│ ComfyUI Engine │
|
│ ComfyUI Engine │
|
||||||
│ port 8188 │
|
│ port 8188 │
|
||||||
│ RealVisXL V5.0 Ltng │
|
│ RealVisXL V5.0 Ltng │
|
||||||
│ 4× NVIDIA L4 (96 GB) │
|
|
||||||
└─────────────────────────┘
|
└─────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key rule:** The iPad app **never** talks to ComfyUI directly. It only talks to the Gateway on `:8080`.
|
**Key changes in v2:**
|
||||||
|
1. The API now runs on port **`8082`** to avoid conflicts.
|
||||||
|
2. The [style](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/dreamweaver_batch_processor.py#394-414) parameter is deprecated in favor of `keywords` (array of strings) and [room_type](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/dw_gateway_v2.py#171-186).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. How Keywords Become Prompts
|
## 2. Dynamic Keyword Expansion Flow
|
||||||
|
|
||||||
### 2.1 The Prompt Expansion System
|
Instead of injecting keywords into a rigid template, the new backend reads the `keywords` and [room_type](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/dw_gateway_v2.py#171-186), and asks a local LLM (Qwen 3.5 27B) to act as an interior designer:
|
||||||
|
|
||||||
Each interior style in the app is backed by a **prompt template** in `comfy_engine/prompts/`. When the user taps a style card (or types keywords), those keywords get **merged into the template** to build the final ComfyUI prompt injected into node `9` (positive CLIPTextEncode).
|
1. **User input:** `keywords: ["blue marble", "gold veins", "renaissance"]`, `room_type: "bathroom"`
|
||||||
|
2. **Backend LLM Expansion:** The LLM knows that a "bathroom" cannot have beds and needs wet-area materials. It creates a rich positive prompt: *"renaissance revival luxury interior design, blue veined marble flooring, gold brass fixtures..."*
|
||||||
|
3. **ComfyUI Generation:** The expanded prompt is sent to ComfyUI for generation.
|
||||||
|
|
||||||
**Prompt template structure** (from [scandinavian_minimalist.txt](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/prompts/scandinavian_minimalist.txt)):
|
**Supported Room Types:**
|
||||||
```
|
`bedroom`, `living_room`, `bathroom`, `kitchen`, `dining_room`, `home_office`, `hallway`, `balcony`.
|
||||||
POSITIVE PROMPT:
|
|
||||||
scandinavian minimalist interior design, light oak wood flooring, neutral beige textiles,
|
|
||||||
abundant natural light streaming through large windows, clean white walls, ...
|
|
||||||
Style Weight: <lora:Interior_Style_Scandi:0.8>
|
|
||||||
|
|
||||||
NEGATIVE PROMPT:
|
|
||||||
(worst quality, low quality, illustration, 3d render...), heavy ornamentation,...
|
|
||||||
|
|
||||||
TECHNICAL PARAMETERS:
|
|
||||||
- Denoising Strength: 0.70
|
|
||||||
- CFG Scale: 7.0
|
|
||||||
- Recommended Sampler: dpmpp_2m_karras
|
|
||||||
- Steps: 30-40
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Keyword Expansion Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User taps: ["marble", "gold", "luxury"]
|
|
||||||
+
|
|
||||||
Style selected: "art_deco"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Backend expands:
|
|
||||||
base_prompt = art_deco_luxe.txt (POSITIVE PROMPT section)
|
|
||||||
user_keywords_str = "marble, gold, luxury"
|
|
||||||
final_prompt = base_prompt + ", " + user_keywords_str
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Injected into ComfyUI workflow:
|
|
||||||
node "9" → CLIPTextEncode → text: [final_prompt]
|
|
||||||
node "10" → CLIPTextEncode → text: [negative_prompt from template]
|
|
||||||
node "1" → LoadImage → image: [uploaded filename]
|
|
||||||
node "13" → KSampler → denoise: 0.72, cfg: 7.5, steps: 35
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Available Styles and Their Keywords (for the Style Picker UI)
|
|
||||||
|
|
||||||
| Style ID | Display Name | Suggested Keywords Palette |
|
|
||||||
|---|---|---|
|
|
||||||
| `scandinavian` | Scandinavian Minimalist | oak, linen, white, hygge, cozy, birch, natural |
|
|
||||||
| `art_deco` | Art Deco Luxe | gold, marble, velvet, geometric, 1920s, brass, crystal |
|
|
||||||
| `biophilic` | Biophilic Organic | green wall, stone, rattan, terracotta, botanical, moss |
|
|
||||||
| `cyberpunk` | Cyberpunk Neon | neon, chrome, holographic, dark, LED, futuristic, blade runner |
|
|
||||||
| `japandi` | Japandi Fusion | wabi-sabi, ash wood, ceramic, zen, minimal, shoji, serene |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. API Reference — What Sayan Needs to Call
|
## 3. API Reference — New v2 Endpoints
|
||||||
|
|
||||||
### BASE URL
|
### BASE URL
|
||||||
```
|
```
|
||||||
http://54.172.172.2:8080
|
http://54.172.172.2:8082
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Once we attach an Elastic IP or domain, swap this in `AppConfig.swift`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.1 `GET /health` — Liveness Check
|
### 3.1 `GET /health` — Liveness Check
|
||||||
|
Call this on app launch to confirm the v2 server is up.
|
||||||
Call this on app launch to confirm the server is up before showing the Generate button.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```http
|
|
||||||
GET http://54.172.172.2:8080/health
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"comfyui": true,
|
"comfyui": true,
|
||||||
"gpu": "4x NVIDIA L4 (96GB VRAM)",
|
"gpu": "4x NVIDIA L4 (96GB VRAM)",
|
||||||
"model": "RealVisXL V5.0 Lightning"
|
"model": "RealVisXL V5.0 Lightning",
|
||||||
|
"llm_expansion": true,
|
||||||
|
"version": "2.0.0"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Swift:**
|
### 3.2 `GET /room-types`
|
||||||
```swift
|
Returns all supported room types and their required design context (useful if you want to build UI tooltips).
|
||||||
func checkServerHealth() async throws -> Bool {
|
```json
|
||||||
let url = URL(string: "\(AppConfig.baseURL)/health")!
|
{
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
"room_types": {
|
||||||
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
|
"bedroom": {
|
||||||
return json.status == "ok"
|
"description": "a private sleeping space",
|
||||||
|
"key_elements": ["bed", "bedside tables", "wardrobe", "soft lighting", "textiles", "headboard"]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 3.3 `POST /dream-weaver/expand` (Preview Prompt)
|
||||||
|
Use this if you want the user to **preview** the LLM's generated prompt before committing to a generation.
|
||||||
### 3.2 `POST /dream-weaver` — Submit Generation Job (Async)
|
**Request (JSON):**
|
||||||
|
```json
|
||||||
Use this for the main generation flow. Returns a `job_id` immediately; poll for result.
|
{
|
||||||
|
"keywords": ["blue marble", "gold veins", "renaissance"],
|
||||||
|
"room_type": "bathroom"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"style_name": "Renaissance Luxury",
|
||||||
|
"positive_prompt": "renaissance revival luxury interior design, blue veined marble flooring...",
|
||||||
|
"negative_prompt": "(worst quality, low quality...), extra windows...",
|
||||||
|
"cfg": 7.5,
|
||||||
|
"denoise": 0.72,
|
||||||
|
"steps": 30,
|
||||||
|
"source": "ollama_local"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 `POST /dream-weaver` (Submit Generation)
|
||||||
|
Use this for the main generation flow.
|
||||||
**Request:** `multipart/form-data`
|
**Request:** `multipart/form-data`
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| [image](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/a100_deployment_executor.py#306-322) | File (JPEG/PNG) | ✅ | The room photo from camera or library |
|
| [image](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/a100_deployment_executor.py#306-322) | File | ✅ | The room photo (JPEG/PNG) |
|
||||||
| [style](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/dreamweaver_batch_processor.py#394-414) | String | ✅ | One of: `scandinavian`, `art_deco`, `biophilic`, `cyberpunk`, `japandi` |
|
| `keywords` | String | ✅ | Comma-separated user keywords e.g. `"gold, marble, luxury"` |
|
||||||
| `keywords` | String | ➕ | Comma-separated user keywords e.g. `"gold, marble, luxury"` |
|
| [room_type](file:///F:/Workin%20In%20Progress/DESINEURON/GITLAB/Project_Velocity/comfy_engine/scripts/dw_gateway_v2.py#171-186) | String | ✅ | e.g. `"living_room"`, `"bedroom"` |
|
||||||
| `denoise` | Float | ➕ | 0.5–0.85 (default `0.72`). Higher = more creative |
|
| `additional_notes` | String | ➕ | (Optional) e.g. `"make it feel like a luxury hotel"` |
|
||||||
|
| `denoise` | Float | ➕ | (Optional) 0.5–0.85. If omitted, LLM decides. |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"job_id": "a1b2c3d4-...",
|
"job_id": "a1b2c3d4-...",
|
||||||
"status": "processing",
|
"status": "processing",
|
||||||
|
"prompt_preview": "renaissance revival luxury interior design...",
|
||||||
"poll_url": "/dream-weaver/status/a1b2c3d4-...",
|
"poll_url": "/dream-weaver/status/a1b2c3d4-...",
|
||||||
"result_url": "/dream-weaver/result/a1b2c3d4-..."
|
"result_url": "/dream-weaver/result/a1b2c3d4-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Swift example:**
|
---
|
||||||
|
|
||||||
|
## 4. Polling & Downloading (Unchanged from v1)
|
||||||
|
|
||||||
|
**Poll Job Status:**
|
||||||
|
`GET /dream-weaver/status/{job_id}` every 2 seconds until `ready == true`.
|
||||||
|
|
||||||
|
**Download Result:**
|
||||||
|
`GET /dream-weaver/result/{job_id}` returns the raw PNG stream.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Updated Swift Example (v2)
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
func submitGeneration(image: UIImage, style: String, keywords: [String]) async throws -> GenerationJob {
|
func submitGenerationV2(image: UIImage, roomType: String, keywords: [String]) async throws -> GenerationJob {
|
||||||
let url = URL(string: "\(AppConfig.baseURL)/dream-weaver")!
|
let url = URL(string: "\(AppConfig.baseURL)/dream-weaver")!
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
@@ -160,26 +150,17 @@ func submitGeneration(image: UIImage, style: String, keywords: [String]) async t
|
|||||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
var body = Data()
|
var body = Data()
|
||||||
// Image field
|
|
||||||
|
// 1. Image
|
||||||
let imageData = image.jpegData(compressionQuality: 0.85)!
|
let imageData = image.jpegData(compressionQuality: 0.85)!
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
body.appendMultipartForm(boundary: boundary, name: "image", filename: "room.jpg", contentType: "image/jpeg", data: imageData)
|
||||||
body.append("Content-Disposition: form-data; name=\"image\"; filename=\"room.jpg\"\r\n".data(using: .utf8)!)
|
|
||||||
body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
|
|
||||||
body.append(imageData)
|
|
||||||
body.append("\r\n".data(using: .utf8)!)
|
|
||||||
|
|
||||||
// Style field
|
// 2. Room Type
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
body.appendMultipartForm(boundary: boundary, name: "room_type", value: roomType)
|
||||||
body.append("Content-Disposition: form-data; name=\"style\"\r\n\r\n".data(using: .utf8)!)
|
|
||||||
body.append("\(style)\r\n".data(using: .utf8)!)
|
|
||||||
|
|
||||||
// Keywords field (user tapped keywords)
|
// 3. Keywords
|
||||||
if !keywords.isEmpty {
|
|
||||||
let kwString = keywords.joined(separator: ", ")
|
let kwString = keywords.joined(separator: ", ")
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
body.appendMultipartForm(boundary: boundary, name: "keywords", value: kwString)
|
||||||
body.append("Content-Disposition: form-data; name=\"keywords\"\r\n\r\n".data(using: .utf8)!)
|
|
||||||
body.append("\(kwString)\r\n".data(using: .utf8)!)
|
|
||||||
}
|
|
||||||
|
|
||||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
@@ -187,326 +168,30 @@ func submitGeneration(image: UIImage, style: String, keywords: [String]) async t
|
|||||||
let (data, _) = try await URLSession.shared.data(for: request)
|
let (data, _) = try await URLSession.shared.data(for: request)
|
||||||
return try JSONDecoder().decode(GenerationJob.self, from: data)
|
return try JSONDecoder().decode(GenerationJob.self, from: data)
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
---
|
// Helper extension for building multipart forms cleanly
|
||||||
|
extension Data {
|
||||||
### 3.3 `GET /dream-weaver/status/{job_id}` — Poll Job Status
|
mutating func appendMultipartForm(boundary: String, name: String, value: String) {
|
||||||
|
self.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
Poll every **2 seconds** until `ready == true`.
|
self.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
|
||||||
|
self.append("\(value)\r\n".data(using: .utf8)!)
|
||||||
**Response while processing:**
|
|
||||||
```json
|
|
||||||
{ "status": "processing", "ready": false, "style": "art_deco" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response when done:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "done",
|
|
||||||
"ready": true,
|
|
||||||
"result_url": "/dream-weaver/result/a1b2c3d4-...",
|
|
||||||
"style": "art_deco"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Swift polling loop:**
|
|
||||||
```swift
|
|
||||||
func pollForResult(jobId: String) async throws -> URL {
|
|
||||||
let statusURL = URL(string: "\(AppConfig.baseURL)/dream-weaver/status/\(jobId)")!
|
|
||||||
|
|
||||||
for _ in 0..<150 { // max 5 min (150 × 2s)
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
|
||||||
let (data, _) = try await URLSession.shared.data(from: statusURL)
|
|
||||||
let status = try JSONDecoder().decode(JobStatus.self, from: data)
|
|
||||||
|
|
||||||
if status.ready {
|
|
||||||
return URL(string: "\(AppConfig.baseURL)/dream-weaver/result/\(jobId)")!
|
|
||||||
}
|
}
|
||||||
if status.status == "error" {
|
|
||||||
throw DreamWeaverError.generationFailed(status.error ?? "Unknown")
|
mutating func appendMultipartForm(boundary: String, name: String, filename: String, contentType: String, data: Data) {
|
||||||
|
self.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
self.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||||
|
self.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
|
||||||
|
self.append(data)
|
||||||
|
self.append("\r\n".data(using: .utf8)!)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
throw DreamWeaverError.timeout
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3.4 `GET /dream-weaver/result/{job_id}` — Download Result Image
|
## 6. Sayan's Action Checklist (v2)
|
||||||
|
|
||||||
Returns a PNG image stream directly. Download and display to user.
|
- [ ] Change `AppConfig.baseURL` port to `8082` (e.g., `http://54.172.172.2:8082`).
|
||||||
|
- [ ] Add a UI element for the user to select the **Room Type** (`bedroom`, `living_room`, `bathroom`, etc.).
|
||||||
```swift
|
- [ ] Change the `POST /dream-weaver` payload from `{style}` to `{keywords, room_type}`.
|
||||||
func downloadResult(resultURL: URL) async throws -> UIImage {
|
- [ ] (Optional) Use the new `GET /dream-weaver/expand` endpoint to let the user preview and edit the AI-generated prompt before generating.
|
||||||
let (data, _) = try await URLSession.shared.data(from: resultURL)
|
|
||||||
guard let image = UIImage(data: data) else {
|
|
||||||
throw DreamWeaverError.invalidImageData
|
|
||||||
}
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 `POST /dream-weaver/sync` — One-Shot Blocking Call
|
|
||||||
|
|
||||||
For **testing only** or fast network connections. Waits up to 120 seconds and returns the image directly.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// Same multipart form as /dream-weaver but returns PNG bytes directly
|
|
||||||
// Not recommended for production — use async flow above
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Keyword → Prompt Expansion (Backend Change Required)
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> The current [dw_gateway.py](file:///C:/Windows/Temp/dw_gateway.py) does NOT yet accept `keywords` from the app.
|
|
||||||
> Sagnik needs to add keyword expansion to the gateway. Here is the exact code change:
|
|
||||||
|
|
||||||
**In [dw_gateway.py](file:///C:/Windows/Temp/dw_gateway.py), update [build_workflow()](file:///C:/Windows/Temp/dw_gateway.py#33-65):**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Prompt library — maps style ID to (positive_base, negative, cfg, denoise, steps)
|
|
||||||
STYLE_LIBRARY = {
|
|
||||||
"scandinavian": {
|
|
||||||
"pos": "scandinavian minimalist interior design, light oak wood flooring, neutral beige textiles, abundant natural light, clean white walls, simple functional furniture, cozy hygge atmosphere, architectural photography, 8k resolution, photorealistic",
|
|
||||||
"neg": "(worst quality, low quality, illustration, 3d render, painting, cartoon, sketch), blurry, distorted, extra windows, unrealistic lighting, structural changes",
|
|
||||||
"cfg": 7.0, "denoise": 0.70, "steps": 30,
|
|
||||||
},
|
|
||||||
"art_deco": {
|
|
||||||
"pos": "art deco luxury interior design, geometric chevron patterns, gold brass accents, rich velvet upholstery emerald and sapphire, sunburst mirrors, polished marble flooring, crystal chandeliers, 1920s glamour, 8k resolution, photorealistic",
|
|
||||||
"neg": "(worst quality, low quality, illustration, 3d render, painting, cartoon, sketch), blurry, distorted, structural changes, rustic, minimalism, cheap materials",
|
|
||||||
"cfg": 7.5, "denoise": 0.72, "steps": 30,
|
|
||||||
},
|
|
||||||
"biophilic": {
|
|
||||||
"pos": "biophilic organic interior design, living green walls with ferns and moss, natural stone accent walls, rattan and bamboo furniture, abundant houseplants, earth tone sage green and terracotta, 8k resolution, photorealistic, dappled sunlight",
|
|
||||||
"neg": "(worst quality, low quality, illustration, 3d render, painting, cartoon, sketch), blurry, distorted, structural changes, synthetic materials, plastic plants",
|
|
||||||
"cfg": 7.0, "denoise": 0.68, "steps": 30,
|
|
||||||
},
|
|
||||||
"cyberpunk": {
|
|
||||||
"pos": "cyberpunk neon interior design, high contrast LED strip lighting electric blue and hot pink, reflective chrome surfaces, dark matte walls, futuristic furniture, glowing circuit patterns, tech-noir blade runner aesthetic, 8k resolution, photorealistic, volumetric fog",
|
|
||||||
"neg": "(worst quality, low quality, illustration, 3d render, painting, cartoon, sketch), blurry, distorted, structural changes, natural daylight, rustic elements",
|
|
||||||
"cfg": 8.0, "denoise": 0.75, "steps": 30,
|
|
||||||
},
|
|
||||||
"japandi": {
|
|
||||||
"pos": "japandi fusion interior design, wabi-sabi textures, low-profile furniture, muted earth tones warm grays soft browns, handmade ceramic accents, light ash wood, shoji screen elements, minimal decoration, zen garden elements, 8k resolution, photorealistic, serene",
|
|
||||||
"neg": "(worst quality, low quality, illustration, 3d render, painting, cartoon, sketch), blurry, distorted, structural changes, bright colors, ornate decoration, cluttered",
|
|
||||||
"cfg": 6.5, "denoise": 0.70, "steps": 30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def build_workflow(img_name: str, style: str = "scandinavian",
|
|
||||||
keywords: str = "", denoise_override: float = None) -> dict:
|
|
||||||
s = STYLE_LIBRARY.get(style, STYLE_LIBRARY["scandinavian"])
|
|
||||||
|
|
||||||
# Merge user keywords into base positive prompt
|
|
||||||
pos = s["pos"]
|
|
||||||
if keywords.strip():
|
|
||||||
pos = pos + ", " + keywords.strip()
|
|
||||||
|
|
||||||
cfg = s["cfg"]
|
|
||||||
denoise = denoise_override if denoise_override else s["denoise"]
|
|
||||||
steps = s["steps"]
|
|
||||||
neg = s["neg"]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"1": {"class_type": "CheckpointLoaderSimple",
|
|
||||||
"inputs": {"ckpt_name": "realvisxlV50_v50LightningBakedvae.safetensors"}},
|
|
||||||
"2": {"class_type": "LoadImage",
|
|
||||||
"inputs": {"image": img_name, "upload": "image"}},
|
|
||||||
"3": {"class_type": "CLIPTextEncode",
|
|
||||||
"inputs": {"text": pos, "clip": ["1", 1]}}, # ← POSITIVE PROMPT
|
|
||||||
"4": {"class_type": "CLIPTextEncode",
|
|
||||||
"inputs": {"text": neg, "clip": ["1", 1]}}, # ← NEGATIVE PROMPT
|
|
||||||
"5": {"class_type": "VAEEncode",
|
|
||||||
"inputs": {"pixels": ["2", 0], "vae": ["1", 2]}},
|
|
||||||
"6": {"class_type": "KSampler",
|
|
||||||
"inputs": {"model": ["1", 0], "positive": ["3", 0], "negative": ["4", 0],
|
|
||||||
"latent_image": ["5", 0],
|
|
||||||
"seed": int(time.time()) % 999983,
|
|
||||||
"steps": steps, "cfg": cfg,
|
|
||||||
"sampler_name": "dpmpp_2m", "scheduler": "karras",
|
|
||||||
"denoise": denoise}},
|
|
||||||
"7": {"class_type": "VAEDecode",
|
|
||||||
"inputs": {"samples": ["6", 0], "vae": ["1", 2]}},
|
|
||||||
"8": {"class_type": "SaveImage",
|
|
||||||
"inputs": {"images": ["7", 0], "filename_prefix": f"dreamweaver_{style}"}}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Also update the endpoint signatures** to accept `keywords`:
|
|
||||||
```python
|
|
||||||
@app.post("/dream-weaver")
|
|
||||||
async def dream_weaver(
|
|
||||||
image: UploadFile = File(...),
|
|
||||||
style: str = Form(default="scandinavian"),
|
|
||||||
keywords: str = Form(default=""), # ← ADD THIS
|
|
||||||
denoise: float = Form(default=0.0), # 0.0 = use style default
|
|
||||||
):
|
|
||||||
...
|
|
||||||
wf = build_workflow(comfy_name, style=style, keywords=keywords,
|
|
||||||
denoise_override=denoise if denoise > 0 else None)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Swift Data Models
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// AppConfig.swift
|
|
||||||
struct AppConfig {
|
|
||||||
static let baseURL = "http://54.172.172.2:8080"
|
|
||||||
// Change this to HTTPS domain once SSL is set up
|
|
||||||
}
|
|
||||||
|
|
||||||
// Models
|
|
||||||
struct GenerationJob: Codable {
|
|
||||||
let jobId: String
|
|
||||||
let status: String
|
|
||||||
let pollUrl: String
|
|
||||||
let resultUrl: String
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case jobId = "job_id"
|
|
||||||
case status, pollUrl = "poll_url", resultUrl = "result_url"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct JobStatus: Codable {
|
|
||||||
let status: String
|
|
||||||
let ready: Bool
|
|
||||||
let resultUrl: String?
|
|
||||||
let error: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case status, ready, resultUrl = "result_url", error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HealthResponse: Codable {
|
|
||||||
let status: String
|
|
||||||
let comfyui: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DreamWeaverError: Error {
|
|
||||||
case generationFailed(String)
|
|
||||||
case timeout
|
|
||||||
case invalidImageData
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style model for the style picker
|
|
||||||
struct InteriorStyle: Identifiable {
|
|
||||||
let id: String // used as the `style` form field value
|
|
||||||
let displayName: String
|
|
||||||
let keywords: [String] // shown as tappable chips in UI
|
|
||||||
let previewImage: String // local asset name
|
|
||||||
}
|
|
||||||
|
|
||||||
let availableStyles: [InteriorStyle] = [
|
|
||||||
InteriorStyle(id: "scandinavian", displayName: "Scandinavian", keywords: ["oak","linen","white","hygge","cozy","birch"], previewImage: "style_scandi"),
|
|
||||||
InteriorStyle(id: "art_deco", displayName: "Art Deco Luxe", keywords: ["gold","marble","velvet","geometric","brass","crystal"], previewImage: "style_artdeco"),
|
|
||||||
InteriorStyle(id: "biophilic", displayName: "Biophilic", keywords: ["green wall","stone","rattan","terracotta","botanical"], previewImage: "style_biophilic"),
|
|
||||||
InteriorStyle(id: "cyberpunk", displayName: "Cyberpunk Neon", keywords: ["neon","chrome","LED","futuristic","dark","holographic"], previewImage: "style_cyberpunk"),
|
|
||||||
InteriorStyle(id: "japandi", displayName: "Japandi Fusion", keywords: ["wabi-sabi","ceramic","ash wood","zen","minimal"], previewImage: "style_japandi"),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Complete Generation Flow (UI → Server → Result)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. User opens camera / library → picks a room photo
|
|
||||||
2. User selects a style card → style ID captured
|
|
||||||
3. User optionally taps keyword chips → keywords[] array built
|
|
||||||
4. User taps "Generate" →
|
|
||||||
|
|
||||||
POST /dream-weaver (multipart)
|
|
||||||
image: <jpeg data>
|
|
||||||
style: "art_deco"
|
|
||||||
keywords: "gold, marble, luxury hotel"
|
|
||||||
denoise: 0.72
|
|
||||||
|
|
||||||
5. Server returns { job_id: "abc123", status: "processing" }
|
|
||||||
6. App shows loading/progress UI
|
|
||||||
7. App polls GET /dream-weaver/status/abc123 every 2 seconds
|
|
||||||
8. When ready == true →
|
|
||||||
GET /dream-weaver/result/abc123 → returns PNG bytes
|
|
||||||
9. App displays result full-screen with save/share options
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected latency on 4× L4 GPU server:** `~15–20 seconds` end-to-end.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. WebSocket Progress (Optional Advanced Feature)
|
|
||||||
|
|
||||||
If Sayan wants a real-time progress bar (e.g. "Step 12/30"), connect directly to ComfyUI's WebSocket **only if port 8188 is opened**. Otherwise, polling `/status` is sufficient.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
// WebSocket — only if 8188 is exposed externally
|
|
||||||
class ComfyProgressWebSocket: NSObject, URLSessionWebSocketDelegate {
|
|
||||||
var onProgress: ((Int, Int) -> Void)?
|
|
||||||
var task: URLSessionWebSocketTask?
|
|
||||||
|
|
||||||
func connect(clientId: String) {
|
|
||||||
let url = URL(string: "ws://54.172.172.2:8188/ws?clientId=\(clientId)")!
|
|
||||||
task = URLSession.shared.webSocketTask(with: url)
|
|
||||||
task?.resume()
|
|
||||||
listen()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func listen() {
|
|
||||||
task?.receive { [weak self] result in
|
|
||||||
if case .success(let message) = result,
|
|
||||||
case .string(let text) = message,
|
|
||||||
let data = text.data(using: .utf8),
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
let type = json["type"] as? String, type == "progress",
|
|
||||||
let inner = json["data"] as? [String: Int] {
|
|
||||||
self?.onProgress?(inner["value"] ?? 0, inner["max"] ?? 30)
|
|
||||||
}
|
|
||||||
self?.listen() // recurse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Port 8188 is currently **not open externally** in the security group. Only port 8080 is. To use WebSocket progress, Sagnik needs to add an inbound rule for 8188. Until then, using `/status` polling every 2s gives good enough UX.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Error Handling
|
|
||||||
|
|
||||||
| HTTP Status | Meaning | UI Action |
|
|
||||||
|---|---|---|
|
|
||||||
| `200` | Success | Show result or job_id |
|
|
||||||
| `404` on `/status` | Job expired (> 30 min) | "Session expired. Please retry." |
|
|
||||||
| `500` | Generation failed (OOM, model error) | "Generation failed. Try a simpler image." |
|
|
||||||
| Connection error | Server down or no internet | "Checking server…" + retry logic |
|
|
||||||
|
|
||||||
The job [status](file:///C:/Windows/Temp/dw_gateway.py#154-164) field can also be `"error"` with an `error` field explaining what failed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Quick Checklist for Sayan
|
|
||||||
|
|
||||||
- [ ] Update `AppConfig.swift` with `baseURL = "http://54.172.172.2:8080"`
|
|
||||||
- [ ] Implement `POST /dream-weaver` multipart with `image + style + keywords`
|
|
||||||
- [ ] Implement polling loop on `GET /dream-weaver/status/{job_id}`
|
|
||||||
- [ ] Implement image download from `GET /dream-weaver/result/{job_id}`
|
|
||||||
- [ ] Add `GET /health` check on app launch
|
|
||||||
- [ ] Build keyword chips UI with the 5 style palettes from Section 2.3
|
|
||||||
- [ ] Test with the 20 sample images in `comfy_engine/test_inputs/`
|
|
||||||
|
|
||||||
## 10. Quick Checklist for Sagnik (backend)
|
|
||||||
|
|
||||||
- [ ] Update [dw_gateway.py](file:///C:/Windows/Temp/dw_gateway.py) with the full `STYLE_LIBRARY` dict (Section 4)
|
|
||||||
- [ ] Add `keywords: str = Form(default="")` to both POST endpoints
|
|
||||||
- [ ] Pass keywords into [build_workflow()](file:///C:/Windows/Temp/dw_gateway.py#33-65) for prompt expansion
|
|
||||||
- [ ] Redeploy gateway on port 8080 (`nohup python3 dw_gateway.py &`)
|
|
||||||
- [ ] (Optional) Open port 8188 in security group for WebSocket progress
|
|
||||||
|
|||||||
72
.Agent Context/Bibels/infrastructure_deployment_manifest.md
Normal file
72
.Agent Context/Bibels/infrastructure_deployment_manifest.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Dream Weaver — Infrastructure & Connectivity Manifest
|
||||||
|
|
||||||
|
**Environment:** Production AWS Node
|
||||||
|
**Last Verified:** 2026-03-14
|
||||||
|
**Status:** ✅ HEALTHY
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Server Instance
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Active Public IP: `54.91.19.60`**
|
||||||
|
> The previous Elastic IP (`54.172.172.2`) is currently detached and will time out. Ensure all your connection strings use the active IP.
|
||||||
|
|
||||||
|
| Component | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Instance ID** | `i-0e4eab5fe67cf9abe` |
|
||||||
|
| **Instance Name** | Desineuron AWS Node 4x L4 (96GB VRAM) Spot |
|
||||||
|
| **Public IP (Active)** | **`54.91.19.60`** |
|
||||||
|
| **Private IP** | `172.31.46.190` |
|
||||||
|
| **VPC ID** | `vpc-081d2397920aad268` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Security Group Settings
|
||||||
|
**Security Group ID:** `sg-0b144c17b1b89f4c6` (Synapse-Ops)
|
||||||
|
The following Inbound rules are explicitly confirmed and open:
|
||||||
|
|
||||||
|
| Protocol | Port | Source | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| TCP | **22** | `0.0.0.0/0` | SSH Access |
|
||||||
|
| TCP | **8082** | `0.0.0.0/0` | **Dream Weaver API v2 (Current)** |
|
||||||
|
| TCP | **8188** | `0.0.0.0/0` | ComfyUI Internal API |
|
||||||
|
| TCP | **8000** | `0.0.0.0/0` | ComfyUI Web UI (Alternate) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Services & Endpoints
|
||||||
|
|
||||||
|
### 1. Dream Weaver Gateway v2
|
||||||
|
* **Port:** `8082`
|
||||||
|
* **Status:** ✅ Active
|
||||||
|
* **Health Check:** `http://54.91.19.60:8082/health`
|
||||||
|
* **Main Endpoint:** `POST http://54.91.19.60:8082/dream-weaver`
|
||||||
|
|
||||||
|
### 2. ComfyUI Engine
|
||||||
|
* **Port:** `8188`
|
||||||
|
* **Status:** ✅ Active
|
||||||
|
* **Prompt Endpoint:** `POST http://54.91.19.60:8188/prompt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 SSH Configuration
|
||||||
|
|
||||||
|
**Local Key File Path:** `f:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\desineuron-l4-node.pem`
|
||||||
|
|
||||||
|
### Quick Connect Command
|
||||||
|
```bash
|
||||||
|
ssh -i "path/to/desineuron-l4-node.pem" ubuntu@54.91.19.60
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Operator Checklist (Troubleshooting)
|
||||||
|
|
||||||
|
1. **Verify API Process:**
|
||||||
|
`ps aux | grep dw_gateway_v2`
|
||||||
|
2. **Check Logs:**
|
||||||
|
`tail -f /home/ubuntu/gw_v2.log`
|
||||||
|
3. **Check Port Listeners:**
|
||||||
|
`sudo netstat -tulpn | grep 8082`
|
||||||
|
4. **No Zombie Processes:**
|
||||||
|
Port 8080 has been cleared. Only 8082 is serving the gateway.
|
||||||
@@ -11,6 +11,6 @@ enum AppConfig {
|
|||||||
!override.isEmpty, override != "$(BASE_URL)" {
|
!override.isEmpty, override != "$(BASE_URL)" {
|
||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
return "http://54.172.172.2:8080"
|
return "http://54.91.19.60:8082"
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ struct SunPosition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SunMath {
|
enum SunMath {
|
||||||
|
|
||||||
|
// MARK: - Single Position
|
||||||
|
|
||||||
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
|
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
|
||||||
let timezone = TimeZone.current
|
let timezone = TimeZone.current
|
||||||
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
|
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
|
||||||
@@ -51,11 +54,13 @@ enum SunMath {
|
|||||||
return SunPosition(azimuth: azimuth, elevation: elevation)
|
return SunPosition(azimuth: azimuth, elevation: elevation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Hourly Arc (used by legacy code & DashedSunLine)
|
||||||
|
|
||||||
|
/// 5-sample dictionary kept for backward compat with the Dollhouse slider.
|
||||||
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
|
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let sampleHours = [8, 10, 12, 14, 16]
|
let sampleHours = [8, 10, 12, 14, 16]
|
||||||
var output: [Date: SunPosition] = [:]
|
var output: [Date: SunPosition] = [:]
|
||||||
|
|
||||||
for hour in sampleHours {
|
for hour in sampleHours {
|
||||||
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
|
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
|
||||||
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||||
@@ -64,25 +69,62 @@ enum SunMath {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeDegrees(_ value: Double) -> Double {
|
/// Dense arc for the AR overlay — one sample per hour from 4 AM to 8 PM.
|
||||||
|
/// Filters out below-horizon positions (elevation < -5°).
|
||||||
|
static func sunPathArc(for date: Date, coordinate: CLLocationCoordinate2D) -> [(date: Date, position: SunPosition)] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var result: [(Date, SunPosition)] = []
|
||||||
|
for hour in 4...20 {
|
||||||
|
guard let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) else { continue }
|
||||||
|
let pos = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||||
|
// include a small below-horizon buffer so arc starts/ends smoothly
|
||||||
|
if pos.elevation > -5 {
|
||||||
|
result.append((sampleDate, pos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approximate sunrise and sunset by scanning for elevation sign changes.
|
||||||
|
static func sunRiseSet(for date: Date, coordinate: CLLocationCoordinate2D) -> (rise: Date?, set: Date?) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
var rise: Date? = nil
|
||||||
|
var set: Date? = nil
|
||||||
|
var prevElevation: Double? = nil
|
||||||
|
var prevDate: Date? = nil
|
||||||
|
|
||||||
|
for minuteOffset in stride(from: 0, through: 24 * 60, by: 10) {
|
||||||
|
guard let sampleDate = calendar.date(byAdding: .minute, value: minuteOffset, to: calendar.startOfDay(for: date)) else { continue }
|
||||||
|
let pos = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||||
|
if let prev = prevElevation, let prevD = prevDate {
|
||||||
|
if prev < 0 && pos.elevation >= 0 { rise = prevD }
|
||||||
|
if prev >= 0 && pos.elevation < 0 { set = prevD }
|
||||||
|
}
|
||||||
|
prevElevation = pos.elevation
|
||||||
|
prevDate = sampleDate
|
||||||
|
}
|
||||||
|
return (rise, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
static func normalizeDegrees(_ value: Double) -> Double {
|
||||||
let reduced = value.truncatingRemainder(dividingBy: 360.0)
|
let reduced = value.truncatingRemainder(dividingBy: 360.0)
|
||||||
return reduced >= 0 ? reduced : reduced + 360.0
|
return reduced >= 0 ? reduced : reduced + 360.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Date helpers
|
||||||
|
|
||||||
private extension Date {
|
private extension Date {
|
||||||
var utcHours: Double {
|
var utcHours: Double {
|
||||||
let calendar = Calendar(identifier: .gregorian)
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
|
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
|
||||||
let hours = Double(comps.hour ?? 0)
|
return Double(comps.hour ?? 0) + Double(comps.minute ?? 0) / 60.0 + Double(comps.second ?? 0) / 3600.0
|
||||||
let minutes = Double(comps.minute ?? 0)
|
|
||||||
let seconds = Double(comps.second ?? 0)
|
|
||||||
return hours + minutes / 60.0 + seconds / 3600.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var julianDay: Double {
|
var julianDay: Double {
|
||||||
let interval = timeIntervalSince1970
|
timeIntervalSince1970 / 86_400.0 + 2_440_587.5
|
||||||
return (interval / 86_400.0) + 2_440_587.5
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ final class ComfyClient {
|
|||||||
/// Returns `true` if `{ "status": "ok" }`.
|
/// Returns `true` if `{ "status": "ok" }`.
|
||||||
func checkHealth() async -> Bool {
|
func checkHealth() async -> Bool {
|
||||||
guard let url = URL(string: "\(baseURL)/health") else { return false }
|
guard let url = URL(string: "\(baseURL)/health") else { return false }
|
||||||
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
var request = URLRequest(url: url)
|
||||||
|
request.timeoutInterval = 30.0
|
||||||
|
guard let (data, _) = try? await URLSession.shared.data(for: request),
|
||||||
let json = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
|
let json = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -30,9 +32,9 @@ final class ComfyClient {
|
|||||||
/// Full pipeline: upload → queue → poll → download.
|
/// Full pipeline: upload → queue → poll → download.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - source: Room photo from camera or library.
|
/// - source: Room photo from camera or library.
|
||||||
/// - style: One of `scandinavian`, `art_deco`, `biophilic`, `cyberpunk`, `japandi`.
|
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
|
||||||
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
||||||
func generateImage(source: UIImage, style: String, keywords: String) async throws -> UIImage {
|
func generateImage(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
|
||||||
let normalised = source.fixedOrientation()
|
let normalised = source.fixedOrientation()
|
||||||
let resized = normalised.resizedSquare(to: 1024)
|
let resized = normalised.resizedSquare(to: 1024)
|
||||||
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
||||||
@@ -40,7 +42,7 @@ final class ComfyClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Submit job → get job_id
|
// 1. Submit job → get job_id
|
||||||
let job = try await submitJob(imageData: imageData, style: style, keywords: keywords)
|
let job = try await submitJob(imageData: imageData, roomType: roomType, keywords: keywords)
|
||||||
|
|
||||||
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
|
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
|
||||||
let resultURL = try await pollUntilReady(jobId: job.jobId)
|
let resultURL = try await pollUntilReady(jobId: job.jobId)
|
||||||
@@ -51,7 +53,7 @@ final class ComfyClient {
|
|||||||
|
|
||||||
// MARK: - Step 1: POST /dream-weaver
|
// MARK: - Step 1: POST /dream-weaver
|
||||||
|
|
||||||
private func submitJob(imageData: Data, style: String, keywords: String) async throws -> GenerationJob {
|
private func submitJob(imageData: Data, roomType: String, keywords: String) async throws -> GenerationJob {
|
||||||
guard let url = URL(string: "\(baseURL)/dream-weaver") else {
|
guard let url = URL(string: "\(baseURL)/dream-weaver") else {
|
||||||
throw DreamWeaverError.generationFailed("Invalid gateway URL")
|
throw DreamWeaverError.generationFailed("Invalid gateway URL")
|
||||||
}
|
}
|
||||||
@@ -60,9 +62,10 @@ final class ComfyClient {
|
|||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.timeoutInterval = 180.0
|
||||||
request.httpBody = buildMultipart(
|
request.httpBody = buildMultipart(
|
||||||
imageData: imageData,
|
imageData: imageData,
|
||||||
style: style,
|
roomType: roomType,
|
||||||
keywords: keywords,
|
keywords: keywords,
|
||||||
boundary: boundary
|
boundary: boundary
|
||||||
)
|
)
|
||||||
@@ -110,7 +113,7 @@ final class ComfyClient {
|
|||||||
|
|
||||||
// MARK: - Multipart Builder
|
// MARK: - Multipart Builder
|
||||||
|
|
||||||
private func buildMultipart(imageData: Data, style: String, keywords: String, boundary: String) -> Data {
|
private func buildMultipart(imageData: Data, roomType: String, keywords: String, boundary: String) -> Data {
|
||||||
var body = Data()
|
var body = Data()
|
||||||
let crlf = "\r\n"
|
let crlf = "\r\n"
|
||||||
|
|
||||||
@@ -121,10 +124,10 @@ final class ComfyClient {
|
|||||||
body += imageData
|
body += imageData
|
||||||
body += crlf
|
body += crlf
|
||||||
|
|
||||||
// style field — must be one of the 5 preset IDs
|
// roomType field
|
||||||
body += "--\(boundary)\(crlf)"
|
body += "--\(boundary)\(crlf)"
|
||||||
body += "Content-Disposition: form-data; name=\"style\"\(crlf)\(crlf)"
|
body += "Content-Disposition: form-data; name=\"room_type\"\(crlf)\(crlf)"
|
||||||
body += style
|
body += roomType
|
||||||
body += crlf
|
body += crlf
|
||||||
|
|
||||||
// keywords field — user's optional comma-separated additions
|
// keywords field — user's optional comma-separated additions
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import CoreMotion
|
|||||||
import SceneKit
|
import SceneKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - ARSunOverlayView
|
||||||
|
|
||||||
struct ARSunOverlayView: UIViewRepresentable {
|
struct ARSunOverlayView: UIViewRepresentable {
|
||||||
@Binding var sunNodesReady: Bool
|
@Binding var sunNodesReady: Bool
|
||||||
|
let vm: SunseekerViewModel
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(sunNodesReady: $sunNodesReady)
|
Coordinator(sunNodesReady: $sunNodesReady, vm: vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIView(context: Context) -> ARSCNView {
|
func makeUIView(context: Context) -> ARSCNView {
|
||||||
@@ -18,7 +21,7 @@ struct ARSunOverlayView: UIViewRepresentable {
|
|||||||
view.automaticallyUpdatesLighting = true
|
view.automaticallyUpdatesLighting = true
|
||||||
|
|
||||||
let config = ARWorldTrackingConfiguration()
|
let config = ARWorldTrackingConfiguration()
|
||||||
config.worldAlignment = .gravityAndHeading
|
config.worldAlignment = .gravityAndHeading // north = -Z axis
|
||||||
view.session.run(config)
|
view.session.run(config)
|
||||||
|
|
||||||
context.coordinator.attach(to: view)
|
context.coordinator.attach(to: view)
|
||||||
@@ -32,87 +35,242 @@ struct ARSunOverlayView: UIViewRepresentable {
|
|||||||
coordinator.stop()
|
coordinator.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator
|
||||||
|
|
||||||
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
|
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
|
||||||
private let locationManager = CLLocationManager()
|
|
||||||
private let motionManager = CMMotionManager()
|
|
||||||
private weak var sceneView: ARSCNView?
|
private weak var sceneView: ARSCNView?
|
||||||
private var heading: CLLocationDirection = 0
|
private let vm: SunseekerViewModel
|
||||||
private var coordinate: CLLocationCoordinate2D?
|
|
||||||
@Binding private var sunNodesReady: Bool
|
@Binding private var sunNodesReady: Bool
|
||||||
|
|
||||||
init(sunNodesReady: Binding<Bool>) {
|
// Scene node containers (replaced on each rebuild)
|
||||||
|
private var arcRootNode = SCNNode()
|
||||||
|
private var currentSunNode = SCNNode()
|
||||||
|
private var isSceneBuilt = false
|
||||||
|
|
||||||
|
// Fallback timer for CoreMotion-only mode
|
||||||
|
private var fallbackTimer: Timer?
|
||||||
|
private var limitedTrackingStart: Date?
|
||||||
|
|
||||||
|
init(sunNodesReady: Binding<Bool>, vm: SunseekerViewModel) {
|
||||||
_sunNodesReady = sunNodesReady
|
_sunNodesReady = sunNodesReady
|
||||||
super.init()
|
self.vm = vm
|
||||||
locationManager.delegate = self
|
|
||||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
|
||||||
locationManager.headingFilter = 1
|
|
||||||
locationManager.requestWhenInUseAuthorization()
|
|
||||||
locationManager.startUpdatingLocation()
|
|
||||||
locationManager.startUpdatingHeading()
|
|
||||||
startMotion()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func attach(to sceneView: ARSCNView) {
|
func attach(to sceneView: ARSCNView) {
|
||||||
self.sceneView = sceneView
|
self.sceneView = sceneView
|
||||||
addSunPathNodesIfPossible()
|
sceneView.scene.rootNode.addChildNode(arcRootNode)
|
||||||
|
sceneView.scene.rootNode.addChildNode(currentSunNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
motionManager.stopDeviceMotionUpdates()
|
vm.stop()
|
||||||
locationManager.stopUpdatingHeading()
|
fallbackTimer?.invalidate()
|
||||||
locationManager.stopUpdatingLocation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
// MARK: - ARSCNViewDelegate — per-frame update
|
||||||
guard coordinate == nil, let location = locations.last else { return }
|
|
||||||
coordinate = location.coordinate
|
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
|
||||||
addSunPathNodesIfPossible()
|
guard vm.isReady else { return }
|
||||||
|
|
||||||
|
// Build arc once
|
||||||
|
if !isSceneBuilt {
|
||||||
|
DispatchQueue.main.async { self.buildScene() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
// Update current sun orb every frame
|
||||||
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
|
if let cur = vm.currentPosition {
|
||||||
addSunPathNodesIfPossible()
|
let pos = vm.worldPosition(for: cur, radius: 1.8)
|
||||||
|
currentSunNode.position = pos
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startMotion() {
|
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
|
||||||
guard motionManager.isDeviceMotionAvailable else { return }
|
switch camera.trackingState {
|
||||||
motionManager.deviceMotionUpdateInterval = 0.1
|
case .limited(let reason):
|
||||||
motionManager.startDeviceMotionUpdates()
|
print("[Sunseeker] Tracking limited: \(reason)")
|
||||||
|
if limitedTrackingStart == nil {
|
||||||
|
limitedTrackingStart = Date()
|
||||||
|
// After 5s of limited tracking, switch to CoreMotion attitude fallback
|
||||||
|
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
|
||||||
|
self?.activateCoreMotionFallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .normal:
|
||||||
|
limitedTrackingStart = nil
|
||||||
|
fallbackTimer?.invalidate()
|
||||||
|
fallbackTimer = nil
|
||||||
|
case .notAvailable:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addSunPathNodesIfPossible() {
|
// MARK: - Scene Building
|
||||||
guard
|
|
||||||
let sceneView,
|
|
||||||
let coordinate,
|
|
||||||
!sunNodesReady
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
let samples = SunMath.sunPathSamples(for: Date(), coordinate: coordinate)
|
private func buildScene() {
|
||||||
let sorted = samples.sorted { $0.key < $1.key }
|
guard let sceneView else { return }
|
||||||
let root = SCNNode()
|
|
||||||
let northOffset = (heading).radians
|
// Remove old nodes
|
||||||
|
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||||
|
currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||||
|
|
||||||
|
let arc = vm.arc
|
||||||
let radius: Float = 1.8
|
let radius: Float = 1.8
|
||||||
|
var positions: [SCNVector3] = []
|
||||||
|
|
||||||
for (_, pos) in sorted {
|
// Hourly marker spheres + time labels
|
||||||
let elevation = Float(pos.elevation.radians)
|
for (date, pos) in arc {
|
||||||
let azimuth = Float((pos.azimuth).radians) - Float(northOffset)
|
guard pos.elevation > -5 else { continue }
|
||||||
let x = radius * cos(elevation) * sin(azimuth)
|
let worldPos = vm.worldPosition(for: pos, radius: radius)
|
||||||
let y = radius * sin(elevation)
|
positions.append(worldPos)
|
||||||
let z = -radius * cos(elevation) * cos(azimuth)
|
|
||||||
|
|
||||||
let sphere = SCNSphere(radius: 0.03)
|
let sphere = SCNSphere(radius: 0.018)
|
||||||
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow
|
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
|
||||||
let node = SCNNode(geometry: sphere)
|
sphere.firstMaterial?.lightingModel = .constant
|
||||||
node.position = SCNVector3(x, y, z)
|
let markerNode = SCNNode(geometry: sphere)
|
||||||
root.addChildNode(node)
|
markerNode.position = worldPos
|
||||||
|
arcRootNode.addChildNode(markerNode)
|
||||||
|
|
||||||
|
// Time label (only on even hours to avoid clutter)
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let hour = calendar.component(.hour, from: date)
|
||||||
|
if hour % 2 == 0 {
|
||||||
|
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
|
||||||
|
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
|
||||||
|
arcRootNode.addChildNode(labelNode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sceneView.scene.rootNode.addChildNode(root)
|
// Continuous arc line
|
||||||
|
if positions.count >= 2 {
|
||||||
|
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
|
||||||
|
arcRootNode.addChildNode(lineNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sunrise marker
|
||||||
|
if let riseDate = vm.riseSet.rise {
|
||||||
|
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: vm.coordinate!)
|
||||||
|
let wPos = vm.worldPosition(for: risePos, radius: radius)
|
||||||
|
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sunset marker
|
||||||
|
if let setDate = vm.riseSet.set {
|
||||||
|
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: vm.coordinate!)
|
||||||
|
let wPos = vm.worldPosition(for: setPos, radius: radius)
|
||||||
|
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current sun orb (large, animated glow)
|
||||||
|
if let cur = vm.currentPosition {
|
||||||
|
let orb = SCNSphere(radius: 0.055)
|
||||||
|
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
|
||||||
|
orb.firstMaterial?.emission.contents = UIColor.systemYellow
|
||||||
|
orb.firstMaterial?.lightingModel = .constant
|
||||||
|
let orbNode = SCNNode(geometry: orb)
|
||||||
|
orbNode.position = vm.worldPosition(for: cur, radius: radius)
|
||||||
|
|
||||||
|
// Pulse animation
|
||||||
|
let pulse = CABasicAnimation(keyPath: "scale")
|
||||||
|
pulse.fromValue = SCNVector3(1, 1, 1)
|
||||||
|
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
|
||||||
|
pulse.duration = 1.2
|
||||||
|
pulse.autoreverses = true
|
||||||
|
pulse.repeatCount = .infinity
|
||||||
|
orbNode.addAnimation(pulse, forKey: "pulse")
|
||||||
|
|
||||||
|
let label = makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
|
||||||
|
label.position = SCNVector3(0, 0.09, 0)
|
||||||
|
orbNode.addChildNode(label)
|
||||||
|
currentSunNode.addChildNode(orbNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSceneBuilt = true
|
||||||
sunNodesReady = true
|
sunNodesReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Node Factories
|
||||||
|
|
||||||
|
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
|
||||||
|
let root = SCNNode()
|
||||||
|
let sphere = SCNSphere(radius: 0.035)
|
||||||
|
sphere.firstMaterial?.diffuse.contents = color
|
||||||
|
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
|
||||||
|
sphere.firstMaterial?.lightingModel = .constant
|
||||||
|
let markerNode = SCNNode(geometry: sphere)
|
||||||
|
markerNode.position = pos
|
||||||
|
root.addChildNode(markerNode)
|
||||||
|
|
||||||
|
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
|
||||||
|
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
|
||||||
|
root.addChildNode(labelNode)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a billboard SCNText node that always faces the camera.
|
||||||
|
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
|
||||||
|
let scnText = SCNText(string: text, extrusionDepth: 0)
|
||||||
|
scnText.font = UIFont.systemFont(ofSize: fontSize * 100, weight: .medium)
|
||||||
|
scnText.firstMaterial?.diffuse.contents = color
|
||||||
|
scnText.firstMaterial?.lightingModel = .constant
|
||||||
|
scnText.isWrapped = false
|
||||||
|
|
||||||
|
let textNode = SCNNode(geometry: scnText)
|
||||||
|
textNode.scale = SCNVector3(fontSize / 100, fontSize / 100, fontSize / 100)
|
||||||
|
// Billboard constraint — always face camera
|
||||||
|
let constraint = SCNBillboardConstraint()
|
||||||
|
constraint.freeAxes = .Y
|
||||||
|
textNode.constraints = [constraint]
|
||||||
|
// Centre text
|
||||||
|
let (min, max) = textNode.boundingBox
|
||||||
|
textNode.pivot = SCNMatrix4MakeTranslation((max.x - min.x) / 2, 0, 0)
|
||||||
|
return textNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a line strip SCNNode connecting all positions.
|
||||||
|
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||||
|
guard positions.count >= 2 else { return SCNNode() }
|
||||||
|
|
||||||
|
var vertices: [SCNVector3] = positions
|
||||||
|
var indices: [Int32] = []
|
||||||
|
for i in 0..<(vertices.count - 1) {
|
||||||
|
indices.append(Int32(i))
|
||||||
|
indices.append(Int32(i + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertexSource = SCNGeometrySource(vertices: vertices)
|
||||||
|
let element = SCNGeometryElement(
|
||||||
|
indices: indices,
|
||||||
|
primitiveType: .line
|
||||||
|
)
|
||||||
|
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
|
||||||
|
geometry.firstMaterial?.diffuse.contents = color
|
||||||
|
geometry.firstMaterial?.lightingModel = .constant
|
||||||
|
return SCNNode(geometry: geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hourLabel(from date: Date) -> String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "ha"
|
||||||
|
fmt.amSymbol = "am"
|
||||||
|
fmt.pmSymbol = "pm"
|
||||||
|
return fmt.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreMotion Fallback
|
||||||
|
|
||||||
|
private func activateCoreMotionFallback() {
|
||||||
|
// In fallback mode we rely on CMMotionManager attitude (already running in SunseekerViewModel)
|
||||||
|
// and just keep the scene nodes updated via the 1s tick in the VM.
|
||||||
|
print("[Sunseeker] Switched to CoreMotion fallback — ARKit tracking unavailable.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Degree helpers
|
||||||
|
|
||||||
private extension Double {
|
private extension Double {
|
||||||
var radians: Double { self * .pi / 180.0 }
|
var radians: Double { self * .pi / 180.0 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,11 +75,8 @@ struct InventoryView: View {
|
|||||||
switch store.mode {
|
switch store.mode {
|
||||||
case .sunseeker:
|
case .sunseeker:
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
SimulatorUnavailableCard(
|
SimulatorSunOverlayView(sunNodesReady: $store.sunNodesReady)
|
||||||
icon: "camera.metering.unknown",
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
title: "AR Not Available in Simulator",
|
|
||||||
message: "Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature."
|
|
||||||
)
|
|
||||||
#else
|
#else
|
||||||
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
||||||
#endif
|
#endif
|
||||||
@@ -178,17 +175,21 @@ private struct SimulatorUnavailableCard: View {
|
|||||||
|
|
||||||
private struct SunseekerPanel: View {
|
private struct SunseekerPanel: View {
|
||||||
@Binding var sunNodesReady: Bool
|
@Binding var sunNodesReady: Bool
|
||||||
|
@State private var vm = SunseekerViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
ARSunOverlayView(sunNodesReady: $sunNodesReady)
|
ARSunOverlayView(sunNodesReady: $sunNodesReady, vm: vm)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
|
||||||
|
// Retained as a stylistic design element framing the AR view
|
||||||
DashedSunLine()
|
DashedSunLine()
|
||||||
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 80)
|
.padding(.vertical, 80)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Info block
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Sunseeker")
|
Text("Sunseeker")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -201,6 +202,44 @@ private struct SunseekerPanel: View {
|
|||||||
GlassBlurView(style: .systemThinMaterial)
|
GlassBlurView(style: .systemThinMaterial)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !vm.isReady && vm.locationError == nil {
|
||||||
|
// Loading state
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
Text("Looking for the Sun...")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(Color.black.opacity(0.6).clipShape(Capsule()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error banner (e.g. Location Denied)
|
||||||
|
if let error = vm.locationError {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
Text(error)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Spacer()
|
||||||
|
Button("Settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.white.opacity(0.2))
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(Color.red.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,19 +247,22 @@ private struct SunseekerPanel: View {
|
|||||||
|
|
||||||
// MARK: - Dream Weaver
|
// MARK: - Dream Weaver
|
||||||
|
|
||||||
/// Available interior styles from integration guide §2.3
|
/// Available room types from integration guide §2
|
||||||
private struct InteriorStyle: Identifiable {
|
private struct RoomType: Identifiable {
|
||||||
let id: String // sent as the `style` form field
|
let id: String // sent as the `room_type` form field
|
||||||
let displayName: String
|
let displayName: String
|
||||||
let icon: String // SF Symbol
|
let icon: String // SF Symbol
|
||||||
}
|
}
|
||||||
|
|
||||||
private let dreamWeaverStyles: [InteriorStyle] = [
|
private let roomTypes: [RoomType] = [
|
||||||
InteriorStyle(id: "scandinavian", displayName: "Scandi", icon: "snowflake"),
|
RoomType(id: "bedroom", displayName: "Bedroom", icon: "bed.double"),
|
||||||
InteriorStyle(id: "art_deco", displayName: "Art Deco", icon: "sparkles"),
|
RoomType(id: "living_room", displayName: "Living Rm", icon: "sofa"),
|
||||||
InteriorStyle(id: "biophilic", displayName: "Biophilic",icon: "leaf"),
|
RoomType(id: "bathroom", displayName: "Bathroom", icon: "drop"),
|
||||||
InteriorStyle(id: "cyberpunk", displayName: "Cyberpunk",icon: "bolt"),
|
RoomType(id: "kitchen", displayName: "Kitchen", icon: "refrigerator"),
|
||||||
InteriorStyle(id: "japandi", displayName: "Japandi", icon: "mountain.2"),
|
RoomType(id: "dining_room", displayName: "Dining Rm", icon: "fork.knife"),
|
||||||
|
RoomType(id: "home_office", displayName: "Office", icon: "desktopcomputer"),
|
||||||
|
RoomType(id: "hallway", displayName: "Hallway", icon: "door.left.hand.open"),
|
||||||
|
RoomType(id: "balcony", displayName: "Balcony", icon: "sun.max"),
|
||||||
]
|
]
|
||||||
|
|
||||||
private struct DreamWeaverPanel: View {
|
private struct DreamWeaverPanel: View {
|
||||||
@@ -230,8 +272,8 @@ private struct DreamWeaverPanel: View {
|
|||||||
@Binding var errorMessage: String?
|
@Binding var errorMessage: String?
|
||||||
@Binding var showCamera: Bool
|
@Binding var showCamera: Bool
|
||||||
|
|
||||||
/// Selected style ID — sent as `style` field (§3.2). nil = none chosen yet.
|
/// Selected room type ID — sent as `room_type` field. nil = none chosen yet.
|
||||||
@State private var selectedStyle: String? = nil
|
@State private var selectedRoomType: String? = nil
|
||||||
/// Optional extra keywords — sent as `keywords` field (§3.2)
|
/// Optional extra keywords — sent as `keywords` field (§3.2)
|
||||||
@State private var keywords: String = ""
|
@State private var keywords: String = ""
|
||||||
/// Server health: nil = checking, true = online, false = offline
|
/// Server health: nil = checking, true = online, false = offline
|
||||||
@@ -319,34 +361,34 @@ private struct DreamWeaverPanel: View {
|
|||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Style picker (§2.3) ───────────────────────────────────────
|
// ── Room Type picker ───────────────────────────────────────
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(dreamWeaverStyles) { style in
|
ForEach(roomTypes) { room in
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.3)) {
|
withAnimation(.spring(response: 0.3)) {
|
||||||
// Tap again to deselect
|
// Tap again to deselect
|
||||||
selectedStyle = selectedStyle == style.id ? nil : style.id
|
selectedRoomType = selectedRoomType == room.id ? nil : room.id
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: style.icon)
|
Image(systemName: room.icon)
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
Text(style.displayName)
|
Text(room.displayName)
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(selectedStyle == style.id
|
.fill(selectedRoomType == room.id
|
||||||
? Color(red: 0.231, green: 0.510, blue: 0.965)
|
? Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||||
: Color.white.opacity(0.08))
|
: Color.white.opacity(0.08))
|
||||||
)
|
)
|
||||||
.foregroundStyle(selectedStyle == style.id ? .white : .white.opacity(0.6))
|
.foregroundStyle(selectedRoomType == room.id ? .white : .white.opacity(0.6))
|
||||||
.overlay(
|
.overlay(
|
||||||
Capsule()
|
Capsule()
|
||||||
.stroke(selectedStyle == style.id
|
.stroke(selectedRoomType == room.id
|
||||||
? Color.clear
|
? Color.clear
|
||||||
: Color.white.opacity(0.12), lineWidth: 1)
|
: Color.white.opacity(0.12), lineWidth: 1)
|
||||||
)
|
)
|
||||||
@@ -360,7 +402,7 @@ private struct DreamWeaverPanel: View {
|
|||||||
// ── Keywords input ───────────────────────────────────────────
|
// ── Keywords input ───────────────────────────────────────────
|
||||||
PromptInputBar(
|
PromptInputBar(
|
||||||
text: $keywords,
|
text: $keywords,
|
||||||
isDisabled: sourceImage == nil || isProcessing
|
isDisabled: sourceImage == nil || isProcessing || serverOnline == false
|
||||||
) {
|
) {
|
||||||
Task { await generate() }
|
Task { await generate() }
|
||||||
}
|
}
|
||||||
@@ -391,12 +433,16 @@ private struct DreamWeaverPanel: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func generate() async {
|
private func generate() async {
|
||||||
guard let sourceImage, !isProcessing else { return }
|
guard let sourceImage, !isProcessing else { return }
|
||||||
|
if serverOnline == false {
|
||||||
|
errorMessage = "Server is currently offline. Please try again later."
|
||||||
|
return
|
||||||
|
}
|
||||||
isProcessing = true
|
isProcessing = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
do {
|
do {
|
||||||
let result = try await ComfyClient.shared.generateImage(
|
let result = try await ComfyClient.shared.generateImage(
|
||||||
source: sourceImage,
|
source: sourceImage,
|
||||||
style: selectedStyle ?? dreamWeaverStyles[0].id, // default: scandinavian
|
roomType: selectedRoomType ?? roomTypes[0].id, // default: bedroom
|
||||||
keywords: keywords.trimmingCharacters(in: .whitespaces)
|
keywords: keywords.trimmingCharacters(in: .whitespaces)
|
||||||
)
|
)
|
||||||
withAnimation(.easeInOut(duration: 0.4)) {
|
withAnimation(.easeInOut(duration: 0.4)) {
|
||||||
@@ -419,7 +465,7 @@ private struct PromptInputBar: View {
|
|||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
@State private var shimmer = false
|
@State private var shimmer = false
|
||||||
|
|
||||||
private let placeholder = "gold, marble, luxury... (optional keywords)"
|
private let placeholder = "gold, marble, luxury, etc."
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import SceneKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
|
||||||
|
/// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator.
|
||||||
|
/// Uses a synthetic camera, fake location, and mock heading instead of ARKit.
|
||||||
|
struct SimulatorSunOverlayView: UIViewRepresentable {
|
||||||
|
@Binding var sunNodesReady: Bool
|
||||||
|
|
||||||
|
// Fake location (e.g. San Francisco)
|
||||||
|
private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||||
|
private let mockHeading: Double = 0 // North
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> SCNView {
|
||||||
|
let view = SCNView(frame: .zero)
|
||||||
|
view.scene = SCNScene()
|
||||||
|
view.allowsCameraControl = true // Swipe around the 3D space
|
||||||
|
view.autoenablesDefaultLighting = true
|
||||||
|
view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
|
||||||
|
view.isPlaying = true // Force render loop
|
||||||
|
view.showsStatistics = true // Prove it's rendering
|
||||||
|
|
||||||
|
// Setup synthetic camera
|
||||||
|
let cameraNode = SCNNode()
|
||||||
|
cameraNode.camera = SCNCamera()
|
||||||
|
cameraNode.camera?.zFar = 100
|
||||||
|
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered
|
||||||
|
view.scene?.rootNode.addChildNode(cameraNode)
|
||||||
|
|
||||||
|
context.coordinator.attach(to: view)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: SCNView, context: Context) {}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject {
|
||||||
|
private weak var sceneView: SCNView?
|
||||||
|
@Binding private var sunNodesReady: Bool
|
||||||
|
|
||||||
|
private let mockLocation: CLLocationCoordinate2D
|
||||||
|
private let mockHeading: Double
|
||||||
|
|
||||||
|
private var arcRootNode = SCNNode()
|
||||||
|
private var currentSunNode = SCNNode()
|
||||||
|
|
||||||
|
private var updateTimer: Timer?
|
||||||
|
|
||||||
|
init(sunNodesReady: Binding<Bool>, mockLocation: CLLocationCoordinate2D, mockHeading: Double) {
|
||||||
|
_sunNodesReady = sunNodesReady
|
||||||
|
self.mockLocation = mockLocation
|
||||||
|
self.mockHeading = mockHeading
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func attach(to view: SCNView) {
|
||||||
|
self.sceneView = view
|
||||||
|
view.scene?.rootNode.addChildNode(arcRootNode)
|
||||||
|
view.scene?.rootNode.addChildNode(currentSunNode)
|
||||||
|
buildScene()
|
||||||
|
startRealTimeTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
updateTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRealTimeTick() {
|
||||||
|
// Update current sun position every second
|
||||||
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation)
|
||||||
|
// Need to remove previous child as we are completely replacing it
|
||||||
|
self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||||
|
|
||||||
|
let radius: Float = 1.8
|
||||||
|
let orb = SCNSphere(radius: 0.055)
|
||||||
|
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
|
||||||
|
orb.firstMaterial?.emission.contents = UIColor.systemYellow
|
||||||
|
orb.firstMaterial?.lightingModel = .constant
|
||||||
|
let orbNode = SCNNode(geometry: orb)
|
||||||
|
orbNode.position = self.worldPosition(for: cur, radius: radius)
|
||||||
|
|
||||||
|
let pulse = CABasicAnimation(keyPath: "scale")
|
||||||
|
pulse.fromValue = SCNVector3(1, 1, 1)
|
||||||
|
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
|
||||||
|
pulse.duration = 1.2
|
||||||
|
pulse.autoreverses = true
|
||||||
|
pulse.repeatCount = .infinity
|
||||||
|
orbNode.addAnimation(pulse, forKey: "pulse")
|
||||||
|
|
||||||
|
let label = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
|
||||||
|
label.position = SCNVector3(0, 0.09, 0)
|
||||||
|
orbNode.addChildNode(label)
|
||||||
|
|
||||||
|
self.currentSunNode.addChildNode(orbNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildScene() {
|
||||||
|
let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation)
|
||||||
|
let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation)
|
||||||
|
let radius: Float = 1.8
|
||||||
|
var positions: [SCNVector3] = []
|
||||||
|
|
||||||
|
// Hourly blocks
|
||||||
|
for (date, pos) in arc {
|
||||||
|
guard pos.elevation > -5 else { continue }
|
||||||
|
let worldPos = worldPosition(for: pos, radius: radius)
|
||||||
|
positions.append(worldPos)
|
||||||
|
|
||||||
|
let sphere = SCNSphere(radius: 0.018)
|
||||||
|
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
|
||||||
|
sphere.firstMaterial?.lightingModel = .constant
|
||||||
|
let markerNode = SCNNode(geometry: sphere)
|
||||||
|
markerNode.position = worldPos
|
||||||
|
arcRootNode.addChildNode(markerNode)
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let hour = calendar.component(.hour, from: date)
|
||||||
|
if hour % 2 == 0 {
|
||||||
|
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
|
||||||
|
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
|
||||||
|
arcRootNode.addChildNode(labelNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if positions.count >= 2 {
|
||||||
|
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
|
||||||
|
arcRootNode.addChildNode(lineNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let riseDate = riseSet.rise {
|
||||||
|
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation)
|
||||||
|
let wPos = worldPosition(for: risePos, radius: radius)
|
||||||
|
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let setDate = riseSet.set {
|
||||||
|
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation)
|
||||||
|
let wPos = worldPosition(for: setPos, radius: radius)
|
||||||
|
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate current sun node synchronously for first frame
|
||||||
|
updateTimer?.fire()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.sunNodesReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Math equivalent from SunseekerViewModel
|
||||||
|
|
||||||
|
private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
|
||||||
|
let elev = Float(sun.elevation * .pi / 180.0)
|
||||||
|
let az = Float(sun.azimuth * .pi / 180.0)
|
||||||
|
let x = radius * cos(elev) * sin(az)
|
||||||
|
let y = radius * sin(elev)
|
||||||
|
let z = -radius * cos(elev) * cos(az)
|
||||||
|
return SCNVector3(x, y, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: SceneKit Factories
|
||||||
|
|
||||||
|
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
|
||||||
|
let root = SCNNode()
|
||||||
|
let sphere = SCNSphere(radius: 0.035)
|
||||||
|
sphere.firstMaterial?.diffuse.contents = color
|
||||||
|
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
|
||||||
|
sphere.firstMaterial?.lightingModel = .constant
|
||||||
|
let markerNode = SCNNode(geometry: sphere)
|
||||||
|
markerNode.position = pos
|
||||||
|
root.addChildNode(markerNode)
|
||||||
|
|
||||||
|
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
|
||||||
|
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
|
||||||
|
root.addChildNode(labelNode)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
|
||||||
|
// SCNText is buggy in Simulator. Render text to a UIImage instead.
|
||||||
|
let font = UIFont.systemFont(ofSize: 40, weight: .bold)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: color
|
||||||
|
]
|
||||||
|
let size = (text as NSString).size(withAttributes: attributes)
|
||||||
|
|
||||||
|
// Add some padding
|
||||||
|
let paddedSize = CGSize(width: size.width + 10, height: size.height + 10)
|
||||||
|
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: paddedSize)
|
||||||
|
let image = renderer.image { context in
|
||||||
|
(text as NSString).draw(
|
||||||
|
in: CGRect(x: 5, y: 5, width: size.width, height: size.height),
|
||||||
|
withAttributes: attributes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the image onto an SCNPlane
|
||||||
|
// A 100x50 image becomes a 0.1 x 0.05 meter plane
|
||||||
|
let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0)
|
||||||
|
plane.firstMaterial?.diffuse.contents = image
|
||||||
|
plane.firstMaterial?.isDoubleSided = true
|
||||||
|
plane.firstMaterial?.lightingModel = .constant
|
||||||
|
|
||||||
|
let textNode = SCNNode(geometry: plane)
|
||||||
|
// Statically scale the plane up so it is readable next to the spheres
|
||||||
|
textNode.scale = SCNVector3(1.5, 1.5, 1.5)
|
||||||
|
|
||||||
|
let constraint = SCNBillboardConstraint()
|
||||||
|
constraint.freeAxes = .all
|
||||||
|
textNode.constraints = [constraint]
|
||||||
|
|
||||||
|
return textNode
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||||
|
guard positions.count >= 2 else { return SCNNode() }
|
||||||
|
|
||||||
|
var vertices: [SCNVector3] = positions
|
||||||
|
var indices: [Int32] = []
|
||||||
|
for i in 0..<(vertices.count - 1) {
|
||||||
|
indices.append(Int32(i))
|
||||||
|
indices.append(Int32(i + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertexSource = SCNGeometrySource(vertices: vertices)
|
||||||
|
let element = SCNGeometryElement(
|
||||||
|
indices: indices,
|
||||||
|
primitiveType: .line
|
||||||
|
)
|
||||||
|
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
|
||||||
|
geometry.firstMaterial?.diffuse.contents = color
|
||||||
|
geometry.firstMaterial?.lightingModel = .constant
|
||||||
|
return SCNNode(geometry: geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hourLabel(from date: Date) -> String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "ha"
|
||||||
|
fmt.amSymbol = "am"
|
||||||
|
fmt.pmSymbol = "pm"
|
||||||
|
return fmt.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import CoreMotion
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SceneKit
|
||||||
|
|
||||||
|
// MARK: - SunseekerViewModel
|
||||||
|
|
||||||
|
/// Owns all sensor state for the Sunseeker AR overlay.
|
||||||
|
/// Separates CoreLocation / CoreMotion concerns from the ARKit view layer.
|
||||||
|
@Observable
|
||||||
|
final class SunseekerViewModel: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
/// True once we have both a GPS fix and a valid heading.
|
||||||
|
private(set) var isReady = false
|
||||||
|
|
||||||
|
/// Latest GPS coordinate. nil until first fix.
|
||||||
|
private(set) var coordinate: CLLocationCoordinate2D?
|
||||||
|
|
||||||
|
/// Latest true heading (0 = North, clockwise).
|
||||||
|
private(set) var heading: Double = 0
|
||||||
|
|
||||||
|
/// Dense hourly arc for today.
|
||||||
|
private(set) var arc: [(date: Date, position: SunPosition)] = []
|
||||||
|
|
||||||
|
/// Current real-time sun position (updated every second).
|
||||||
|
private(set) var currentPosition: SunPosition?
|
||||||
|
|
||||||
|
/// Sunrise and sunset for today.
|
||||||
|
private(set) var riseSet: (rise: Date?, set: Date?) = (nil, nil)
|
||||||
|
|
||||||
|
/// Diagnostic string for the UI when location access is denied.
|
||||||
|
private(set) var locationError: String?
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private let locationManager = CLLocationManager()
|
||||||
|
private let motionManager = CMMotionManager()
|
||||||
|
private var updateTimer: Timer?
|
||||||
|
|
||||||
|
// MARK: - Init / Deinit
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
locationManager.delegate = self
|
||||||
|
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||||
|
locationManager.headingFilter = 1.0
|
||||||
|
locationManager.requestWhenInUseAuthorization()
|
||||||
|
locationManager.startUpdatingLocation()
|
||||||
|
locationManager.startUpdatingHeading()
|
||||||
|
startMotionUpdates()
|
||||||
|
startRealTimeTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Control
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
motionManager.stopDeviceMotionUpdates()
|
||||||
|
locationManager.stopUpdatingLocation()
|
||||||
|
locationManager.stopUpdatingHeading()
|
||||||
|
updateTimer?.invalidate()
|
||||||
|
updateTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - World-Space Transform
|
||||||
|
|
||||||
|
/// Converts a solar `SunPosition` into a SceneKit world-space position on a sphere of given `radius`.
|
||||||
|
/// Orientation is relative to ARWorldTrackingConfiguration(.gravityAndHeading), so north = -Z axis.
|
||||||
|
func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
|
||||||
|
let elev = Float(sun.elevation.radians)
|
||||||
|
let az = Float(sun.azimuth.radians) // clockwise from north
|
||||||
|
let x = radius * cos(elev) * sin(az)
|
||||||
|
let y = radius * sin(elev)
|
||||||
|
let z = -radius * cos(elev) * cos(az) // -Z = north in ARKit gravity+heading
|
||||||
|
return SCNVector3(x, y, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private func startMotionUpdates() {
|
||||||
|
guard motionManager.isDeviceMotionAvailable else { return }
|
||||||
|
motionManager.deviceMotionUpdateInterval = 0.05
|
||||||
|
motionManager.startDeviceMotionUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRealTimeTick() {
|
||||||
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
|
guard let self, let coord = self.coordinate else { return }
|
||||||
|
self.currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshArc() {
|
||||||
|
guard let coord = coordinate else { return }
|
||||||
|
arc = SunMath.sunPathArc(for: Date(), coordinate: coord)
|
||||||
|
riseSet = SunMath.sunRiseSet(for: Date(), coordinate: coord)
|
||||||
|
currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
|
||||||
|
isReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CLLocationManagerDelegate
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
guard coordinate == nil, let loc = locations.last else { return }
|
||||||
|
coordinate = loc.coordinate
|
||||||
|
refreshArc()
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
||||||
|
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
|
||||||
|
if coordinate != nil { isReady = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
print("[Sunseeker] Location error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
switch manager.authorizationStatus {
|
||||||
|
case .denied, .restricted:
|
||||||
|
locationError = "Location access needed to calculate the sun path. Please enable it in Settings."
|
||||||
|
case .notDetermined:
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
default:
|
||||||
|
locationError = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Degree helpers (internal to this file)
|
||||||
|
|
||||||
|
private extension Double {
|
||||||
|
var radians: Double { self * .pi / 180.0 }
|
||||||
|
}
|
||||||
@@ -13,5 +13,8 @@
|
|||||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user