diff --git a/.agents/skills/dxf-import/SKILL.md b/.agents/skills/dxf-import/SKILL.md new file mode 100644 index 000000000..00da0a6f0 --- /dev/null +++ b/.agents/skills/dxf-import/SKILL.md @@ -0,0 +1,551 @@ +--- +name: dxf-import +description: > + Use this skill for ANY task touching DXF import functionality in Pascal Editor. + Covers all five modules: DxfValidator, DxfGeometryParser, VisionAnalyzer, + MergeEngine, McpImporter. Triggers on keywords: dxf, import, floor plan, + CAD, wall detection, parallel lines, vision analyze, merge engine, + dxf-validator, dxf-geometry-parser, dxf-preview, ImportDxfTool, + vision-analyze API route. Also triggers when user asks to "add import", + "fix import", or "test import" related to building/architectural files. +--- + +# DXF Import — Complete Domain Knowledge + +## 1. Feature Overview + +Import architectural floor plan DXF files into Pascal Editor as fully editable +scenes (Wall / Slab / Door / Window / Zone nodes). The pipeline uses a +dual-channel architecture: precise geometry from code + semantic understanding +from AI vision, merged before writing to Pascal via MCP tools. + +Out of scope for Phase 1: +- .dwg format (binary, needs server-side ezdxf conversion — Phase 2) +- Multi-floor auto-detection +- Furniture/fixture block mapping to Item nodes + +--- + +## 2. User-Facing Flow + +``` +Upload DXF + ↓ +Front-end quick render (Canvas, < 500ms) ← user sees preview, decides to import + ↓ +User clicks "Confirm Import" + ↓ (two parallel tracks start here) + ├─ Channel A: Web Worker geometry parse → coords JSON + └─ Channel B: canvas.toDataURL → API route → Vision model → semantic JSON + ↓ +Merge layer combines A + B + ↓ +MCP tool call sequence writes Pascal scene + ↓ +Editor opens with imported scene + + original DXF rendered as Guide node (overlay for manual correction) +``` + +Progress bar shown to user after "Confirm Import": +- Stage 1: "解析中…" (Channel A running) +- Stage 2: "识别中…" (Channel B running, parallel) +- Stage 3: "融合中…" (MergeEngine) +- Stage 4: "生成场景…" (MCP writes) + +--- + +## 3. File Locations + +Follow Pascal's layer rules strictly. Never put logic in wrong layer. + +``` +packages/core/src/lib/importers/ + dxf-validator.ts ← pure logic, no React, no UI + dxf-geometry-parser.ts ← pure logic, runs in Web Worker + dxf-merge-engine.ts ← pure logic, no React + mcp-importer.ts ← pure logic, calls @pascal-app/mcp tools + +apps/editor/components/tools/ + ImportDxfTool.tsx ← tool entry UI, file picker + +apps/editor/components/ + DxfPreview.tsx ← Canvas 2D preview renderer + DxfValidationFeedback.tsx ← rejection reason display + ImportProgress.tsx ← 4-stage progress bar + +apps/editor/app/api/vision-analyze/ + route.ts ← server-side only, holds Vision API key +``` + +**Layer rules (from AGENTS.md):** +- `packages/core` — zero UI, zero React, zero rendering imports +- `packages/viewer` — rendering only, never imports from `apps/editor` +- `apps/editor` — composes everything, owns all UI + +--- + +## 4. DxfValidator — Rejection Gate + +Run before user confirms import. Uses dxf-parser entity list as input. +Output type: `ValidationResult`. + +```typescript +type ValidationResult = { + passed: boolean + confidence: number // 0–1 + warnings: string[] // soft issues, user can proceed + rejectReasons: string[] // hard blocks, import refused +} +``` + +### Hard reject (any one → refuse) + +| Check | Method | Example reject case | +|---|---|---| +| Scale out of range | BBox diagonal < 3m OR > 500m | Mechanical part (48×32mm) | +| Too few line entities | LINE + LWPOLYLINE count < 10 | Pure annotation or empty file | +| Mechanical entity dominance | CIRCLE + SPLINE > 60% of all entities | Gear, pipe fitting | +| No parallel line pairs | Zero pairs with spacing 80–400mm | Circuit diagram, structural plan | +| No closable region | No line group that can form a closed polygon | Isolated line fragments | +| File too large | File size > 10MB | Reject before parsing | + +### Soft warnings (allowed to proceed, show to user) + +| Check | Warning message | +|---|---| +| No recognisable layer names | "未找到墙体图层(如 WALL、墙),识别准确率可能降低" | +| Low parallel pair ratio | "仅识别到 X% 的线段为墙体,建议检查图层命名" | +| Many arcs (ARC > 20% of entities) | "检测到弧形元素,弧墙导入需要额外处理" | +| No DIMENSION entities | "未找到尺寸标注,请确认图纸单位(mm 或 m)" | +| File > 1MB | "图纸较复杂,导入时间可能较长" | + +### Rejection feedback format (shown to user) + +Rejection message MUST contain: reason + detected data + suggested action. +Never show only "无法导入" without explanation. + +Example: +``` +❌ 无法导入此文件 + +检测结果: +• 图纸范围:48mm × 32mm(疑似机械零件图) +• 直线实体中未发现平行线对(墙体特征缺失) +• CIRCLE 实体占比 71% + +建议: +• 请确认上传的是建筑平面图(户型图) +• 如果是正确的户型图,请检查图纸单位设置 +``` + +--- + +## 5. DxfPreview — Quick Canvas Render + +Goal: show outline within 500ms of upload. Uses HTML5 Canvas 2D only. +Do NOT use Three.js or any 3D renderer here. + +### Filter order (apply in sequence until entity count < 2000) + +1. Remove: HATCH, SOLID, VIEWPORT (no visual value for preview) +2. Remove: TEXT, MTEXT, DIMENSION (annotation) +3. Remove: INSERT sub-entities (keep only insertion point) +4. Spatial uniform downsample (last resort) + +### Performance tiers + +| Filtered entity count | Strategy | Target time | +|---|---|---| +| < 2,000 | Render all | < 200ms | +| 2,000–8,000 | Wall layer only, skip annotations | < 500ms | +| > 8,000 | Uniform spatial downsample to 2,000 | < 500ms, show notice | +| File > 10MB | Reject before parsing | Instant | + +### Screenshot for Channel B + +After preview renders, get screenshot with: +```typescript +const dataUrl = canvas.toDataURL('image/png') +``` +DO NOT use headless browser (Puppeteer/Playwright). Reuse the already-rendered +preview canvas. Resize to 1024×1024px before sending to Vision API. + +--- + +## 6. Channel A — DxfGeometryParser + +Run in Web Worker (never block main thread). +Input: dxf-parser entity list. +Output: CoordsJSON (see schema below). + +### Step 1 — Layer analysis + +``` +Priority order: +1. Layers whose name contains: WALL, 墙, A-WALL, 承重墙, 隔墙, ARCH-WALL + → process these entities first, high confidence +2. No recognised layer names found + → fall back: apply geometric heuristics to ALL LINE + LWPOLYLINE +3. Always skip layers containing: HATCH, TEXT, DIM, ANNO, FURNITURE, 家具, 标注 +``` + +### Step 2 — Coordinate normalisation + +``` +1. Read $INSUNITS from DXF header + → 4 = mm, 6 = m, missing = infer from BBox size + Inference rule: if BBox max dimension < 100 → assume metres + if BBox max dimension ≥ 100 → assume millimetres + +2. Convert all coordinates to metres (Pascal internal unit) + +3. Round all coordinates to 0.001m precision (1mm) + +4. Endpoint snapping: + if distance between two endpoints < 0.005m (5mm) → merge to same point +``` + +### Step 3 — Parallel line pair detection (wall recognition core) + +For each line segment A, find candidate partner B where ALL conditions hold: + +``` +angle difference < 1° (parallel) +perpendicular distance ∈ [0.08, 0.40]m (valid wall thickness 80–400mm) +length difference < 20% (excludes annotation lines) +projection overlap > 30% of shorter segment length +``` + +If multiple B candidates → pick highest overlap × (1 / distance_variance) score. + +Result per matched pair: +``` +centreline = midpoint line between A and B +thickness = perpendicular distance +→ emit WallCandidate { start, end, thickness } +``` + +### Step 4 — Intersection correction + +Handle all junction types. Failure here causes disconnected walls and prevents +Slab auto-generation (v0.6.0 feature requires closed wall loops). + +``` +L-junction: snap both wall endpoints to exact intersection point +T-junction: split the through-wall at intersection; all three endpoints + snap to intersection +Cross (+): four wall segments; all four endpoints snap to centre intersection +Oblique: solve line equations to find intersection point; snap endpoints +``` + +Tolerance: if computed intersection is within 10mm of existing endpoint, +snap rather than create new point. + +### Step 5 — Door and window detection + +``` +Door signature: short LINE segment (< 1.2m) + ARC on same wall face + → DoorOpening { wallId, positionAlongWall, width, height=2.1 } + +Window signature: wall gap (LINE missing in double-wall pair) OR + block INSERT with name containing WIN / WINDOW / 窗 + → WindowOpening { wallId, positionAlongWall, width, height=1.2 } + +If confidence < 0.7 → mark as unresolved, let Channel B resolve +``` + +### CoordsJSON output schema + +```typescript +type CoordsJSON = { + unit: 'm' + bbox: { minX: number; minY: number; maxX: number; maxY: number } + walls: Array<{ + id: string // e.g. "w_001" + start: [number, number] + end: [number, number] + thickness: number // metres + height: number // default 2.8m if not in DXF + layerName?: string + }> + openings: Array<{ + id: string // e.g. "o_001" + type: 'door' | 'window' | 'unresolved' + wallId: string + positionAlongWall: number // 0–1 ratio + width: number + height: number + confidence: number + }> + closedRegions: Array<{ + id: string + polygon: Array<[number, number]> + }> + confidence: number + warnings: string[] +} +``` + +--- + +## 7. Channel B — VisionAnalyzer + +Server-side only (`apps/editor/app/api/vision-analyze/route.ts`). +Vision API key MUST NOT appear in any client bundle or be logged. + +Model: claude-sonnet-4-20250514 (multimodal). +Input: base64 PNG (1024×1024), from canvas.toDataURL(). +Timeout: 10 seconds. On timeout → skip Channel B, continue with Channel A only. + +### System prompt (send exactly, do not paraphrase) + +``` +You are an architectural floor plan analyser. Analyse the image and respond +ONLY with valid JSON — no prose, no markdown fences, no explanation. + +Rules: +1. If this is NOT an architectural floor plan (mechanical drawing, circuit + diagram, site plan, etc.) return: {"valid":false,"reason":""} +2. All coordinates are relative to image: top-left=(0,0), bottom-right=(1,1). + x increases rightward, y increases downward. +3. Use standard Chinese room names: 客厅, 主卧, 次卧, 厨房, 卫生间, 餐厅, + 书房, 阳台, 走廊, 玄关, 储藏室. +4. Report confidence per element (0.0–1.0). Below 0.6 = uncertain. +5. Never invent data. If unsure, omit the element. +``` + +### SemanticJSON output schema + +```typescript +type SemanticJSON = { + valid: boolean + reason?: string // only when valid=false + confidence: number // overall + rooms: Array<{ + name: string // Chinese room name + center: [number, number] // relative 0–1 coords + approxAreaM2: number + confidence: number + }> + openings: Array<{ + type: 'door' | 'window' | 'sliding_door' | 'opening' + location: [number, number] // relative 0–1 coords + facing?: 'north'|'south'|'east'|'west' + confidence: number + }> + wallTypes: Array<{ + location: [number, number] + type: 'exterior' | 'interior' | 'load_bearing' + confidence: number + }> + warnings: string[] +} +``` + +--- + +## 8. MergeEngine + +Input: CoordsJSON (Channel A) + SemanticJSON (Channel B). +Output: list of Pascal nodes ready for createNode(). + +### Coordinate system conversion (B → A) + +Channel B uses relative image coords (0–1), must convert to real metres: + +```typescript +realX = relativeX * (bbox.maxX - bbox.minX) + bbox.minX +realY = (1 - relativeY) * (bbox.maxY - bbox.minY) + bbox.minY +// Note: Y axis is flipped (image Y down, world Y up) +``` + +### Merge rules (apply in order) + +``` +RULE 1 — B confirms A wall: + Channel B reports a wall-type region overlapping a Channel A WallCandidate + → use A coordinates (precise), attach B metadata (wall type, adjacent room names) + +RULE 2 — B resolves A ambiguity: + Channel A has two WallCandidates overlapping same region (ambiguous) + Channel B confidence for one > 0.75 + → keep the candidate spatially closer to B location + +RULE 3 — B finds opening A missed: + Channel B reports door/window, Channel A has 'unresolved' or nothing nearby + → convert B relative coords to real coords + → find nearest wall within 0.3m + → create DoorOpening or WindowOpening on that wall + +RULE 4 — Room name attachment: + Channel B room centre converts to real coords + → find closedRegion in Channel A whose polygon contains that point + → attach { metadata: { name: roomName } } to Zone node + +RULE 5 — Conflict detection: + Channel A wall endpoint vs Channel B reported wall position differ > 10% of wall length + → mark node: { metadata: { importWarning: 'position_mismatch', needsReview: true } } + → still create the node (do not drop it) + → UI shows yellow warning badge on this node +``` + +### When Channel B is unavailable (timeout or API error) + +Continue with Channel A result only. Log warning. Do not fail the import. +Room names will be absent; user can add them manually. + +--- + +## 9. McpImporter — Pascal Scene Write + +Uses `@pascal-app/mcp` tools. Write order is mandatory — never reorder. + +``` +Step 1: load_scene (if project exists) OR create new scene +Step 2: create_building +Step 3: create_level (elevation=0, height=2.8 unless DXF provides height) +Step 4: create_wall × N (all walls, batch) +Step 5: add_door × M (after walls exist — doors reference wallId) +Step 6: add_window × K (after walls exist) +Step 7: set_zone × Z (room boundaries with metadata.name from merge) +Step 8: (automatic) v0.6.0 closed wall loop → Slab auto-generated, no action needed +Step 9: create GuideNode with original DXF rendered as PNG overlay +``` + +### Node construction (from AGENTS.md convention) + +```typescript +// ALWAYS use NodeType.parse then createNode — never construct raw objects +const wall = WallNode.parse({ + type: 'wall', + parentId: levelId, + start: [x1, y1, 0], + end: [x2, y2, 0], + thickness: 0.24, + height: 2.8, + visible: true, + metadata: { + importSource: 'dxf', + layerName: originalLayer, + wallType: mergedType, // 'exterior' | 'interior' | 'load_bearing' + needsReview: false + } +}) +useScene.getState().createNode(wall, levelId) +``` + +### Version conflict handling + +``` +On MCP tool response live_sync_version_conflict: + → call load_scene to reload + → retry the failed mutation + → max 3 retries + → on 3rd failure: show user "导入冲突,请刷新页面后重试" +``` + +--- + +## 10. Accuracy Expectations + +Set these expectations when discussing or testing the feature. + +| DXF type | Channel A alone | With Channel B | Main risk | +|---|---|---|---| +| Residential, named layers | 90–95% | 93–97% | Complex junctions | +| Residential, no layer names | 75–85% | 83–92% | Wall misidentification | +| Scanned-to-vector old drawings | 50–65% | 65–75% | Discontinuous lines | +| Curved / oblique walls | 60–75% | 70–82% | Arc centreline calc | +| Mechanical / circuit / gear | — | — | Blocked by Validator | + +**Correction workflow for imperfect imports:** +1. Guide node (original DXF as PNG overlay) stays visible after import +2. Nodes with `metadata.needsReview=true` show yellow badge in editor +3. User can toggle Guide visibility to compare and manually correct +4. "Re-import" option lets user adjust parameters (wall thickness range, + layer selection) and retry + +--- + +## 11. Dependencies + +``` +dxf-parser npm, browser-safe, no native deps — parse DXF text +@pascal-app/mcp already in repo ≥ 0.4.0 — MCP tool calls +@pascal-app/core already in repo ≥ 0.6.0 — node schemas +Anthropic Vision server-side only, claude-sonnet-4-20250514 +``` + +Install dxf-parser: +```bash +bun add dxf-parser --filter=@pascal-app/core +``` + +--- + +## 12. Critical Constraints (never violate) + +``` +✗ Do NOT import dxf-parser in Node.js server code — browser bundle only +✗ Do NOT put ANTHROPIC_API_KEY in any client-side file or log +✗ Do NOT construct raw node objects — always WallNode.parse({}) first +✗ Do NOT import from apps/editor inside packages/core or packages/viewer +✗ Do NOT call useScene from inside packages/core lib functions + (lib functions are pure; stores are accessed only from React components + or systems running in render loop) +✗ Do NOT skip endpoint snapping — disconnected walls break Slab auto-generation +✗ Do NOT run DxfGeometryParser on main thread — use Web Worker +``` + +--- + +## 13. Testing Checklist + +Before marking any phase complete, verify: + +``` +□ Upload a valid residential DXF → preview renders in < 500ms +□ Upload a gear/mechanical DXF → validator rejects with clear reason +□ Upload file > 10MB → rejected before parsing +□ Parallel line detection finds ≥ 90% of walls in test fixture +□ L/T/cross junctions produce connected wall endpoints (no gaps > 5mm) +□ Closed wall loops trigger Slab auto-generation (v0.6.0 behaviour) +□ Channel B timeout (mock 11s delay) → import completes with Channel A only +□ Nodes with needsReview=true show yellow badge in editor +□ Guide node (DXF overlay) visible and toggleable after import +□ MCP version conflict → auto-retry succeeds on retry 1 or 2 +□ No ANTHROPIC_API_KEY appears in browser network requests +□ bun typecheck passes with zero errors +□ bun test packages/core passes +``` + +--- + +## 14. Development Phase Order + +Implement in this order. Do not start a later phase before earlier ones pass +the testing checklist. + +``` +Phase 1 (P0): DxfValidator + DxfPreview + → file upload → canvas render → reject/warn before confirm + +Phase 2 (P0): DxfGeometryParser + McpImporter (Channel A only) + → full import pipeline without AI vision + → Guide node overlay + → basic door/window detection + +Phase 3 (P1): VisionAnalyzer + MergeEngine (add Channel B) + → semantic room names + → AI-confirmed openings + → conflict warnings (needsReview badge) + +Phase 4 (P1): UX polish + → 4-stage progress bar + → re-import with parameter adjustment + → ImportProgress + DxfValidationFeedback components + +Phase 5 (P2 — future): .dwg support + → server-side ezdxf conversion to .dxf + → reuse entire existing pipeline unchanged +``` diff --git a/.claude/settings.json b/.claude/settings.json index f5ad70468..390bff32f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,5 +3,6 @@ "allow": [ "Bash(npx turbo:*)" ] - } + }, + "enableAllProjectMcpServers": true } diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..25e16eec4 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.pascal-dev] +args = ["/Users/user/github/pascal-editor/packages/mcp/dist/bin/pascal-mcp.js"] +command = "bun" diff --git a/.env.example b/.env.example index daa84046e..81ea11529 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,23 @@ # Dev server port (default: 3000) # PORT=3000 + +# AI floor-plan assistant. The service still starts without a key and reports +# its unconfigured state through /api/ai/health. +# AI_PROVIDER=azure-openai +# AZURE_OPENAI_ENDPOINT=https://your-resource.cognitiveservices.azure.com +# AZURE_OPENAI_API_KEY=your_azure_openai_api_key +# AZURE_OPENAI_DEPLOYMENT=your_deployment_name +# AZURE_OPENAI_API_VERSION=2024-10-21 + +# OpenRouter is also supported when AI_PROVIDER is omitted. +# OPENROUTER_API_KEY=your_openrouter_api_key +# OPENROUTER_MODEL=your_tool_calling_model +# OPENROUTER_FALLBACK_API_KEY=optional_fallback_key +# OPENROUTER_FALLBACK_MODEL=optional_fallback_model + +# The editor proxies /api/ai to this internal service URL. +# AI_AGENT_URL=http://127.0.0.1:8788 + +# Use the same data path for the editor and the AI-spawned Pascal MCP process. +# PASCAL_DATA_DIR=/absolute/path/to/pascal-data diff --git a/.gitignore b/.gitignore index 38cfe8883..ca182aa9d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,18 @@ build dist *.tsbuildinfo +# .NET / Visual Studio local state +pascal-reverse-proxy/.vs/ +pascal-reverse-proxy/bin/ +pascal-reverse-proxy/obj/ +pascal-reverse-proxy/*.csproj.user + +# Runtime databases +pascal-reverse-proxy/data/ +pascal-reverse-proxy/*.db +pascal-reverse-proxy/*.db-shm +pascal-reverse-proxy/*.db-wal + # Debug npm-debug.log* @@ -45,6 +57,7 @@ yarn-error.log* /.playwright-mcp og-test .env*.local +apps/editor/env.download # Worktrees .worktrees diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..8f5492798 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "pascal-dev": { + "command": "bun", + "args": [ + "/Users/user/github/pascal-editor/packages/mcp/dist/bin/pascal-mcp.js" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index d55de2981..5319cf268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,3 +49,67 @@ Invoke the `review-architecture` skill (`.agents/skills/review-architecture/SKIL - After two consecutive tool failures, stop and change approach. - Don't introduce backwards-compatibility shims, dead code, or speculative abstractions. - Don't write new comments unless they explain a non-obvious *why*. + +--- + +## DXF Import Feature — File System Job Queue + +All DXF import jobs are stored under PASCAL_DATA_DIR/dxf-imports/. + +### Directory structure +``` +PASCAL_DATA_DIR/ + dxf-imports/ + 2026-05-28/ + job_<8位hex>/ + original.dxf # 原始上传(只写一次,不可修改) + preview.png # canvas 截图(只写一次) + job.json # job 元数据 + 运行历史 + coords_.json # Channel A 输出(每次运行带时间戳) + semantic_.json # Channel B 输出 + merged_.json # 融合结果 + coords_latest.json # 符号链接 → 最新 coords + semantic_latest.json # 符号链接 → 最新 semantic + merged_latest.json # 符号链接 → 最新 merged +``` + +### Rules +- Job ID: `crypto.randomBytes(4).toString('hex')` +- Timestamps in filenames: `HHmmss` (local time, no date — date is in parent folder) +- `job.json` must be updated after every step +- Channel A and B run in parallel; each writes its own file independently +- MCP importer reads `merged_latest.json` only +- Never delete job folders — they are the audit trail + +### job.json schema +```json +{ + "jobId": "a3f9c2e1", + "createdAt": "2026-05-28T14:30:20Z", + "status": "pending|validating|processing|merged|imported|failed", + "sourceFile": "original.dxf", + "sceneId": null, + "params": { "wallThicknessMin": 0.08, "wallThicknessMax": 0.40 }, + "runs": [ + { + "runAt": "2026-05-28T14:30:22Z", + "coordsFile": "coords_143022.json", + "semanticFile": "semantic_143028.json", + "mergedFile": "merged_143031.json", + "channelBSkipped": false, + "error": null + } + ] +} +``` + +### New files for this feature +- `packages/core/src/lib/importers/job-store.ts` — job 文件夹创建/读写 +- `packages/core/src/lib/importers/dxf-validator.ts` +- `packages/core/src/lib/importers/dxf-geometry-parser.ts` +- `packages/core/src/lib/importers/dxf-merge-engine.ts` +- `packages/core/src/lib/importers/mcp-importer.ts` +- `apps/editor/components/tools/ImportDxfTool.tsx` +- `apps/editor/components/DxfPreview.tsx` +- `apps/editor/app/api/vision-analyze/route.ts` +- `apps/editor/app/api/vision-analyze/prompts/floor-plan-analyzer.md` \ No newline at end of file diff --git a/CATALOG_ITEMS.md b/CATALOG_ITEMS.md new file mode 100644 index 000000000..83abd4d49 --- /dev/null +++ b/CATALOG_ITEMS.md @@ -0,0 +1,319 @@ +# 家具目录条目格式(`CATALOG_ITEMS`) + +本文说明 [`packages/editor/src/components/ui/item-catalog/catalog-items.tsx`](./packages/editor/src/components/ui/item-catalog/catalog-items.tsx) 中每条 `AssetInput` 的字段含义,以及如何追加、删除自定义条目。 + +类型定义:[`packages/core/src/schema/nodes/item.ts`](./packages/core/src/schema/nodes/item.ts) 中的 `assetSchema`。 + +--- + +## 目录 + +- [参考示例](#参考示例-cactus) +- [字段一览](#字段一览) +- [分类取值](#分类取值) +- [URL 要求](#url-要求thumbnail--src--floorplanurl) +- [GLB 模型要求](#glb-模型要求src) +- [尺寸与校正](#尺寸与校正dimensions--offset--rotation--scale) +- [`attachTo` 与 `surface`](#attachto-与-surface) +- [`tags` 与侧栏筛选](#tags-与侧栏筛选) +- [开发 UI:添加 / 删除](#开发-ui添加--删除-catalog-itemstsx) +- [手动改源码](#手动追加一条目录) +- [相关文件](#相关文件) +- [注意事项](#注意事项) + +--- + +## 参考示例(Cactus) + +```ts +{ + id: 'cactus', + category: 'furniture', + name: 'Cactus', + tags: ['cactus', 'rack', 'stand', 'floor'], + thumbnail: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/cactus/thumbnail.png', + src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/cactus/model.glb', + floorPlanUrl: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/cactus/floor-plan.png', + dimensions: [0.34, 0.39, 0.27], + offset: [-0.0039, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], +}, +``` + +--- + +## 字段一览 + +| 字段 | 必填 | 类型 | 说明 | +| --- | --- | --- | --- | +| `id` | ✅ | `string` | 目录内唯一标识,建议小写 + 连字符 | +| `category` | ✅ | `string` | 侧栏分类,见 [分类取值](#分类取值) | +| `name` | ✅ | `string` | 显示名称 | +| `thumbnail` | ✅ | `string` | 缩略图 URL | +| `src` | ✅ | `string` | 3D 模型 `.glb` / `.gltf`,见 [GLB 要求](#glb-模型要求src) | +| `dimensions` | 推荐 | `[w, h, d]` | 占位尺寸(**米**):宽、高、深;碰撞与平面图 | +| `offset` | 推荐 | `[x, y, z]` | 模型校正平移(米),默认 `[0,0,0]` | +| `rotation` | 推荐 | `[x, y, z]` | 模型校正旋转(**弧度**) | +| `scale` | 推荐 | `[x, y, z]` | 模型校正缩放,默认 `[1,1,1]` | +| `tags` | 推荐 | `string[]` | 筛选与搜索;见 [tags](#tags-与侧栏筛选) | +| `floorPlanUrl` | 可选 | `string` | 平面图俯视图 | +| `attachTo` | 可选 | `'wall' \| 'wall-side' \| 'ceiling'` | 不设则为落地摆放 | +| `surface` | 可选 | `{ height: number }` | 可叠放台面高度(米) | +| `interactive` | 可选 | 对象 | 灯控、动画等 | +| `source` | 可选 | `'library' \| 'community' \| 'mine'` | 来源筛选 | +| `isDraft` | 可选 | `boolean` | 草稿标记 | + +--- + +## 分类取值 + +**Furnish → Items** 侧栏按 `category` 分 Tab: + +| `category` | 侧栏 Tab | +| --- | --- | +| `furniture` | Furniture | +| `appliance` | Appliance | +| `kitchen` | Kitchen | +| `bathroom` | Bathroom | +| `outdoor` | Outdoor | + +`door`、`window` 类型存在,但门/窗通常用 **Build** 工具栏;自定义门模型可放在 `furniture` 并设 `attachTo: 'wall'` / `'wall-side'`。 + +--- + +## URL 要求(`thumbnail` / `src` / `floorPlanUrl`) + +校验见 `packages/core/src/schema/asset-url.ts`: + +- ✅ `https://…` 公网地址 +- ✅ `/items/…/model.glb`(对应 `apps/editor/public/items/`) +- ✅ `asset://…`(IndexedDB) +- ✅ 开发环境 `http://localhost` / `127.0.0.1` +- ❌ `file://`、`javascript:` 等 + +`src` 须为 **GLB/GLTF**;缩略图、平面图为图片 URL。 + +未设置 `NEXT_PUBLIC_ASSETS_CDN_URL` 时,`/items/...` 走**同源**(本地 dev 即 `localhost:3002`)。 + +--- + +## GLB 模型要求(`src`) + +通过 **Three.js `useGLTF`** 加载,**不支持** FBX、3DS、OBJ(需先在 Blender 等工具转成 GLB)。 + +### 推荐规格 + +| 指标 | 推荐 | 可接受 | 不推荐 | +| --- | --- | --- | --- | +| mesh 数 | **1** | 2~10 | 50+ | +| 三角面 | 500~5k | 5k~30k | 100k+ | +| 文件大小 | < 1 MB | 1~3 MB | > 5 MB | +| 包围盒 | 宽约 0.3~3 m,高约 0.3~7 m | 略大 | 数百~上千「单位」 | +| 原点 | 底面中心贴地 | 可 `offset` 微调 | 几何离原点数百米 | + +内置参考:Cactus / Tree 约 **1 mesh、<1MB、几米见方**。 + +### 坐标与 CAD 导出 + +- 单位按 **米**;`dimensions` 也是米。 +- 落地:节点在 `(x, 0, z)`,模型底面应在 `y ≈ 0`。 +- CAD/SketchUp 常见问题:根节点 90° matrix、`Active View` 大坐标、网格远离原点 → **有占位、看不见**。 + +### `dimensions` / `scale` / `offset` 分工 + +| 字段 | 作用 | +| --- | --- | +| `dimensions` | **逻辑占位**(碰撞、能否放下、平面图),**不**缩放 GLB | +| `scale` | 视觉缩放(如毫米模型 `scale: [0.001, 0.001, 0.001]`) | +| `offset` | 视觉平移;模型已居中时多为 `[0,0,0]` | + +`dimensions` 应与 **`scale` 后的视觉占地** 一致,否则会出现「看得见但放不进」或相反。 + +### 贴墙门示例(毫米 GLB) + +GLB 包围盒约 788×145×2042(毫米)、高度沿 Z 轴时,目录可类似: + +```ts +{ + id: 'my-door', + category: 'furniture', + name: '单开门', + tags: ['wall', 'door', 'custom'], + thumbnail: '/icons/couch.png', + src: 'https://example.com/door.glb', + dimensions: [0.9, 2.1, 0.12], + offset: [0, 0, 0], + rotation: [-Math.PI / 2, 0, 0], + scale: [0.001, 0.001, 0.001], + attachTo: 'wall-side', +}, +``` + +放置时把光标移到 **墙体表面** 再点击,不要点在地板上。 + +### 编辑器自动居中 + +加载家具时,若包围盒中心距原点 > 约 10 m(CAD 导出),Viewer 会**自动平移**几何到放置点附近(落地、贴墙、吊顶均适用)。实现:[`wrap-scene-for-placement.ts`](./packages/viewer/src/lib/wrap-scene-for-placement.ts)。 + +仍建议在 DCC 中规范导出;自动居中不能替代正确的 `scale` / `rotation`。 + +### Blender 导出清单 + +1. 删除无 mesh 的辅助节点(如 `Active View`) +2. **Ctrl+J** 合并为一个对象 +3. 原点设到物体底面中心,**Apply All Transforms** +4. 导出 **GLB**,+Y Up,尺寸为米级 +5. 用 [glTF Viewer](https://gltf-viewer.donmccurdy.com/) 复核 + +### 常见问题 + +| 现象 | 可能原因 | 处理 | +| --- | --- | --- | +| 有占位、看不见 | 原点远 + `scale` 不对 | 重导出;调 `scale`/`rotation`;硬刷新 | +| 地面放不进(红圈) | `dimensions` 过大或旧物占格 | 改 `dimensions`;删场景中旧节点 | +| 贴墙无反应 | 未点在墙上;`attachTo` 错误 | 用 `wall`/`wall-side`;光标贴墙 | +| 加载失败 | CORS / 非 GLB | 控制台 `Could not load` | + +--- + +## 尺寸与校正(`dimensions` / `offset` / `rotation` / `scale`) + +- `dimensions`:逻辑占位,不改 GLB 文件。 +- `offset` / `rotation` / `scale`:对齐 Pascal 放置约定;新模型几乎都要调。 +- `rotation` 单位为**弧度**(`Math.PI / 2` = 90°)。 + +流程:先放置 → 悬空/陷地/朝向不对 → 改三项直至贴合。 + +--- + +## `attachTo` 与 `surface` + +| `attachTo` | 行为 | +| --- | --- | +| (省略) | 落地,`ItemTool` 地面放置 | +| `wall` | 贴墙(烟感、开关、门洞等) | +| `wall-side` | 侧向贴墙(搁板、门扇等) | +| `ceiling` | 吊顶 | + +`surface: { height: n }`:可在该高度上叠放其他物品(如电视柜 `0.35`)。 + +--- + +## `tags` 与侧栏筛选 + +- **放置方式**(蓝芯片):`floor`、`wall`、`ceiling`、`countertop` +- **功能标签**(紫/灰芯片):任意字符串,用于搜索 + +建议: + +- 落地物:含 `floor` +- 贴墙物:含 `wall`(不要只写 `floor`) +- **通过「添加」Tab 写入的条目**:自动带 `custom`,并可在同 Tab **删除**;手动改源码时请加 `'custom'`,否则开发 UI 里删不掉 + +--- + +## 开发 UI:添加 / 删除 `catalog-items.tsx` + +仅本地 **`bun dev`**(`NODE_ENV=development`)可用。侧栏 **「添加」** Tab。 + +### 添加 + +填写名称、分类、尺寸、`attachTo` 等;模型/缩略图/平面图可填 **URL** 或上传文件(**URL 优先**)。 + +点击 **「写入 catalog-items.tsx 并放置」**: + +1. 上传文件时保存到 `apps/editor/public/items//` +2. 在 `catalog-items.tsx` 末尾追加条目(`tags` 含 `custom`) +3. 跳转到 **Items** 进入放置 + +API:`POST /api/catalog-items`(`multipart/form-data`,字段 `metadata` + 可选 `model` / `thumbnail` / `floorPlan`) + +### 删除 + +Tab 底部 **「删除自定义家具」**: + +- 仅列出带 **`custom`** 标签的条目 +- 确认后从 `catalog-items.tsx` 删除该对象,并删除 `public/items//`(若存在) +- **内置 CDN 家具**不可删 + +API:`DELETE /api/catalog-items?id=<条目id>` + +**说明:** 删除目录**不会**移除场景里已放置的实例;需手动删节点或重新 Load Build。 + +--- + +## 手动追加一条目录 + +### 资源 + +| 资源 | 格式 | 说明 | +| --- | --- | --- | +| 模型 | `.glb` | 见 [GLB 要求](#glb-模型要求src) | +| 缩略图 | PNG/WebP | `thumbnail` | +| 平面图(可选) | PNG | `floorPlanUrl` | + +### 代码位置 + +在 `CATALOG_ITEMS` 数组末尾追加(`/** Built-in catalog plus` 标记之前): + +```ts +{ + id: 'my-new-chair', + category: 'furniture', + name: 'My Chair', + tags: ['floor', 'chair', 'custom'], + thumbnail: '/items/my-new-chair/thumbnail.png', + src: '/items/my-new-chair/model.glb', + dimensions: [0.6, 0.9, 0.6], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], +}, +``` + +保存后热更新,在 **Furnish → Items** 对应分类中可见。 + +### 最小条目(仅验证加载) + +```ts +{ + id: 'test-chair', + category: 'furniture', + name: 'Test Chair', + tags: ['floor', 'custom'], + thumbnail: '/icons/couch.png', + src: 'https://example.com/model.glb', + dimensions: [1, 1, 1], +}, +``` + +占位可能不准,需后续补全 `scale` / `offset` / `rotation`。 + +--- + +## 相关文件 + +| 文件 | 作用 | +| --- | --- | +| [`catalog-items.tsx`](./packages/editor/src/components/ui/item-catalog/catalog-items.tsx) | 目录数据 | +| [`item-catalog.tsx`](./packages/editor/src/components/ui/item-catalog/item-catalog.tsx) | 目录网格、选中放置 | +| [`add-catalog-panel.tsx`](./packages/editor/src/components/ui/sidebar/panels/add-catalog-panel.tsx) | 添加 / 删除 UI | +| [`apps/editor/app/api/catalog-items/route.ts`](./apps/editor/app/api/catalog-items/route.ts) | 开发环境 POST / DELETE API | +| [`apps/editor/lib/catalog-items-fs.ts`](./apps/editor/lib/catalog-items-fs.ts) | 读写源码与 `public/items/` | +| [`item.ts`](./packages/core/src/schema/nodes/item.ts) | `AssetInput` schema | +| [`wrap-scene-for-placement.ts`](./packages/viewer/src/lib/wrap-scene-for-placement.ts) | GLB 远离原点时自动居中 | +| [`asset-catalog.ts`](./packages/mcp/src/tools/asset-catalog.ts) | MCP 目录(需单独同步 `id`) | + +--- + +## 注意事项 + +1. 新增 `src` 前阅读 [GLB 模型要求](#glb-模型要求src)。 +2. `id` 全文件唯一。 +3. 自定义条目建议保留 **`custom`** 标签,以便开发 UI 删除。 +4. MCP:`place_item` 使用 `MCP_CATALOG_ITEMS`,Agent 要能放置需在 MCP 目录同步相同 `id`。 +5. 场景内已放置家具的 `item.asset` 会写入 JSON;`Load Build` 不依赖目录条目是否仍存在。 +6. 不支持 FBX/3DS 直接作 `src`;须转 GLB。 diff --git a/Pascal_DXF_Import_Design.docx b/Pascal_DXF_Import_Design.docx new file mode 100644 index 000000000..697fb91c8 Binary files /dev/null and b/Pascal_DXF_Import_Design.docx differ diff --git a/README.md b/README.md index 1d237bcf8..80357ede8 100644 --- a/README.md +++ b/README.md @@ -417,3 +417,4 @@ npm publish --workspace=@pascal-app/viewer --access public --- pascalorg/editor | Trendshift + diff --git a/SAVE_BUILD_JSON.md b/SAVE_BUILD_JSON.md new file mode 100644 index 000000000..7d1a71d00 --- /dev/null +++ b/SAVE_BUILD_JSON.md @@ -0,0 +1,390 @@ +# Save Build 导出 JSON 字段说明 + +在 **Settings → Save & Load → Save Build** 时,编辑器会把当前场景序列化为 JSON 并下载为 `layout_YYYY-MM-DD.json`(例如 `layout_2026-05-26.json`)。 + +实现见 `packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx`:导出内容为 `useScene` 中的 **`nodes`** 与 **`rootNodeIds`**,不含编辑器 UI 状态、不含 `collections`(收藏夹分组另存于场景 store,但 Save Build 未写入该字段)。 + +**Load Build** 读取同一格式:`{ nodes, rootNodeIds }` 即可还原场景。 + +--- + +## 顶层结构 + +```json +{ + "nodes": { /* 所有节点的字典,key 为节点 id */ }, + "rootNodeIds": [ /* 场景根节点 id 列表 */ ] +} +``` + +| 字段 | 类型 | 含义 | +|------|------|------| +| `nodes` | `Record` | 场景中**每一个**节点的完整数据。键名与节点内的 `id` 一致。 | +| `rootNodeIds` | `string[]` | 场景树的**顶层**节点 id。常见为 `site_…` 或 `building_…`。Load 时从这些 id 开始遍历整棵树。 | + +--- + +## 节点图如何串起来 + +每个节点通过以下字段构成一棵树(部分节点还有横向引用,如墙上的门窗): + +| 关系字段 | 含义 | +|----------|------| +| `id` | 全局唯一标识,格式一般为 `{type}_{随机串}`,如 `wall_0j28n7nskm2sst7m`。 | +| `type` | 节点类型,决定还有哪些字段合法(见下文分类型说明)。 | +| `parentId` | 父节点 `id`;顶层节点为 `null`。 | +| `children` | **仅 id 列表**(字符串数组),指向子节点;子节点的完整数据仍在 `nodes` 里。 | + +典型层级(由外到内): + +``` +site(地块) + └── building(楼栋) + └── level(楼层) + ├── wall / slab / zone / item / stair / … + └── wall.children → 墙上的 door / window / item +``` + +**注意:** `children` 里只存 id,不要省略对应条目;`nodes` 中必须存在每个被引用的 id。 + +--- + +## 所有节点共有的字段(BaseNode) + +绝大多数节点都包含: + +| 字段 | 类型 | 必填 | 含义 | +|------|------|------|------| +| `object` | `"node"` | 是 | 固定字面量,表示这是一条场景节点记录。 | +| `id` | `string` | 是 | 节点唯一 id。 | +| `type` | `string` | 是 | 节点类型(`site`、`building`、`level`、`wall`、`item` 等)。 | +| `parentId` | `string \| null` | 是 | 父节点 id;无父级时为 `null`。 | +| `visible` | `boolean` | 否 | 是否在视图中显示,默认 `true`。 | +| `metadata` | `object` | 否 | 任意 JSON 元数据,默认 `{}`。可存工具来源、临时标记等。 | +| `name` | `string` | 否 | 在侧栏/树中显示的名称。 | +| `camera` | `Camera` | 否 | 与该节点关联的**已保存视角**(切到该楼层/区域时恢复)。见 [camera](#camera-对象)。 | +| `children` | `string[]` | 视类型 | 子节点 id 列表;有子节点的类型才出现。 | + +--- + +## `camera` 对象 + +常出现在 `level`、`zone` 等节点上: + +```json +"camera": { + "position": [32.19, 13.35, 32.63], + "target": [4.85, 0, 7.36], + "mode": "perspective" +} +``` + +| 字段 | 类型 | 含义 | +|------|------|------| +| `position` | `[x, y, z]` | 相机位置,世界坐标,单位:**米**。 | +| `target` | `[x, y, z]` | 相机看向的目标点。 | +| `mode` | `"perspective"` \| `"orthographic"` | 透视 / 正交。 | +| `fov` | `number` | 可选,透视时的视野角度。 | +| `zoom` | `number` | 可选,正交时的缩放。 | + +--- + +## `material` / 材质相关字段 + +墙、楼板、天花板、门、窗等结构节点可能带有材质。常见两种写法: + +1. **`materialPreset`**:预设名(如 `"white"`、`"brick"`、`"wood"`)。 +2. **`material`**:自定义材质对象: + +```json +"material": { + "preset": "custom", + "properties": { + "color": "#ffffff", + "roughness": 0.5, + "metalness": 0, + "opacity": 1, + "transparent": false, + "side": "front" + }, + "texture": { + "url": "https://…/texture.jpg", + "repeat": [1, 1] + } +} +``` + +墙体还可分内外侧:`interiorMaterial` / `exteriorMaterial` 及对应 `*MaterialPreset`。 + +--- + +## 各 `type` 专有字段 + +### `site` — 地块 + +| 字段 | 含义 | +|------|------| +| `polygon` | 用地红线,`{ type: "polygon", points: [[x,z], …] }`,单位米,XZ 平面。 | +| `children` | 一般为 `building` 节点 id 列表。 | + +### `building` — 楼栋 + +| 字段 | 含义 | +|------|------| +| `position` | `[x, y, z]` 在地块坐标系中的位置。 | +| `rotation` | `[x, y, z]` 旋转(弧度)。 | +| `children` | `level` 节点 id 列表。 | + +### `level` — 楼层 + +| 字段 | 含义 | +|------|------| +| `level` | 楼层编号,`0` 通常为地面层,向上递增。 | +| `children` | 该层上的 `wall`、`slab`、`zone`、`item`、`stair`、`roof`、`scan`、`guide`、`spawn` 等 id。 | + +### `wall` — 墙 + +| 字段 | 含义 | +|------|------| +| `start` | `[x, z]` 墙起点(楼层平面坐标)。 | +| `end` | `[x, z]` 墙终点。 | +| `thickness` | 墙厚(米),可选。 | +| `height` | 墙高(米),可选。 | +| `curveOffset` | 弧形墙的中点偏移(米),可选。 | +| `frontSide` / `backSide` | `"interior"` / `"exterior"` / `"unknown"`,剖切显示用。 | +| `children` | 附着的 `item` / `door` / `window` 的 id(若使用独立门窗节点)。 | +| `material*` | 墙体材质(见上)。 | + +### `slab` — 楼板 / 地面 + +| 字段 | 含义 | +|------|------| +| `polygon` | `[[x,z], …]` 外轮廓顶点。 | +| `holes` | 洞轮廓数组,每个洞是一个 `[[x,z], …]` 多边形。 | +| `holeMetadata` | 与 `holes` 对应的元数据(楼梯开口等)。 | +| `elevation` | 板顶标高(米),默认约 `0.05`。 | +| `autoFromWalls` | 是否由闭合墙线自动生成。 | + +### `ceiling` — 天花板 + +| 字段 | 含义 | +|------|------| +| `polygon` | 同楼板,XZ 轮廓。 | +| `holes` / `holeMetadata` | 同楼板。 | +| `height` | 天花板高度(米),默认约 `2.5`。 | +| `autoFromWalls` | 是否自动生成。 | +| `children` | 可附着的 `item`(如吊灯)id。 | + +### `zone` — 功能分区 + +| 字段 | 含义 | +|------|------| +| `name` | 分区名称(如 "Living Room")。 | +| `polygon` | `[[x,z], …]` 分区边界。 | +| `color` | 十六进制颜色,如 `"#3b82f6"`。 | + +### `column` — 柱 + +含大量样式字段(`style`、`crossSection`、`height`、`radius` 等),用于参数化柱体几何。导出 JSON 会保留你在编辑器中设置的全部柱参数。 + +### `fence` — 围栏 + +| 字段 | 含义 | +|------|------| +| `start` / `end` | 围栏起终点 `[x, z]`。 | +| `height` / `thickness` | 高度、厚度(米)。 | +| `style` | `"slat"` / `"rail"` / `"privacy"` 等。 | +| `color` | 颜色。 | +| 其他 | `postSpacing`、`baseStyle`、`curveOffset` 等几何/样式参数。 | + +### `stair` — 楼梯 + +| 字段 | 含义 | +|------|------| +| `position` | `[x, y, z]` 楼梯锚点。 | +| `rotation` | 绕 Y 轴旋转(**弧度**,标量 `number`)。 | +| `fromLevelId` / `toLevelId` | 连接的起止楼层 id。 | +| `stairType` | `"straight"` / `"curved"` / `"spiral"`。 | +| `width` / `totalRise` / `stepCount` / `thickness` | 宽、总升高、踏步数、结构厚度。 | +| `fillToFloor` | 是否填充实心到楼板。 | +| `railingMode` | 栏杆:`none` / `left` / `right` / `both`。 | +| 材质 | `material`、`railingMaterial`、`treadMaterial` 等。 | + +### `roof` — 屋顶组 + +| 字段 | 含义 | +|------|------| +| `position` | 屋顶组中心 `[x, y, z]`。 | +| `rotation` | 绕 Y 轴旋转(弧度)。 | +| `children` | `roof-segment`(`rseg_…`)节点 id 列表。 | + +### `roof-segment`(`type`: `"roof-segment"`)— 屋顶段 + +| 字段 | 含义 | +|------|------| +| `roofType` | `hip` / `gable` / `shed` / `gambrel` / `dutch` / `mansard` / `flat`。 | +| `width` / `depth` | 平面 footprint 尺寸(米)。 | +| `wallHeight` / `roofHeight` | 墙体高度、屋顶高度。 | +| `overhang` | 挑檐伸出距离。 | + +> **旧版导出:** 部分早期 JSON 中 `roof` 可能带有 `length`、`leftWidth`、`rightWidth` 等字段,属于旧数据格式;新编辑器以 `roof` + `roof-segment` 为主。 + +### `item` — 家具 / 门窗模型 / 设备 + +物品类节点使用 **`asset`** 描述模型与目录信息: + +| 字段 | 含义 | +|------|------| +| `position` | `[x, y, z]` 在世界/父级坐标系中的位置(米)。 | +| `rotation` | `[rx, ry, rz]` 旋转(**弧度**)。 | +| `scale` | `[sx, sy, sz]` 实例缩放。 | +| `side` | `"front"` / `"back"`,贴墙时的朝向。 | +| `wallId` | 附着的墙 id(与 `asset.attachTo` 配合)。 | +| `wallT` | 沿墙参数位置 `0~1`(0 起点,1 终点)。 | +| `children` | 叠放在此物品上的子 `item` id(如台面上的小物件)。 | +| `collectionIds` | 所属收藏夹 id 列表(若使用过 collections 功能)。 | +| `asset` | 见下表。 | + +#### `item.asset` 对象 + +| 字段 | 含义 | +|------|------| +| `id` | 目录条目 id(如 `lounge-chair`),与 `catalog-items.tsx` 中一致。 | +| `category` | 分类:`furniture`、`kitchen`、`window`、`door` 等。 | +| `name` | 显示名。 | +| `thumbnail` | 目录缩略图 URL 或路径。 | +| `src` | **GLB/GLTF 模型** URL 或路径(如 `/items/…/model.glb`、`https://…`)。 | +| `floorPlanUrl` | 可选,2D 平面图俯视图。 | +| `dimensions` | `[宽, 高, 深]` 逻辑占位尺寸(米)。 | +| `offset` | 模型校正平移 `[x,y,z]`(米)。 | +| `rotation` | 模型校正旋转 `[x,y,z]`(弧度)。 | +| `scale` | 模型校正缩放 `[x,y,z]`。 | +| `attachTo` | 可选:`wall` / `wall-side` / `ceiling`,表示贴墙或吊顶。 | +| `tags` | 可选字符串数组,用于目录筛选。 | +| `surface` | 可选 `{ height: number }`,表示可在其上放置其他物品的高度。 | +| `interactive` | 可选,灯具开关、动画等交互配置。 | + +**说明:** 许多门窗在导出 JSON 里 `type` 仍为 `"item"`,通过 `asset.category` 与 `asset.attachTo: "wall"` 区分;schema 里也支持独立的 `door` / `window` 节点类型(字段更多,含门扇分段、开启方式等)。 + +### `door` — 门(参数化) + +除 BaseNode 与 `position` / `rotation` / `wallId` / `side` 外,还有 `width`、`height`、`doorType`、`segments`(门扇分格)、`swingAngle`、`openingKind` 等大量参数。完整列表见 `packages/core/src/schema/nodes/door.ts`。 + +### `window` — 窗(参数化) + +类似门,含 `windowType`、`width`、`height`、`operationState`、`frameThickness` 等。见 `packages/core/src/schema/nodes/window.ts`。 + +### `scan` — 3D 扫描参考 + +| 字段 | 含义 | +|------|------| +| `url` | 扫描模型 URL(`.glb` / `.gltf` 或 `asset://…`)。 | +| `position` / `rotation` | 位姿。 | +| `scale` | 统一缩放系数(标量)。 | +| `opacity` | 不透明度 `0~100`。 | + +### `guide` — 参考平面图 + +| 字段 | 含义 | +|------|------| +| `url` | 图片 URL 或 `asset://…`(浏览器本地存储 id)。 | +| `position` / `rotation` | 位姿。 | +| `scale` | 缩放。 | +| `opacity` | `0~100`。 | +| `scaleReference` | 可选比例尺校准:`start`、`end`、`realLengthMeters`、`metersPerUnit` 等。 | + +### `spawn` — 出生点 / 漫游起点 + +| 字段 | 含义 | +|------|------| +| `position` | `[x, y, z]`。 | +| `rotation` | 绕 Y 轴朝向(弧度)。 | + +--- + +## 坐标与单位约定 + +| 约定 | 说明 | +|------|------| +| 长度单位 | **米(m)** | +| 平面墙/板/区 | 使用 **`[x, z]`** 两点或 polygon,忽略竖向(高度由 `height` / `elevation` 等单独字段表示)。 | +| 旋转 | 节点 `rotation` 多为 **`[rx, ry, rz]` 弧度**;楼梯、屋顶、spawn 等有时为绕 Y 的**单个弧度值**。 | +| `item.asset.rotation` | 模型**校正**旋转,同样是弧度。 | + +--- + +## 最小示例 + +```json +{ + "nodes": { + "building_abc": { + "object": "node", + "id": "building_abc", + "type": "building", + "parentId": null, + "visible": true, + "metadata": {}, + "children": ["level_0"], + "position": [0, 0, 0], + "rotation": [0, 0, 0] + }, + "level_0": { + "object": "node", + "id": "level_0", + "type": "level", + "parentId": "building_abc", + "visible": true, + "metadata": {}, + "children": ["item_chair"], + "level": 0 + }, + "item_chair": { + "object": "node", + "id": "item_chair", + "type": "item", + "name": "Chair", + "parentId": "level_0", + "visible": true, + "metadata": {}, + "position": [2, 0, 3], + "rotation": [0, 0, 0], + "scale": [1, 1, 1], + "asset": { + "id": "chair", + "category": "furniture", + "name": "Chair", + "thumbnail": "/items/chair/thumbnail.png", + "src": "/items/chair/model.glb", + "dimensions": [0.6, 0.9, 0.6], + "offset": [0, 0, 0], + "rotation": [0, 0, 0], + "scale": [1, 1, 1] + } + } + }, + "rootNodeIds": ["building_abc"] +} +``` + +--- + +## 与云端场景 API 的区别 + +| 方式 | 内容 | +|------|------| +| **Save Build** | 本机下载的 `layout_*.json`,仅 `nodes` + `rootNodeIds`。 | +| **云端 `/api/scenes`** | 可能包含项目元数据、版本号等;节点结构与本 JSON 同类,但包装在外层 API 响应中。 | + +手工编辑 JSON 后可用 **Load Build** 导入;修改时务必保持 **id 唯一**、`parentId` / `children` **互相引用一致**,且 `src` / `url` 等资源地址可被编辑器加载(`https://`、`/`、`asset://` 等,见 `AssetUrl` 校验规则)。 + +--- + +## 相关源码 + +| 文件 | 说明 | +|------|------| +| `packages/editor/src/lib/scene.ts` | `SceneGraph` 类型定义 | +| `packages/core/src/schema/base.ts` | 节点基类 | +| `packages/core/src/schema/nodes/*.ts` | 各类型 Zod schema | +| `apps/editor/public/demos/demo_1.json` | 完整示例场景 | diff --git a/WINDOWS_DEPLOY_RUNBOOK.md b/WINDOWS_DEPLOY_RUNBOOK.md new file mode 100644 index 000000000..ebddb5faa --- /dev/null +++ b/WINDOWS_DEPLOY_RUNBOOK.md @@ -0,0 +1,216 @@ +# Windows Deploy Runbook + +This document describes how to deploy and run this project on another Windows machine without Docker. + +## 1. Install Prerequisites + +Install these tools on the target Windows machine: + +- Git +- Bun 1.3.x +- .NET SDK/Runtime for `net10.0` +- Node.js LTS, optional but recommended for toolchain compatibility + +Verify: + +```powershell +git --version +bun --version +dotnet --version +node --version +``` + +## 2. Clone The Project + +```powershell +git clone +cd pascalorg-editor0-9-1 +``` + +Install root workspace dependencies: + +```powershell +bun install +``` + +Install AI MCP dependencies: + +```powershell +cd pascal-ai-mcp +bun install +cd .. +``` + +## 3. Prepare Data Files + +Create a data directory on the target machine: + +```powershell +mkdir C:\pascal-data +``` + +Copy these files from the source machine: + +```text +C:\Users\lytec\.pascal\data\pascal.db +C:\Users\lytec\.pascal\data\pascal-reverse-proxy.db +``` + +Place them on the target machine: + +```text +C:\pascal-data\pascal.db +C:\pascal-data\pascal-reverse-proxy.db +``` + +The target Windows user must have read and write access to `C:\pascal-data`. + +## 4. Create `.env.local` + +Create `.env.local` in the project root: + +```env +PASCAL_DB_PATH=C:/pascal-data/pascal.db +PASCAL_PROXY_DB_PATH=C:/pascal-data/pascal-reverse-proxy.db + +MADORI_API_URL=https://cad.madori-navi.jp/analyze-dxf +MADORI_API_KEY= + +NEXT_ALLOWED_DEV_ORIGINS=:8000 +PASCAL_ALLOW_CATALOG_SOURCE_WRITE=true +NEXT_PUBLIC_ALLOW_CATALOG_SOURCE_WRITE=true + +OPENROUTER_API_KEY= +OPENROUTER_MODEL= +NEXT_PUBLIC_AI_ASSISTANT_URL=http://:5900/ +``` + +Example: + +```env +NEXT_ALLOWED_DEV_ORIGINS=192.168.100.230:8000 +``` + +If the target machine IP changes, update `.env.local`, rebuild the editor, and restart the editor service. + +## 5. Build The Editor + +```powershell +cd apps\editor +bun run build +``` + +The build may print Next.js NFT trace warnings. They are warnings only if the command exits successfully. + +## 6. Start The Editor + +Open a PowerShell window: + +```powershell +cd apps\editor +bun run start +``` + +The editor listens on: + +```text +http://0.0.0.0:3002 +``` + +## 7. Start The Reverse Proxy + +Open another PowerShell window: + +```powershell +cd pascal-reverse-proxy +dotnet run +``` + +The reverse proxy listens on: + +```text +http://0.0.0.0:8000 +``` + +Users should access the project through the proxy: + +```text +http://:8000 +``` + +Example: + +```text +http://192.168.100.230:8000 +``` + +## 8. Start AI MCP + +Open another PowerShell window if AI chat is needed: + +```powershell +cd pascal-ai-mcp +bun run start +``` + +The AI MCP service listens on: + +```text +http://0.0.0.0:8788 +``` + +The AI assistant iframe currently expects the AI front/chat page on port `5900` unless `NEXT_PUBLIC_AI_ASSISTANT_URL` is configured. +Use the root URL, not a fixed `/#/thread/...` URL. A fixed thread URL may point to a conversation that only exists on the source machine. + +## 9. Windows Firewall + +Allow inbound access for these ports as needed: + +```text +8000 Reverse proxy entry point. This is the main port users should access. +3002 Editor service. Usually only the proxy needs this, but local testing may use it. +8788 AI MCP service, if AI chat is used. +5900 AI front/chat page, if used. +``` + +At minimum, expose port `8000` to other users on the LAN. + +## 10. Common Checks + +Check proxy health: + +```text +http://:8000/proxy/health +``` + +Check scene API: + +```text +http://:8000/api/scenes/ +``` + +Check catalog API: + +```text +http://:8000/api/catalog-items +``` + +## 11. Restart After Config Changes + +After changing `.env.local`: + +1. Stop the editor process. +2. Rebuild: + +```powershell +cd apps\editor +bun run build +``` + +3. Start again: + +```powershell +bun run start +``` + +Restart the reverse proxy if proxy configuration or database path configuration changed. diff --git a/apps/editor/app/api/ai/[...path]/route.ts b/apps/editor/app/api/ai/[...path]/route.ts new file mode 100644 index 000000000..a663864b0 --- /dev/null +++ b/apps/editor/app/api/ai/[...path]/route.ts @@ -0,0 +1,40 @@ +import { type NextRequest, NextResponse } from 'next/server' + +const AI_AGENT_URL = (process.env.AI_AGENT_URL ?? 'http://127.0.0.1:8788').replace(/\/+$/, '') + +async function proxy(request: NextRequest, context: { params: Promise<{ path: string[] }> }) { + const { path } = await context.params + const target = `${AI_AGENT_URL}/${path.map(encodeURIComponent).join('/')}` + try { + const response = await fetch(target, { + method: request.method, + headers: + request.method === 'GET' || request.method === 'DELETE' + ? undefined + : { 'Content-Type': request.headers.get('content-type') ?? 'application/json' }, + body: + request.method === 'GET' || request.method === 'DELETE' ? undefined : await request.text(), + cache: 'no-store', + }) + // Buffer the full upstream body before responding. Streaming + // `response.body` through for a multi-minute /chat generation risked the + // client receiving a truncated/empty body (then failing `response.json()` + // with "Unexpected end of JSON input") even though the agent finished and + // saved the session. Reading it fully here avoids that truncation. + const bodyText = await response.text() + return new NextResponse(bodyText, { + status: response.status, + headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' }, + }) + } catch (error) { + console.error('[ai-proxy] agent unavailable:', error) + return NextResponse.json( + { error: 'AI agent is unavailable. Start pascal-ai-mcp and try again.' }, + { status: 503 }, + ) + } +} + +export const GET = proxy +export const POST = proxy +export const DELETE = proxy diff --git a/apps/editor/app/api/catalog-items/dev-overlay/route.ts b/apps/editor/app/api/catalog-items/dev-overlay/route.ts new file mode 100644 index 000000000..4cbcf2e4c --- /dev/null +++ b/apps/editor/app/api/catalog-items/dev-overlay/route.ts @@ -0,0 +1,43 @@ +import type { AssetInput } from '@pascal-app/core' +import { NextResponse } from 'next/server' +import { readDevCatalogOverlay, writeDevCatalogOverlay } from '@/lib/catalog-dev-overlay' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +function canReadCatalogOverlay(): boolean { + return ( + process.env.NODE_ENV === 'development' || + process.env.PASCAL_ALLOW_CATALOG_SOURCE_WRITE === 'true' + ) +} + +function normalizeCatalogEntry(entry: AssetInput): AssetInput { + return { + ...entry, + offset: entry.offset ?? [0, 0, 0], + rotation: entry.rotation ?? [0, 0, 0], + scale: entry.scale ?? [1, 1, 1], + } +} + +export async function GET() { + if (!canReadCatalogOverlay()) { + return NextResponse.json({ error: 'Catalog overlay is disabled for this server.' }, { status: 403 }) + } + + let items = await readDevCatalogOverlay() + if (items.length === 0) { + try { + const { CATALOG_ITEMS } = await import('@pascal-app/editor/catalog') + items = CATALOG_ITEMS.filter((item) => item.tags?.includes('custom')).map(normalizeCatalogEntry) + if (items.length > 0) { + await writeDevCatalogOverlay(items) + } + } catch (error) { + console.warn('[catalog-dev-overlay] bootstrap failed:', error) + } + } + + return NextResponse.json({ items }) +} diff --git a/apps/editor/app/api/catalog-items/route.ts b/apps/editor/app/api/catalog-items/route.ts new file mode 100644 index 000000000..d84d6820b --- /dev/null +++ b/apps/editor/app/api/catalog-items/route.ts @@ -0,0 +1,255 @@ +import type { AssetInput } from '@pascal-app/core' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { upsertDevCatalogOverlay, removeDevCatalogOverlay } from '@/lib/catalog-dev-overlay' +import { + appendCatalogEntryToSource, + removeCatalogEntryFromSource, + removeCatalogItemPublicDir, + resolveCatalogAssets, + resolveCatalogAssetsForUpdate, + uniqueCatalogId, + updateCatalogEntryInSource, +} from '@/lib/catalog-items-fs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const metadataSchema = z.object({ + name: z.string().min(1), + category: z.string().min(1), + id: z.string().optional(), + tags: z.array(z.string()).optional(), + dimensions: z.tuple([z.number(), z.number(), z.number()]), + offset: z.tuple([z.number(), z.number(), z.number()]).optional(), + rotation: z.tuple([z.number(), z.number(), z.number()]).optional(), + scale: z.tuple([z.number(), z.number(), z.number()]).optional(), + attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), + surfaceHeight: z.number().positive().optional(), + srcUrl: z.string().optional(), + thumbnailUrl: z.string().optional(), + floorPlanUrl: z.string().optional(), +}) + +function canWriteCatalogSource(): boolean { + return ( + process.env.NODE_ENV === 'development' || + process.env.PASCAL_ALLOW_CATALOG_SOURCE_WRITE === 'true' + ) +} + +function devOnly(): NextResponse | null { + if (!canWriteCatalogSource()) { + return NextResponse.json( + { error: 'Catalog source writing is disabled for this server.' }, + { status: 403 }, + ) + } + return null +} + +export async function POST(request: Request) { + const blocked = devOnly() + if (blocked) return blocked + + try { + const form = await request.formData() + + const metadataRaw = form.get('metadata') + if (typeof metadataRaw !== 'string') { + return NextResponse.json({ error: 'metadata JSON is missing.' }, { status: 400 }) + } + + const parsed = metadataSchema.safeParse(JSON.parse(metadataRaw)) + if (!parsed.success) { + return NextResponse.json( + { error: 'metadata is invalid.', details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const model = form.get('model') + const modelFile = model instanceof File && model.size > 0 ? model : null + const thumb = form.get('thumbnail') + const thumbnailFile = thumb instanceof File && thumb.size > 0 ? thumb : null + const floorPlan = form.get('floorPlan') + const floorPlanFile = floorPlan instanceof File && floorPlan.size > 0 ? floorPlan : null + + const meta = parsed.data + const itemId = await uniqueCatalogId(meta.name, meta.id) + + if (modelFile) { + const lower = modelFile.name.toLowerCase() + if (!(lower.endsWith('.glb') || lower.endsWith('.gltf'))) { + return NextResponse.json({ error: 'Model must be a .glb or .gltf file.' }, { status: 400 }) + } + } + + const assets = await resolveCatalogAssets({ + itemId, + srcUrl: meta.srcUrl, + thumbnailUrl: meta.thumbnailUrl, + floorPlanUrl: meta.floorPlanUrl, + model: modelFile, + thumbnail: thumbnailFile, + floorPlan: floorPlanFile, + }) + + const entry: AssetInput = { + id: itemId, + category: meta.category, + name: meta.name, + tags: meta.tags?.length ? meta.tags : ['floor', 'custom'], + thumbnail: assets.thumbnail, + src: assets.src, + ...(assets.floorPlanUrl ? { floorPlanUrl: assets.floorPlanUrl } : {}), + dimensions: meta.dimensions, + offset: meta.offset ?? [0, 0, 0], + rotation: meta.rotation ?? [0, 0, 0], + scale: meta.scale ?? [1, 1, 1], + ...(meta.attachTo ? { attachTo: meta.attachTo } : {}), + ...(meta.surfaceHeight !== undefined ? { surface: { height: meta.surfaceHeight } } : {}), + } + + const result = await appendCatalogEntryToSource(entry) + await upsertDevCatalogOverlay(entry) + + const usedRemoteUrls = Boolean(meta.srcUrl?.trim()) + return NextResponse.json({ + ok: true, + entry: result.entry, + filePath: result.filePath, + message: usedRemoteUrls + ? 'Added catalog item with remote asset URLs. Refresh the sidebar to see it.' + : 'Added catalog item and copied assets into apps/editor/public/items/. Refresh the sidebar to see it.', + }) + } catch (error) { + console.error('[catalog-items] write failed:', error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Failed to write catalog item.', + }, + { status: 500 }, + ) + } +} + +const updateMetadataSchema = metadataSchema.extend({ + id: z.string().min(1), + existingSrc: z.string().optional(), + existingThumbnail: z.string().optional(), + existingFloorPlanUrl: z.string().optional(), +}) + +export async function PATCH(request: Request) { + const blocked = devOnly() + if (blocked) return blocked + + try { + const form = await request.formData() + const metadataRaw = form.get('metadata') + if (typeof metadataRaw !== 'string') { + return NextResponse.json({ error: 'metadata JSON is missing.' }, { status: 400 }) + } + + const parsed = updateMetadataSchema.safeParse(JSON.parse(metadataRaw)) + if (!parsed.success) { + return NextResponse.json( + { error: 'metadata is invalid.', details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const model = form.get('model') + const modelFile = model instanceof File && model.size > 0 ? model : null + const thumb = form.get('thumbnail') + const thumbnailFile = thumb instanceof File && thumb.size > 0 ? thumb : null + const floorPlan = form.get('floorPlan') + const floorPlanFile = floorPlan instanceof File && floorPlan.size > 0 ? floorPlan : null + + const meta = parsed.data + const itemId = meta.id + + if (modelFile) { + const lower = modelFile.name.toLowerCase() + if (!(lower.endsWith('.glb') || lower.endsWith('.gltf'))) { + return NextResponse.json({ error: 'Model must be a .glb or .gltf file.' }, { status: 400 }) + } + } + + const assets = await resolveCatalogAssetsForUpdate({ + itemId, + srcUrl: meta.srcUrl, + thumbnailUrl: meta.thumbnailUrl, + floorPlanUrl: meta.floorPlanUrl, + model: modelFile, + thumbnail: thumbnailFile, + floorPlan: floorPlanFile, + existingSrc: meta.existingSrc, + existingThumbnail: meta.existingThumbnail, + existingFloorPlanUrl: meta.existingFloorPlanUrl, + }) + + const entry: AssetInput = { + id: itemId, + category: meta.category, + name: meta.name, + tags: meta.tags?.length ? meta.tags : ['floor', 'custom'], + thumbnail: assets.thumbnail, + src: assets.src, + ...(assets.floorPlanUrl ? { floorPlanUrl: assets.floorPlanUrl } : {}), + dimensions: meta.dimensions, + offset: meta.offset ?? [0, 0, 0], + rotation: meta.rotation ?? [0, 0, 0], + scale: meta.scale ?? [1, 1, 1], + ...(meta.attachTo ? { attachTo: meta.attachTo } : {}), + ...(meta.surfaceHeight !== undefined ? { surface: { height: meta.surfaceHeight } } : {}), + } + + const result = await updateCatalogEntryInSource(itemId, entry) + await upsertDevCatalogOverlay(entry) + + return NextResponse.json({ + ok: true, + entry: result.entry, + filePath: result.filePath, + message: `Updated catalog item "${entry.name}" (id: ${itemId}).`, + }) + } catch (error) { + console.error('[catalog-items] update failed:', error) + const message = error instanceof Error ? error.message : 'Failed to update catalog item.' + const status = + message.includes('Cannot') || message.includes('not found') || message.includes('mismatch') + ? 400 + : 500 + return NextResponse.json({ error: message }, { status }) + } +} + +export async function DELETE(request: Request) { + const blocked = devOnly() + if (blocked) return blocked + + try { + const id = new URL(request.url).searchParams.get('id')?.trim() + if (!id) { + return NextResponse.json({ error: 'id query parameter is missing.' }, { status: 400 }) + } + + const result = await removeCatalogEntryFromSource(id) + await removeCatalogItemPublicDir(id) + await removeDevCatalogOverlay(id) + + return NextResponse.json({ + ok: true, + id: result.id, + filePath: result.filePath, + message: `Deleted catalog item "${id}" and refreshed the sidebar.`, + }) + } catch (error) { + console.error('[catalog-items] delete failed:', error) + const message = error instanceof Error ? error.message : 'Failed to delete catalog item.' + const status = message.includes('Cannot') || message.includes('not found') ? 400 : 500 + return NextResponse.json({ error: message }, { status }) + } +} diff --git a/apps/editor/app/api/dxf-import-scene/route.ts b/apps/editor/app/api/dxf-import-scene/route.ts new file mode 100644 index 000000000..866abca5c --- /dev/null +++ b/apps/editor/app/api/dxf-import-scene/route.ts @@ -0,0 +1,41 @@ +// Server-side only — Node.js APIs allowed. +import { type NextRequest, NextResponse } from 'next/server' +import type { MergeResult } from '@pascal-app/core/importers' +import type { CoordsJSON } from '@pascal-app/core/importers' +import { guardSceneApiRequest, sceneApiPreflight } from '@/lib/scene-api-security' +import { buildAndSaveScene } from '@/lib/dxf-scene-builder' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export function OPTIONS(request: NextRequest) { + return sceneApiPreflight(request) +} + +export async function POST(request: NextRequest): Promise { + const guard = guardSceneApiRequest(request) + if (guard) return guard + + let body: { + name?: string + mergeResult: MergeResult + coords: CoordsJSON + guideImageUrl?: string + } + try { + body = (await request.json()) as typeof body + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!body.mergeResult || !body.coords) { + return NextResponse.json({ error: 'mergeResult and coords are required' }, { status: 400 }) + } + + const result = await buildAndSaveScene(body.mergeResult, body.coords, { + name: body.name, + guideImageUrl: body.guideImageUrl, + }) + + return NextResponse.json(result) +} diff --git a/apps/editor/app/api/dxf-jobs/[jobId]/madori/route.ts b/apps/editor/app/api/dxf-jobs/[jobId]/madori/route.ts new file mode 100644 index 000000000..c9c15e0ca --- /dev/null +++ b/apps/editor/app/api/dxf-jobs/[jobId]/madori/route.ts @@ -0,0 +1,189 @@ +// Server-side only — Node.js APIs allowed. +// +// POST /api/dxf-jobs/[jobId]/madori +// Runs the 3dMadori pipeline for an existing job: +// 1. Read original.dxf from the job folder +// 2. Call the 3dMadori /analyze-dxf API +// 3. Save the returned XML to madori_.xml ← the audit-trail file +// 4. Parse XML → MergeResult (parseMadori) +// 5. Save merged_.json +// 6. Build + persist the Pascal scene +// 7. Update job.json (status, sceneId) +// +// POST /api/dxf-jobs/[jobId]/madori?rerun=1 +// Skips steps 1–3 (reuses madori_latest.xml) and re-runs steps 4–7. +// Use when you want to re-import without calling the external API again. + +import { + appendRun, + getJob, + readMadoriXml, + readOriginalDxf, + setJobSceneId, + updateJobStatus, + writeMadoriXml, + writeRunOutput, +} from '@pascal-app/core/job-store' +import { parseMadori } from '@pascal-app/core/importers' +import { type NextRequest, NextResponse } from 'next/server' +import { buildAndSaveScene } from '@/lib/dxf-scene-builder' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +// Read from env; falls back to localhost for local dev. +const MADORI_API_URL = process.env['MADORI_API_URL'] ?? 'http://localhost:8000' +const MADORI_API_KEY = process.env['MADORI_API_KEY'] ?? '' +const MADORI_ANALYZE_DXF_URL = resolveAnalyzeDxfUrl(MADORI_API_URL) + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ jobId: string }> }, +): Promise { + const { jobId } = await params + const rerun = request.nextUrl.searchParams.get('rerun') === '1' + + // Verify the job exists + let job: Awaited> + try { + job = await getJob(jobId) + } catch (err) { + return NextResponse.json({ error: `Job not found: ${jobId}` }, { status: 404 }) + } + + await updateJobStatus(jobId, 'processing') + + let xml: string + + if (rerun) { + // Re-run: read already-saved XML + try { + xml = await readMadoriXml(jobId) + } catch { + return NextResponse.json( + { error: 'madori_latest.xml not found — run without ?rerun=1 first' }, + { status: 400 }, + ) + } + } else { + // Fresh run: call 3dMadori /analyze-dxf with the original DXF file + let dxfBuffer: Buffer + try { + dxfBuffer = await readOriginalDxf(jobId) + } catch { + return NextResponse.json({ error: 'original.dxf not found in job folder' }, { status: 400 }) + } + + const formData = new FormData() + formData.append('file', new Blob([dxfBuffer], { type: 'application/octet-stream' }), 'original.dxf') + + let analyzeRes: Response + try { + analyzeRes = await fetch(MADORI_ANALYZE_DXF_URL, { + method: 'POST', + headers: MADORI_API_KEY ? { 'x-api-key': MADORI_API_KEY } : {}, + body: formData, + signal: AbortSignal.timeout(60_000), // 60 s timeout + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + await updateJobStatus(jobId, 'failed') + return NextResponse.json({ error: `analyze-dxf call failed: ${msg}` }, { status: 502 }) + } + + if (!analyzeRes.ok) { + const body = await analyzeRes.text().catch(() => '') + await updateJobStatus(jobId, 'failed') + return NextResponse.json( + { error: `analyze-dxf returned ${analyzeRes.status}`, detail: body }, + { status: 502 }, + ) + } + + let analyzeData: { xml?: string; [k: string]: unknown } + try { + analyzeData = (await analyzeRes.json()) as typeof analyzeData + } catch { + await updateJobStatus(jobId, 'failed') + return NextResponse.json({ error: 'analyze-dxf response is not valid JSON' }, { status: 502 }) + } + + xml = analyzeData.xml ?? '' + if (!xml) { + await updateJobStatus(jobId, 'failed') + return NextResponse.json({ error: 'analyze-dxf returned empty XML' }, { status: 502 }) + } + + // Step 3: persist XML (audit trail — allows re-run without re-calling API) + try { + await writeMadoriXml(jobId, xml) + } catch (err) { + // Non-fatal: log and continue — the import can still succeed + console.warn(`[madori] writeMadoriXml failed for job ${jobId}:`, err) + } + } + + // Step 4: parse XML → MergeResult + const { mergeResult, coords, warnings } = parseMadori(xml) + + if (mergeResult.walls.length === 0) { + await updateJobStatus(jobId, 'failed') + return NextResponse.json( + { error: 'parseMadori produced no walls — check the DXF layer (基本構造(躯体))', warnings }, + { status: 422 }, + ) + } + + // Step 5: persist merged JSON + const runAt = new Date().toISOString() + let mergedFile: string | null = null + try { + mergedFile = await writeRunOutput(jobId, 'merged', mergeResult) + } catch (err) { + console.warn(`[madori] writeRunOutput failed for job ${jobId}:`, err) + } + + // Step 6: build + save Pascal scene + let sceneResult: Awaited> + try { + sceneResult = await buildAndSaveScene(mergeResult, coords, { + name: job.jobId, // caller can rename the scene later + operation: 'madori_import', + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + await updateJobStatus(jobId, 'failed') + return NextResponse.json({ error: `Scene save failed: ${msg}` }, { status: 500 }) + } + + // Step 7: update job record + try { + await appendRun(jobId, { + runAt, + coordsFile: null, + semanticFile: null, + mergedFile, + channelBSkipped: true, + madoriXmlFile: rerun ? null : 'madori_latest.xml', + error: null, + }) + await setJobSceneId(jobId, sceneResult.sceneId) + await updateJobStatus(jobId, 'imported') + } catch (err) { + // Job bookkeeping failure is non-fatal — scene was already saved + console.warn(`[madori] job record update failed for ${jobId}:`, err) + } + + return NextResponse.json({ + sceneId: sceneResult.sceneId, + wallCount: sceneResult.wallCount, + openingCount: sceneResult.openingCount, + zoneCount: sceneResult.zoneCount, + warnings: sceneResult.warnings, + }) +} + +function resolveAnalyzeDxfUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, '') + return trimmed.endsWith('/analyze-dxf') ? trimmed : `${trimmed}/analyze-dxf` +} diff --git a/apps/editor/app/api/dxf-jobs/[jobId]/run/route.ts b/apps/editor/app/api/dxf-jobs/[jobId]/run/route.ts new file mode 100644 index 000000000..5479421aa --- /dev/null +++ b/apps/editor/app/api/dxf-jobs/[jobId]/run/route.ts @@ -0,0 +1,57 @@ +import { appendRun, updateJobStatus, writeRunOutput } from '@pascal-app/core/job-store' +import { type NextRequest, NextResponse } from 'next/server' + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ jobId: string }> }, +): Promise { + const { jobId } = await params + + let coordsJSON: unknown + let mergedData: unknown + let semanticFile: string | null + let channelBSkipped: boolean + + try { + const body = (await req.json()) as { + coordsJSON?: unknown + mergedData?: unknown + semanticFile?: string | null + channelBSkipped?: boolean + } + coordsJSON = body.coordsJSON + mergedData = body.mergedData + semanticFile = body.semanticFile ?? null + channelBSkipped = body.channelBSkipped ?? false + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!coordsJSON || !mergedData) { + return NextResponse.json({ error: 'coordsJSON and mergedData are required' }, { status: 400 }) + } + + try { + const runAt = new Date().toISOString() + const [coordsFile, mergedFile] = await Promise.all([ + writeRunOutput(jobId, 'coords', coordsJSON), + writeRunOutput(jobId, 'merged', mergedData), + ]) + + await appendRun(jobId, { + runAt, + coordsFile, + semanticFile, + mergedFile, + channelBSkipped, + error: null, + }) + + await updateJobStatus(jobId, 'merged') + + return NextResponse.json({ ok: true, coordsFile, mergedFile }) + } catch (err) { + console.error(`[dxf-jobs] run failed for job ${jobId}:`, err) + return NextResponse.json({ error: 'Failed to save run output' }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/dxf-jobs/[jobId]/status/route.ts b/apps/editor/app/api/dxf-jobs/[jobId]/status/route.ts new file mode 100644 index 000000000..6f3039ae9 --- /dev/null +++ b/apps/editor/app/api/dxf-jobs/[jobId]/status/route.ts @@ -0,0 +1,36 @@ +import { type JobStatus, updateJobStatus } from '@pascal-app/core/job-store' +import { type NextRequest, NextResponse } from 'next/server' + +const VALID_STATUSES = new Set([ + 'pending', 'validating', 'processing', 'merged', 'imported', 'failed', +]) + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ jobId: string }> }, +): Promise { + const { jobId } = await params + + let status: JobStatus + try { + const body = (await req.json()) as { status?: string } + if (!body.status || !VALID_STATUSES.has(body.status as JobStatus)) { + return NextResponse.json({ error: 'Invalid or missing status' }, { status: 400 }) + } + status = body.status as JobStatus + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + try { + await updateJobStatus(jobId, status) + return NextResponse.json({ ok: true }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('Job not found')) { + return NextResponse.json({ error: msg }, { status: 404 }) + } + console.error(`[dxf-jobs] status update failed for job ${jobId}:`, err) + return NextResponse.json({ error: 'Failed to update status' }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/dxf-jobs/route.ts b/apps/editor/app/api/dxf-jobs/route.ts new file mode 100644 index 000000000..2b48717ce --- /dev/null +++ b/apps/editor/app/api/dxf-jobs/route.ts @@ -0,0 +1,47 @@ +import { type JobPipeline, createJob, updateJobStatus } from '@pascal-app/core/job-store' +import { type NextRequest, NextResponse } from 'next/server' + +export async function POST(req: NextRequest): Promise { + let dxfText: string + let previewDataUrl: string + let params: { wallThicknessMin: number; wallThicknessMax: number } + let pipeline: JobPipeline + + try { + const body = (await req.json()) as { + dxfText?: string + previewDataUrl?: string + params?: { wallThicknessMin: number; wallThicknessMax: number } + pipeline?: JobPipeline + } + dxfText = body.dxfText ?? '' + previewDataUrl = body.previewDataUrl ?? '' + params = body.params ?? { wallThicknessMin: 0.08, wallThicknessMax: 0.4 } + pipeline = body.pipeline === 'madori' ? 'madori' : 'geo+ai' + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!dxfText) { + return NextResponse.json({ error: 'dxfText is required' }, { status: 400 }) + } + + const dxfBuffer = new TextEncoder().encode(dxfText) + + let previewBuffer: Uint8Array + if (previewDataUrl.startsWith('data:')) { + const base64 = previewDataUrl.slice(previewDataUrl.indexOf(',') + 1) + previewBuffer = Buffer.from(base64, 'base64') + } else { + previewBuffer = new Uint8Array(0) + } + + try { + const job = await createJob(dxfBuffer, previewBuffer, params, pipeline) + await updateJobStatus(job.jobId, 'processing') + return NextResponse.json({ jobId: job.jobId, pipeline: job.pipeline }) + } catch (err) { + console.error('[dxf-jobs] createJob failed:', err) + return NextResponse.json({ error: 'Failed to create job' }, { status: 500 }) + } +} diff --git a/apps/editor/app/api/pascal-function-static/[sceneId]/[...filePath]/route.ts b/apps/editor/app/api/pascal-function-static/[sceneId]/[...filePath]/route.ts new file mode 100644 index 000000000..613e73e2e --- /dev/null +++ b/apps/editor/app/api/pascal-function-static/[sceneId]/[...filePath]/route.ts @@ -0,0 +1,67 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import path from 'node:path' +import { Readable } from 'node:stream' +import { NextResponse } from 'next/server' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ sceneId: string; filePath: string[] }> } + +const ALLOWED_EXTENSIONS = new Map([ + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.png', 'image/png'], + ['.webp', 'image/webp'], + ['.gif', 'image/gif'], + ['.json', 'application/json; charset=utf-8'], + ['.mp4', 'video/mp4'], + ['.webm', 'video/webm'], +]) + +function isSafeSceneId(sceneId: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(sceneId) +} + +function isSafeFilePath(filePath: string[]): boolean { + return ( + filePath.length > 0 && + filePath.every((part) => part.length > 0 && part !== '.' && part !== '..' && path.basename(part) === part) + ) +} + +export async function GET(_request: Request, { params }: RouteParams) { + const { sceneId, filePath } = await params + const contentType = ALLOWED_EXTENSIONS.get(path.extname(filePath.at(-1) ?? '').toLowerCase()) + + if (!isSafeSceneId(sceneId) || !isSafeFilePath(filePath) || !contentType) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + + const root = path.resolve(process.cwd(), '..', '..', 'pascal-function-statuc') + const sceneRoot = path.join(root, sceneId) + const resolvedFilePath = path.resolve(sceneRoot, ...filePath) + + if (!resolvedFilePath.startsWith(`${sceneRoot}${path.sep}`)) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + + try { + const info = await stat(resolvedFilePath) + if (!info.isFile()) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + + const stream = Readable.toWeb(createReadStream(resolvedFilePath)) as ReadableStream + return new Response(stream, { + headers: { + 'Cache-Control': 'no-store', + 'Content-Length': String(info.size), + 'Content-Type': contentType, + }, + }) + } catch { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } +} diff --git a/apps/editor/app/api/pic-to-3d/defaults/route.ts b/apps/editor/app/api/pic-to-3d/defaults/route.ts new file mode 100644 index 000000000..da1a2e131 --- /dev/null +++ b/apps/editor/app/api/pic-to-3d/defaults/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' +import { + PIC_TO3D_DEFAULT_PARAMS, + PIC_TO3D_PARAM_GROUPS, + PIC_TO3D_PRESETS, + PIC2THREE_NODES, +} from '@/lib/pic-to-3d/workflow-params' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET() { + return NextResponse.json({ + defaults: PIC_TO3D_DEFAULT_PARAMS, + presets: PIC_TO3D_PRESETS.map(({ id, label, description, params }) => ({ + id, + label, + description, + params, + })), + paramGroups: PIC_TO3D_PARAM_GROUPS, + nodes: PIC2THREE_NODES, + }) +} diff --git a/apps/editor/app/api/pic-to-3d/download/route.ts b/apps/editor/app/api/pic-to-3d/download/route.ts new file mode 100644 index 000000000..6daaab49d --- /dev/null +++ b/apps/editor/app/api/pic-to-3d/download/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server' +import { downloadGlbFromComfy, type GlbOutputRef } from '@/lib/pic-to-3d/comfyui' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + const params = new URL(request.url).searchParams + const filename = params.get('filename')?.trim() + if (!filename) { + return NextResponse.json({ error: 'filename がありません。' }, { status: 400 }) + } + + const ref: GlbOutputRef = { + filename, + subfolder: params.get('subfolder')?.trim() ?? '', + type: params.get('type')?.trim() || 'output', + } + + try { + const buffer = await downloadGlbFromComfy(ref) + const downloadName = + params.get('downloadName')?.trim() || + filename.split('/').pop() || + 'pic2three_output.glb' + + return new NextResponse(buffer, { + headers: { + 'Content-Type': 'model/gltf-binary', + 'Content-Disposition': `attachment; filename="${downloadName.replace(/"/g, '')}"`, + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + console.error('[pic-to-3d] download failed:', error) + const message = error instanceof Error ? error.message : 'ダウンロードに失敗しました' + return NextResponse.json({ error: message }, { status: 502 }) + } +} diff --git a/apps/editor/app/api/pic-to-3d/generate/route.ts b/apps/editor/app/api/pic-to-3d/generate/route.ts new file mode 100644 index 000000000..537e17586 --- /dev/null +++ b/apps/editor/app/api/pic-to-3d/generate/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { + applyPicTo3DParams, + loadPic2ThreeWorkflow, + parsePicTo3DParams, + patchWorkflowImage, + queueWorkflow, + uploadImageToComfy, +} from '@/lib/pic-to-3d/comfyui' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const MAX_IMAGE_BYTES = 20 * 1024 * 1024 + +export async function POST(request: Request) { + try { + const form = await request.formData() + const file = form.get('image') + if (!(file instanceof File) || file.size === 0) { + return NextResponse.json({ error: '画像ファイルをアップロードしてください。' }, { status: 400 }) + } + if (file.size > MAX_IMAGE_BYTES) { + return NextResponse.json({ error: '画像は 20 MB 以下にしてください。' }, { status: 400 }) + } + + const mime = file.type || 'image/jpeg' + if (!mime.startsWith('image/')) { + return NextResponse.json({ error: '画像形式のみ対応しています。' }, { status: 400 }) + } + + const bytes = new Uint8Array(await file.arrayBuffer()) + const safeName = file.name.replace(/[^\w.\-]+/g, '_') || 'upload.jpg' + + const paramsRaw = form.get('params') + let workflowParams = parsePicTo3DParams(undefined) + if (typeof paramsRaw === 'string' && paramsRaw.trim()) { + try { + workflowParams = parsePicTo3DParams(JSON.parse(paramsRaw)) + } catch { + return NextResponse.json({ error: 'params が有効な JSON ではありません。' }, { status: 400 }) + } + } + + const imageName = await uploadImageToComfy(safeName, bytes, mime) + let workflow = await loadPic2ThreeWorkflow() + workflow = patchWorkflowImage(workflow, imageName) + workflow = applyPicTo3DParams(workflow, workflowParams) + const promptId = await queueWorkflow(workflow) + + return NextResponse.json({ + ok: true, + promptId, + imageName, + appliedParams: workflowParams, + message: 'ComfyUI(混元 3D 2.1)に送信しました。生成完了までお待ちください。', + }) + } catch (error) { + console.error('[pic-to-3d] generate failed:', error) + const message = + error instanceof Error ? error.message : '生成タスクの送信に失敗しました。ComfyUI が起動し、ネットワークに到達できるか確認してください。' + return NextResponse.json({ error: message }, { status: 502 }) + } +} diff --git a/apps/editor/app/api/pic-to-3d/status/route.ts b/apps/editor/app/api/pic-to-3d/status/route.ts new file mode 100644 index 000000000..e7bcfc4a8 --- /dev/null +++ b/apps/editor/app/api/pic-to-3d/status/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server' +import { fetchPromptHistory, parseHistoryStatus } from '@/lib/pic-to-3d/comfyui' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + const promptId = new URL(request.url).searchParams.get('promptId')?.trim() + if (!promptId) { + return NextResponse.json({ error: 'promptId がありません。' }, { status: 400 }) + } + + try { + const record = await fetchPromptHistory(promptId) + if (!record) { + return NextResponse.json({ ok: true, state: 'pending' as const }) + } + + const parsed = parseHistoryStatus(record) + if (parsed.state === 'error') { + return NextResponse.json({ + ok: false, + state: 'error' as const, + error: parsed.error ?? '生成に失敗しました', + }) + } + if (parsed.state === 'complete' && parsed.glb) { + return NextResponse.json({ + ok: true, + state: 'complete' as const, + glb: parsed.glb, + downloadName: parsed.glb.filename.split('/').pop() ?? 'model.glb', + }) + } + + return NextResponse.json({ ok: true, state: 'pending' as const }) + } catch (error) { + console.error('[pic-to-3d] status failed:', error) + const message = error instanceof Error ? error.message : 'ステータスの取得に失敗しました' + return NextResponse.json({ error: message }, { status: 502 }) + } +} diff --git a/apps/editor/app/api/vision-analyze/prompts/floor-plan-analyzer.md b/apps/editor/app/api/vision-analyze/prompts/floor-plan-analyzer.md new file mode 100644 index 000000000..629388522 --- /dev/null +++ b/apps/editor/app/api/vision-analyze/prompts/floor-plan-analyzer.md @@ -0,0 +1,228 @@ +# Floor Plan Analyzer — Runtime Prompt +# Location: apps/editor/app/api/vision-analyze/prompts/floor-plan-analyzer.md +# Usage: loaded by route.ts at request time, injected as system prompt to Vision model. +# DO NOT hardcode this content in route.ts — load from file to allow prompt iteration +# without code changes. +# Field names in the JSON output MUST match the SemanticJSON TypeScript type: +# wallTypes (not wallHints), openings.type ∈ {door|window|sliding_door|opening} + +--- + +You are an architectural floor plan analyser embedded in Pascal Editor, a 3D building design tool. A user has uploaded a DXF file and you are looking at a rendered preview image of that file. + +Your sole job is to extract structured information from the image and return it as valid JSON. You must not explain, describe, or add any prose. + +## Output contract + +Respond with ONLY a single JSON object. No markdown fences. No preamble. No trailing text. The response must be directly parseable by `JSON.parse()`. + +--- + +## Step 1 — Validity check + +First, decide: is this an architectural floor plan? + +**It IS a floor plan if it shows:** +- Rooms enclosed by walls (rectangles or polygons with thickness) +- Doors (arc + line symbol, or gap in wall) +- Windows (thin parallel lines on wall, or gap in exterior wall) +- Room labels in Chinese or English (客厅, bedroom, etc.) +- Possibly dimension annotations and a north arrow + +**It is NOT a floor plan if it shows:** +- Mechanical parts (gears, bolts, cross-sections with 45° hatching) +- Electrical circuits or schematics +- Site plans or topographic maps (no enclosed rooms) +- Structural engineering drawings (columns, beams only, no room enclosures) +- A single isolated object with no room enclosure + +**If NOT a floor plan, return immediately:** +``` +{"valid":false,"reason":""} +``` +Stop. Do not attempt to extract rooms or openings. + +--- + +## Step 2 — Extract structured data + +If it IS a floor plan, return this exact structure (all top-level fields required): + +``` +{ + "valid": true, + "confidence": 0.0, + "rooms": [], + "openings": [], + "wallTypes": [], + "warnings": [] +} +``` + +--- + +### `confidence` (number, 0.0–1.0) + +Overall confidence that the extracted data is correct. + +| Range | Meaning | +|---|---| +| 0.9–1.0 | Clear, well-labelled professional drawing | +| 0.7–0.9 | Readable but some ambiguity | +| 0.5–0.7 | Poor image quality or complex layout | +| < 0.5 | Return `valid: false` instead | + +--- + +### `rooms` (array) + +One entry per enclosed room or functional space you can identify. + +``` +{ + "name": "客厅", + "center": [0.52, 0.48], + "approxAreaM2": 25, + "confidence": 0.92 +} +``` + +**`name`**: Use standard Chinese room names from this list exactly: +客厅, 餐厅, 主卧, 次卧, 儿童房, 书房, 厨房, 卫生间, 主卫, 客卫, 阳台, 玄关, 走廊, 过道, 储藏室, 工人房, 车库, 楼梯间 + +- If a label is visible, use it (translate to Chinese if in English). +- If no label but room is identifiable by shape and context, infer the name and set `confidence` < 0.75. +- If the room cannot be identified, use `"未知房间"`. + +**`center`**: `[x, y]` relative image coordinates. Top-left = `[0, 0]`, bottom-right = `[1, 1]`. x increases left→right, y increases top→bottom. Place the point at the visual centre of the room interior (not including wall thickness). + +**`approxAreaM2`**: Estimate in square metres. Use visible dimension annotations if present; otherwise estimate from the room's proportion relative to the whole plan. Typical ranges: 卫生间 3–8 ㎡, 卧室 10–20 ㎡, 客厅 15–40 ㎡. Set to 0 if you cannot estimate. + +**`confidence`**: 0.0–1.0 for this specific room entry. Omit entries below 0.55. + +--- + +### `openings` (array) + +One entry per door, window, or opening you can identify. + +``` +{ + "type": "door", + "location": [0.35, 0.42], + "facing": "south", + "confidence": 0.85 +} +``` + +**`type`** (required): exactly one of: +- `"door"` — hinged door (arc + line symbol) +- `"sliding_door"` — sliding door (two parallel lines across opening) +- `"window"` — window (thin lines or gap on exterior wall) +- `"opening"` — open passage without a door + +**`location`** (required): `[x, y]` centre of the opening in relative image coordinates. + +**`facing`** (optional): cardinal direction the door opens toward or the window faces. Include only if determinable from the drawing or a north arrow. Values: `"north"` `"south"` `"east"` `"west"`. + +**`confidence`** (required): 0.0–1.0 for this specific opening. Set below 0.7 if the symbol is ambiguous or partially obscured. Omit entries below 0.55. + +--- + +### `wallTypes` (array) + +Observations about wall character that help the geometry engine. Include only entries where you have meaningful visual evidence (`confidence` ≥ 0.70). + +``` +{ + "location": [0.50, 0.10], + "type": "exterior", + "confidence": 0.88 +} +``` + +**`type`** (required): exactly one of: +- `"exterior"` — outer perimeter wall (visually thicker, on boundary of the plan) +- `"interior"` — internal partition wall (thinner, divides rooms) +- `"load_bearing"` — explicitly marked or visually indicated as structural (bold line, special symbol) + +**`location`** (required): any one point on the wall segment in relative image coordinates. + +--- + +### `warnings` (array of strings) + +List any issues that may affect import quality. Be specific with coordinates or directions where helpful. + +Examples: +- `"图纸旋转约 15°,坐标识别可能存在偏差"` +- `"西北角存在疑似弧形墙,几何解析需要特殊处理"` +- `"房间标注字体过小,部分房间名称无法确认"` +- `"图纸存在多个重叠图层,房间边界不清晰"` +- `"未发现北向标志,方位信息不可用"` +- `"图像分辨率较低,细节识别受限"` + +Use empty array `[]` if there are no warnings. + +--- + +## Precision rules + +- Coordinates: round to 2 decimal places (e.g. `0.34`, not `0.3421687`) +- Areas: round to nearest integer (e.g. `15`, not `15.3`) +- Confidence: round to 2 decimal places (e.g. `0.85`, not `0.8521`) + +--- + +## What to omit + +- Do NOT include furniture (sofas, beds, tables) — only structural elements and room spaces +- Do NOT include dimension lines or text annotations as room entries +- Do NOT include hatching patterns as walls +- Do NOT guess at elements you cannot clearly see — omit rather than fabricate +- Do NOT include any entry with `confidence` below 0.55 +- Do NOT include `wallTypes` entries unless you have visual evidence of wall character (≥ 0.70) + +--- + +## Complete valid example + +Input: a clear residential floor plan with 3 bedrooms, living room, kitchen, 2 bathrooms. + +``` +{ + "valid": true, + "confidence": 0.91, + "rooms": [ + {"name": "客厅", "center": [0.52, 0.55], "approxAreaM2": 28, "confidence": 0.95}, + {"name": "餐厅", "center": [0.52, 0.38], "approxAreaM2": 12, "confidence": 0.88}, + {"name": "厨房", "center": [0.78, 0.38], "approxAreaM2": 9, "confidence": 0.92}, + {"name": "主卧", "center": [0.20, 0.30], "approxAreaM2": 18, "confidence": 0.90}, + {"name": "次卧", "center": [0.20, 0.68], "approxAreaM2": 12, "confidence": 0.87}, + {"name": "儿童房","center": [0.80, 0.70], "approxAreaM2": 10, "confidence": 0.82}, + {"name": "主卫", "center": [0.35, 0.22], "approxAreaM2": 6, "confidence": 0.89}, + {"name": "客卫", "center": [0.65, 0.22], "approxAreaM2": 4, "confidence": 0.86}, + {"name": "玄关", "center": [0.50, 0.92], "approxAreaM2": 4, "confidence": 0.80} + ], + "openings": [ + {"type": "door", "location": [0.50, 0.83], "facing": "south", "confidence": 0.93}, + {"type": "door", "location": [0.28, 0.42], "confidence": 0.88}, + {"type": "door", "location": [0.28, 0.58], "confidence": 0.85}, + {"type": "door", "location": [0.68, 0.45], "confidence": 0.87}, + {"type": "door", "location": [0.44, 0.26], "confidence": 0.84}, + {"type": "door", "location": [0.56, 0.26], "confidence": 0.82}, + {"type": "window", "location": [0.10, 0.55], "facing": "west", "confidence": 0.90}, + {"type": "window", "location": [0.52, 0.10], "facing": "north", "confidence": 0.88}, + {"type": "window", "location": [0.90, 0.55], "facing": "east", "confidence": 0.85}, + {"type": "window", "location": [0.90, 0.70], "facing": "east", "confidence": 0.83} + ], + "wallTypes": [ + {"location": [0.10, 0.50], "type": "exterior", "confidence": 0.92}, + {"location": [0.50, 0.10], "type": "exterior", "confidence": 0.91}, + {"location": [0.90, 0.50], "type": "exterior", "confidence": 0.92}, + {"location": [0.50, 0.90], "type": "exterior", "confidence": 0.90}, + {"location": [0.35, 0.50], "type": "interior", "confidence": 0.85} + ], + "warnings": [] +} +``` diff --git a/apps/editor/app/api/vision-analyze/route.test.ts b/apps/editor/app/api/vision-analyze/route.test.ts new file mode 100644 index 000000000..1f7e96c3f --- /dev/null +++ b/apps/editor/app/api/vision-analyze/route.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeSemanticResponse } from './route' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function validBase() { + return { + valid: true, + confidence: 0.91, + rooms: [ + { name: '客厅', center: [0.52, 0.55], approxAreaM2: 28, confidence: 0.95 }, + ], + openings: [ + { type: 'door', location: [0.50, 0.83], facing: 'south', confidence: 0.93 }, + ], + wallTypes: [ + { location: [0.10, 0.50], type: 'exterior', confidence: 0.92 }, + ], + warnings: [], + } +} + +// ─── valid=false passthrough ────────────────────────────────────────────────── + +describe('normalizeSemanticResponse — valid=false', () => { + test('passes through reason when model rejects', () => { + const r = normalizeSemanticResponse({ valid: false, reason: '这是机械图纸' }) + expect(r.valid).toBe(false) + expect(r.reason).toBe('这是机械图纸') + expect(r.rooms).toHaveLength(0) + }) + + test('uses fallback reason when reason is missing', () => { + const r = normalizeSemanticResponse({ valid: false }) + expect(r.valid).toBe(false) + expect(r.reason).toBeTruthy() + }) +}) + +// ─── invalid inputs ─────────────────────────────────────────────────────────── + +describe('normalizeSemanticResponse — invalid inputs', () => { + test('null returns valid=false', () => { + expect(normalizeSemanticResponse(null).valid).toBe(false) + }) + + test('array returns valid=false', () => { + expect(normalizeSemanticResponse([]).valid).toBe(false) + }) + + test('string returns valid=false', () => { + expect(normalizeSemanticResponse('oops').valid).toBe(false) + }) + + test('number returns valid=false', () => { + expect(normalizeSemanticResponse(42).valid).toBe(false) + }) + + test('empty object with no confidence returns valid=false (confidence defaults to 0)', () => { + expect(normalizeSemanticResponse({}).valid).toBe(false) + }) + + test('object with confidence below threshold returns valid=false', () => { + expect(normalizeSemanticResponse({ valid: true, confidence: 0.4 }).valid).toBe(false) + }) +}) + +// ─── wallHints alias ────────────────────────────────────────────────────────── + +describe('normalizeSemanticResponse — wallHints alias', () => { + test('wallHints is promoted to wallTypes', () => { + const input = { + ...validBase(), + wallTypes: undefined, + wallHints: [{ location: [0.1, 0.5], type: 'exterior', confidence: 0.88 }], + } + const r = normalizeSemanticResponse(input) + expect(r.wallTypes).toHaveLength(1) + expect(r.wallTypes[0]!.type).toBe('exterior') + }) + + test('wallTypes takes precedence over wallHints when both present', () => { + const input = { + ...validBase(), + wallTypes: [{ location: [0.9, 0.5], type: 'interior', confidence: 0.85 }], + wallHints: [{ location: [0.1, 0.5], type: 'exterior', confidence: 0.88 }], + } + const r = normalizeSemanticResponse(input) + expect(r.wallTypes).toHaveLength(1) + expect(r.wallTypes[0]!.type).toBe('interior') // wallTypes wins + }) +}) + +// ─── confidence filtering ───────────────────────────────────────────────────── + +describe('normalizeSemanticResponse — confidence filtering', () => { + test('rooms with confidence < 0.55 are dropped', () => { + const input = { + ...validBase(), + rooms: [ + { name: '客厅', center: [0.5, 0.5], approxAreaM2: 20, confidence: 0.9 }, + { name: '储藏室', center: [0.1, 0.1], approxAreaM2: 3, confidence: 0.4 }, // dropped + ], + } + const r = normalizeSemanticResponse(input) + expect(r.rooms).toHaveLength(1) + expect(r.rooms[0]!.name).toBe('客厅') + }) + + test('openings with confidence < 0.55 are dropped', () => { + const input = { + ...validBase(), + openings: [ + { type: 'door', location: [0.5, 0.8], confidence: 0.9 }, + { type: 'window', location: [0.1, 0.5], confidence: 0.5 }, // dropped + ], + } + const r = normalizeSemanticResponse(input) + expect(r.openings).toHaveLength(1) + }) + + test('wallTypes with confidence < 0.70 are dropped (stricter threshold)', () => { + const input = { + ...validBase(), + wallTypes: [ + { location: [0.1, 0.5], type: 'exterior', confidence: 0.92 }, + { location: [0.5, 0.1], type: 'interior', confidence: 0.65 }, // dropped (< 0.70) + ], + } + const r = normalizeSemanticResponse(input) + expect(r.wallTypes).toHaveLength(1) + }) + + test('entry without confidence field is kept (treated as acceptable)', () => { + const input = { + ...validBase(), + rooms: [{ name: '客厅', center: [0.5, 0.5], approxAreaM2: 20 }], + } + const r = normalizeSemanticResponse(input) + expect(r.rooms).toHaveLength(1) + }) +}) + +// ─── type coercion ──────────────────────────────────────────────────────────── + +describe('normalizeSemanticResponse — type coercion', () => { + test('unknown opening type is coerced to "opening"', () => { + const input = { + ...validBase(), + openings: [{ type: 'french_window', location: [0.5, 0.5], confidence: 0.8 }], + } + const r = normalizeSemanticResponse(input) + expect(r.openings[0]!.type).toBe('opening') + }) + + test('unknown wall type is coerced to "interior"', () => { + const input = { + ...validBase(), + wallTypes: [{ location: [0.5, 0.5], type: 'structural', confidence: 0.85 }], + } + const r = normalizeSemanticResponse(input) + expect(r.wallTypes[0]!.type).toBe('interior') + }) + + test('unknown facing direction is omitted', () => { + const input = { + ...validBase(), + openings: [{ type: 'window', location: [0.5, 0.5], facing: 'northeast', confidence: 0.85 }], + } + const r = normalizeSemanticResponse(input) + expect(r.openings[0]!.facing).toBeUndefined() + }) + + test('coordinates are clamped to [0, 1]', () => { + const input = { + ...validBase(), + rooms: [{ name: '客厅', center: [1.5, -0.2], approxAreaM2: 20, confidence: 0.9 }], + } + const r = normalizeSemanticResponse(input) + expect(r.rooms[0]!.center[0]).toBe(1) + expect(r.rooms[0]!.center[1]).toBe(0) + }) + + test('malformed center falls back to [0.5, 0.5]', () => { + const input = { + ...validBase(), + rooms: [{ name: '客厅', center: 'bad', approxAreaM2: 20, confidence: 0.9 }], + } + const r = normalizeSemanticResponse(input) + expect(r.rooms[0]!.center).toEqual([0.5, 0.5]) + }) + + test('confidence is rounded to 2 decimal places', () => { + const r = normalizeSemanticResponse({ ...validBase(), confidence: 0.912345 }) + expect(r.confidence).toBe(0.91) + }) + + test('approxAreaM2 is rounded to integer', () => { + const input = { + ...validBase(), + rooms: [{ name: '客厅', center: [0.5, 0.5], approxAreaM2: 15.7, confidence: 0.9 }], + } + const r = normalizeSemanticResponse(input) + expect(r.rooms[0]!.approxAreaM2).toBe(16) + }) + + test('missing name defaults to "未知房间"', () => { + const input = { + ...validBase(), + rooms: [{ center: [0.5, 0.5], approxAreaM2: 10, confidence: 0.8 }], + } + const r = normalizeSemanticResponse(input) + expect(r.rooms[0]!.name).toBe('未知房间') + }) +}) + +// ─── valid happy path ───────────────────────────────────────────────────────── + +describe('normalizeSemanticResponse — happy path', () => { + test('passes through a well-formed response unchanged (modulo rounding)', () => { + const r = normalizeSemanticResponse(validBase()) + expect(r.valid).toBe(true) + expect(r.confidence).toBe(0.91) + expect(r.rooms).toHaveLength(1) + expect(r.rooms[0]!.name).toBe('客厅') + expect(r.openings).toHaveLength(1) + expect(r.openings[0]!.type).toBe('door') + expect(r.openings[0]!.facing).toBe('south') + expect(r.wallTypes).toHaveLength(1) + expect(r.wallTypes[0]!.type).toBe('exterior') + expect(r.warnings).toHaveLength(0) + }) + + test('warnings string array is preserved', () => { + const input = { + ...validBase(), + warnings: ['图纸旋转约 15°', '未发现北向标志'], + } + const r = normalizeSemanticResponse(input) + expect(r.warnings).toEqual(['图纸旋转约 15°', '未发现北向标志']) + }) + + test('non-string warnings are stripped', () => { + const input = { ...validBase(), warnings: ['ok', 42, null, 'also ok'] } + const r = normalizeSemanticResponse(input) + expect(r.warnings).toEqual(['ok', 'also ok']) + }) + + test('missing arrays default to empty', () => { + const r = normalizeSemanticResponse({ valid: true, confidence: 0.8 }) + expect(r.rooms).toEqual([]) + expect(r.openings).toEqual([]) + expect(r.wallTypes).toEqual([]) + expect(r.warnings).toEqual([]) + }) + + test('sliding_door type is preserved', () => { + const input = { + ...validBase(), + openings: [{ type: 'sliding_door', location: [0.5, 0.5], confidence: 0.8 }], + } + const r = normalizeSemanticResponse(input) + expect(r.openings[0]!.type).toBe('sliding_door') + }) + + test('load_bearing wallType is preserved', () => { + const input = { + ...validBase(), + wallTypes: [{ location: [0.3, 0.3], type: 'load_bearing', confidence: 0.88 }], + } + const r = normalizeSemanticResponse(input) + expect(r.wallTypes[0]!.type).toBe('load_bearing') + }) +}) diff --git a/apps/editor/app/api/vision-analyze/route.ts b/apps/editor/app/api/vision-analyze/route.ts new file mode 100644 index 000000000..336fdcdfb --- /dev/null +++ b/apps/editor/app/api/vision-analyze/route.ts @@ -0,0 +1,322 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import Anthropic from '@anthropic-ai/sdk' +import { type NextRequest, NextResponse } from 'next/server' +import type { SemanticJSON } from '@pascal-app/core/importers' +import { writeRunOutput } from '@pascal-app/core/job-store' + +// ─── Constants ──────────────────────────────────────────────────────────────── + +// Sonnet-class model with vision support (configurable via env for future upgrades). +const VISION_MODEL = + (process.env['VISION_MODEL'] as string | undefined) ?? 'claude-sonnet-4-5-20251001' + +// Maximum accepted base64 payload (≈ 5 MB decoded image). +// Rejects oversized requests before they reach the Anthropic API. +const MAX_BASE64_LEN = 7 * 1024 * 1024 // 7 MB base64 string ≈ 5 MB decoded + +// Server-side timeout — slightly longer than the client's 10 s so the client +// abort fires first and the route can return a clean fallback shape. +const SERVER_TIMEOUT_MS = 14_000 + +// ─── Prompt loading ─────────────────────────────────────────────────────────── + +let promptCache: string | null = null + +async function getSystemPrompt(): Promise { + if (promptCache) return promptCache + + // process.cwd() is the app dir when started from apps/editor, + // but the monorepo root when started via bun --filter from root. + const candidates = [ + join(process.cwd(), 'app/api/vision-analyze/prompts/floor-plan-analyzer.md'), + join(process.cwd(), 'apps/editor/app/api/vision-analyze/prompts/floor-plan-analyzer.md'), + ] + + let raw: string | null = null + for (const p of candidates) { + try { raw = await readFile(p, 'utf-8'); break } catch { /* try next */ } + } + if (!raw) throw new Error('floor-plan-analyzer.md not found in any candidate path') + + // Strip file-level comment lines (starting with #) and separator lines (---). + const lines = raw.split('\n') + const bodyStart = lines.findIndex( + l => l.trim() !== '' && !l.startsWith('#') && l.trim() !== '---', + ) + promptCache = lines.slice(bodyStart).join('\n').trim() + return promptCache +} + +// ─── Response normalisation ─────────────────────────────────────────────────── + +const VALID_OPENING_TYPES = new Set(['door', 'window', 'sliding_door', 'opening']) +const VALID_WALL_TYPES = new Set(['exterior', 'interior', 'load_bearing']) +const VALID_FACINGS = new Set(['north', 'south', 'east', 'west']) +const MIN_CONFIDENCE = 0.55 + +/** + * Coerce and validate the raw model response into a SemanticJSON object. + * + * - Accepts `wallHints` as an alias for `wallTypes` in case the model drifts. + * - Drops any entry whose `confidence` is below MIN_CONFIDENCE. + * - Returns a valid-false fallback if the overall structure is unusable. + */ +export function normalizeSemanticResponse(raw: unknown): SemanticJSON { + const fallback = (reason: string): SemanticJSON => ({ + valid: false, + reason, + confidence: 0, + rooms: [], + openings: [], + wallTypes: [], + warnings: [], + }) + + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return fallback('invalid_response_shape') + } + + const r = raw as Record + + // valid=false path — trust the model's assessment + if (r['valid'] === false) { + return { + valid: false, + reason: typeof r['reason'] === 'string' ? r['reason'] : 'model_rejected', + confidence: 0, + rooms: [], + openings: [], + wallTypes: [], + warnings: [], + } + } + + const confidence = typeof r['confidence'] === 'number' ? r['confidence'] : 0 + if (confidence < MIN_CONFIDENCE) return fallback('confidence_too_low') + + // Rooms + const rawRooms = Array.isArray(r['rooms']) ? r['rooms'] : [] + const rooms = rawRooms + .filter((room): room is Record => typeof room === 'object' && room !== null) + .filter(room => typeof room['confidence'] !== 'number' || room['confidence'] >= MIN_CONFIDENCE) + .map(room => ({ + name: typeof room['name'] === 'string' ? room['name'] : '未知房间', + center: normalizeRelCoord(room['center']), + approxAreaM2: typeof room['approxAreaM2'] === 'number' ? Math.round(room['approxAreaM2']) : 0, + confidence: typeof room['confidence'] === 'number' ? round2(room['confidence']) : 0, + })) + + // Openings — accept both 'location' and 'position' field names + const rawOpenings = Array.isArray(r['openings']) ? r['openings'] : [] + const openings = rawOpenings + .filter((o): o is Record => typeof o === 'object' && o !== null) + .filter(o => typeof o['confidence'] !== 'number' || o['confidence'] >= MIN_CONFIDENCE) + .map(o => { + const type = VALID_OPENING_TYPES.has(String(o['type'])) + ? (String(o['type']) as SemanticJSON['openings'][number]['type']) + : 'opening' + const location = normalizeRelCoord(o['location'] ?? o['position']) + const facing = VALID_FACINGS.has(String(o['facing'])) + ? (String(o['facing']) as 'north' | 'south' | 'east' | 'west') + : undefined + return { + type, + location, + ...(facing !== undefined ? { facing } : {}), + confidence: typeof o['confidence'] === 'number' ? round2(o['confidence']) : 0, + } + }) + + // wallTypes — accept 'wallHints' as alias (prompt drift guard) + const rawWallTypes = Array.isArray(r['wallTypes']) + ? r['wallTypes'] + : Array.isArray(r['wallHints']) + ? r['wallHints'] + : [] + + const wallTypes = rawWallTypes + .filter((w): w is Record => typeof w === 'object' && w !== null) + .filter(w => typeof w['confidence'] !== 'number' || w['confidence'] >= 0.7) + .map(w => ({ + location: normalizeRelCoord(w['location']), + type: VALID_WALL_TYPES.has(String(w['type'])) + ? (String(w['type']) as SemanticJSON['wallTypes'][number]['type']) + : 'interior', + confidence: typeof w['confidence'] === 'number' ? round2(w['confidence']) : 0, + })) + + // Warnings + const warnings = Array.isArray(r['warnings']) + ? r['warnings'].filter((w): w is string => typeof w === 'string') + : [] + + return { + valid: true, + confidence: round2(confidence), + rooms, + openings, + wallTypes, + warnings, + } +} + +function normalizeRelCoord(raw: unknown): [number, number] { + if (Array.isArray(raw) && raw.length >= 2) { + const x = typeof raw[0] === 'number' ? Math.max(0, Math.min(1, raw[0])) : 0.5 + const y = typeof raw[1] === 'number' ? Math.max(0, Math.min(1, raw[1])) : 0.5 + return [round2(x), round2(y)] + } + return [0.5, 0.5] +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} + +// ─── Route handler ──────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest): Promise { + // ── API key guard ───────────────────────────────────────────────────────── + const apiKey = process.env['ANTHROPIC_API_KEY'] + if (!apiKey) { + return NextResponse.json( + { error: 'ANTHROPIC_API_KEY not configured' }, + { status: 503 }, + ) + } + + // ── Parse + validate request body ──────────────────────────────────────── + let imageDataUrl: string + let jobId: string | undefined + try { + const body = (await req.json()) as { imageDataUrl?: string; jobId?: string } + imageDataUrl = body.imageDataUrl ?? '' + jobId = body.jobId + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!imageDataUrl.startsWith('data:image/')) { + return NextResponse.json( + { error: 'imageDataUrl must start with data:image/' }, + { status: 400 }, + ) + } + + const commaIdx = imageDataUrl.indexOf(',') + if (commaIdx === -1) { + return NextResponse.json({ error: 'Malformed data URL' }, { status: 400 }) + } + + const base64Data = imageDataUrl.slice(commaIdx + 1) + + if (base64Data.length > MAX_BASE64_LEN) { + return NextResponse.json( + { error: `Image too large (max ${MAX_BASE64_LEN / 1024 / 1024} MB base64)` }, + { status: 413 }, + ) + } + + // Determine media type — only png and jpeg are accepted by the Anthropic API + const rawMediaType = imageDataUrl.slice(5, commaIdx).replace(';base64', '') + const mediaType: 'image/png' | 'image/jpeg' = + rawMediaType === 'image/jpeg' ? 'image/jpeg' : 'image/png' + + // ── Load prompt (module-level cache) ────────────────────────────────────── + let systemPrompt: string + try { + systemPrompt = await getSystemPrompt() + } catch (err) { + console.error('[vision-analyze] failed to load system prompt:', err) + return channelBFallback('prompt_load_failed') + } + + // ── Call Anthropic Vision API ────────────────────────────────────────────── + const client = new Anthropic({ apiKey }) + const timeoutCtrl = new AbortController() + const timeoutId = setTimeout(() => timeoutCtrl.abort(), SERVER_TIMEOUT_MS) + + let rawText: string + try { + const message = await client.messages.create( + { + model: VISION_MODEL, + max_tokens: 2048, + // Prompt caching: the system prompt is large and identical across + // all requests — cache it to save input tokens (5-min TTL). + system: [ + { + type: 'text', + text: systemPrompt, + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { type: 'base64', media_type: mediaType, data: base64Data }, + }, + { + type: 'text', + text: '请分析这张建筑平面图并返回 JSON 对象,严格遵循系统提示中的格式要求。', + }, + ], + }, + ], + }, + { signal: timeoutCtrl.signal }, + ) + + rawText = message.content.find(b => b.type === 'text')?.text ?? '' + } catch (err) { + const isAbort = + err instanceof Error && (err.name === 'AbortError' || err.message.includes('aborted')) + const reason = isAbort ? 'server_timeout' : (err instanceof Error ? err.message : 'api_error') + console.error('[vision-analyze] Anthropic API error:', reason) + return channelBFallback(reason, isAbort ? 504 : 502) + } finally { + clearTimeout(timeoutId) + } + + // ── Parse + normalise model output ──────────────────────────────────────── + let parsed: unknown + try { + // Strip accidental markdown fences if the model ignores the instruction + const cleaned = rawText.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '') + parsed = JSON.parse(cleaned) + } catch { + console.warn('[vision-analyze] model returned non-JSON:', rawText.slice(0, 200)) + return channelBFallback('model_returned_non_json') + } + + const semantic = normalizeSemanticResponse(parsed) + + let semanticFile: string | undefined + if (jobId) { + try { + semanticFile = await writeRunOutput(jobId, 'semantic', semantic) + } catch (err) { + console.warn('[vision-analyze] failed to save semantic output for job', jobId, err) + } + } + + return NextResponse.json({ ...semantic, ...(semanticFile ? { semanticFile } : {}) }) +} + +/** Returns a valid "channel B unavailable" shape so the client continues with Channel A. */ +function channelBFallback(reason: string, status = 200): NextResponse { + const body: SemanticJSON = { + valid: false, + reason, + confidence: 0, + rooms: [], + openings: [], + wallTypes: [], + warnings: [], + } + return NextResponse.json(body, { status }) +} diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 390aa6e4a..53854ab04 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -6,28 +6,32 @@ @custom-variant dark (&:is(.dark *)); @theme { + /* Style follow-up: MeasureNavi light theme uses Inter as the UI font. */ --font-sans: - var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, + var(--font-inter), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: - var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, + var(--font-jetbrains-mono), var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-pixel: var(--font-geist-pixel-square), var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + /* Style follow-up: legacy font-barlow utilities now resolve to Inter. */ --font-barlow: - var(--font-barlow), var(--font-geist-sans), ui-sans-serif, system-ui, + var(--font-inter), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; } @theme inline { + /* Style follow-up: keep Tailwind font tokens aligned with MeasureNavi. */ --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-barlow), sans-serif; - --font-mono: var(--font-geist-mono), monospace; + --font-sans: var(--font-inter), sans-serif; + --font-mono: var(--font-jetbrains-mono), monospace; --font-pixel: var(--font-geist-pixel-square), var(--font-geist-mono), monospace; - --font-barlow: var(--font-barlow), sans-serif; + /* Style follow-up: keep existing font-barlow class names visually aligned with Inter. */ + --font-barlow: var(--font-inter), sans-serif; --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); diff --git a/apps/editor/app/import-dxf/page.tsx b/apps/editor/app/import-dxf/page.tsx new file mode 100644 index 000000000..461f281a1 --- /dev/null +++ b/apps/editor/app/import-dxf/page.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { ImportDxfTool } from '@/components/tools/ImportDxfTool' + +export default function ImportDxfPage() { + const router = useRouter() + + const close = () => { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'pascal:dxf-import-close' }, window.location.origin) + return + } + router.push('/scenes') + } + + return ( +
{ + if (event.target === event.currentTarget) close() + }} + > + { + const target = `/_pascal/scene/${buildingId}` + if (window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'pascal:dxf-import-complete', target }, window.location.origin) + return + } + router.push(target) + }} + /> +
+ ) +} diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index c0a6df2a0..e2008b278 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -1,9 +1,11 @@ import { Agentation } from 'agentation' import { GeistPixelSquare } from 'geist/font/pixel' -import { Barlow } from 'next/font/google' +import { Inter, JetBrains_Mono } from 'next/font/google' import localFont from 'next/font/local' import { ClientBootstrap } from './client-bootstrap' +import '@photo-sphere-viewer/core/index.css' import './globals.css' +import './measurenavi-editor-theme.css' const geistSans = localFont({ src: './fonts/GeistVF.woff', @@ -14,10 +16,19 @@ const geistMono = localFont({ variable: '--font-geist-mono', }) -const barlow = Barlow({ +// Style follow-up: MeasureNavi rules require Inter for UI text. +const inter = Inter({ subsets: ['latin'], weight: ['400', '500', '600', '700'], - variable: '--font-barlow', + variable: '--font-inter', + display: 'swap', +}) + +// Style follow-up: MeasureNavi rules use JetBrains Mono for numeric/code UI. +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + weight: ['400', '500'], + variable: '--font-jetbrains-mono', display: 'swap', }) @@ -28,10 +39,12 @@ export default function RootLayout({ }>) { return ( + {process.env.NODE_ENV === 'development' && ( + + + + + + + + + + + +
+
+ + +
+ MeasureNavi +
+ + + + + +
+ + + + + +
+ + +
+ +
+ Avatar +
+
+
+ +
+
+
+ + +
+ + +
+ + +
+
+

+ 欢迎回来,美咲 +

+

查看最新项目进度,推进您的空间计测与改造施工工作。

+
+ +
+ + +
+
+ + +
+ + +
+ + + + +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+
+ + +
+ +
+ + + + +
+ + + + +
+ + +
+
+

© 2025 - LYTech Inc.

+

+ Measure Navi - 空間計測・設計・リフォーム支援サービス +

+
+
+ + + + + + + + + + +
+ + + + diff --git a/pascal-reverse-front/login.html b/pascal-reverse-front/login.html new file mode 100644 index 000000000..1ab2c8d46 --- /dev/null +++ b/pascal-reverse-front/login.html @@ -0,0 +1,588 @@ + + + + + + + MeasureNavi - 登录 / Login / ログイン + + + + + + + + + + + + + + + +
+ + + + + + +
+ + +
+ +
+ MeasureNavi Logo +
+ + + + + +
+ + +
+ +
+

欢迎回来

+

请输入您的凭证以访问空间测绘工作台

+
+ + +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + 忘记密码? +
+
+ + + + + +
+
+
+ + +
+ +
+ + +
+
+ + 演示快捷账户 +
+
+ + +
+
+ + + +
+ + +

+ 还没有 MeasureNavi 开发者账号? + 立即免费注册 +

+ +
+ + +
+ +
+ © 2026 LYTech Inc. +
+
+ +
+ +
+ + +
+ + + + diff --git a/pascal-reverse-front/logo-web-icon.svg b/pascal-reverse-front/logo-web-icon.svg new file mode 100644 index 000000000..65c84cbb2 --- /dev/null +++ b/pascal-reverse-front/logo-web-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pascal-reverse-front/logo.svg b/pascal-reverse-front/logo.svg new file mode 100644 index 000000000..cb10f1791 --- /dev/null +++ b/pascal-reverse-front/logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/pascal-reverse-proxy/Program.cs b/pascal-reverse-proxy/Program.cs new file mode 100644 index 000000000..d648617b2 --- /dev/null +++ b/pascal-reverse-proxy/Program.cs @@ -0,0 +1,825 @@ +using System.Net; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("PascalDatabase")); +builder.Services.Configure(builder.Configuration.GetSection("ProxyDatabase")); +builder.Services.Configure(builder.Configuration.GetSection("ProxyAuth")); +builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +var app = builder.Build(); +var frontRoot = Path.GetFullPath(Path.Combine(app.Environment.ContentRootPath, "..", "pascal-reverse-front")); +var frontIndex = Path.Combine(frontRoot, "index.html"); +var frontLogo = Path.Combine(frontRoot, "logo.svg"); +var proxyDatabaseOptions = app.Services.GetRequiredService>().Value; +var proxyDatabasePath = ResolveProxyDatabasePath(app.Environment.ContentRootPath, proxyDatabaseOptions); +var coverRoot = Path.Combine(Path.GetDirectoryName(proxyDatabasePath) ?? app.Environment.ContentRootPath, "covers"); +var proxyDatabaseLock = new SemaphoreSlim(1, 1); +InitializeProxyDatabase(proxyDatabasePath); + +app.UseWebSockets(); + +app.MapGet("/proxy/health", (IOptions authOptions, IConfiguration configuration) => +{ + var destination = configuration["ReverseProxy:Clusters:pascal:Destinations:editor:Address"]; + return Results.Ok(new + { + status = "ok", + proxy = "pascal-reverse-proxy", + authEnabled = authOptions.Value.Enabled, + destination, + timestamp = DateTimeOffset.UtcNow, + }); +}); + +app.MapGet("/proxy/scenes", async (IOptions databaseOptions, CancellationToken cancellationToken) => +{ + var databasePath = ResolvePascalDatabasePath(databaseOptions.Value, Environment.GetEnvironmentVariables()); + if (!File.Exists(databasePath)) + { + return Results.NotFound(new + { + error = "pascal_database_not_found", + path = databasePath, + }); + } + + var scenes = new List(); + Dictionary projectOverrides; + Dictionary sceneCoverUrls; + + await proxyDatabaseLock.WaitAsync(cancellationToken); + try + { + projectOverrides = LoadProjectOverrides(proxyDatabasePath); + sceneCoverUrls = LoadSceneCoverUrls(proxyDatabasePath); + } + finally + { + proxyDatabaseLock.Release(); + } + + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = databasePath, + Mode = SqliteOpenMode.ReadOnly, + Cache = SqliteCacheMode.Shared, + }.ToString(); + + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, + name, + project_id, + owner_id, + thumbnail_url, + version, + created_at, + updated_at, + size_bytes, + node_count + FROM scenes + ORDER BY updated_at DESC, id ASC + LIMIT $limit + """; + command.Parameters.AddWithValue("$limit", Math.Clamp(databaseOptions.Value.ListLimit, 1, 500)); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var sceneId = reader.GetString(0); + projectOverrides.TryGetValue(sceneId, out var overrideData); + sceneCoverUrls.TryGetValue(sceneId, out var storedCoverUrl); + var coverUrl = overrideData?.CoverType == "gradient" ? overrideData.CoverValue : storedCoverUrl; + + scenes.Add(new SceneListItem( + sceneId, + overrideData?.Name ?? reader.GetString(1), + ReadNullableString(reader, 2), + ReadNullableString(reader, 3), + coverUrl ?? ReadNullableString(reader, 4), + reader.GetInt32(5), + reader.GetString(6), + reader.GetString(7), + reader.GetInt64(8), + reader.GetInt32(9), + overrideData?.Description, + overrideData?.CoverType, + overrideData?.CoverValue, + $"/_pascal/scene/{Uri.EscapeDataString(sceneId)}")); + } + + return Results.Ok(new + { + scenes, + databasePath, + }); +}); + +app.MapPost("/proxy/scenes/{id}/cover", async ( + string id, + HttpContext context, + IOptions proxyOptions, + CancellationToken cancellationToken) => +{ + if (!IsSafeSceneId(id)) + { + return Results.BadRequest(new { error = "invalid_scene_id" }); + } + + if (!context.Request.HasFormContentType) + { + return Results.BadRequest(new { error = "expected_multipart_form" }); + } + + var form = await context.Request.ReadFormAsync(cancellationToken); + var file = form.Files.GetFile("file"); + if (file is null || file.Length == 0) + { + return Results.BadRequest(new { error = "missing_file" }); + } + + if (file.Length > 5 * 1024 * 1024) + { + return Results.BadRequest(new { error = "file_too_large", maxBytes = 5 * 1024 * 1024 }); + } + + var extension = ContentTypeToExtension(file.ContentType); + if (extension is null) + { + return Results.BadRequest(new { error = "unsupported_image_type" }); + } + + var dbPath = ResolveProxyDatabasePath(app.Environment.ContentRootPath, proxyOptions.Value); + var localCoverRoot = Path.Combine(Path.GetDirectoryName(dbPath) ?? app.Environment.ContentRootPath, "covers"); + string coverUrl; + + await proxyDatabaseLock.WaitAsync(cancellationToken); + try + { + Directory.CreateDirectory(localCoverRoot); + DeleteExistingSceneCovers(localCoverRoot, id); + + var fileName = $"{id}{extension}"; + var path = Path.Combine(localCoverRoot, fileName); + await using (var stream = File.Create(path)) + { + await file.CopyToAsync(stream, cancellationToken); + } + + var relativePath = Path.Combine("covers", fileName).Replace('\\', '/'); + coverUrl = BuildSceneCoverUrl(fileName, File.GetLastWriteTimeUtc(path)); + await UpsertProjectCover(dbPath, id, coverUrl, relativePath, file.ContentType, file.Length, cancellationToken); + } + finally + { + proxyDatabaseLock.Release(); + } + + return Results.Ok(new + { + coverUrl, + }); +}); + +app.MapPut("/proxy/scenes/{id}/metadata", async ( + string id, + ProjectMetadataRequest request, + IOptions proxyOptions, + CancellationToken cancellationToken) => +{ + if (!IsSafeSceneId(id)) + { + return Results.BadRequest(new { error = "invalid_scene_id" }); + } + + var dbPath = ResolveProxyDatabasePath(app.Environment.ContentRootPath, proxyOptions.Value); + + await proxyDatabaseLock.WaitAsync(cancellationToken); + try + { + await UpsertProjectOverride( + dbPath, + id, + request.Name, + request.Description, + request.CoverType, + request.CoverValue, + cancellationToken); + } + finally + { + proxyDatabaseLock.Release(); + } + + return Results.Ok(new { status = "ok" }); +}); + +app.MapGet("/proxy/login", (HttpContext context, IOptions authOptions) => +{ + var options = authOptions.Value; + if (!options.Enabled) + { + return Results.Ok(new { status = "auth_disabled" }); + } + + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + return Results.Problem("ProxyAuth:ApiKey must be configured when auth is enabled.", statusCode: 500); + } + + var key = context.Request.Query["key"].ToString(); + if (!ConstantTimeEquals(key, options.ApiKey)) + { + return Results.Unauthorized(); + } + + context.Response.Cookies.Append( + options.CookieName, + options.ApiKey, + new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Lax, + Secure = context.Request.IsHttps, + Expires = DateTimeOffset.UtcNow.AddDays(7), + }); + + return Results.Redirect("/"); +}); + +app.MapPost("/proxy/logout", (HttpContext context, IOptions authOptions) => +{ + context.Response.Cookies.Delete(authOptions.Value.CookieName); + return Results.Ok(new { status = "logged_out" }); +}); + +app.Use(async (context, next) => +{ + var options = context.RequestServices.GetRequiredService>().Value; + if (!options.Enabled || !IsProtectedPath(context.Request.Path, options.ProtectedPathPrefixes)) + { + await next(); + return; + } + + if (string.IsNullOrWhiteSpace(options.ApiKey)) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new + { + error = "proxy_auth_misconfigured", + message = "ProxyAuth:ApiKey must be configured when auth is enabled.", + }); + return; + } + + var token = ReadToken(context, options); + if (ConstantTimeEquals(token, options.ApiKey)) + { + await next(); + return; + } + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { error = "unauthorized" }); +}); + +app.MapGet("/", () => Results.File(frontIndex, "text/html; charset=utf-8")); +app.MapGet("/scenes", () => Results.File(frontIndex, "text/html; charset=utf-8")); +app.MapGet("/logo.svg", () => Results.File(frontLogo, "image/svg+xml")); +app.MapGet("/proxy/covers/{fileName}", (string fileName, IOptions proxyOptions) => +{ + if (Path.GetFileName(fileName) != fileName) + { + return Results.BadRequest(); + } + + var dbPath = ResolveProxyDatabasePath(app.Environment.ContentRootPath, proxyOptions.Value); + var localCoverRoot = Path.Combine(Path.GetDirectoryName(dbPath) ?? app.Environment.ContentRootPath, "covers"); + var path = Path.Combine(localCoverRoot, fileName); + if (!File.Exists(path)) + { + return Results.NotFound(); + } + + return Results.File(path, GetCoverContentType(Path.GetExtension(path))); +}); + +app.MapGet("/api/pascal-function-static/{**relativePath}", (string relativePath) => +{ + var pathParts = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathParts.Length < 2 || !IsSafeSceneId(pathParts[0])) + { + return Results.NotFound(); + } + + if (pathParts.Any(part => part is "." or ".." || Path.GetFileName(part) != part)) + { + return Results.NotFound(); + } + + var staticRoot = Path.GetFullPath(Path.Combine(app.Environment.ContentRootPath, "..", "pascal-function-statuc")); + var filePath = Path.GetFullPath(Path.Combine(new[] { staticRoot }.Concat(pathParts).ToArray())); + if (!filePath.StartsWith(staticRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + !File.Exists(filePath)) + { + return Results.NotFound(); + } + + return Results.File(filePath, GetStaticContentType(Path.GetExtension(filePath)), enableRangeProcessing: true); +}); + +if (Directory.Exists(frontRoot)) +{ + app.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider(frontRoot), + RequestPath = "", + }); +} + +app.MapReverseProxy(); + +app.Run(); + +static string? ReadNullableString(SqliteDataReader reader, int ordinal) +{ + return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); +} + +static void SetSqliteBusyTimeout(SqliteConnection connection) +{ + using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA busy_timeout = 30000"; + command.ExecuteNonQuery(); +} + +static async Task SetSqliteBusyTimeoutAsync(SqliteConnection connection, CancellationToken cancellationToken) +{ + await using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA busy_timeout = 30000"; + await command.ExecuteNonQueryAsync(cancellationToken); +} + +static Dictionary LoadSceneCoverUrls(string databasePath) +{ + var covers = new Dictionary(StringComparer.Ordinal); + if (!File.Exists(databasePath)) return covers; + + using var connection = new SqliteConnection(CreateProxyDatabaseConnectionString(databasePath, SqliteOpenMode.ReadOnly)); + connection.Open(); + SetSqliteBusyTimeout(connection); + + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT scene_id, cover_url + FROM project_covers + """; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + covers[reader.GetString(0)] = reader.GetString(1); + } + + return covers; +} + +static string BuildSceneCoverUrl(string fileName, DateTime updatedAt) +{ + return $"/proxy/covers/{Uri.EscapeDataString(fileName)}?v={updatedAt.Ticks}"; +} + +static void DeleteExistingSceneCovers(string coverRoot, string sceneId) +{ + foreach (var extension in new[] { ".jpg", ".jpeg", ".png", ".webp", ".gif" }) + { + var path = Path.Combine(coverRoot, $"{sceneId}{extension}"); + if (File.Exists(path)) File.Delete(path); + } +} + +static string? ContentTypeToExtension(string contentType) +{ + return contentType.ToLowerInvariant() switch + { + "image/jpeg" => ".jpg", + "image/png" => ".png", + "image/webp" => ".webp", + "image/gif" => ".gif", + _ => null, + }; +} + +static string GetCoverContentType(string extension) +{ + return extension.ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + ".gif" => "image/gif", + _ => "application/octet-stream", + }; +} + +static string GetStaticContentType(string extension) +{ + return extension.ToLowerInvariant() switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".webp" => "image/webp", + ".gif" => "image/gif", + ".mp4" => "video/mp4", + ".webm" => "video/webm", + ".mov" => "video/quicktime", + ".json" => "application/json", + ".txt" => "text/plain; charset=utf-8", + ".vtt" => "text/vtt; charset=utf-8", + _ => "application/octet-stream", + }; +} + +static bool IsSafeSceneId(string id) +{ + if (id.Length is < 1 or > 128) return false; + return id.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_'); +} + +static string ResolveProxyDatabasePath(string contentRootPath, ProxyDatabaseOptions options) +{ + if (!string.IsNullOrWhiteSpace(options.Path)) + { + return Path.GetFullPath(options.Path); + } + + var proxyDbPath = Environment.GetEnvironmentVariable("PASCAL_PROXY_DB_PATH"); + if (!string.IsNullOrWhiteSpace(proxyDbPath)) + { + return Path.GetFullPath(proxyDbPath); + } + + proxyDbPath = Environment.GetEnvironmentVariable("PROXY_DB_PATH"); + if (!string.IsNullOrWhiteSpace(proxyDbPath)) + { + return Path.GetFullPath(proxyDbPath); + } + + var dotenv = ReadRepositoryDotEnv(); + if (dotenv.TryGetValue("PASCAL_PROXY_DB_PATH", out var dotenvProxyDbPath) && + !string.IsNullOrWhiteSpace(dotenvProxyDbPath)) + { + return Path.GetFullPath(dotenvProxyDbPath); + } + + if (dotenv.TryGetValue("PROXY_DB_PATH", out var dotenvDbPath) && + !string.IsNullOrWhiteSpace(dotenvDbPath)) + { + return Path.GetFullPath(dotenvDbPath); + } + + return Path.Combine(contentRootPath, "data", "proxy.db"); +} + +static void InitializeProxyDatabase(string databasePath) +{ + Directory.CreateDirectory(Path.GetDirectoryName(databasePath) ?? "."); + using var connection = new SqliteConnection(CreateProxyDatabaseConnectionString(databasePath, SqliteOpenMode.ReadWriteCreate)); + connection.Open(); + SetSqliteBusyTimeout(connection); + + using var command = connection.CreateCommand(); + command.CommandText = """ + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS project_covers ( + scene_id TEXT PRIMARY KEY, + cover_url TEXT NOT NULL, + file_path TEXT NOT NULL, + content_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS project_overrides ( + scene_id TEXT PRIMARY KEY, + name TEXT, + description TEXT, + cover_type TEXT, + cover_value TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """; + command.ExecuteNonQuery(); +} + +static Dictionary LoadProjectOverrides(string databasePath) +{ + var overrides = new Dictionary(StringComparer.Ordinal); + if (!File.Exists(databasePath)) return overrides; + + using var connection = new SqliteConnection(CreateProxyDatabaseConnectionString(databasePath, SqliteOpenMode.ReadOnly)); + connection.Open(); + SetSqliteBusyTimeout(connection); + + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT scene_id, name, description, cover_type, cover_value + FROM project_overrides + """; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + overrides[reader.GetString(0)] = new ProjectOverride( + ReadNullableString(reader, 1), + ReadNullableString(reader, 2), + ReadNullableString(reader, 3), + ReadNullableString(reader, 4)); + } + + return overrides; +} + +static async Task UpsertProjectCover( + string databasePath, + string sceneId, + string coverUrl, + string filePath, + string contentType, + long sizeBytes, + CancellationToken cancellationToken) +{ + await using var connection = new SqliteConnection(CreateProxyDatabaseConnectionString(databasePath, SqliteOpenMode.ReadWriteCreate)); + await connection.OpenAsync(cancellationToken); + await SetSqliteBusyTimeoutAsync(connection, cancellationToken); + + var now = DateTimeOffset.UtcNow.ToString("O"); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO project_covers ( + scene_id, cover_url, file_path, content_type, size_bytes, created_at, updated_at + ) VALUES ( + $sceneId, $coverUrl, $filePath, $contentType, $sizeBytes, $now, $now + ) + ON CONFLICT(scene_id) DO UPDATE SET + cover_url = excluded.cover_url, + file_path = excluded.file_path, + content_type = excluded.content_type, + size_bytes = excluded.size_bytes, + updated_at = excluded.updated_at + """; + command.Parameters.AddWithValue("$sceneId", sceneId); + command.Parameters.AddWithValue("$coverUrl", coverUrl); + command.Parameters.AddWithValue("$filePath", filePath); + command.Parameters.AddWithValue("$contentType", contentType); + command.Parameters.AddWithValue("$sizeBytes", sizeBytes); + command.Parameters.AddWithValue("$now", now); + await command.ExecuteNonQueryAsync(cancellationToken); +} + +static async Task UpsertProjectOverride( + string databasePath, + string sceneId, + string? name, + string? description, + string? coverType, + string? coverValue, + CancellationToken cancellationToken) +{ + await using var connection = new SqliteConnection(CreateProxyDatabaseConnectionString(databasePath, SqliteOpenMode.ReadWriteCreate)); + await connection.OpenAsync(cancellationToken); + await SetSqliteBusyTimeoutAsync(connection, cancellationToken); + + var now = DateTimeOffset.UtcNow.ToString("O"); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO project_overrides ( + scene_id, name, description, cover_type, cover_value, created_at, updated_at + ) VALUES ( + $sceneId, $name, $description, $coverType, $coverValue, $now, $now + ) + ON CONFLICT(scene_id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + cover_type = excluded.cover_type, + cover_value = excluded.cover_value, + updated_at = excluded.updated_at + """; + command.Parameters.AddWithValue("$sceneId", sceneId); + command.Parameters.AddWithValue("$name", string.IsNullOrWhiteSpace(name) ? DBNull.Value : name); + command.Parameters.AddWithValue("$description", string.IsNullOrWhiteSpace(description) ? DBNull.Value : description); + command.Parameters.AddWithValue("$coverType", string.IsNullOrWhiteSpace(coverType) ? DBNull.Value : coverType); + command.Parameters.AddWithValue("$coverValue", string.IsNullOrWhiteSpace(coverValue) ? DBNull.Value : coverValue); + command.Parameters.AddWithValue("$now", now); + await command.ExecuteNonQueryAsync(cancellationToken); +} + +static string CreateProxyDatabaseConnectionString(string databasePath, SqliteOpenMode mode) +{ + return new SqliteConnectionStringBuilder + { + DataSource = databasePath, + Mode = mode, + Pooling = false, + }.ToString(); +} + +static string ResolvePascalDatabasePath( + PascalDatabaseOptions options, + System.Collections.IDictionary environment) +{ + if (!string.IsNullOrWhiteSpace(options.Path)) + { + return Path.GetFullPath(options.Path); + } + + var pascalDbPath = environment["PASCAL_DB_PATH"]?.ToString(); + if (!string.IsNullOrWhiteSpace(pascalDbPath)) + { + return Path.GetFullPath(pascalDbPath); + } + + var pascalDataDir = environment["PASCAL_DATA_DIR"]?.ToString(); + if (!string.IsNullOrWhiteSpace(pascalDataDir)) + { + return Path.GetFullPath(Path.Combine(pascalDataDir, "pascal.db")); + } + + var dotenv = ReadRepositoryDotEnv(); + if (dotenv.TryGetValue("PASCAL_DB_PATH", out var dotenvDbPath) && + !string.IsNullOrWhiteSpace(dotenvDbPath)) + { + return Path.GetFullPath(dotenvDbPath); + } + + if (dotenv.TryGetValue("PASCAL_DATA_DIR", out var dotenvDataDir) && + !string.IsNullOrWhiteSpace(dotenvDataDir)) + { + return Path.GetFullPath(Path.Combine(dotenvDataDir, "pascal.db")); + } + + if (OperatingSystem.IsWindows()) + { + var appData = environment["APPDATA"]?.ToString(); + if (!string.IsNullOrWhiteSpace(appData)) + { + return Path.Combine(appData, "Pascal", "data", "pascal.db"); + } + } + + var xdgDataHome = environment["XDG_DATA_HOME"]?.ToString(); + if (!string.IsNullOrWhiteSpace(xdgDataHome)) + { + return Path.Combine(xdgDataHome, "pascal", "data", "pascal.db"); + } + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".pascal", + "data", + "pascal.db"); +} + +static Dictionary ReadRepositoryDotEnv() +{ + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, ".env.local"), + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".env.local"), + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".env.local"), + Path.Combine(Directory.GetCurrentDirectory(), ".env.local"), + Path.Combine(Directory.GetCurrentDirectory(), "..", ".env.local"), + }; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var path = candidates.Select(Path.GetFullPath).FirstOrDefault(File.Exists); + if (path is null) return result; + + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) continue; + + var separator = line.IndexOf('='); + if (separator <= 0) continue; + + var key = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim().Trim('"').Trim('\''); + if (key.Length > 0) result[key] = value; + } + + return result; +} + +static string? ReadToken(HttpContext context, ProxyAuthOptions options) +{ + if (context.Request.Cookies.TryGetValue(options.CookieName, out var cookieToken)) + { + return cookieToken; + } + + if (context.Request.Headers.TryGetValue(options.HeaderName, out var headerToken)) + { + return headerToken.ToString(); + } + + var authorization = context.Request.Headers.Authorization.ToString(); + const string bearerPrefix = "Bearer "; + if (authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return authorization[bearerPrefix.Length..].Trim(); + } + + return null; +} + +static bool IsProtectedPath(PathString path, string[] prefixes) +{ + if (prefixes.Length == 0) return false; + + foreach (var prefix in prefixes) + { + if (string.IsNullOrWhiteSpace(prefix)) continue; + if (path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; +} + +static bool ConstantTimeEquals(string? left, string? right) +{ + if (left is null || right is null) return false; + + var leftBytes = System.Text.Encoding.UTF8.GetBytes(left); + var rightBytes = System.Text.Encoding.UTF8.GetBytes(right); + return leftBytes.Length == rightBytes.Length && + System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); +} + +sealed class ProxyAuthOptions +{ + public bool Enabled { get; init; } + public string ApiKey { get; init; } = ""; + public string HeaderName { get; init; } = "X-Pascal-Proxy-Key"; + public string CookieName { get; init; } = "PascalProxyAuth"; + public string[] ProtectedPathPrefixes { get; init; } = + [ + "/api/scenes", + "/_pascal/scene", + "/scenes", + "/proxy/scenes", + ]; +} + +sealed class PascalDatabaseOptions +{ + public string Path { get; init; } = ""; + public int ListLimit { get; init; } = 100; +} + +sealed class ProxyDatabaseOptions +{ + public string Path { get; init; } = ""; +} + +sealed record SceneListItem( + string Id, + string Name, + string? ProjectId, + string? OwnerId, + string? ThumbnailUrl, + int Version, + string CreatedAt, + string UpdatedAt, + long SizeBytes, + int NodeCount, + string? Description, + string? CoverType, + string? CoverValue, + string EditorUrl); + +sealed record ProjectOverride( + string? Name, + string? Description, + string? CoverType, + string? CoverValue); + +sealed record ProjectMetadataRequest( + string? Name, + string? Description, + string? CoverType, + string? CoverValue); diff --git a/pascal-reverse-proxy/Properties/launchSettings.json b/pascal-reverse-proxy/Properties/launchSettings.json new file mode 100644 index 000000000..768e11388 --- /dev/null +++ b/pascal-reverse-proxy/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "hotReloadEnabled": false, + "applicationUrl": "http://0.0.0.0:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production", + "ASPNETCORE_HOSTINGSTARTUPEXCLUDEASSEMBLIES": "Microsoft.AspNetCore.Watch.BrowserRefresh", + "DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH": "1" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "hotReloadEnabled": false, + "applicationUrl": "https://localhost:8001;http://0.0.0.0:8000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production", + "ASPNETCORE_HOSTINGSTARTUPEXCLUDEASSEMBLIES": "Microsoft.AspNetCore.Watch.BrowserRefresh", + "DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH": "1" + } + } + } +} diff --git a/pascal-reverse-proxy/README.md b/pascal-reverse-proxy/README.md new file mode 100644 index 000000000..e38d8ebb2 --- /dev/null +++ b/pascal-reverse-proxy/README.md @@ -0,0 +1,143 @@ +# Pascal Reverse Proxy + +Small .NET reverse proxy for the Pascal editor. It is intended to be the single browser-facing entry point while the Pascal Next.js editor stays bound to localhost. + +## Topology + +```text +Browser + -> http://localhost:8000 + -> this .NET proxy + -> pascal-reverse-front/index.html for the home page + -> http://127.0.0.1:3002 for Pascal editor / API / SSE +``` + +The proxy serves the custom front page and keeps Pascal as the internal editor: + +- `/` -> `../pascal-reverse-front/index.html` +- `/scenes` -> `../pascal-reverse-front/index.html` +- `/proxy/scenes` -> read-only scene list from Pascal's SQLite database +- `/_pascal/**` -> Pascal editor site with the `/_pascal` prefix removed +- `/_next/**` +- `/api/scenes/**` +- `/api/scenes/{id}/events` + +YARP handles streaming responses, so the Pascal scene-events SSE endpoint can continue to drive live editor updates. + +## Run + +Start the Pascal editor first: + +```powershell +npm.cmd run dev --workspace=editor +``` + +Then start the proxy: + +```powershell +dotnet run --project pascal-reverse-proxy +``` + +Open: + +```text +http://localhost:8000/ +``` + +The custom home page loads project cards from `/proxy/scenes`. Clicking a card opens the original Pascal editor at `/_pascal/scene/{id}`. + +The Pascal editor target defaults to: + +```text +http://127.0.0.1:3002/ +``` + +Change it in `appsettings.json` at: + +```json +"ReverseProxy": { + "Clusters": { + "pascal": { + "Destinations": { + "editor": { + "Address": "http://127.0.0.1:3002/" + } + } + } + } +} +``` + +## SQLite Scene List + +`/proxy/scenes` reads the existing Pascal SQLite database directly and only selects scene metadata, not `graph_json`. + +Path resolution matches Pascal's storage defaults: + +1. `PascalDatabase:Path` from `appsettings.json` +2. `PASCAL_DB_PATH` +3. `PASCAL_DATA_DIR/pascal.db` +4. `%APPDATA%/Pascal/data/pascal.db` on Windows +5. `$XDG_DATA_HOME/pascal/data/pascal.db` +6. `$HOME/.pascal/data/pascal.db` + +To force a database path: + +```json +"PascalDatabase": { + "Path": "C:\\Users\\you\\AppData\\Roaming\\Pascal\\data\\pascal.db", + "ListLimit": 100 +} +``` + +## Auth Guard + +Auth is disabled by default: + +```json +"ProxyAuth": { + "Enabled": false +} +``` + +When enabled, these paths are protected by default: + +- `/api/scenes` +- `/_pascal/scene` +- `/scenes` +- `/proxy/scenes` + +Accepted credentials: + +- Cookie: `PascalProxyAuth` +- Header: `X-Pascal-Proxy-Key` +- Header: `Authorization: Bearer ` + +For local testing: + +```json +"ProxyAuth": { + "Enabled": true, + "ApiKey": "your-local-secret" +} +``` + +Then visit: + +```text +http://localhost:8000/proxy/login?key=your-local-secret +``` + +Logout: + +```powershell +Invoke-RestMethod -Method Post http://localhost:8000/proxy/logout +``` + +Replace this simple API-key guard with your real auth/session logic before exposing it beyond local development. + +## Health + +```text +http://localhost:8000/proxy/health +``` diff --git a/pascal-reverse-proxy/appsettings.Development.json b/pascal-reverse-proxy/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/pascal-reverse-proxy/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/pascal-reverse-proxy/appsettings.json b/pascal-reverse-proxy/appsettings.json new file mode 100644 index 000000000..bb4f0c24a --- /dev/null +++ b/pascal-reverse-proxy/appsettings.json @@ -0,0 +1,158 @@ +{ + "Urls": "http://0.0.0.0:8000", + "PascalDatabase": { + "Path": "", + "ListLimit": 100 + }, + "ProxyDatabase": { + "Path": "" + }, + "ProxyAuth": { + "Enabled": false, + "ApiKey": "change-me", + "HeaderName": "X-Pascal-Proxy-Key", + "CookieName": "PascalProxyAuth", + "ProtectedPathPrefixes": [ + "/api", + "/_pascal/scene", + "/scenes", + "/proxy/scenes" + ] + }, + "ReverseProxy": { + "Routes": { + "pascal-api": { + "ClusterId": "pascal", + "Match": { + "Path": "/api/{**catch-all}" + } + }, + "pascal-next-assets": { + "ClusterId": "pascal", + "Match": { + "Path": "/_next/{**catch-all}" + } + }, + "pascal-next-dev-stack-frames": { + "ClusterId": "pascal", + "Match": { + "Path": "/__nextjs_original-stack-frames" + } + }, + "pascal-next-devtools-config": { + "ClusterId": "pascal", + "Match": { + "Path": "/__nextjs_devtools_config" + } + }, + "pascal-next-dev-fonts": { + "ClusterId": "pascal", + "Match": { + "Path": "/__nextjs_font/{**catch-all}" + } + }, + "pascal-favicon": { + "ClusterId": "pascal", + "Match": { + "Path": "/favicon.ico" + } + }, + "pascal-pic-to-3d-root": { + "ClusterId": "pascal", + "Match": { + "Path": "/pic-to-3d" + } + }, + "pascal-pic-to-3d-routes": { + "ClusterId": "pascal", + "Match": { + "Path": "/pic-to-3d/{**catch-all}" + } + }, + "pascal-import-dxf-root": { + "ClusterId": "pascal", + "Match": { + "Path": "/import-dxf" + } + }, + "pascal-import-dxf-routes": { + "ClusterId": "pascal", + "Match": { + "Path": "/import-dxf/{**catch-all}" + } + }, + "pascal-prefixed-site": { + "ClusterId": "pascal", + "Match": { + "Path": "/_pascal/{**catch-all}" + }, + "Transforms": [ + { + "PathRemovePrefix": "/_pascal" + } + ] + }, + "pascal-prefixed-root": { + "ClusterId": "pascal", + "Match": { + "Path": "/_pascal" + }, + "Transforms": [ + { + "PathSet": "/" + } + ] + }, + "pascal-fonts": { + "ClusterId": "pascal", + "Match": { + "Path": "/fonts/{**catch-all}" + } + }, + "pascal-icons": { + "ClusterId": "pascal", + "Match": { + "Path": "/icons/{**catch-all}" + } + }, + "pascal-materials": { + "ClusterId": "pascal", + "Match": { + "Path": "/material/{**catch-all}" + } + }, + "pascal-items": { + "ClusterId": "pascal", + "Match": { + "Path": "/items/{**catch-all}" + } + }, + "pascal-audios": { + "ClusterId": "pascal", + "Match": { + "Path": "/audios/{**catch-all}" + } + } + }, + "Clusters": { + "pascal": { + "HttpRequest": { + "Version": "1.1", + "VersionPolicy": "RequestVersionOrLower" + }, + "Destinations": { + "editor": { + "Address": "http://127.0.0.1:3002/" + } + } + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/pascal-reverse-proxy/pascal-reverse-proxy.csproj b/pascal-reverse-proxy/pascal-reverse-proxy.csproj new file mode 100644 index 000000000..b26360c17 --- /dev/null +++ b/pascal-reverse-proxy/pascal-reverse-proxy.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + pascal_reverse_proxy + + + + + + + + diff --git a/pascal-style-rule/MeasureNavi.html b/pascal-style-rule/MeasureNavi.html new file mode 100644 index 000000000..5fe519359 --- /dev/null +++ b/pascal-style-rule/MeasureNavi.html @@ -0,0 +1,794 @@ + + + + + + + MeasureNavi 编辑器 · 浅色系设计规范 + + + + + + + + +
+
+
Design Specification · Light Theme · v1.0
+

MeasureNavi Editor
浅色系设计规范

+

从编辑器现有浅色主题中提取,并按同一视觉语言补齐弹窗、下拉菜单、上下文菜单、表单控件、加载与反馈组件。文档同时面向设计师、开发者和 AI。

+
+ 来源:MeasureNavi_编辑器界面.html + 主题:Light / 浅色 + 基准字体:Inter + 更新:2026/06/22 +
+
+ + + +
+
Section 01
+

设计原则

+

浅色编辑器不是普通后台页面:内容密度高、画布优先、工具常驻,因此视觉应克制、层级清晰、反馈快速。

+
+
画布优先 Canvas First
画布使用最浅的冷灰绿色,工具面板通过边框、轻阴影和表面色区分,不使用大面积高饱和色。
+
Teal 只表达意图
品牌青绿色用于选中、焦点、主操作和进度;普通信息与未选中图标保持中性灰。
+
紧凑但不拥挤
基础字号 12px,常用控件高 32–34px;通过 8 / 12 / 16px 间距建立节奏。
+
浮层统一
画布工具条、菜单、Popover、Modal 均使用白色表面、低对比边框和带绿色倾向的柔和阴影。
+
状态不能只靠颜色
选中状态同时使用背景、边框或指示条;错误和成功状态同时提供图标与文本。
+
+
+ +
+
Section 02
+

设计 Tokens(可直接复制)

+

本页顶部 :root 是完整规范。下面是实现编辑器界面时最关键的一组。

+
/* Light editor core */ +--mn-primary: #14b8a6; +--mn-primary-strong: #0d9488; +--mn-shell: #ffffff; +--mn-panel: #f8faf9; +--mn-control: #eef3f1; +--mn-control-hover: #e5ece9; +--mn-rail: #f4f7f6; +--mn-canvas: #f3f6f5; +--mn-text: #17211f; +--mn-text-secondary: #475569; +--mn-text-muted: #82908d; +--mn-line: rgba(15, 23, 42, .08); +--mn-line-strong: rgba(15, 23, 42, .14); +--mn-focus-ring: 0 0 0 3px rgba(20, 184, 166, .11); +--mn-radius-md: 10px; +--mn-shadow-floating: 0 12px 32px rgba(46, 67, 61, .14);
+
命名约定:shell 是应用最外层,panel 是侧栏/属性区,control 是输入框与可点击块,canvas 是工作画布。
+
+ +
+
Section 03
+

颜色系统

+

颜色取自编辑器浅色主题。中性颜色略带绿灰倾向,与品牌 teal 保持一致。

+
+
Primary
#14B8A6
+
Primary Strong
#0D9488
+
Shell / Raised
#FFFFFF
+
Panel
#F8FAF9
+
Control
#EEF3F1
+
Control Hover
#E5ECE9
+
Rail
#F4F7F6
+
Canvas
#F3F6F5
+
Text Primary
#17211F
+
Text Muted
#82908D
+
Success
#10B981
+
Danger
#E11D48
+
+
禁止把纯黑 #000 用作正文,也不要用 teal 填满大面积侧栏。浅色主题的层次主要依靠表面色差、边框和阴影。
+
+ +
+
Section 04
+

字体与信息层级

+

编辑器使用 Inter。界面文字比营销页面更紧凑,所有数值输入应使用等宽数字特性。

+
24px / 700 / -0.02em · 文档或空状态大标题
Bathroom with walk-in shower
+
13px / 700 · 面板标题、项目标题、Modal 标题
Wall properties
+
11px / 600 · 按钮、字段值、主要操作文本
Preview · Latest (v3) · Ground floor
+
10px / 400–700 · 标签、辅助说明、Tab、快捷键
All changes saved · 12.4 m²
+
9px / 600 · 素材卡名称、角标、密集型数据
THICK WALL 30CM
+
+
行高
控件单行 1;正文与说明 1.5–1.65;两行以上的帮助文本不低于 1.5。
+
截断
项目名、素材名使用单行省略号;Tooltip 可显示完整名称。
+
数字
尺寸、角度、坐标使用 font-variant-numeric: tabular-nums,单位与数字间留 4px。
+
+
+ +
+
Section 05
+

编辑器布局结构

+

桌面端采用固定工具区域 + 弹性画布。最小宽度 1024px;小于此宽度应提示使用桌面端,不压缩到不可操作。

+
+
+
Bathroom with walk-in shower
Edit▶ Preview
+
+
+
+
+
+
+
+
+
+
Topbar
52px 高;项目区建议 310px;模式切换居中偏左;操作区靠右。
+
Tool Rail
58px 宽;按钮 42×42px;图标 16–20px;选中项带 teal 背景与左侧 3px 指示条。
+
Library
默认 280px,紧凑断点 230px;内部 padding 14–16px;内容独立滚动。
+
Canvas
占据全部剩余空间;主网格 80px,辅网格 16px;浮动工具距边缘 14–20px。
+
Inspector
展开宽 290px,关闭宽 0;220ms 过渡;属性组 12px 圆角、14px padding。
+
+
+ +
+
Section 06
+

页面已有组件

+

EXTRACTED 以下组件直接继承当前编辑器页面的视觉规则。

+
+
+
6.1 按钮
+
高度 32px;默认圆角 10px;主按钮只用于 Preview、Save、Confirm 等关键动作。
+
+ + + + + +
+
+
+
6.2 模式切换
+
用于互斥视图,不等同于普通 Tab。容器为 pill,激活项白底。
+
+
+
+
6.3 搜索与属性输入
+
默认 control 背景;Focus 后白底、teal 边框与 3px focus ring。
+
+
+
+
6.4 Library Tabs
+
文字 10px;活动项使用 2px teal 下划线。
+
+
+
+
6.5 Tooltip
+
延迟 300–500ms 显示;紧随图标按钮;不放可交互内容。
+ 参考图设置 +
+
+
6.6 Toast
+
右下角 18px;默认 2.2–3s 消失;最多同时显示 3 条。
+
Preview is ready×
+
+
+
+ +
+
Section 07
+

补充的编辑器常用组件

+

ADDED 原页面未完整展示,以下按现有浅色主题延伸,可直接作为后续实现标准。

+ +
7.1 Dropdown / Context Menu
+
Dropdown 由按钮触发;Context Menu 在指针位置出现。二者视觉一致,宽度 180–260px,菜单项不低于 34px。
+
+
+ +
+

菜单行为

+

点击触发器打开;点击外部或按 Esc 关闭;方向空间不足时自动翻转;当前选项使用 ✓ 或选中背景,不只改变文字颜色。

+
+
+
+ +
+
7.2 完整表单控件
+
属性面板内字段高度 30px;Modal 与设置页字段高度 34px。标签始终位于字段上方或固定左列。
+
+
+
应用于当前选中的墙体。
+
+
+
最多 200 个字符。
+
+
+
Visible
+
+
+
+
+
+ +
+
7.3 Modal / Confirm Dialog
+
普通 Modal 420–560px;复杂设置可到 720px。危险操作必须显示对象名称、后果与明确的危险按钮。
+ +
Modal 遮罩为 rgba(15,23,42,.44)。打开时锁定页面滚动并将焦点移入;关闭后焦点返回原触发器。
+ +
+
7.4 Popover / 帮助卡
+
用于参数解释、颜色选择、快捷操作。与 Tooltip 不同,Popover 可包含按钮和输入控件。
+
+

Grid snapping

+

对象移动时吸附到 0.50m 网格。按住 Alt 可临时忽略吸附。

+
+
+ +
+
7.5 Loading / Progress / Skeleton
+
+
Generating preview…
+
Uploading reference · 64%
+
+
No materials found
Try another keyword or clear filters.
+
+ +
+
7.6 通知状态
+
+
Changes saved×
+
Connection is unstable×
+
Upload failed×
+
+
+ +
+
Section 08
+

交互状态与动效

+

每个交互组件至少实现 Default、Hover、Focus、Active/Selected、Disabled;异步组件增加 Loading 与 Error。

+
+ + + + + + + + + + +
状态视觉规则行为规则
Hover中性控件背景由 #EEF3F1 → #E5ECE9;图标/文字加深。150ms;仅鼠标设备使用,不作为唯一提示。
Focusteal 边框 + 3px 半透明 ring。键盘焦点必须可见;Tab 顺序与视觉顺序一致。
Selectedteal soft 背景 + teal 边框/指示条;文字加深。提供 aria-selectedaria-pressed 或 checked 状态。
Disabled整体透明度 42%,不使用 hover。禁止点击,并说明不可用原因(必要时 Tooltip)。
Loading按钮保留原宽度,文字可替换为 spinner + 动作中。防止重复提交;超过 10s 提供取消或状态说明。
Errordanger 边框/文本 + 图标,错误说明紧邻字段。焦点移动到首个错误;保留用户输入。
+
+
+
微交互
150ms ease:按钮、Tab、图标、输入框、Tooltip。
+
面板开合
220ms cubic-bezier(.2,.8,.2,1):Library、Inspector、Popover。
+
Modal
遮罩淡入 180ms;容器从 translateY(8px) + scale(.98) 进入,220ms。
+
减少动态
遵循 prefers-reduced-motion,关闭位移、缩放和循环动画。
+
+
+ +
+
Section 09
+

可访问性与键盘规范

+

编辑器高度依赖图标和快捷键,因此语义、焦点与可点击面积必须在实现阶段同时处理。

+
+
可点击尺寸
桌面高密度工具最小 32×32px,主 Rail 为 42×42px;触控场景扩展到至少 44×44px。
+
图标按钮
必须有 aria-label;Tooltip 文本与 aria-label 保持一致。
+
键盘
Esc 取消当前工具/关闭浮层;Enter/Space 激活按钮;方向键浏览菜单、Tabs 与单选组。
+
焦点管理
Modal 使用焦点陷阱;菜单打开后焦点进入首项;关闭后返回触发器。
+
对比度
正文至少 4.5:1;大字和非文本关键图形至少 3:1;Muted 文本不承载关键操作。
+
快捷键冲突
输入框聚焦时,不触发 V / Z / X 等全局工具快捷键;Mac 与 Windows 文案分别显示 ⌘ / Ctrl。
+
+
+ +
+
Section 10
+

AI 生成与开发实现约束

+

以下约束用于让 AI 生成的新页面或组件继续保持 MeasureNavi 编辑器浅色主题的一致性。

+
AI_IMPLEMENTATION_CONTRACT +theme = "light" +font = "Inter" +base_spacing_unit = 4px +default_control_height = 32px | 34px +default_radius = 10px +panel_radius = 12px +floating_radius = 12px | 16px +primary_action = teal_gradient +selected_state = teal_soft_background + teal_indicator +neutral_control = #EEF3F1 +neutral_control_hover = #E5ECE9 +focus = teal_border + 3px_translucent_ring +dangerous_action = confirmation_required +icon_only_button = aria-label_required +menu_close = outside_click | Escape +modal_focus = trapped_and_restored +hardcoded_color = avoid_when_token_exists +dark_theme_token = do_not_mix_into_light_theme
+
组件来源标记
+
+
EXTRACTED
来自原编辑器页面,可视为当前产品真实样式。
+
ADDED
原页面未展示,按现有 token、尺寸、圆角、阴影和交互逻辑补齐;后续实现应优先采用。
+
+
本规范只定义浅色系。未来制作深色系时应保持组件结构、尺寸与行为一致,只替换主题颜色、边框与阴影 token。
+
+ +
MeasureNavi Editor Light Theme Design Specification v1.0 · Based on MeasureNavi_编辑器界面.html · 2026/06/22
+
+ + diff --git a/pic-to-3D/__pycache__/run_pic2three.cpython-312.pyc b/pic-to-3D/__pycache__/run_pic2three.cpython-312.pyc new file mode 100644 index 000000000..ebb11314d Binary files /dev/null and b/pic-to-3D/__pycache__/run_pic2three.cpython-312.pyc differ diff --git a/pic-to-3D/pic2threeAPI.json b/pic-to-3D/pic2threeAPI.json new file mode 100644 index 000000000..779fe3f8c --- /dev/null +++ b/pic-to-3D/pic2threeAPI.json @@ -0,0 +1,210 @@ +{ + "3": { + "inputs": { + "seed": 270168091554485, + "steps": 20, + "cfg": 8, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": [ + "70", + 0 + ], + "positive": [ + "80", + 0 + ], + "negative": [ + "80", + 1 + ], + "latent_image": [ + "66", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "Kサンプラー" + } + }, + "51": { + "inputs": { + "crop": "none", + "clip_vision": [ + "54", + 1 + ], + "image": [ + "99", + 0 + ] + }, + "class_type": "CLIPVisionEncode", + "_meta": { + "title": "CLIPビジョンエンコード" + } + }, + "54": { + "inputs": { + "ckpt_name": "hunyuan_3d_v2.1.safetensors" + }, + "class_type": "ImageOnlyCheckpointLoader", + "_meta": { + "title": "画像のみのチェックポイントローダー(img2vidモデル)" + } + }, + "56": { + "inputs": { + "image": "1780017127211.jpg" + }, + "class_type": "LoadImage", + "_meta": { + "title": "画像を読み込む" + } + }, + "61": { + "inputs": { + "num_chunks": 4000, + "octree_resolution": 128, + "samples": [ + "3", + 0 + ], + "vae": [ + "54", + 2 + ] + }, + "class_type": "VAEDecodeHunyuan3D", + "_meta": { + "title": "VAEDecodeHunyuan3D" + } + }, + "62": { + "inputs": { + "threshold": 0.6000000000000001, + "voxel": [ + "61", + 0 + ] + }, + "class_type": "VoxelToMeshBasic", + "_meta": { + "title": "VoxelToMeshBasic" + } + }, + "66": { + "inputs": { + "resolution": 1536, + "batch_size": 1 + }, + "class_type": "EmptyLatentHunyuan3Dv2", + "_meta": { + "title": "EmptyLatentHunyuan3Dv2" + } + }, + "70": { + "inputs": { + "shift": 1.0000000000000002, + "model": [ + "54", + 0 + ] + }, + "class_type": "ModelSamplingAuraFlow", + "_meta": { + "title": "モデルサンプリングオーラフロー" + } + }, + "80": { + "inputs": { + "clip_vision_output": [ + "51", + 0 + ] + }, + "class_type": "Hunyuan3Dv2Conditioning", + "_meta": { + "title": "Hunyuan3Dv2Conditioning" + } + }, + "81": { + "inputs": { + "algorithm": "surface net", + "threshold": 0.6, + "voxel": [ + "61", + 0 + ] + }, + "class_type": "VoxelToMesh", + "_meta": { + "title": "VoxelToMesh" + } + }, + "82": { + "inputs": { + "filename_prefix": "mesh/ComfyUI_lowpoly_500-5k_under1MB", + "image": "", + "mesh": [ + "81", + 0 + ] + }, + "class_type": "SaveGLB", + "_meta": { + "title": "SaveGLB" + } + }, + "93": { + "inputs": { + "filename_prefix": "ComfyUI", + "images": [ + "99", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "画像を保存" + } + }, + "99": { + "inputs": { + "rem_mode": "Inspyrenet", + "image_output": "Preview", + "save_prefix": "ComfyUI", + "torchscript_jit": false, + "add_background": "white", + "refine_foreground": false, + "images": [ + "56", + 0 + ] + }, + "class_type": "easy imageRemBg", + "_meta": { + "title": "Image Remove Bg" + } + }, + "100": { + "inputs": { + "seed": 87955604 + }, + "class_type": "TencentModelTo3DUVNode", + "_meta": { + "title": "Hunyuan3D: モデルからUVへ" + } + }, + "102": { + "inputs": { + "seed": 1939050466 + }, + "class_type": "TencentModelTo3DUVNode", + "_meta": { + "title": "Hunyuan3D: モデルからUVへ" + } + } +} \ No newline at end of file diff --git a/pic-to-3D/run_pic2three.py b/pic-to-3D/run_pic2three.py new file mode 100644 index 000000000..d8576da17 --- /dev/null +++ b/pic-to-3D/run_pic2three.py @@ -0,0 +1,164 @@ +import copy +import json +import mimetypes +import time +import uuid +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +COMFYUI_BASE_URL = "http://192.168.100.250:8188" +WORKFLOW_FILE = "pic2threeAPI.json" +INPUT_IMAGE = "test.jpg" +OUTPUT_FALLBACK_NAME = "pic2three_output.glb" +LOAD_IMAGE_NODE_ID = "56" + + +def http_json(method, path, payload=None, timeout=30): + url = f"{COMFYUI_BASE_URL}{path}" + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = Request(url, data=data, headers=headers, method=method) + with urlopen(request, timeout=timeout) as response: + body = response.read() + return json.loads(body.decode("utf-8")) if body else {} + + +def upload_image(image_path): + boundary = f"----ComfyUIBoundary{uuid.uuid4().hex}" + content_type = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream" + file_bytes = image_path.read_bytes() + + parts = [ + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="image"; filename="{image_path.name}"\r\n' + f"Content-Type: {content_type}\r\n\r\n" + ).encode("utf-8"), + file_bytes, + ( + f"\r\n--{boundary}\r\n" + 'Content-Disposition: form-data; name="overwrite"\r\n\r\n' + "true\r\n" + ).encode("utf-8"), + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="type"\r\n\r\n' + "input\r\n" + f"--{boundary}--\r\n" + ).encode("utf-8"), + ] + body = b"".join(parts) + + request = Request( + f"{COMFYUI_BASE_URL}/upload/image", + data=body, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + method="POST", + ) + with urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + + +def queue_prompt(prompt): + client_id = str(uuid.uuid4()) + response = http_json("POST", "/prompt", {"prompt": prompt, "client_id": client_id}, timeout=30) + return response["prompt_id"] + + +def wait_for_history(prompt_id, poll_interval=2, timeout=1800): + deadline = time.time() + timeout + while time.time() < deadline: + history = http_json("GET", f"/history/{prompt_id}", timeout=30) + if prompt_id in history: + record = history[prompt_id] + status = record.get("status", {}) + if status.get("status_str") == "error": + messages = status.get("messages", []) + raise RuntimeError(f"ComfyUI prompt failed: {messages}") + return record + time.sleep(poll_interval) + raise TimeoutError(f"Timed out waiting for prompt {prompt_id}") + + +def collect_glb_outputs(value): + found = [] + if isinstance(value, dict): + filename = value.get("filename") + if isinstance(filename, str) and filename.lower().endswith(".glb"): + found.append( + { + "filename": filename, + "subfolder": value.get("subfolder", ""), + "type": value.get("type", "output"), + } + ) + for child in value.values(): + found.extend(collect_glb_outputs(child)) + elif isinstance(value, list): + for child in value: + found.extend(collect_glb_outputs(child)) + return found + + +def download_output(output_info, destination): + params = urlencode( + { + "filename": output_info["filename"], + "subfolder": output_info.get("subfolder", ""), + "type": output_info.get("type", "output"), + } + ) + with urlopen(f"{COMFYUI_BASE_URL}/view?{params}", timeout=120) as response: + destination.write_bytes(response.read()) + + +def main(): + script_dir = Path(__file__).resolve().parent + workflow_path = script_dir / WORKFLOW_FILE + image_path = script_dir / INPUT_IMAGE + + if not workflow_path.exists(): + raise FileNotFoundError(f"Workflow not found: {workflow_path}") + if not image_path.exists(): + raise FileNotFoundError(f"Input image not found: {image_path}") + + workflow = json.loads(workflow_path.read_text(encoding="utf-8")) + + uploaded = upload_image(image_path) + image_name = uploaded["name"] + if uploaded.get("subfolder"): + image_name = f"{uploaded['subfolder']}/{image_name}" + + prompt = copy.deepcopy(workflow) + prompt[LOAD_IMAGE_NODE_ID]["inputs"]["image"] = image_name + + print(f"Uploaded image: {image_name}") + prompt_id = queue_prompt(prompt) + print(f"Queued prompt: {prompt_id}") + + history = wait_for_history(prompt_id) + glb_outputs = collect_glb_outputs(history.get("outputs", {})) + if not glb_outputs: + raise RuntimeError("Prompt completed, but no .glb output was found in ComfyUI history.") + + output_info = glb_outputs[-1] + destination = script_dir / (Path(output_info["filename"]).name or OUTPUT_FALLBACK_NAME) + if destination.suffix.lower() != ".glb": + destination = script_dir / OUTPUT_FALLBACK_NAME + + download_output(output_info, destination) + print(f"Saved GLB: {destination}") + + +if __name__ == "__main__": + try: + main() + except (HTTPError, URLError, TimeoutError, RuntimeError, FileNotFoundError, KeyError) as exc: + raise SystemExit(f"Error: {exc}") diff --git a/pic-to-3D/test.jpg b/pic-to-3D/test.jpg new file mode 100644 index 000000000..2a687543e Binary files /dev/null and b/pic-to-3D/test.jpg differ diff --git "a/pic-to-3D/\350\205\276\350\256\257\346\267\267\345\205\2033D2.1-low1.json" "b/pic-to-3D/\350\205\276\350\256\257\346\267\267\345\205\2033D2.1-low1.json" new file mode 100644 index 000000000..779fe3f8c --- /dev/null +++ "b/pic-to-3D/\350\205\276\350\256\257\346\267\267\345\205\2033D2.1-low1.json" @@ -0,0 +1,210 @@ +{ + "3": { + "inputs": { + "seed": 270168091554485, + "steps": 20, + "cfg": 8, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1, + "model": [ + "70", + 0 + ], + "positive": [ + "80", + 0 + ], + "negative": [ + "80", + 1 + ], + "latent_image": [ + "66", + 0 + ] + }, + "class_type": "KSampler", + "_meta": { + "title": "Kサンプラー" + } + }, + "51": { + "inputs": { + "crop": "none", + "clip_vision": [ + "54", + 1 + ], + "image": [ + "99", + 0 + ] + }, + "class_type": "CLIPVisionEncode", + "_meta": { + "title": "CLIPビジョンエンコード" + } + }, + "54": { + "inputs": { + "ckpt_name": "hunyuan_3d_v2.1.safetensors" + }, + "class_type": "ImageOnlyCheckpointLoader", + "_meta": { + "title": "画像のみのチェックポイントローダー(img2vidモデル)" + } + }, + "56": { + "inputs": { + "image": "1780017127211.jpg" + }, + "class_type": "LoadImage", + "_meta": { + "title": "画像を読み込む" + } + }, + "61": { + "inputs": { + "num_chunks": 4000, + "octree_resolution": 128, + "samples": [ + "3", + 0 + ], + "vae": [ + "54", + 2 + ] + }, + "class_type": "VAEDecodeHunyuan3D", + "_meta": { + "title": "VAEDecodeHunyuan3D" + } + }, + "62": { + "inputs": { + "threshold": 0.6000000000000001, + "voxel": [ + "61", + 0 + ] + }, + "class_type": "VoxelToMeshBasic", + "_meta": { + "title": "VoxelToMeshBasic" + } + }, + "66": { + "inputs": { + "resolution": 1536, + "batch_size": 1 + }, + "class_type": "EmptyLatentHunyuan3Dv2", + "_meta": { + "title": "EmptyLatentHunyuan3Dv2" + } + }, + "70": { + "inputs": { + "shift": 1.0000000000000002, + "model": [ + "54", + 0 + ] + }, + "class_type": "ModelSamplingAuraFlow", + "_meta": { + "title": "モデルサンプリングオーラフロー" + } + }, + "80": { + "inputs": { + "clip_vision_output": [ + "51", + 0 + ] + }, + "class_type": "Hunyuan3Dv2Conditioning", + "_meta": { + "title": "Hunyuan3Dv2Conditioning" + } + }, + "81": { + "inputs": { + "algorithm": "surface net", + "threshold": 0.6, + "voxel": [ + "61", + 0 + ] + }, + "class_type": "VoxelToMesh", + "_meta": { + "title": "VoxelToMesh" + } + }, + "82": { + "inputs": { + "filename_prefix": "mesh/ComfyUI_lowpoly_500-5k_under1MB", + "image": "", + "mesh": [ + "81", + 0 + ] + }, + "class_type": "SaveGLB", + "_meta": { + "title": "SaveGLB" + } + }, + "93": { + "inputs": { + "filename_prefix": "ComfyUI", + "images": [ + "99", + 0 + ] + }, + "class_type": "SaveImage", + "_meta": { + "title": "画像を保存" + } + }, + "99": { + "inputs": { + "rem_mode": "Inspyrenet", + "image_output": "Preview", + "save_prefix": "ComfyUI", + "torchscript_jit": false, + "add_background": "white", + "refine_foreground": false, + "images": [ + "56", + 0 + ] + }, + "class_type": "easy imageRemBg", + "_meta": { + "title": "Image Remove Bg" + } + }, + "100": { + "inputs": { + "seed": 87955604 + }, + "class_type": "TencentModelTo3DUVNode", + "_meta": { + "title": "Hunyuan3D: モデルからUVへ" + } + }, + "102": { + "inputs": { + "seed": 1939050466 + }, + "class_type": "TencentModelTo3DUVNode", + "_meta": { + "title": "Hunyuan3D: モデルからUVへ" + } + } +} \ No newline at end of file diff --git a/scripts/__pycache__/check_glb_model.cpython-313.pyc b/scripts/__pycache__/check_glb_model.cpython-313.pyc new file mode 100644 index 000000000..85175cadf Binary files /dev/null and b/scripts/__pycache__/check_glb_model.cpython-313.pyc differ diff --git a/scripts/check_glb_model.py b/scripts/check_glb_model.py new file mode 100755 index 000000000..5bf8dd791 --- /dev/null +++ b/scripts/check_glb_model.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +"""Inspect GLB assets for Pascal catalog readiness using only the Python stdlib.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + + +GLB_MAGIC = b"glTF" +JSON_CHUNK = 0x4E4F534A +TRIANGLES_MODE = 4 + + +class GlbError(ValueError): + pass + + +@dataclass +class Bounds: + minimum: list[float] + maximum: list[float] + + @classmethod + def empty(cls) -> "Bounds": + return cls([math.inf] * 3, [-math.inf] * 3) + + def include(self, point: list[float]) -> None: + for axis in range(3): + self.minimum[axis] = min(self.minimum[axis], point[axis]) + self.maximum[axis] = max(self.maximum[axis], point[axis]) + + @property + def valid(self) -> bool: + return all(math.isfinite(value) for value in self.minimum + self.maximum) + + @property + def size(self) -> list[float]: + return [self.maximum[i] - self.minimum[i] for i in range(3)] + + @property + def center(self) -> list[float]: + return [(self.maximum[i] + self.minimum[i]) / 2 for i in range(3)] + + +def round_values(values: Iterable[float], digits: int = 4) -> list[float]: + return [round(value, digits) for value in values] + + +def matrix_identity() -> list[float]: + return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + + +def matrix_multiply(left: list[float], right: list[float]) -> list[float]: + result = [0.0] * 16 + for column in range(4): + for row in range(4): + result[column * 4 + row] = sum( + left[k * 4 + row] * right[column * 4 + k] for k in range(4) + ) + return result + + +def node_matrix(node: dict[str, Any]) -> list[float]: + if isinstance(node.get("matrix"), list) and len(node["matrix"]) == 16: + return [float(value) for value in node["matrix"]] + + tx, ty, tz = node.get("translation", [0, 0, 0]) + sx, sy, sz = node.get("scale", [1, 1, 1]) + x, y, z, w = node.get("rotation", [0, 0, 0, 1]) + x2, y2, z2 = x + x, y + y, z + z + xx, xy, xz = x * x2, x * y2, x * z2 + yy, yz, zz = y * y2, y * z2, z * z2 + wx, wy, wz = w * x2, w * y2, w * z2 + return [ + (1 - (yy + zz)) * sx, + (xy + wz) * sx, + (xz - wy) * sx, + 0, + (xy - wz) * sy, + (1 - (xx + zz)) * sy, + (yz + wx) * sy, + 0, + (xz + wy) * sz, + (yz - wx) * sz, + (1 - (xx + yy)) * sz, + 0, + tx, + ty, + tz, + 1, + ] + + +def transform_point(matrix: list[float], point: list[float]) -> list[float]: + x, y, z = point + return [ + matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12], + matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13], + matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14], + ] + + +def read_glb(path: Path) -> tuple[dict[str, Any], int]: + data = path.read_bytes() + if len(data) < 20 or data[:4] != GLB_MAGIC: + raise GlbError("not a GLB file (missing glTF header)") + version, declared_length = struct.unpack_from(" tuple[Bounds, int]: + nodes = document.get("nodes", []) + meshes = document.get("meshes", []) + accessors = document.get("accessors", []) + scenes = document.get("scenes", []) + scene_index = document.get("scene", 0) + bounds = Bounds.empty() + missing_bounds = 0 + + def walk(node_index: int, parent_matrix: list[float]) -> None: + nonlocal missing_bounds + node = nodes[node_index] + world = matrix_multiply(parent_matrix, node_matrix(node)) + mesh_index = node.get("mesh") + if isinstance(mesh_index, int) and 0 <= mesh_index < len(meshes): + for primitive in meshes[mesh_index].get("primitives", []): + accessor_index = primitive.get("attributes", {}).get("POSITION") + if not isinstance(accessor_index, int) or accessor_index >= len(accessors): + missing_bounds += 1 + continue + accessor = accessors[accessor_index] + minimum, maximum = accessor.get("min"), accessor.get("max") + if not ( + isinstance(minimum, list) + and isinstance(maximum, list) + and len(minimum) == 3 + and len(maximum) == 3 + ): + missing_bounds += 1 + continue + for x in (minimum[0], maximum[0]): + for y in (minimum[1], maximum[1]): + for z in (minimum[2], maximum[2]): + bounds.include(transform_point(world, [x, y, z])) + for child in node.get("children", []): + walk(child, world) + + if scenes and 0 <= scene_index < len(scenes): + for root in scenes[scene_index].get("nodes", []): + walk(root, matrix_identity()) + return bounds, missing_bounds + + +def parse_txt_dimensions(path: Path) -> list[float] | None: + companion = path.with_suffix(".txt") + if not companion.exists(): + return None + text = companion.read_text(encoding="utf-8", errors="replace") + match = re.search( + r"(?:尺寸|尺⼨|dimensions?)\s*[::]\s*([0-9.]+)\s*[×xX*]\s*([0-9.]+)\s*[×xX*]\s*([0-9.]+)", + text, + re.IGNORECASE, + ) + return [float(match.group(i)) for i in range(1, 4)] if match else None + + +def inspect(path: Path, expected: list[float] | None, tolerance: float) -> dict[str, Any]: + document, binary_size = read_glb(path) + bounds, missing_bounds = scene_bounds(document) + if not bounds.valid: + raise GlbError("could not derive bounds; POSITION accessors need min/max") + + size, center = bounds.size, bounds.center + suggested_offset = [-center[0], -bounds.minimum[1], -center[2]] + accessors = document.get("accessors", []) + meshes = document.get("meshes", []) + materials = document.get("materials", []) + images = document.get("images", []) + buffer_views = document.get("bufferViews", []) + + vertex_count = 0 + triangle_count = 0 + primitive_count = 0 + for mesh in meshes: + for primitive in mesh.get("primitives", []): + primitive_count += 1 + position_index = primitive.get("attributes", {}).get("POSITION") + if isinstance(position_index, int) and position_index < len(accessors): + vertex_count += int(accessors[position_index].get("count", 0)) + if primitive.get("mode", TRIANGLES_MODE) == TRIANGLES_MODE: + index = primitive.get("indices") + count = ( + accessors[index].get("count", 0) + if isinstance(index, int) and index < len(accessors) + else accessors[position_index].get("count", 0) + if isinstance(position_index, int) and position_index < len(accessors) + else 0 + ) + triangle_count += int(count) // 3 + + embedded_image_bytes = 0 + external_images = 0 + image_mime_types: dict[str, int] = {} + for image in images: + mime = image.get("mimeType", "external/unknown") + image_mime_types[mime] = image_mime_types.get(mime, 0) + 1 + view_index = image.get("bufferView") + if isinstance(view_index, int) and view_index < len(buffer_views): + embedded_image_bytes += int(buffer_views[view_index].get("byteLength", 0)) + elif image.get("uri"): + external_images += 1 + + warnings: list[str] = [] + file_size = path.stat().st_size + if file_size > 8 * 1024 * 1024: + warnings.append("file is larger than 8 MiB; compress textures/mesh for catalog use") + if embedded_image_bytes > 5 * 1024 * 1024: + warnings.append("embedded textures exceed 5 MiB; WebP/KTX2 compression is recommended") + if missing_bounds: + warnings.append(f"{missing_bounds} primitive(s) had no POSITION min/max and were skipped") + if abs(bounds.minimum[1]) > 0.02: + warnings.append("model bottom is not at Y=0") + if abs(center[0]) > 0.05 or abs(center[2]) > 0.05: + warnings.append("model is not centered on X/Z; use the suggested offset or re-export") + extensions = document.get("extensionsUsed", []) + if "KHR_materials_pbrSpecularGlossiness" in extensions: + warnings.append("uses legacy KHR_materials_pbrSpecularGlossiness; metallic-roughness PBR is preferred") + if any( + node.get("matrix") + or node.get("scale", [1, 1, 1]) != [1, 1, 1] + or node.get("rotation", [0, 0, 0, 1]) != [0, 0, 0, 1] + for node in document.get("nodes", []) + ): + warnings.append("contains non-identity node transforms; acceptable, but baked transforms are easier to maintain") + + expected = expected or parse_txt_dimensions(path) + dimension_check = None + if expected: + relative_errors = [ + abs(size[i] - expected[i]) / max(abs(expected[i]), 1e-9) for i in range(3) + ] + dimension_check = { + "expected": round_values(expected), + "relativeError": round_values(relative_errors), + "withinTolerance": all(error <= tolerance for error in relative_errors), + } + if not dimension_check["withinTolerance"]: + warnings.append( + f"measured dimensions differ from expected by more than {tolerance:.0%}" + ) + + return { + "path": str(path), + "ok": not any("could not" in warning for warning in warnings), + "fileSizeBytes": file_size, + "generator": document.get("asset", {}).get("generator"), + "bounds": { + "min": round_values(bounds.minimum), + "max": round_values(bounds.maximum), + "size": round_values(size), + "center": round_values(center), + }, + "suggestedCatalog": { + "dimensions": round_values(size), + "offset": round_values(suggested_offset), + "rotation": [0, 0, 0], + "scale": [1, 1, 1], + }, + "geometry": { + "meshes": len(meshes), + "primitives": primitive_count, + "vertices": vertex_count, + "triangles": triangle_count, + }, + "textures": { + "count": len(images), + "embeddedBytes": embedded_image_bytes, + "externalCount": external_images, + "mimeTypes": image_mime_types, + }, + "materials": len(materials), + "binaryChunkBytes": binary_size, + "extensionsUsed": extensions, + "dimensionCheck": dimension_check, + "warnings": warnings, + } + + +def find_glbs(paths: list[str]) -> list[Path]: + result: list[Path] = [] + for raw in paths: + path = Path(raw).expanduser() + if path.is_dir(): + result.extend(sorted(path.rglob("*.glb"))) + elif path.suffix.lower() == ".glb": + result.append(path) + else: + print(f"warning: skipped non-GLB path: {path}", file=sys.stderr) + return result + + +def print_human(report: dict[str, Any]) -> None: + size = report["bounds"]["size"] + suggested = report["suggestedCatalog"] + geometry, textures = report["geometry"], report["textures"] + print(f"\n{report['path']}") + print(f" size W×H×D : {size[0]} × {size[1]} × {size[2]} m") + print(f" dimensions : {suggested['dimensions']}") + print(f" offset : {suggested['offset']}") + print( + f" geometry : {geometry['meshes']} meshes, {geometry['vertices']} vertices, " + f"{geometry['triangles']} triangles" + ) + print( + f" textures : {textures['count']} image(s), " + f"{textures['embeddedBytes'] / 1024 / 1024:.2f} MiB embedded" + ) + if report["dimensionCheck"]: + state = "PASS" if report["dimensionCheck"]["withinTolerance"] else "FAIL" + print(f" expected : {state} {report['dimensionCheck']['expected']}") + if report["warnings"]: + for warning in report["warnings"]: + print(f" WARN : {warning}") + else: + print(" OK : no catalog-readiness warnings") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="GLB file(s) or directories") + parser.add_argument( + "--expected", + nargs=3, + type=float, + metavar=("WIDTH", "HEIGHT", "DEPTH"), + help="expected W H D dimensions in meters; otherwise reads a companion .txt", + ) + parser.add_argument( + "--tolerance", type=float, default=0.05, help="dimension relative tolerance (default: 0.05)" + ) + parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") + args = parser.parse_args() + + paths = find_glbs(args.paths) + if not paths: + print("no GLB files found", file=sys.stderr) + return 2 + + reports: list[dict[str, Any]] = [] + failed = False + for path in paths: + try: + reports.append(inspect(path, args.expected, args.tolerance)) + except (OSError, GlbError, json.JSONDecodeError) as error: + failed = True + reports.append({"path": str(path), "ok": False, "error": str(error)}) + + if args.json: + print(json.dumps(reports, ensure_ascii=False, indent=2)) + else: + for report in reports: + if "error" in report: + print(f"\n{report['path']}\n ERROR : {report['error']}") + else: + print_human(report) + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tooling/make_slabs_editable.py b/tooling/make_slabs_editable.py new file mode 100644 index 000000000..e5ae783b4 --- /dev/null +++ b/tooling/make_slabs_editable.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Make Pascal layout slab nodes manually editable. + +This is intentionally text-based instead of json.loads/json.dumps so it can +preserve large layout files and survive files that the editor can import but +strict Python JSON parsing may reject due legacy text encoding quirks. +""" + +from __future__ import annotations + +import argparse +import re +import shutil +from pathlib import Path + + +TYPE_SLAB_RE = re.compile(r'"type"\s*:\s*"slab"') +AUTO_FROM_WALLS_RE = re.compile(r'("autoFromWalls"\s*:\s*)true\b') +AUTO_FROM_WALLS_ANY_RE = re.compile(r'"autoFromWalls"\s*:\s*(?:true|false)\b') + + +def read_text(path: Path) -> str: + for encoding in ("utf-8-sig", "utf-8", "cp932"): + try: + return path.read_text(encoding=encoding) + except UnicodeDecodeError: + continue + return path.read_text(encoding="utf-8", errors="replace") + + +def collect_slab_object_ranges(text: str) -> list[tuple[int, int]]: + object_stack: list[int] = [] + slab_starts: set[int] = set() + ranges: list[tuple[int, int]] = [] + in_string = False + escaped = False + + pos = 0 + while pos < len(text): + ch = text[pos] + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + pos += 1 + continue + + if object_stack: + match = TYPE_SLAB_RE.match(text, pos) + if match: + slab_starts.add(object_stack[-1]) + pos = match.end() + continue + + if ch == '"': + in_string = True + elif ch == "{": + object_stack.append(pos) + elif ch == "}": + if object_stack: + start = object_stack.pop() + if start in slab_starts: + ranges.append((start, pos + 1)) + slab_starts.remove(start) + + pos += 1 + + return sorted(ranges) + + +def make_slab_object_editable(obj: str) -> tuple[str, bool]: + replaced, count = AUTO_FROM_WALLS_RE.subn(r"\1false", obj) + if count > 0: + return replaced, True + + if AUTO_FROM_WALLS_ANY_RE.search(obj): + return obj, False + + insert_at = obj.rfind("}") + if insert_at < 0: + return obj, False + + newline_match = re.search(r"\n([ \t]*)}", obj[insert_at - 80 : insert_at + 1]) + indent = newline_match.group(1) if newline_match else " " + field = f',\n{indent}"autoFromWalls": false' + return obj[:insert_at] + field + obj[insert_at:], True + + +def make_slabs_editable(text: str) -> tuple[str, int, int]: + ranges = collect_slab_object_ranges(text) + parts: list[str] = [] + cursor = 0 + changed = 0 + + for start, end in ranges: + if start < cursor: + continue + obj = text[start:end] + updated, did_change = make_slab_object_editable(obj) + parts.append(text[cursor:start]) + parts.append(updated) + cursor = end + if did_change: + changed += 1 + + parts.append(text[cursor:]) + return "".join(parts), len(ranges), changed + + +def default_output_path(input_path: Path) -> Path: + return input_path.with_name(f"{input_path.stem}.editable{input_path.suffix}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description='Set every Pascal layout node with "type": "slab" to "autoFromWalls": false.', + ) + parser.add_argument("input", type=Path, help="Layout JSON file to update.") + parser.add_argument( + "output", + type=Path, + nargs="?", + help="Output JSON path. Defaults to .editable.json unless --in-place is used.", + ) + parser.add_argument( + "--in-place", + action="store_true", + help="Overwrite the input file after writing a .bak backup.", + ) + parser.add_argument( + "--no-backup", + action="store_true", + help="Do not create a .bak file when using --in-place.", + ) + args = parser.parse_args() + + input_path = args.input.resolve() + if not input_path.exists(): + raise SystemExit(f"Input file not found: {input_path}") + + if args.in_place and args.output: + raise SystemExit("Do not pass an output path together with --in-place.") + + text = read_text(input_path) + updated, found, changed = make_slabs_editable(text) + + output_path = input_path if args.in_place else (args.output.resolve() if args.output else default_output_path(input_path)) + + if args.in_place and not args.no_backup: + backup_path = input_path.with_suffix(input_path.suffix + ".bak") + shutil.copy2(input_path, backup_path) + print(f"Backup written: {backup_path}") + + output_path.write_text(updated, encoding="utf-8", newline="") + print(f"Slab nodes found: {found}") + print(f"Slab nodes changed: {changed}") + print(f"Output written: {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())