yesilcam-studios 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/bin/yesilcam-mcp.js +3 -0
- package/bin/yesilcam.js +3 -0
- package/package.json +17 -0
- package/src/cli.js +120 -0
- package/src/client.js +54 -0
- package/src/config.js +18 -0
- package/src/constants.js +6 -0
- package/src/jobs.js +45 -0
- package/src/maket/analyze.js +16 -0
- package/src/maket/demo/dxfWriter.js +46 -0
- package/src/maket/demo/plans.js +95 -0
- package/src/maket/dxf/parser.js +243 -0
- package/src/maket/plan/classify.js +74 -0
- package/src/maket/plan/fromJson.js +41 -0
- package/src/maket/plan/geometry.js +347 -0
- package/src/maket/plan/pipeline.js +29 -0
- package/src/mcp.js +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# yesilcam-studios
|
|
2
|
+
|
|
3
|
+
Yeşilçam Studios from your terminal (`yesilcam`) and from Claude (`yesilcam-mcp`). Same account, same token wallet as https://yesilcamstudios.com/app/.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g yesilcam-studios
|
|
7
|
+
yesilcam login # email + password, token saved to ~/.config/yesilcam/config.json (0600)
|
|
8
|
+
yesilcam balance
|
|
9
|
+
yesilcam image "a lighthouse at dusk, 35mm film" --tier standard --out lighthouse.png
|
|
10
|
+
yesilcam video --from 1234 --duration 5 --wait
|
|
11
|
+
yesilcam tryon --garment 88 --person 77 --out look.png
|
|
12
|
+
yesilcam maket analyze plan.dxf # local, free
|
|
13
|
+
yesilcam maket save plan.dxf --title "Villa"
|
|
14
|
+
yesilcam pricing
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Claude (MCP): `yesilcam mcp` prints the config snippet. Claude Code: `claude mcp add yesilcam -e YESILCAM_TOKEN=<token> -- yesilcam-mcp`.
|
|
18
|
+
Get a token with `yesilcam login --json`, or set `YESILCAM_TOKEN` in the environment.
|
package/bin/yesilcam.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yesilcam-studios",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Yeşilçam Studios CLI and MCP server: AI film, fashion try-on and Maket from your terminal or from Claude.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "yesilcam": "bin/yesilcam.js", "yesilcam-mcp": "bin/yesilcam-mcp.js" },
|
|
8
|
+
"files": ["bin", "src", "README.md"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"scripts": { "test": "vitest run", "pack": "npm pack" },
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
13
|
+
"commander": "^15.0.0",
|
|
14
|
+
"zod": "^4.6.5"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": { "vitest": "^2.1.8" }
|
|
17
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, extname } from 'node:path'
|
|
4
|
+
import { createInterface } from 'node:readline/promises'
|
|
5
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
6
|
+
import { login, makeClient, ApiError } from './client.js'
|
|
7
|
+
import { readConfig, writeConfig, clearConfig, getUserToken, CONFIG_FILE } from './config.js'
|
|
8
|
+
import { imageJob, videoJob, tryonJob, waitForJob, describeJob, TIERS, ASPECTS } from './jobs.js'
|
|
9
|
+
import { analyzePlanText } from './maket/analyze.js'
|
|
10
|
+
import { SITE_URL } from './constants.js'
|
|
11
|
+
|
|
12
|
+
const out = (program, data, human) => { if (program.opts().json) console.log(JSON.stringify(data, null, 2)); else console.log(human ?? JSON.stringify(data, null, 2)) }
|
|
13
|
+
const fmtInt = (n) => Number(n).toLocaleString('en-US')
|
|
14
|
+
const client = () => { const t = getUserToken(); if (!t) { console.error('Not logged in. Run: yesilcam login'); process.exit(2) } return makeClient(t) }
|
|
15
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }
|
|
16
|
+
|
|
17
|
+
async function download(url, file) {
|
|
18
|
+
const res = await fetch(url); if (!res.ok) throw new Error(`download failed (${res.status})`)
|
|
19
|
+
writeFileSync(file, Buffer.from(await res.arrayBuffer())); return file
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runJob(program, c, payload, opts) {
|
|
23
|
+
const r = await c.createJob(payload)
|
|
24
|
+
const jobId = r?.job_id || r?.id || r?.job?.id
|
|
25
|
+
if (!jobId) { out(program, r); return }
|
|
26
|
+
if (!opts.wait) { out(program, { job_id: String(jobId), status: 'queued' }, `Job ${jobId} queued. Check: yesilcam jobs status ${jobId} --wait`); return }
|
|
27
|
+
const job = await waitForJob(c, jobId, { onTick: (j) => { if (!program.opts().json && j) process.stderr.write(`\r${j.status}${j.render_progress ? ` ${j.render_progress}%` : ''} `) } })
|
|
28
|
+
if (!program.opts().json) process.stderr.write('\n')
|
|
29
|
+
const d = describeJob(job)
|
|
30
|
+
if (opts.out && d?.result?.url) { await download(d.result.url, opts.out); d.saved = opts.out }
|
|
31
|
+
out(program, d, d.result ? `Done: ${d.result.url}${d.saved ? `\nSaved: ${d.saved}` : ''}` : `Job ${d.id}: ${d.status}${d.error ? ` (${d.error})` : ''}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildProgram() {
|
|
35
|
+
const program = new Command()
|
|
36
|
+
program.name('yesilcam').description('Yeşilçam Studios: AI film, fashion try-on and Maket from your terminal.').version('0.1.0')
|
|
37
|
+
.option('--json', 'machine-readable output')
|
|
38
|
+
|
|
39
|
+
program.command('login').description('Sign in with your Yeşilçam Studios account')
|
|
40
|
+
.option('--email <email>').option('--password <password>').option('--token <userToken>', 'use an existing user token')
|
|
41
|
+
.action(async (o) => {
|
|
42
|
+
if (o.token) { writeConfig({ ...readConfig(), token: o.token }); out(program, { ok: true }, `Token saved to ${CONFIG_FILE}`); return }
|
|
43
|
+
let { email, password } = o
|
|
44
|
+
if (!email || !password) {
|
|
45
|
+
const rl = createInterface({ input, output })
|
|
46
|
+
email = email || await rl.question('Email: ')
|
|
47
|
+
password = password || await rl.question('Password: ')
|
|
48
|
+
rl.close()
|
|
49
|
+
}
|
|
50
|
+
const r = await login(email.trim(), password)
|
|
51
|
+
writeConfig({ token: r.token, user: r.user })
|
|
52
|
+
out(program, { ok: true, user: r.user }, `Signed in as ${r.user.name} ${r.user.lastname} <${r.user.email}>. Token saved to ${CONFIG_FILE}`)
|
|
53
|
+
})
|
|
54
|
+
program.command('logout').description('Remove the saved token').action(() => { clearConfig(); out(program, { ok: true }, 'Signed out.') })
|
|
55
|
+
program.command('whoami').description('Account and plan').action(async () => { const r = await client().me(); out(program, r, `${r.name} ${r.lastname} · plan: ${r.subscriptionPackageType || 'social-hive'}`) })
|
|
56
|
+
program.command('balance').description('Token balance').action(async () => { const r = await client().balance(); out(program, r, `${fmtInt(r.balance)} tokens`) })
|
|
57
|
+
program.command('pricing').description('Production costs (tokens) and token packs').action(async () => {
|
|
58
|
+
const c = client(); const [ops, packs] = await Promise.all([c.modulePricing(), c.tokenPackages()])
|
|
59
|
+
const rows = ops.filter((r) => ['ai-studio', 'fashion-tryon'].includes(r.module_key) && String(r.status ?? '1') === '1').map((r) => ({ module: r.module_key, operation: r.operation_key, tokens: Number(r.base_cost_tokens), free_per_month: Number(r.monthly_free_limit || 0) })).sort((a, b) => a.tokens - b.tokens)
|
|
60
|
+
const pk = packs.map((p) => ({ id: p.id, title: p.title, tokens: Number(p.token_amount), bonus_percent: Number(p.bonus_percentage), usd: Number(p.price_usd), usd_without_plan: p.price_usd_standard != null ? Number(p.price_usd_standard) : null, try: Number(p.price_tr), try_without_plan: p.price_tr_standard != null ? Number(p.price_tr_standard) : null }))
|
|
61
|
+
out(program, { operations: rows, packs: pk }, [...rows.map((r) => `${r.module.padEnd(14)} ${r.operation.padEnd(24)} ${fmtInt(r.tokens).padStart(10)} tokens${r.free_per_month ? ` (${r.free_per_month} free/month)` : ''}`), '', ...pk.map((p) => `${p.title.padEnd(14)} ${fmtInt(p.tokens).padStart(10)} tokens $${p.usd} with plan / $${p.usd_without_plan ?? p.usd} without${p.bonus_percent ? ` +${p.bonus_percent}% bonus` : ''}`)].join('\n'))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const assets = program.command('assets').description('Your library (images, videos)')
|
|
65
|
+
assets.command('list').option('--kind <kind>', 'image|video').option('--limit <n>', '', '40').action(async (o) => {
|
|
66
|
+
const r = await client().assets({ kind: o.kind, limit: Number(o.limit) })
|
|
67
|
+
const list = r?.assets || r?.items || r || []
|
|
68
|
+
out(program, list, list.map((a) => `${String(a.id).padEnd(8)} ${String(a.kind).padEnd(6)} ${a.label || ''} ${a.spaces_url || ''}`).join('\n') || 'No assets.')
|
|
69
|
+
})
|
|
70
|
+
assets.command('upload <file>').option('--kind <kind>', 'image|video|audio').option('--label <label>').action(async (file, o) => {
|
|
71
|
+
const ext = extname(file).toLowerCase(); const mime = MIME[ext] || 'application/octet-stream'
|
|
72
|
+
const kind = o.kind || (mime.startsWith('video') ? 'video' : mime.startsWith('audio') ? 'audio' : 'image')
|
|
73
|
+
const b64 = `data:${mime};base64,${readFileSync(file).toString('base64')}`
|
|
74
|
+
const r = await client().uploadAsset(b64, kind, o.label || basename(file))
|
|
75
|
+
out(program, r, `Uploaded asset ${r?.asset_id || r?.id || ''} ${r?.url || r?.spaces_url || ''}`)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const jobs = program.command('jobs').description('Generation jobs')
|
|
79
|
+
jobs.command('list').option('--limit <n>', '', '20').action(async (o) => { const r = await client().jobs({ limit: Number(o.limit) }); const list = r?.jobs || []; out(program, list, list.map((j) => `${String(j.id).padEnd(8)} ${String(j.job_type).padEnd(16)} ${String(j.status).padEnd(11)} ${j.quality_tier || ''}`).join('\n') || 'No jobs.') })
|
|
80
|
+
jobs.command('status <id>').option('--wait').option('--out <file>').action(async (id, o) => {
|
|
81
|
+
const c = client(); const job = o.wait ? await waitForJob(c, id) : (await c.jobStatus(Number(id))).job
|
|
82
|
+
const d = describeJob(job); if (o.out && d?.result?.url) { await download(d.result.url, o.out); d.saved = o.out }
|
|
83
|
+
out(program, d, `${d.id} ${d.type} ${d.status}${d.result ? ` ${d.result.url}` : ''}${d.saved ? `\nSaved: ${d.saved}` : ''}`)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
program.command('image <prompt>').description('Generate an image (text2image)')
|
|
87
|
+
.option('--tier <tier>', TIERS.join('|'), 'draft').option('--aspect <ratio>', ASPECTS.join('|'), '16:9').option('--framing <f>', 'waist_up|waist_down|full_body')
|
|
88
|
+
.option('--wait', 'wait for the result').option('--out <file>', 'download result (implies --wait)')
|
|
89
|
+
.action(async (prompt, o) => runJob(program, client(), imageJob({ prompt, tier: o.tier, aspect: o.aspect, framing: o.framing }), { wait: o.wait || Boolean(o.out), out: o.out }))
|
|
90
|
+
program.command('video [prompt]').description('Generate a video (text2video, or image2video with --from)')
|
|
91
|
+
.option('--from <assetId>', 'source image asset id').option('--tier <tier>', TIERS.join('|'), 'draft').option('--aspect <ratio>', '', '16:9').option('--duration <s>').option('--resolution <r>', '480p|720p|1080p').option('--audio', 'generate audio')
|
|
92
|
+
.option('--wait').option('--out <file>')
|
|
93
|
+
.action(async (prompt = '', o) => runJob(program, client(), videoJob({ prompt, tier: o.tier, aspect: o.aspect, sourceAssetId: o.from, duration: o.duration, resolution: o.resolution, generateAudio: o.audio }), { wait: o.wait || Boolean(o.out), out: o.out }))
|
|
94
|
+
program.command('tryon').description('Fashion try-on: garment on a person or character')
|
|
95
|
+
.requiredOption('--garment <assetId>').option('--person <assetId>').option('--character <id>').option('--prompt <text>', 'style note').option('--wait').option('--out <file>')
|
|
96
|
+
.action(async (o) => { if (!o.person && !o.character) { console.error('--person <assetId> or --character <id> is required'); process.exit(2) } return runJob(program, client(), tryonJob({ garmentAssetId: o.garment, personAssetId: o.person, characterId: o.character, prompt: o.prompt }), { wait: o.wait || Boolean(o.out), out: o.out }) })
|
|
97
|
+
program.command('characters').description('List your characters').action(async () => { const r = await client().characters(); const list = r?.characters || r || []; out(program, list, list.map((c) => `${String(c.id).padEnd(8)} ${c.name || ''}`).join('\n') || 'No characters.') })
|
|
98
|
+
|
|
99
|
+
const maket = program.command('maket').description('Floor plan → 3D (Maket)')
|
|
100
|
+
maket.command('analyze <file>').description('Analyze a DXF/JSON plan locally (free)').action((file) => { const a = analyzePlanText(readFileSync(file, 'utf8'), basename(file)); out(program, a, `${a.name}: ${a.stats.wallCount} walls, ${a.stats.doorCount} doors, ${a.stats.windowCount} windows, ${a.stats.roomCount} rooms, gross ${a.stats.grossArea?.toFixed?.(1)} m² (${a.units.name}${a.units.guessed ? ', guessed' : ''})\nlayers: ${a.layers.map((l) => `${l.name}=${l.role}`).join(', ')}`) })
|
|
101
|
+
maket.command('list').description('Saved Maket projects').action(async () => { const r = await client().maketList(); out(program, r.items, r.items.map((p) => `${String(p.id).padEnd(6)} ${p.title} ${p.source_kind} ${new Date(p.updated_date_time * 1000).toISOString().slice(0, 10)}`).join('\n') || 'No projects.') })
|
|
102
|
+
maket.command('save <file>').description('Save a plan as a Maket project').option('--title <title>').action(async (file, o) => {
|
|
103
|
+
const text = readFileSync(file, 'utf8'); const a = analyzePlanText(text, basename(file))
|
|
104
|
+
const r = await client().maketCreate({ title: o.title || basename(file, extname(file)), source_kind: a.source_kind, source_text: text, settings: { overrides: {}, wallHeight: 2.9 }, stats: a.stats })
|
|
105
|
+
out(program, r, `Saved project ${r.id}. Open it at ${SITE_URL}/app/maket`)
|
|
106
|
+
})
|
|
107
|
+
maket.command('get <id>').description('Download a project’s plan').option('--out <file>').action(async (id, o) => { const r = await client().maketGet(Number(id)); const p = r.project; const file = o.out || `${p.title || 'maket'}.${p.source_kind === 'json' ? 'json' : 'dxf'}`; writeFileSync(file, p.source_text); out(program, { id: p.id, title: p.title, saved: file }, `Saved: ${file}`) })
|
|
108
|
+
maket.command('delete <id>').action(async (id) => { await client().maketDelete(Number(id)); out(program, { ok: true }, 'Deleted.') })
|
|
109
|
+
|
|
110
|
+
program.command('mcp').description('Print the Claude MCP configuration snippet').action(() => {
|
|
111
|
+
console.log(JSON.stringify({ mcpServers: { yesilcam: { command: 'yesilcam-mcp', env: { YESILCAM_TOKEN: '<your token: yesilcam login --json>' } } } }, null, 2))
|
|
112
|
+
})
|
|
113
|
+
return program
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function main(argv) {
|
|
117
|
+
const program = buildProgram()
|
|
118
|
+
try { await program.parseAsync(argv) }
|
|
119
|
+
catch (e) { if (e instanceof ApiError || e?.message) { console.error(`Error: ${e.message}`); process.exit(1) } throw e }
|
|
120
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { API_URL, APP_KEY, APP_SECRET, REGISTRATION_SOURCE } from './constants.js'
|
|
2
|
+
|
|
3
|
+
const base = API_URL.replace(/\/+$/, '') + '/'
|
|
4
|
+
let appTokenCache = null
|
|
5
|
+
|
|
6
|
+
export class ApiError extends Error { constructor(message, data) { super(message); this.data = data } }
|
|
7
|
+
|
|
8
|
+
async function post(path, body, bearer, fetchImpl = globalThis.fetch) {
|
|
9
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
10
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`
|
|
11
|
+
const res = await fetchImpl(base + path, { method: 'POST', headers, body: JSON.stringify(body || {}) })
|
|
12
|
+
const data = await res.json().catch(() => null)
|
|
13
|
+
if (!data || data.status !== true) throw new ApiError((data && (data.message || data?.result?.error)) || `request_failed (${res.status})`, data)
|
|
14
|
+
return data.result
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function getAppToken(fetchImpl) {
|
|
18
|
+
if (appTokenCache) return appTokenCache
|
|
19
|
+
const r = await post('get-token', { key: APP_KEY, secret: APP_SECRET }, '', fetchImpl)
|
|
20
|
+
if (!r?.token) throw new ApiError('app_token_missing')
|
|
21
|
+
appTokenCache = r.token
|
|
22
|
+
return appTokenCache
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** E-posta + şifre → kullanıcı token'ı ve profil. */
|
|
26
|
+
export async function login(email, password, fetchImpl) {
|
|
27
|
+
const app = await getAppToken(fetchImpl)
|
|
28
|
+
const r = await post('flow/user-login', { credential: email, password }, app, fetchImpl)
|
|
29
|
+
if (!r?.token || !r?.id) throw new ApiError('invalid_login_response')
|
|
30
|
+
return { token: r.token, user: { id: String(r.id), name: r.name || '', lastname: r.lastname || '', email: r.email || '' } }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function makeClient(userToken, fetchImpl) {
|
|
34
|
+
if (!userToken) throw new ApiError('not_logged_in')
|
|
35
|
+
const flow = (name, body) => post(`flow/${name}`, body, userToken, fetchImpl)
|
|
36
|
+
return {
|
|
37
|
+
flow,
|
|
38
|
+
balance: () => flow('get-token-balance'),
|
|
39
|
+
me: () => flow('get-user-subscription-infos'),
|
|
40
|
+
modulePricing: () => flow('get-all-module-pricing'),
|
|
41
|
+
tokenPackages: () => flow('get-token-packages'),
|
|
42
|
+
routes: () => flow('aistudio-list-routes'),
|
|
43
|
+
assets: (p = {}) => flow('aistudio-asset-list', p),
|
|
44
|
+
uploadAsset: (fileBase64, kind = 'image', label = '') => flow('aistudio-upload-asset', { file_base64: fileBase64, kind, label }),
|
|
45
|
+
createJob: (p) => flow('aistudio-create-job', p),
|
|
46
|
+
jobStatus: (jobId) => flow('aistudio-job-status', { job_id: jobId }),
|
|
47
|
+
jobs: (p = {}) => flow('aistudio-job-list', p),
|
|
48
|
+
characters: () => flow('aistudio-character-list'),
|
|
49
|
+
maketList: () => flow('maket-list-projects'),
|
|
50
|
+
maketGet: (projectId) => flow('maket-get-project', { projectId }),
|
|
51
|
+
maketCreate: (p) => flow('maket-create-project', p),
|
|
52
|
+
maketDelete: (projectId) => flow('maket-delete-project', { projectId }),
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
export const CONFIG_DIR = process.env.YESILCAM_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'yesilcam')
|
|
6
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
7
|
+
|
|
8
|
+
export function readConfig() {
|
|
9
|
+
try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) } catch { return {} }
|
|
10
|
+
}
|
|
11
|
+
export function writeConfig(cfg) {
|
|
12
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
|
|
13
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
|
|
14
|
+
try { chmodSync(CONFIG_FILE, 0o600) } catch {}
|
|
15
|
+
}
|
|
16
|
+
export function clearConfig() { if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE) }
|
|
17
|
+
/** Öncelik: YESILCAM_TOKEN env > config dosyası. */
|
|
18
|
+
export function getUserToken() { return process.env.YESILCAM_TOKEN || readConfig().token || '' }
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Uygulama anahtarı: web uygulamasıyla aynı (get-token). Kullanıcı token'ı ayrı alınır ve diske yazılır.
|
|
2
|
+
export const API_URL = process.env.YESILCAM_API_URL || 'https://prod.apiollon.com/'
|
|
3
|
+
export const APP_KEY = process.env.YESILCAM_APP_KEY || 'F0RRDrlPigAYTmOSweyhBmo6aYZOLpTA8qGCGQe2wkmDHJYBF9'
|
|
4
|
+
export const APP_SECRET = process.env.YESILCAM_APP_SECRET || 'anHHiSHfgOZ5zJKxWGJPO2PPNdIgL2HwdgUchEuC7bh5BLU'
|
|
5
|
+
export const REGISTRATION_SOURCE = 'yesilcam-web'
|
|
6
|
+
export const SITE_URL = 'https://yesilcamstudios.com'
|
package/src/jobs.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// İş oluşturma ve bekleme. Sözleşme: aistudio_jobs.status ∈ queued|processing|succeeded|failed.
|
|
2
|
+
export const TIERS = ['draft', 'standard', 'cinema', 'cinemapro']
|
|
3
|
+
export const ASPECTS = ['1:1', '16:9', '9:16', '3:4', '4:3']
|
|
4
|
+
|
|
5
|
+
export function imageJob({ prompt, tier = 'draft', aspect = '16:9', framing }) {
|
|
6
|
+
const p = { job_type: 'text2image', prompt, quality_tier: tier, aspect_ratio: aspect }
|
|
7
|
+
if (framing) p.framing = framing
|
|
8
|
+
return p
|
|
9
|
+
}
|
|
10
|
+
export function videoJob({ prompt = '', tier = 'draft', aspect = '16:9', sourceAssetId, duration, resolution, generateAudio }) {
|
|
11
|
+
const p = { job_type: sourceAssetId ? 'image2video' : 'text2video', prompt, quality_tier: tier, aspect_ratio: aspect }
|
|
12
|
+
if (sourceAssetId) p.source_asset_id = Number(sourceAssetId)
|
|
13
|
+
if (duration) p.duration = Number(duration)
|
|
14
|
+
if (resolution) p.resolution = resolution
|
|
15
|
+
if (generateAudio) p.generate_audio = true
|
|
16
|
+
return p
|
|
17
|
+
}
|
|
18
|
+
export function tryonJob({ garmentAssetId, personAssetId, characterId, prompt = '' }) {
|
|
19
|
+
const p = { job_type: 'tryon', garment_asset_id: Number(garmentAssetId), prompt, quality_tier: 'standard' }
|
|
20
|
+
if (characterId) p.character_id = Number(characterId)
|
|
21
|
+
else if (personAssetId) p.source_asset_id = Number(personAssetId)
|
|
22
|
+
return p
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
26
|
+
const isDone = (s) => ['succeeded', 'failed', 'cancelled', 'refunded'].includes(String(s))
|
|
27
|
+
|
|
28
|
+
/** İş bitene kadar bekler (varsayılan 4 sn aralık, 20 dk). onTick(job) ilerleme için. */
|
|
29
|
+
export async function waitForJob(client, jobId, { intervalMs = 4000, timeoutMs = 20 * 60 * 1000, onTick, sleepImpl = sleep } = {}) {
|
|
30
|
+
const started = Date.now()
|
|
31
|
+
for (;;) {
|
|
32
|
+
const r = await client.jobStatus(Number(jobId))
|
|
33
|
+
const job = r?.job
|
|
34
|
+
onTick?.(job)
|
|
35
|
+
if (job && isDone(job.status)) return job
|
|
36
|
+
if (Date.now() - started > timeoutMs) throw new Error('job_wait_timeout')
|
|
37
|
+
await sleepImpl(intervalMs)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function describeJob(job) {
|
|
41
|
+
if (!job) return null
|
|
42
|
+
return { id: String(job.id), type: job.job_type, status: job.status, tier: job.quality_tier, progress: job.render_progress != null ? Number(job.render_progress) : null,
|
|
43
|
+
tokens_charged: job.tokens_charged != null ? Number(job.tokens_charged) : null, error: job.error_code || null,
|
|
44
|
+
result: job.result_asset ? { asset_id: String(job.result_asset.id), kind: job.result_asset.kind, url: job.result_asset.spaces_url, thumb: job.result_asset.thumb_url } : null }
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Maket yerel analiz: DXF/JSON → duvar/kapı/pencere/mahal istatistikleri (ağ yok, ücretsiz).
|
|
2
|
+
import { modelFromDxfText, modelFromJsonPlan } from './plan/pipeline.js'
|
|
3
|
+
import { attachOpenings } from './plan/geometry.js'
|
|
4
|
+
import { DEMO_PLANS } from './demo/plans.js'
|
|
5
|
+
|
|
6
|
+
export { DEMO_PLANS }
|
|
7
|
+
export function analyzePlanText(text, name = 'plan', overrides = {}) {
|
|
8
|
+
const isJson = /\.json$/i.test(name) || /^\s*[{[]/.test(text)
|
|
9
|
+
const res = isJson ? modelFromJsonPlan(JSON.parse(text), { overrides }) : modelFromDxfText(text, { overrides })
|
|
10
|
+
const model = attachOpenings(res.model)
|
|
11
|
+
return {
|
|
12
|
+
name, source_kind: isJson ? 'json' : 'dxf', units: res.units,
|
|
13
|
+
layers: res.layers.map((l) => ({ name: l.name, role: l.role, count: l.count })),
|
|
14
|
+
stats: model.stats, rooms: model.rooms.map((r) => ({ name: r.name, area: r.area })),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// PlanModel'i (metre) gerçekçi bir ASCII DXF'e yazar: duvarlar çift çizgi + uç kapakları,
|
|
2
|
+
// kapılar ARC, pencereler LINE, mahaller kapalı LWPOLYLINE, adlar TEXT. Birim: cm ($INSUNITS 5).
|
|
3
|
+
const K = 100; // m → cm
|
|
4
|
+
const f = (v) => (v * K).toFixed(2);
|
|
5
|
+
|
|
6
|
+
export function writeDxf(model, { name = 'MAKET' } = {}) {
|
|
7
|
+
const L = [];
|
|
8
|
+
const pair = (c, v) => L.push(String(c), String(v));
|
|
9
|
+
pair(0, 'SECTION'); pair(2, 'HEADER'); pair(9, '$INSUNITS'); pair(70, 5); pair(9, '$ACADVER'); pair(1, 'AC1015'); pair(0, 'ENDSEC');
|
|
10
|
+
pair(0, 'SECTION'); pair(2, 'TABLES'); pair(0, 'TABLE'); pair(2, 'LAYER');
|
|
11
|
+
for (const [ln, col] of [['A-WALL', 7], ['A-DOOR', 30], ['A-GLAZ', 4], ['A-AREA', 8], ['A-ANNO-TEXT', 2], ['A-FURN', 9], ['A-DIMS', 1]]) {
|
|
12
|
+
pair(0, 'LAYER'); pair(2, ln); pair(70, 0); pair(62, col); pair(6, 'CONTINUOUS');
|
|
13
|
+
}
|
|
14
|
+
pair(0, 'ENDTAB'); pair(0, 'ENDSEC');
|
|
15
|
+
pair(0, 'SECTION'); pair(2, 'ENTITIES');
|
|
16
|
+
const line = (layer, a, b) => { pair(0, 'LINE'); pair(8, layer); pair(10, f(a[0])); pair(20, f(a[1])); pair(30, 0); pair(11, f(b[0])); pair(21, f(b[1])); pair(31, 0); };
|
|
17
|
+
|
|
18
|
+
for (const w of model.walls) {
|
|
19
|
+
const dx = w.b[0] - w.a[0], dy = w.b[1] - w.a[1]; const len = Math.hypot(dx, dy) || 1;
|
|
20
|
+
const nx = (-dy / len) * (w.thickness / 2), ny = (dx / len) * (w.thickness / 2);
|
|
21
|
+
const a1 = [w.a[0] + nx, w.a[1] + ny], b1 = [w.b[0] + nx, w.b[1] + ny];
|
|
22
|
+
const a2 = [w.a[0] - nx, w.a[1] - ny], b2 = [w.b[0] - nx, w.b[1] - ny];
|
|
23
|
+
line('A-WALL', a1, b1); line('A-WALL', a2, b2); line('A-WALL', a1, a2); line('A-WALL', b1, b2);
|
|
24
|
+
}
|
|
25
|
+
for (const p of model.wallPolys || []) {
|
|
26
|
+
pair(0, 'LWPOLYLINE'); pair(8, 'A-WALL'); pair(90, p.points.length); pair(70, 1);
|
|
27
|
+
for (const q of p.points) { pair(10, f(q[0])); pair(20, f(q[1])); }
|
|
28
|
+
}
|
|
29
|
+
for (const w of model.windows) line('A-GLAZ', w.a, w.b);
|
|
30
|
+
for (const d of model.doors) {
|
|
31
|
+
const deg = (d.angle * 180) / Math.PI; const s = d.swing >= 0 ? deg : deg + d.swing; const e = d.swing >= 0 ? deg + d.swing : deg;
|
|
32
|
+
pair(0, 'ARC'); pair(8, 'A-DOOR'); pair(10, f(d.hinge[0])); pair(20, f(d.hinge[1])); pair(30, 0); pair(40, f(d.width)); pair(50, s.toFixed(2)); pair(51, e.toFixed(2));
|
|
33
|
+
const a = d.angle; line('A-DOOR', d.hinge, [d.hinge[0] + Math.cos(a) * d.width, d.hinge[1] + Math.sin(a) * d.width]);
|
|
34
|
+
}
|
|
35
|
+
for (const r of model.rooms) {
|
|
36
|
+
pair(0, 'LWPOLYLINE'); pair(8, 'A-AREA'); pair(90, r.points.length); pair(70, 1);
|
|
37
|
+
for (const q of r.points) { pair(10, f(q[0])); pair(20, f(q[1])); }
|
|
38
|
+
pair(0, 'TEXT'); pair(8, 'A-ANNO-TEXT'); pair(10, f(r.centroid[0])); pair(20, f(r.centroid[1])); pair(30, 0); pair(40, 25); pair(1, r.name);
|
|
39
|
+
}
|
|
40
|
+
for (const l of model.labels) { pair(0, 'TEXT'); pair(8, 'A-ANNO-TEXT'); pair(10, f(l.pos[0])); pair(20, f(l.pos[1])); pair(30, 0); pair(40, 25); pair(1, l.text); }
|
|
41
|
+
for (const d of model.decor) line('A-FURN', d.a, d.b);
|
|
42
|
+
// ölçü metni: yoksayılmalı
|
|
43
|
+
pair(0, 'TEXT'); pair(8, 'A-DIMS'); pair(10, f(model.bounds.cx)); pair(20, f(model.bounds.minY - 1)); pair(30, 0); pair(40, 20); pair(1, (model.bounds.w * 100).toFixed(0));
|
|
44
|
+
pair(0, 'ENDSEC'); pair(0, 'EOF');
|
|
45
|
+
return L.join('\n') + '\n';
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Hazır planlar (metre). Aynı boru hattından geçer; DXF'e de yazılabilir.
|
|
2
|
+
const R = (x0, y0, x1, y1) => [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
|
3
|
+
|
|
4
|
+
export const DEMO_PLANS = [
|
|
5
|
+
{
|
|
6
|
+
id: 'bahceli-ev',
|
|
7
|
+
name: 'Bahçeli Ev',
|
|
8
|
+
subtitle: 'Tek katlı konut · 12 × 9 m',
|
|
9
|
+
units: 'm',
|
|
10
|
+
walls: [
|
|
11
|
+
{ points: R(0, 0, 12, 9), closed: true },
|
|
12
|
+
{ a: [6, 0], b: [6, 9] }, { a: [0, 5.5], b: [6, 5.5] }, { a: [6, 4], b: [12, 4] },
|
|
13
|
+
{ a: [9, 4], b: [9, 9] }, { a: [9, 6.5], b: [12, 6.5] },
|
|
14
|
+
],
|
|
15
|
+
doors: [
|
|
16
|
+
{ hinge: [7, 9], angle: 0, width: 1.0, swing: -90 },
|
|
17
|
+
{ hinge: [7, 4], angle: 0, width: 0.9, swing: -90 },
|
|
18
|
+
{ hinge: [6, 4.2], angle: 90, width: 0.9, swing: 90 },
|
|
19
|
+
{ hinge: [6, 6.5], angle: 90, width: 0.9, swing: 90 },
|
|
20
|
+
{ hinge: [9, 4.8], angle: 90, width: 0.8, swing: -90 },
|
|
21
|
+
{ hinge: [9, 7], angle: 90, width: 0.9, swing: -90 },
|
|
22
|
+
],
|
|
23
|
+
windows: [
|
|
24
|
+
{ a: [1.5, 0], b: [4.5, 0] }, { a: [7.5, 0], b: [10, 0] }, { a: [0, 1.5], b: [0, 3.5] }, { a: [0, 6], b: [0, 8] },
|
|
25
|
+
{ a: [12, 1], b: [12, 3] }, { a: [12, 7], b: [12, 8.5] }, { a: [2, 9], b: [4, 9] }, { a: [10, 9], b: [11.5, 9] },
|
|
26
|
+
{ a: [12, 5], b: [12, 6] },
|
|
27
|
+
],
|
|
28
|
+
rooms: [
|
|
29
|
+
{ name: 'Salon', points: R(0, 0, 6, 5.5) }, { name: 'Mutfak', points: R(6, 0, 12, 4) },
|
|
30
|
+
{ name: 'Hol', points: R(6, 4, 9, 9) }, { name: 'Yatak Odası', points: R(0, 5.5, 6, 9) },
|
|
31
|
+
{ name: 'Banyo', points: R(9, 4, 12, 6.5) }, { name: 'Çalışma Odası', points: R(9, 6.5, 12, 9) },
|
|
32
|
+
],
|
|
33
|
+
decor: [
|
|
34
|
+
{ points: R(6.1, 0.1, 11.9, 0.7), closed: true }, { points: R(11.3, 0.7, 11.9, 3.9), closed: true },
|
|
35
|
+
{ points: R(0.3, 6.4, 2.3, 8.4), closed: true }, { points: R(0.5, 1.2, 0.9, 4.2), closed: true },
|
|
36
|
+
{ points: R(0.9, 1.2, 4.2, 1.6), closed: true }, { points: R(9.2, 4.2, 10.0, 6.3), closed: true },
|
|
37
|
+
{ points: R(9.2, 6.8, 11.6, 7.5), closed: true },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'ofis-kati',
|
|
42
|
+
name: 'Ofis Katı',
|
|
43
|
+
subtitle: 'Açık ofis + toplantı odaları · 20 × 12 m',
|
|
44
|
+
units: 'm',
|
|
45
|
+
walls: [
|
|
46
|
+
{ points: R(0, 0, 20, 12), closed: true },
|
|
47
|
+
{ a: [13, 0], b: [13, 8] }, { a: [13, 5], b: [20, 5] }, { a: [0, 8], b: [20, 8] },
|
|
48
|
+
{ a: [5, 8], b: [5, 12] }, { a: [15, 8], b: [15, 12] },
|
|
49
|
+
],
|
|
50
|
+
doors: [
|
|
51
|
+
{ hinge: [13, 2], angle: 90, width: 0.9, swing: -90 }, { hinge: [13, 6], angle: 90, width: 0.9, swing: -90 },
|
|
52
|
+
{ hinge: [9, 8], angle: 0, width: 1.0, swing: -90 }, { hinge: [5, 9.5], angle: 90, width: 0.9, swing: 90 },
|
|
53
|
+
{ hinge: [15, 9.5], angle: 90, width: 0.9, swing: -90 }, { hinge: [10, 12], angle: 0, width: 1.4, swing: -90 },
|
|
54
|
+
],
|
|
55
|
+
windows: [
|
|
56
|
+
{ a: [2, 0], b: [6, 0] }, { a: [7, 0], b: [11, 0] }, { a: [14, 0], b: [19, 0] },
|
|
57
|
+
{ a: [1, 12], b: [4, 12] }, { a: [16, 12], b: [19, 12] },
|
|
58
|
+
{ a: [20, 1], b: [20, 4] }, { a: [20, 6], b: [20, 7.5] }, { a: [20, 9], b: [20, 11] },
|
|
59
|
+
{ a: [0, 1], b: [0, 4] }, { a: [0, 5], b: [0, 7] }, { a: [0, 9], b: [0, 11] },
|
|
60
|
+
],
|
|
61
|
+
rooms: [
|
|
62
|
+
{ name: 'Açık Ofis', points: R(0, 0, 13, 8) }, { name: 'Toplantı A', points: R(13, 0, 20, 5) },
|
|
63
|
+
{ name: 'Toplantı B', points: R(13, 5, 20, 8) }, { name: 'Mutfak', points: R(0, 8, 5, 12) },
|
|
64
|
+
{ name: 'Koridor', points: R(5, 8, 15, 12) }, { name: 'Yönetici', points: R(15, 8, 20, 12) },
|
|
65
|
+
],
|
|
66
|
+
decor: [
|
|
67
|
+
...[1, 4, 7, 10].flatMap((x) => [2, 5].map((y) => ({ points: R(x, y, x + 1.6, y + 0.8), closed: true }))),
|
|
68
|
+
...[1, 4, 7, 10].flatMap((x) => [3, 6].map((y) => ({ points: R(x, y, x + 1.6, y + 0.8), closed: true }))),
|
|
69
|
+
{ points: R(14.5, 1.5, 18.5, 3.5), closed: true }, { points: R(14.5, 5.8, 18.5, 7.2), closed: true },
|
|
70
|
+
{ points: R(16, 9, 18.5, 10), closed: true }, { points: R(0.2, 11.3, 4.8, 11.9), closed: true },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'studyo-daire',
|
|
75
|
+
name: 'Stüdyo Daire',
|
|
76
|
+
subtitle: 'Kompakt yaşam · 6 × 8 m',
|
|
77
|
+
units: 'm',
|
|
78
|
+
walls: [
|
|
79
|
+
{ points: R(0, 0, 6, 8), closed: true }, { a: [0, 5.5], b: [6, 5.5] }, { a: [2.5, 5.5], b: [2.5, 8] },
|
|
80
|
+
],
|
|
81
|
+
doors: [
|
|
82
|
+
{ hinge: [4, 8], angle: 0, width: 1.0, swing: -90 }, { hinge: [2.5, 6.2], angle: 90, width: 0.8, swing: 90 },
|
|
83
|
+
{ hinge: [3.5, 5.5], angle: 0, width: 0.9, swing: -90 },
|
|
84
|
+
],
|
|
85
|
+
windows: [{ a: [1, 0], b: [5, 0] }, { a: [0, 1], b: [0, 3] }, { a: [6, 1.5], b: [6, 4] }],
|
|
86
|
+
rooms: [
|
|
87
|
+
{ name: 'Yaşam Alanı', points: R(0, 0, 6, 5.5) }, { name: 'Banyo', points: R(0, 5.5, 2.5, 8) },
|
|
88
|
+
{ name: 'Giriş Holü', points: R(2.5, 5.5, 6, 8) },
|
|
89
|
+
],
|
|
90
|
+
decor: [
|
|
91
|
+
{ points: R(0.3, 3.5, 2.3, 5.3), closed: true }, { points: R(3.5, 0.2, 5.8, 0.8), closed: true },
|
|
92
|
+
{ points: R(0.4, 0.4, 2.2, 1.2), closed: true }, { points: R(0.2, 5.7, 1.0, 6.5), closed: true },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Minimal ASCII DXF ayrıştırıcı. Bağımlılık yok.
|
|
2
|
+
// Çıktı: { header: {insunits}, layers: [{name,color}], blocks: {name: entities[]}, entities: [] }
|
|
3
|
+
// Varlık türleri: LINE, LWPOLYLINE, POLYLINE(+VERTEX), CIRCLE, ARC, TEXT, MTEXT, INSERT
|
|
4
|
+
|
|
5
|
+
export function parseDxf(text) {
|
|
6
|
+
const pairs = tokenize(text);
|
|
7
|
+
const out = { header: { insunits: null }, layers: [], blocks: {}, entities: [], warnings: [] };
|
|
8
|
+
let i = 0;
|
|
9
|
+
const n = pairs.length;
|
|
10
|
+
|
|
11
|
+
while (i < n) {
|
|
12
|
+
const [code, value] = pairs[i];
|
|
13
|
+
if (code === 0 && value === 'SECTION') {
|
|
14
|
+
const nameTok = pairs[i + 1];
|
|
15
|
+
const sectionName = nameTok && nameTok[0] === 2 ? nameTok[1] : '';
|
|
16
|
+
i += 2;
|
|
17
|
+
if (sectionName === 'HEADER') i = readHeader(pairs, i, out);
|
|
18
|
+
else if (sectionName === 'TABLES') i = readTables(pairs, i, out);
|
|
19
|
+
else if (sectionName === 'BLOCKS') i = readBlocks(pairs, i, out);
|
|
20
|
+
else if (sectionName === 'ENTITIES') {
|
|
21
|
+
const res = readEntities(pairs, i, 'ENDSEC');
|
|
22
|
+
out.entities = res.entities;
|
|
23
|
+
i = res.index;
|
|
24
|
+
} else i = skipSection(pairs, i);
|
|
25
|
+
} else if (code === 0 && value === 'EOF') break;
|
|
26
|
+
else i++;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tokenize(text) {
|
|
32
|
+
const lines = text.split(/\r\n|\n|\r/);
|
|
33
|
+
const pairs = [];
|
|
34
|
+
for (let k = 0; k + 1 < lines.length; k += 2) {
|
|
35
|
+
const code = parseInt(lines[k].trim(), 10);
|
|
36
|
+
if (Number.isNaN(code)) continue;
|
|
37
|
+
pairs.push([code, lines[k + 1].trim()]);
|
|
38
|
+
}
|
|
39
|
+
return pairs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function skipSection(pairs, i) {
|
|
43
|
+
while (i < pairs.length && !(pairs[i][0] === 0 && pairs[i][1] === 'ENDSEC')) i++;
|
|
44
|
+
return i + 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readHeader(pairs, i, out) {
|
|
48
|
+
while (i < pairs.length) {
|
|
49
|
+
const [code, value] = pairs[i];
|
|
50
|
+
if (code === 0 && value === 'ENDSEC') return i + 1;
|
|
51
|
+
if (code === 9 && value === '$INSUNITS' && pairs[i + 1] && pairs[i + 1][0] === 70) {
|
|
52
|
+
out.header.insunits = parseInt(pairs[i + 1][1], 10);
|
|
53
|
+
i += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return i;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readTables(pairs, i, out) {
|
|
62
|
+
while (i < pairs.length) {
|
|
63
|
+
const [code, value] = pairs[i];
|
|
64
|
+
if (code === 0 && value === 'ENDSEC') return i + 1;
|
|
65
|
+
if (code === 0 && value === 'LAYER') {
|
|
66
|
+
const layer = { name: '0', color: 7 };
|
|
67
|
+
i++;
|
|
68
|
+
while (i < pairs.length && pairs[i][0] !== 0) {
|
|
69
|
+
if (pairs[i][0] === 2) layer.name = pairs[i][1];
|
|
70
|
+
if (pairs[i][0] === 62) layer.color = Math.abs(parseInt(pairs[i][1], 10));
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
out.layers.push(layer);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
return i;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readBlocks(pairs, i, out) {
|
|
82
|
+
while (i < pairs.length) {
|
|
83
|
+
const [code, value] = pairs[i];
|
|
84
|
+
if (code === 0 && value === 'ENDSEC') return i + 1;
|
|
85
|
+
if (code === 0 && value === 'BLOCK') {
|
|
86
|
+
let name = '';
|
|
87
|
+
let base = [0, 0];
|
|
88
|
+
i++;
|
|
89
|
+
while (i < pairs.length && pairs[i][0] !== 0) {
|
|
90
|
+
if (pairs[i][0] === 2) name = pairs[i][1];
|
|
91
|
+
if (pairs[i][0] === 10) base[0] = parseFloat(pairs[i][1]);
|
|
92
|
+
if (pairs[i][0] === 20) base[1] = parseFloat(pairs[i][1]);
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
const res = readEntities(pairs, i, 'ENDBLK');
|
|
96
|
+
out.blocks[name] = { name, base, entities: res.entities };
|
|
97
|
+
i = res.index;
|
|
98
|
+
// ENDBLK'in alt kodlarını atla
|
|
99
|
+
while (i < pairs.length && pairs[i][0] !== 0) i++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
return i;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ENTITY_TYPES = new Set(['LINE', 'LWPOLYLINE', 'POLYLINE', 'CIRCLE', 'ARC', 'TEXT', 'MTEXT', 'INSERT']);
|
|
108
|
+
|
|
109
|
+
function readEntities(pairs, i, terminator) {
|
|
110
|
+
const entities = [];
|
|
111
|
+
while (i < pairs.length) {
|
|
112
|
+
const [code, value] = pairs[i];
|
|
113
|
+
if (code === 0 && value === terminator) return { entities, index: i + 1 };
|
|
114
|
+
if (code === 0 && ENTITY_TYPES.has(value)) {
|
|
115
|
+
const res = readEntity(pairs, i);
|
|
116
|
+
if (res.entity) entities.push(res.entity);
|
|
117
|
+
i = res.index;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
return { entities, index: i };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readEntity(pairs, i) {
|
|
126
|
+
const type = pairs[i][1];
|
|
127
|
+
i++;
|
|
128
|
+
const raw = [];
|
|
129
|
+
while (i < pairs.length && pairs[i][0] !== 0) {
|
|
130
|
+
raw.push(pairs[i]);
|
|
131
|
+
i++;
|
|
132
|
+
}
|
|
133
|
+
const g = (code) => {
|
|
134
|
+
const p = raw.find((r) => r[0] === code);
|
|
135
|
+
return p ? p[1] : undefined;
|
|
136
|
+
};
|
|
137
|
+
const f = (code, def = 0) => {
|
|
138
|
+
const v = g(code);
|
|
139
|
+
return v === undefined ? def : parseFloat(v);
|
|
140
|
+
};
|
|
141
|
+
const layer = g(8) || '0';
|
|
142
|
+
let entity = null;
|
|
143
|
+
|
|
144
|
+
switch (type) {
|
|
145
|
+
case 'LINE':
|
|
146
|
+
entity = { type, layer, a: [f(10), f(20)], b: [f(11), f(21)] };
|
|
147
|
+
break;
|
|
148
|
+
case 'CIRCLE':
|
|
149
|
+
entity = { type, layer, center: [f(10), f(20)], r: f(40) };
|
|
150
|
+
break;
|
|
151
|
+
case 'ARC':
|
|
152
|
+
entity = { type, layer, center: [f(10), f(20)], r: f(40), start: f(50), end: f(51) };
|
|
153
|
+
break;
|
|
154
|
+
case 'TEXT':
|
|
155
|
+
entity = { type, layer, pos: [f(10), f(20)], height: f(40, 1), text: cleanText(g(1) || ''), rotation: f(50) };
|
|
156
|
+
break;
|
|
157
|
+
case 'MTEXT': {
|
|
158
|
+
const parts = raw.filter((r) => r[0] === 3).map((r) => r[1]).join('') + (g(1) || '');
|
|
159
|
+
entity = { type, layer, pos: [f(10), f(20)], height: f(40, 1), text: cleanText(parts), rotation: f(50) };
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'INSERT':
|
|
163
|
+
entity = {
|
|
164
|
+
type, layer, block: g(2) || '', pos: [f(10), f(20)],
|
|
165
|
+
scale: [f(41, 1), f(42, 1)], rotation: f(50),
|
|
166
|
+
};
|
|
167
|
+
break;
|
|
168
|
+
case 'LWPOLYLINE': {
|
|
169
|
+
const pts = [];
|
|
170
|
+
let cur = null;
|
|
171
|
+
for (const [c, v] of raw) {
|
|
172
|
+
if (c === 10) { cur = [parseFloat(v), 0]; pts.push(cur); }
|
|
173
|
+
else if (c === 20 && cur) cur[1] = parseFloat(v);
|
|
174
|
+
}
|
|
175
|
+
const flags = parseInt(g(70) || '0', 10);
|
|
176
|
+
entity = { type: 'POLYLINE', layer, points: pts, closed: (flags & 1) === 1 };
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'POLYLINE': {
|
|
180
|
+
const flags = parseInt(g(70) || '0', 10);
|
|
181
|
+
const pts = [];
|
|
182
|
+
// VERTEX'ler SEQEND'e kadar ayrı varlıklar olarak gelir
|
|
183
|
+
while (i < pairs.length && pairs[i][0] === 0 && pairs[i][1] === 'VERTEX') {
|
|
184
|
+
i++;
|
|
185
|
+
let x = 0, y = 0;
|
|
186
|
+
while (i < pairs.length && pairs[i][0] !== 0) {
|
|
187
|
+
if (pairs[i][0] === 10) x = parseFloat(pairs[i][1]);
|
|
188
|
+
if (pairs[i][0] === 20) y = parseFloat(pairs[i][1]);
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
pts.push([x, y]);
|
|
192
|
+
}
|
|
193
|
+
if (i < pairs.length && pairs[i][0] === 0 && pairs[i][1] === 'SEQEND') {
|
|
194
|
+
i++;
|
|
195
|
+
while (i < pairs.length && pairs[i][0] !== 0) i++;
|
|
196
|
+
}
|
|
197
|
+
entity = { type: 'POLYLINE', layer, points: pts, closed: (flags & 1) === 1 };
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
default:
|
|
201
|
+
entity = null;
|
|
202
|
+
}
|
|
203
|
+
return { entity, index: i };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cleanText(s) {
|
|
207
|
+
// MTEXT biçimlendirme kodlarını ayıkla: \P satır, {\fArial|b0;...}, \H1.2x; vb.
|
|
208
|
+
return s
|
|
209
|
+
.replace(/\\P/g, ' ')
|
|
210
|
+
.replace(/\\[A-Za-z][^;\\]*;/g, '')
|
|
211
|
+
.replace(/[{}]/g, '')
|
|
212
|
+
.replace(/%%[dDpPcC]/g, '')
|
|
213
|
+
.replace(/\s+/g, ' ')
|
|
214
|
+
.trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Blok referanslarını (INSERT) gerçek geometriye açar. Kapı/pencere blokları için konum korunur.
|
|
218
|
+
export function explodeInserts(dxf, depth = 0) {
|
|
219
|
+
const result = [];
|
|
220
|
+
for (const e of dxf.entities) {
|
|
221
|
+
if (e.type !== 'INSERT') { result.push(e); continue; }
|
|
222
|
+
const block = dxf.blocks[e.block];
|
|
223
|
+
result.push(e); // referansı da koru (kapı tespiti için)
|
|
224
|
+
if (!block || depth > 3) continue;
|
|
225
|
+
const cos = Math.cos((e.rotation * Math.PI) / 180);
|
|
226
|
+
const sin = Math.sin((e.rotation * Math.PI) / 180);
|
|
227
|
+
const tf = (p) => {
|
|
228
|
+
const x = (p[0] - block.base[0]) * e.scale[0];
|
|
229
|
+
const y = (p[1] - block.base[1]) * e.scale[1];
|
|
230
|
+
return [e.pos[0] + x * cos - y * sin, e.pos[1] + x * sin + y * cos];
|
|
231
|
+
};
|
|
232
|
+
for (const be of block.entities) {
|
|
233
|
+
const layer = be.layer === '0' ? e.layer : be.layer;
|
|
234
|
+
const copy = { ...be, layer, fromBlock: e.block };
|
|
235
|
+
if (be.a) { copy.a = tf(be.a); copy.b = tf(be.b); }
|
|
236
|
+
if (be.center) { copy.center = tf(be.center); copy.r = be.r * Math.abs(e.scale[0]); if (be.type === 'ARC') { copy.start = be.start + e.rotation; copy.end = be.end + e.rotation; } }
|
|
237
|
+
if (be.points) copy.points = be.points.map(tf);
|
|
238
|
+
if (be.pos && be.type !== 'INSERT') copy.pos = tf(be.pos);
|
|
239
|
+
result.push(copy);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Katman adından eleman rolü çıkarır ve çizim birimini tahmin eder.
|
|
2
|
+
|
|
3
|
+
export const ROLES = ['wall', 'door', 'window', 'text', 'room', 'decor', 'ignore'];
|
|
4
|
+
|
|
5
|
+
export const ROLE_LABELS = {
|
|
6
|
+
wall: 'Duvar', door: 'Kapı', window: 'Pencere', text: 'Yazı', room: 'Mahal', decor: 'Dekor', ignore: 'Yoksay',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const RULES = [
|
|
10
|
+
['ignore', /dim|olcu|ölçü|hatch|tarama|grid|aks|axis|defpoints|viewport|title|antet|frame|cerceve|çerçeve|xref|hidden/i],
|
|
11
|
+
['window', /window|win\b|pencere|glaz|cam|fenster/i],
|
|
12
|
+
['door', /door|kapi|kapı|tür|porte/i],
|
|
13
|
+
['room', /room|mahal|space|zone|area|alan|oda|hacim/i],
|
|
14
|
+
['text', /text|yazi|yazı|anno|label|etiket|note/i],
|
|
15
|
+
['wall', /wall|duvar|mur|wand|a-wall|partition|bolme|bölme|struct|kolon|column/i],
|
|
16
|
+
['decor', /furn|mobilya|esya|eşya|equip|sanit|vitr|stair|merdiven|kitchen|mutfak|banyo|bath|elec|mech|plumb|tesisat|site|peyzaj|landsc|tree|agac|ağaç|car|arac|araç|symbol|sembol/i],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function roleForLayer(name) {
|
|
20
|
+
for (const [role, re] of RULES) if (re.test(name)) return role;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Katman → rol haritası. Hiçbir katman duvar eşleşmezse en kalabalık çizgi katmanı duvar olur.
|
|
25
|
+
export function buildLayerMap(entities, overrides = {}) {
|
|
26
|
+
const counts = new Map();
|
|
27
|
+
for (const e of entities) {
|
|
28
|
+
const c = counts.get(e.layer) || { total: 0, lines: 0, texts: 0, arcs: 0 };
|
|
29
|
+
c.total++;
|
|
30
|
+
if (e.type === 'LINE' || e.type === 'POLYLINE') c.lines++;
|
|
31
|
+
if (e.type === 'TEXT' || e.type === 'MTEXT') c.texts++;
|
|
32
|
+
if (e.type === 'ARC') c.arcs++;
|
|
33
|
+
counts.set(e.layer, c);
|
|
34
|
+
}
|
|
35
|
+
const map = {};
|
|
36
|
+
for (const [name, c] of counts) {
|
|
37
|
+
let role = overrides[name] || roleForLayer(name);
|
|
38
|
+
if (!role) role = c.texts > c.lines ? 'text' : null;
|
|
39
|
+
map[name] = role;
|
|
40
|
+
}
|
|
41
|
+
const hasWall = Object.values(map).includes('wall');
|
|
42
|
+
if (!hasWall) {
|
|
43
|
+
let best = null;
|
|
44
|
+
for (const [name, c] of counts) {
|
|
45
|
+
if (map[name] && map[name] !== 'decor') continue;
|
|
46
|
+
if (overrides[name]) continue;
|
|
47
|
+
if (!best || c.lines > counts.get(best).lines) best = name;
|
|
48
|
+
}
|
|
49
|
+
if (best) map[best] = 'wall';
|
|
50
|
+
}
|
|
51
|
+
for (const name of Object.keys(map)) if (!map[name]) map[name] = 'decor';
|
|
52
|
+
const layers = [...counts.entries()].map(([name, c]) => ({ name, count: c.total, role: map[name] }))
|
|
53
|
+
.sort((a, b) => b.count - a.count);
|
|
54
|
+
return { map, layers };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// DXF $INSUNITS kodları: 1 inç, 2 feet, 4 mm, 5 cm, 6 m
|
|
58
|
+
export function unitsFromInsunits(code) {
|
|
59
|
+
switch (code) {
|
|
60
|
+
case 1: return { name: 'inç', toMeters: 0.0254 };
|
|
61
|
+
case 2: return { name: 'feet', toMeters: 0.3048 };
|
|
62
|
+
case 4: return { name: 'mm', toMeters: 0.001 };
|
|
63
|
+
case 5: return { name: 'cm', toMeters: 0.01 };
|
|
64
|
+
case 6: return { name: 'm', toMeters: 1 };
|
|
65
|
+
default: return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Bina genişliği 3–300 m arasına düşecek birimi seç.
|
|
70
|
+
export function guessUnits(extent) {
|
|
71
|
+
if (extent > 2000) return { name: 'mm', toMeters: 0.001, guessed: true };
|
|
72
|
+
if (extent > 200) return { name: 'cm', toMeters: 0.01, guessed: true };
|
|
73
|
+
return { name: 'm', toMeters: 1, guessed: true };
|
|
74
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Maket JSON plan şeması → DXF benzeri varlık listesi (aynı boru hattından geçer).
|
|
2
|
+
// {
|
|
3
|
+
// name, units: 'm'|'cm'|'mm',
|
|
4
|
+
// walls: [{a:[x,y], b:[x,y]}] veya [{points:[[x,y]...], closed:true}]
|
|
5
|
+
// windows: [{a,b}], doors: [{hinge:[x,y], angle(deg), width, swing:90|-90}],
|
|
6
|
+
// rooms: [{name, points:[[x,y]...]}], labels: [{pos:[x,y], text}], decor: [{a,b}|{points}]
|
|
7
|
+
// }
|
|
8
|
+
export const JSON_LAYERS = { wall: 'DUVAR', window: 'PENCERE', door: 'KAPI', room: 'MAHAL', text: 'YAZI', decor: 'DEKOR' };
|
|
9
|
+
|
|
10
|
+
export function entitiesFromJson(plan) {
|
|
11
|
+
const ents = [];
|
|
12
|
+
const seg = (layer, o) => o.points
|
|
13
|
+
? ents.push({ type: 'POLYLINE', layer, points: o.points, closed: !!o.closed })
|
|
14
|
+
: ents.push({ type: 'LINE', layer, a: o.a, b: o.b });
|
|
15
|
+
(plan.walls || []).forEach((w) => seg(JSON_LAYERS.wall, w));
|
|
16
|
+
(plan.windows || []).forEach((w) => seg(JSON_LAYERS.window, w));
|
|
17
|
+
(plan.decor || []).forEach((d) => seg(JSON_LAYERS.decor, d));
|
|
18
|
+
(plan.doors || []).forEach((d) => {
|
|
19
|
+
const swing = d.swing ?? 90;
|
|
20
|
+
const start = swing >= 0 ? d.angle : d.angle + swing;
|
|
21
|
+
const end = swing >= 0 ? d.angle + swing : d.angle;
|
|
22
|
+
ents.push({ type: 'ARC', layer: JSON_LAYERS.door, center: d.hinge, r: d.width, start, end });
|
|
23
|
+
});
|
|
24
|
+
(plan.rooms || []).forEach((r) => {
|
|
25
|
+
ents.push({ type: 'POLYLINE', layer: JSON_LAYERS.room, points: r.points, closed: true });
|
|
26
|
+
if (r.name) {
|
|
27
|
+
const c = centroid(r.points);
|
|
28
|
+
ents.push({ type: 'TEXT', layer: JSON_LAYERS.text, pos: c, text: r.name, height: 0.25 });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
(plan.labels || []).forEach((l) => ents.push({ type: 'TEXT', layer: JSON_LAYERS.text, pos: l.pos, text: l.text, height: 0.25 }));
|
|
32
|
+
return ents;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function unitsFromName(name) {
|
|
36
|
+
return { mm: { name: 'mm', toMeters: 0.001 }, cm: { name: 'cm', toMeters: 0.01 }, m: { name: 'm', toMeters: 1 } }[name || 'm'] || { name: 'm', toMeters: 1 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function centroid(pts) {
|
|
40
|
+
let x = 0, y = 0; for (const p of pts) { x += p[0]; y += p[1]; } return [x / pts.length, y / pts.length];
|
|
41
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Varlıklar + katman rolleri + birim → PlanModel (metre cinsinden saf veri).
|
|
2
|
+
// PlanModel:
|
|
3
|
+
// { walls:[{a,b,thickness,len,angle}], wallPolys:[{points}], windows:[{a,b,len,angle}],
|
|
4
|
+
// doors:[{hinge:[x,y],angle,width,swing}], rooms:[{name,points,area,centroid}], labels:[{pos,text}],
|
|
5
|
+
// decor:[{a,b}], bounds:{minX,minY,maxX,maxY,w,d,cx,cy}, footprint:[[x,y]...], stats:{} }
|
|
6
|
+
|
|
7
|
+
const DEG = Math.PI / 180;
|
|
8
|
+
|
|
9
|
+
export function buildPlanModel(entities, layerMap, units, options = {}) {
|
|
10
|
+
const s = units.toMeters;
|
|
11
|
+
const defaultThickness = options.defaultThickness ?? 0.2;
|
|
12
|
+
const P = (p) => [p[0] * s, p[1] * s];
|
|
13
|
+
const role = (e) => layerMap[e.layer] || 'decor';
|
|
14
|
+
|
|
15
|
+
const rawWallSegs = [];
|
|
16
|
+
const wallPolys = [];
|
|
17
|
+
const windowSegs = [];
|
|
18
|
+
const doorCandidates = [];
|
|
19
|
+
const roomPolys = [];
|
|
20
|
+
const labels = [];
|
|
21
|
+
const decor = [];
|
|
22
|
+
|
|
23
|
+
const pushSegs = (arr, pts, closed) => {
|
|
24
|
+
for (let i = 0; i + 1 < pts.length; i++) arr.push({ a: pts[i], b: pts[i + 1] });
|
|
25
|
+
if (closed && pts.length > 2) arr.push({ a: pts[pts.length - 1], b: pts[0] });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (const e of entities) {
|
|
29
|
+
const r = role(e);
|
|
30
|
+
if (r === 'ignore') continue;
|
|
31
|
+
|
|
32
|
+
if (e.type === 'TEXT' || e.type === 'MTEXT') {
|
|
33
|
+
if (!e.text || isDimensionText(e.text)) continue;
|
|
34
|
+
if (r === 'decor' && /^[\d.,\s]+$/.test(e.text)) continue;
|
|
35
|
+
labels.push({ pos: P(e.pos), text: e.text, layer: e.layer, size: (e.height || 1) * s });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (e.type === 'LINE') {
|
|
40
|
+
const seg = { a: P(e.a), b: P(e.b) };
|
|
41
|
+
if (segLen(seg) < 1e-4) continue;
|
|
42
|
+
if (r === 'wall') rawWallSegs.push(seg);
|
|
43
|
+
else if (r === 'window') windowSegs.push(seg);
|
|
44
|
+
else if (r === 'door') doorCandidates.push({ kind: 'line', hinge: seg.a, width: segLen(seg), angle: segAngle(seg) });
|
|
45
|
+
else if (r === 'room') { /* mahal çizgileri kapalı çokgen değilse yoksay */ }
|
|
46
|
+
else decor.push(seg);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (e.type === 'POLYLINE') {
|
|
51
|
+
const pts = e.points.map(P);
|
|
52
|
+
if (pts.length < 2) continue;
|
|
53
|
+
if (r === 'wall') {
|
|
54
|
+
if (e.closed && pts.length >= 3 && isThinPolygon(pts)) wallPolys.push({ points: dedupePts(pts) });
|
|
55
|
+
else pushSegs(rawWallSegs, pts, e.closed);
|
|
56
|
+
} else if (r === 'window') pushSegs(windowSegs, pts, e.closed);
|
|
57
|
+
else if (r === 'room') { if (e.closed && pts.length >= 3) roomPolys.push(dedupePts(pts)); }
|
|
58
|
+
else if (r === 'door') { if (pts.length >= 2) doorCandidates.push({ kind: 'line', hinge: pts[0], width: dist(pts[0], pts[pts.length - 1]), angle: angleOf(pts[0], pts[pts.length - 1]) }); }
|
|
59
|
+
else pushSegs(decor, pts, e.closed);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (e.type === 'ARC') {
|
|
64
|
+
const c = P(e.center); const rr = e.r * s;
|
|
65
|
+
if (r === 'door' || (r === 'decor' && e.fromBlock && /door|kapi|kapı/i.test(e.fromBlock))) {
|
|
66
|
+
let sweep = e.end - e.start; while (sweep < 0) sweep += 360; while (sweep > 360) sweep -= 360;
|
|
67
|
+
if (rr > 0.5 && rr < 2.6 && sweep > 45 && sweep < 200) {
|
|
68
|
+
doorCandidates.push({ kind: 'arc', hinge: c, width: rr, angle: e.start * DEG, angleEnd: e.end * DEG, swing: sweep });
|
|
69
|
+
}
|
|
70
|
+
} else if (r !== 'room') decor.push(...arcToSegs(c, rr, e.start, e.end));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (e.type === 'CIRCLE') {
|
|
75
|
+
if (r === 'wall') {
|
|
76
|
+
const c = P(e.center); const rr = e.r * s; // kolon
|
|
77
|
+
wallPolys.push({ points: circlePts(c, rr, 16), column: true });
|
|
78
|
+
} else if (r !== 'room' && r !== 'door') decor.push(...arcToSegs(P(e.center), e.r * s, 0, 360));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (e.type === 'INSERT') {
|
|
83
|
+
if (r === 'door' || /door|kapi|kapı/i.test(e.block)) {
|
|
84
|
+
doorCandidates.push({ kind: 'insert', hinge: P(e.pos), width: 0.9 * Math.abs(e.scale[0] || 1) * (s === 1 ? 1 : 1), angle: e.rotation * DEG, weak: true });
|
|
85
|
+
} else if (r === 'window' || /window|pencere/i.test(e.block)) {
|
|
86
|
+
const w = 1.2; const a = e.rotation * DEG; const p = P(e.pos);
|
|
87
|
+
windowSegs.push({ a: p, b: [p[0] + Math.cos(a) * w, p[1] + Math.sin(a) * w], weak: true });
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { walls, caps } = mergeParallelWalls(rawWallSegs, { defaultThickness, maxThickness: options.maxThickness ?? 0.6 });
|
|
94
|
+
const windows = windowSegs.filter((w) => segLen(w) > 0.2).map((w) => ({ ...w, len: segLen(w), angle: segAngle(w) }));
|
|
95
|
+
const doors = dedupeDoors(doorCandidates);
|
|
96
|
+
|
|
97
|
+
const rooms = roomPolys.map((pts, i) => {
|
|
98
|
+
const area = Math.abs(polygonArea(pts));
|
|
99
|
+
const centroid = polygonCentroid(pts);
|
|
100
|
+
const inside = labels.find((l) => pointInPolygon(l.pos, pts));
|
|
101
|
+
return { name: inside ? inside.text : `Mahal ${i + 1}`, points: pts, area, centroid, labelled: !!inside };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const boundPts = [];
|
|
105
|
+
for (const w of walls) boundPts.push(w.a, w.b);
|
|
106
|
+
for (const p of wallPolys) boundPts.push(...p.points);
|
|
107
|
+
for (const w of windows) boundPts.push(w.a, w.b);
|
|
108
|
+
if (boundPts.length === 0) { for (const d of decor) boundPts.push(d.a, d.b); for (const l of labels) boundPts.push(l.pos); }
|
|
109
|
+
const bounds = boundsOf(boundPts);
|
|
110
|
+
|
|
111
|
+
const hullPts = [...boundPts, ...rooms.flatMap((r) => r.points)];
|
|
112
|
+
const footprint = hullPts.length >= 3 ? convexHull(hullPts) : [];
|
|
113
|
+
const footprintArea = footprint.length >= 3 ? Math.abs(polygonArea(footprint)) : 0;
|
|
114
|
+
const netArea = rooms.reduce((acc, r) => acc + r.area, 0);
|
|
115
|
+
|
|
116
|
+
const stats = {
|
|
117
|
+
wallCount: walls.length + wallPolys.length,
|
|
118
|
+
wallLength: walls.reduce((acc, w) => acc + w.len, 0),
|
|
119
|
+
doorCount: doors.length,
|
|
120
|
+
windowCount: windows.length,
|
|
121
|
+
roomCount: rooms.length,
|
|
122
|
+
labelCount: labels.length,
|
|
123
|
+
grossArea: footprintArea,
|
|
124
|
+
netArea,
|
|
125
|
+
perimeter: footprint.length >= 3 ? polygonPerimeter(footprint) : 0,
|
|
126
|
+
width: bounds.w,
|
|
127
|
+
depth: bounds.d,
|
|
128
|
+
capsRemoved: caps,
|
|
129
|
+
decorCount: decor.length,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
walls, wallPolys, windows, doors, rooms, labels: labels.filter((l) => !rooms.some((r) => r.labelled && r.name === l.text)),
|
|
134
|
+
decor: decor.slice(0, options.maxDecor ?? 6000), bounds, footprint, stats, units,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------- Duvar birleştirme ----------
|
|
139
|
+
// CAD'de duvarlar çoğu kez iki paralel çizgi olarak çizilir. Yakın paralel çiftler tek kalın duvara indirgenir;
|
|
140
|
+
// uçlardaki kısa kapak çizgileri atılır.
|
|
141
|
+
export function mergeParallelWalls(segs, { defaultThickness = 0.2, maxThickness = 0.6, minThickness = 0.04 } = {}) {
|
|
142
|
+
const n = segs.length;
|
|
143
|
+
const used = new Array(n).fill(false);
|
|
144
|
+
const walls = [];
|
|
145
|
+
const info = segs.map((sg) => ({ len: segLen(sg), ang: segAngle(sg), dir: unit(sg) }));
|
|
146
|
+
|
|
147
|
+
const candidates = [];
|
|
148
|
+
for (let i = 0; i < n; i++) {
|
|
149
|
+
for (let j = i + 1; j < n; j++) {
|
|
150
|
+
const cross = Math.abs(info[i].dir[0] * info[j].dir[1] - info[i].dir[1] * info[j].dir[0]);
|
|
151
|
+
if (cross > Math.sin(3 * DEG)) continue;
|
|
152
|
+
const d = pointLineDist(segs[j].a, segs[i]);
|
|
153
|
+
const d2 = pointLineDist(segs[j].b, segs[i]);
|
|
154
|
+
if (Math.abs(d - d2) > 0.03) continue;
|
|
155
|
+
if (d < minThickness || d > maxThickness) continue;
|
|
156
|
+
const ti = [0, info[i].len];
|
|
157
|
+
const tj = [proj(segs[j].a, segs[i]), proj(segs[j].b, segs[i])].sort((x, y) => x - y);
|
|
158
|
+
const overlap = Math.min(ti[1], tj[1]) - Math.max(ti[0], tj[0]);
|
|
159
|
+
const shorter = Math.min(info[i].len, info[j].len);
|
|
160
|
+
if (overlap < Math.max(0.1, 0.3 * shorter)) continue;
|
|
161
|
+
candidates.push({ i, j, d, overlap, tj });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
candidates.sort((x, y) => y.overlap - x.overlap || x.d - y.d);
|
|
165
|
+
|
|
166
|
+
for (const c of candidates) {
|
|
167
|
+
if (used[c.i] || used[c.j]) continue;
|
|
168
|
+
used[c.i] = used[c.j] = true;
|
|
169
|
+
const base = segs[c.i];
|
|
170
|
+
const dir = info[c.i].dir;
|
|
171
|
+
const tmin = Math.min(0, c.tj[0]);
|
|
172
|
+
const tmax = Math.max(info[c.i].len, c.tj[1]);
|
|
173
|
+
// merkez çizgisi: i çizgisinden j'ye doğru yarım kalınlık kaydır
|
|
174
|
+
const normal = [-dir[1], dir[0]];
|
|
175
|
+
const side = Math.sign((segs[c.j].a[0] - base.a[0]) * normal[0] + (segs[c.j].a[1] - base.a[1]) * normal[1]) || 1;
|
|
176
|
+
const off = [normal[0] * side * c.d / 2, normal[1] * side * c.d / 2];
|
|
177
|
+
const a = [base.a[0] + dir[0] * tmin + off[0], base.a[1] + dir[1] * tmin + off[1]];
|
|
178
|
+
const b = [base.a[0] + dir[0] * tmax + off[0], base.a[1] + dir[1] * tmax + off[1]];
|
|
179
|
+
walls.push(makeWall(a, b, c.d, true));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// kapaklar: paired çizgilerin uçlarına dokunan kısa serbest çizgiler
|
|
183
|
+
let caps = 0;
|
|
184
|
+
const pairedEnds = [];
|
|
185
|
+
for (let i = 0; i < n; i++) if (used[i]) pairedEnds.push(segs[i].a, segs[i].b);
|
|
186
|
+
const touches = (p) => pairedEnds.some((q) => dist(p, q) < 0.03);
|
|
187
|
+
for (let i = 0; i < n; i++) {
|
|
188
|
+
if (used[i]) continue;
|
|
189
|
+
const sg = segs[i];
|
|
190
|
+
if (walls.length && info[i].len <= maxThickness * 1.2 && touches(sg.a) && touches(sg.b)) { caps++; continue; }
|
|
191
|
+
walls.push(makeWall(sg.a, sg.b, defaultThickness, false));
|
|
192
|
+
}
|
|
193
|
+
return { walls, caps };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function makeWall(a, b, thickness, merged) {
|
|
197
|
+
return { a, b, thickness, len: dist(a, b), angle: angleOf(a, b), merged };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function dedupeDoors(cands) {
|
|
201
|
+
const strong = cands.filter((c) => !c.weak);
|
|
202
|
+
const pool = strong.length ? [...strong, ...cands.filter((c) => c.weak)] : cands;
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const c of pool) {
|
|
205
|
+
if (out.some((d) => dist(d.hinge, c.hinge) < 0.35)) continue;
|
|
206
|
+
if (c.width < 0.5 || c.width > 2.6) continue;
|
|
207
|
+
out.push({ hinge: c.hinge, angle: c.angle, angleEnd: c.angleEnd, width: c.width, swing: c.swing ?? 90, kind: c.kind });
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------- Yardımcılar ----------
|
|
213
|
+
export function dist(a, b) { return Math.hypot(b[0] - a[0], b[1] - a[1]); }
|
|
214
|
+
export function segLen(s) { return dist(s.a, s.b); }
|
|
215
|
+
export function angleOf(a, b) { return Math.atan2(b[1] - a[1], b[0] - a[0]); }
|
|
216
|
+
export function segAngle(s) { return angleOf(s.a, s.b); }
|
|
217
|
+
function unit(s) { const l = segLen(s) || 1; return [(s.b[0] - s.a[0]) / l, (s.b[1] - s.a[1]) / l]; }
|
|
218
|
+
function proj(p, s) { const u = unit(s); return (p[0] - s.a[0]) * u[0] + (p[1] - s.a[1]) * u[1]; }
|
|
219
|
+
function pointLineDist(p, s) { const u = unit(s); return Math.abs((p[0] - s.a[0]) * u[1] - (p[1] - s.a[1]) * u[0]); }
|
|
220
|
+
|
|
221
|
+
function isDimensionText(t) { return /^[\d.,]+\s*(mm|cm|m|m²|m2)?$/i.test(t.trim()) || /^%%/.test(t); }
|
|
222
|
+
|
|
223
|
+
function dedupePts(pts) {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const p of pts) if (!out.length || dist(out[out.length - 1], p) > 1e-6) out.push(p);
|
|
226
|
+
if (out.length > 1 && dist(out[0], out[out.length - 1]) < 1e-6) out.pop();
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function polygonArea(pts) {
|
|
231
|
+
let a = 0;
|
|
232
|
+
for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; a += p[0] * q[1] - q[0] * p[1]; }
|
|
233
|
+
return a / 2;
|
|
234
|
+
}
|
|
235
|
+
export function polygonPerimeter(pts) { let l = 0; for (let i = 0; i < pts.length; i++) l += dist(pts[i], pts[(i + 1) % pts.length]); return l; }
|
|
236
|
+
export function polygonCentroid(pts) {
|
|
237
|
+
let cx = 0, cy = 0, a = 0;
|
|
238
|
+
for (let i = 0; i < pts.length; i++) {
|
|
239
|
+
const p = pts[i], q = pts[(i + 1) % pts.length]; const f = p[0] * q[1] - q[0] * p[1];
|
|
240
|
+
cx += (p[0] + q[0]) * f; cy += (p[1] + q[1]) * f; a += f;
|
|
241
|
+
}
|
|
242
|
+
if (Math.abs(a) < 1e-9) { const b = boundsOf(pts); return [b.cx, b.cy]; }
|
|
243
|
+
return [cx / (3 * a), cy / (3 * a)];
|
|
244
|
+
}
|
|
245
|
+
export function pointInPolygon(p, pts) {
|
|
246
|
+
let inside = false;
|
|
247
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
248
|
+
const a = pts[i], b = pts[j];
|
|
249
|
+
if ((a[1] > p[1]) !== (b[1] > p[1]) && p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0]) inside = !inside;
|
|
250
|
+
}
|
|
251
|
+
return inside;
|
|
252
|
+
}
|
|
253
|
+
// İnce çokgen: alan / (çevre/2) küçükse duvar kalınlığı gibi davranır (kalın çizilmiş duvar).
|
|
254
|
+
export function isThinPolygon(pts) {
|
|
255
|
+
const area = Math.abs(polygonArea(pts)); const per = polygonPerimeter(pts);
|
|
256
|
+
if (per === 0) return false;
|
|
257
|
+
return area / (per / 2) < 0.6;
|
|
258
|
+
}
|
|
259
|
+
export function boundsOf(pts) {
|
|
260
|
+
if (!pts.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, d: 0, cx: 0, cy: 0 };
|
|
261
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
262
|
+
for (const p of pts) { if (p[0] < minX) minX = p[0]; if (p[0] > maxX) maxX = p[0]; if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1]; }
|
|
263
|
+
return { minX, minY, maxX, maxY, w: maxX - minX, d: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
|
|
264
|
+
}
|
|
265
|
+
export function convexHull(points) {
|
|
266
|
+
const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
267
|
+
if (pts.length < 3) return pts;
|
|
268
|
+
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
|
269
|
+
const lower = []; for (const p of pts) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); lower.push(p); }
|
|
270
|
+
const upper = []; for (let i = pts.length - 1; i >= 0; i--) { const p = pts[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop(); upper.push(p); }
|
|
271
|
+
upper.pop(); lower.pop();
|
|
272
|
+
return lower.concat(upper);
|
|
273
|
+
}
|
|
274
|
+
function arcToSegs(c, r, startDeg, endDeg) {
|
|
275
|
+
let sweep = endDeg - startDeg; while (sweep <= 0) sweep += 360;
|
|
276
|
+
const steps = Math.max(4, Math.ceil(sweep / 15));
|
|
277
|
+
const out = [];
|
|
278
|
+
let prev = null;
|
|
279
|
+
for (let i = 0; i <= steps; i++) {
|
|
280
|
+
const a = (startDeg + (sweep * i) / steps) * DEG;
|
|
281
|
+
const p = [c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r];
|
|
282
|
+
if (prev) out.push({ a: prev, b: p });
|
|
283
|
+
prev = p;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
function circlePts(c, r, n) { const out = []; for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; out.push([c[0] + Math.cos(a) * r, c[1] + Math.sin(a) * r]); } return out; }
|
|
288
|
+
|
|
289
|
+
// ---------- Boşluklar ----------
|
|
290
|
+
// Pencere ve kapıları üzerinde durdukları duvara bağlar; her duvarı [solid|window|door] aralıklarına böler.
|
|
291
|
+
// Eşleşmeyen pencere/kapı 'standalone' kalır (sahnede kendi başına çizilir).
|
|
292
|
+
export function attachOpenings(model, { tolerance = 0.35 } = {}) {
|
|
293
|
+
const wallsOf = (seg) => {
|
|
294
|
+
let best = null;
|
|
295
|
+
for (let i = 0; i < model.walls.length; i++) {
|
|
296
|
+
const w = model.walls[i];
|
|
297
|
+
const u = [(w.b[0] - w.a[0]) / w.len, (w.b[1] - w.a[1]) / w.len];
|
|
298
|
+
const sd = dist(seg.a, seg.b) || 1;
|
|
299
|
+
const v = [(seg.b[0] - seg.a[0]) / sd, (seg.b[1] - seg.a[1]) / sd];
|
|
300
|
+
if (Math.abs(u[0] * v[1] - u[1] * v[0]) > Math.sin(4 * DEG)) continue;
|
|
301
|
+
const dA = Math.abs((seg.a[0] - w.a[0]) * u[1] - (seg.a[1] - w.a[1]) * u[0]);
|
|
302
|
+
if (dA > Math.max(tolerance, w.thickness)) continue;
|
|
303
|
+
const t0 = (seg.a[0] - w.a[0]) * u[0] + (seg.a[1] - w.a[1]) * u[1];
|
|
304
|
+
const t1 = (seg.b[0] - w.a[0]) * u[0] + (seg.b[1] - w.a[1]) * u[1];
|
|
305
|
+
const lo = Math.min(t0, t1), hi = Math.max(t0, t1);
|
|
306
|
+
const overlap = Math.min(hi, w.len) - Math.max(lo, 0);
|
|
307
|
+
if (overlap < 0.5 * (hi - lo)) continue;
|
|
308
|
+
if (!best || dA < best.dA) best = { i, dA, t0: Math.max(0, lo), t1: Math.min(w.len, hi) };
|
|
309
|
+
}
|
|
310
|
+
return best;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
for (const w of model.walls) w.openings = [];
|
|
314
|
+
for (const win of model.windows) {
|
|
315
|
+
const hit = wallsOf(win);
|
|
316
|
+
win.wallIndex = hit ? hit.i : -1;
|
|
317
|
+
if (hit) model.walls[hit.i].openings.push({ type: 'window', t0: hit.t0, t1: hit.t1, ref: win });
|
|
318
|
+
}
|
|
319
|
+
for (const d of model.doors) {
|
|
320
|
+
// Yayın iki ucundan hangisi duvar üzerindeyse kapalı kanat yönü odur.
|
|
321
|
+
const tryAngle = (ang) => wallsOf({ a: d.hinge, b: [d.hinge[0] + Math.cos(ang) * d.width, d.hinge[1] + Math.sin(ang) * d.width] });
|
|
322
|
+
let hit = tryAngle(d.angle);
|
|
323
|
+
if (!hit && d.angleEnd !== undefined) {
|
|
324
|
+
hit = tryAngle(d.angleEnd);
|
|
325
|
+
if (hit) { d.angle = d.angleEnd; d.swing = -Math.abs(d.swing); }
|
|
326
|
+
}
|
|
327
|
+
d.wallIndex = hit ? hit.i : -1;
|
|
328
|
+
if (hit) model.walls[hit.i].openings.push({ type: 'door', t0: hit.t0, t1: hit.t1, ref: d });
|
|
329
|
+
}
|
|
330
|
+
for (const w of model.walls) w.segments = splitIntervals(w.len, w.openings);
|
|
331
|
+
return model;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function splitIntervals(len, openings) {
|
|
335
|
+
const ops = [...openings].sort((a, b) => a.t0 - b.t0);
|
|
336
|
+
const out = [];
|
|
337
|
+
let cur = 0;
|
|
338
|
+
for (const o of ops) {
|
|
339
|
+
const t0 = Math.max(cur, o.t0), t1 = Math.min(len, o.t1);
|
|
340
|
+
if (t1 - t0 < 0.05) continue;
|
|
341
|
+
if (t0 - cur > 0.02) out.push({ type: 'solid', t0: cur, t1: t0 });
|
|
342
|
+
out.push({ type: o.type, t0, t1, ref: o.ref });
|
|
343
|
+
cur = t1;
|
|
344
|
+
}
|
|
345
|
+
if (len - cur > 0.02) out.push({ type: 'solid', t0: cur, t1: len });
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Tek giriş noktası: DXF metni veya JSON planı → PlanModel + katman listesi.
|
|
2
|
+
import { parseDxf, explodeInserts } from '../dxf/parser.js';
|
|
3
|
+
import { buildLayerMap, unitsFromInsunits, guessUnits } from './classify.js';
|
|
4
|
+
import { buildPlanModel, boundsOf } from './geometry.js';
|
|
5
|
+
import { entitiesFromJson, unitsFromName } from './fromJson.js';
|
|
6
|
+
|
|
7
|
+
export function modelFromDxfText(text, { overrides = {}, defaultThickness } = {}) {
|
|
8
|
+
const dxf = parseDxf(text);
|
|
9
|
+
const entities = explodeInserts(dxf);
|
|
10
|
+
if (!entities.length) throw new Error('DXF içinde çizilebilir varlık bulunamadı (LINE, LWPOLYLINE, ARC, TEXT...).');
|
|
11
|
+
const { map, layers } = buildLayerMap(entities, overrides);
|
|
12
|
+
let units = unitsFromInsunits(dxf.header.insunits);
|
|
13
|
+
if (!units) {
|
|
14
|
+
const pts = [];
|
|
15
|
+
for (const e of entities) { if (e.a) pts.push(e.a, e.b); if (e.points) pts.push(...e.points); }
|
|
16
|
+
const b = boundsOf(pts);
|
|
17
|
+
units = guessUnits(Math.max(b.w, b.d));
|
|
18
|
+
}
|
|
19
|
+
const model = buildPlanModel(entities, map, units, { defaultThickness });
|
|
20
|
+
return { model, layers, units, source: 'dxf' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function modelFromJsonPlan(plan, { overrides = {}, defaultThickness } = {}) {
|
|
24
|
+
const entities = entitiesFromJson(plan);
|
|
25
|
+
const { map, layers } = buildLayerMap(entities, overrides);
|
|
26
|
+
const units = unitsFromName(plan.units);
|
|
27
|
+
const model = buildPlanModel(entities, map, units, { defaultThickness });
|
|
28
|
+
return { model, layers, units, source: 'json' };
|
|
29
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Claude için MCP sunucusu (stdio). Araçlar CLI ile aynı istemciyi kullanır.
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { basename, extname } from 'node:path'
|
|
7
|
+
import { makeClient } from './client.js'
|
|
8
|
+
import { getUserToken } from './config.js'
|
|
9
|
+
import { imageJob, videoJob, tryonJob, waitForJob, describeJob, TIERS, ASPECTS } from './jobs.js'
|
|
10
|
+
import { analyzePlanText } from './maket/analyze.js'
|
|
11
|
+
import { SITE_URL } from './constants.js'
|
|
12
|
+
|
|
13
|
+
const text = (data) => ({ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] })
|
|
14
|
+
const fail = (e) => ({ isError: true, content: [{ type: 'text', text: `Error: ${e?.message || e}` }] })
|
|
15
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' }
|
|
16
|
+
|
|
17
|
+
export function createServer({ client: injected } = {}) {
|
|
18
|
+
const server = new McpServer({ name: 'yesilcam', version: '0.1.0' })
|
|
19
|
+
const client = () => injected || makeClient(getUserToken())
|
|
20
|
+
const guarded = (fn) => async (args) => { try { return await fn(args) } catch (e) { return fail(e) } }
|
|
21
|
+
|
|
22
|
+
server.registerTool('yesilcam_balance', { description: 'Token balance of the signed-in Yeşilçam Studios account.', inputSchema: {} },
|
|
23
|
+
guarded(async () => text(await client().balance())))
|
|
24
|
+
server.registerTool('yesilcam_account', { description: 'Account name, email and subscription plan.', inputSchema: {} },
|
|
25
|
+
guarded(async () => text(await client().me())))
|
|
26
|
+
server.registerTool('yesilcam_pricing', { description: 'Production costs in tokens (Film Studio and Fashion Try-On operations) and token packs with prices.', inputSchema: {} },
|
|
27
|
+
guarded(async () => { const c = client(); const [ops, packs] = await Promise.all([c.modulePricing(), c.tokenPackages()]); return text({ operations: ops.filter((r) => ['ai-studio', 'fashion-tryon'].includes(r.module_key)).map((r) => ({ module: r.module_key, operation: r.operation_key, tokens: Number(r.base_cost_tokens), free_per_month: Number(r.monthly_free_limit || 0) })), packs }) }))
|
|
28
|
+
server.registerTool('yesilcam_list_assets', { description: 'List images/videos in the user library. Returns asset ids and URLs.', inputSchema: { kind: z.enum(['image', 'video']).optional(), limit: z.number().int().min(1).max(100).optional() } },
|
|
29
|
+
guarded(async ({ kind, limit }) => text(await client().assets({ kind, limit: limit || 40 }))))
|
|
30
|
+
server.registerTool('yesilcam_upload_asset', { description: 'Upload a local image/video/audio file to the library. Returns the asset id to use in other tools.', inputSchema: { path: z.string(), label: z.string().optional() } },
|
|
31
|
+
guarded(async ({ path, label }) => { const ext = extname(path).toLowerCase(); const mime = MIME[ext] || 'application/octet-stream'; const kind = mime.startsWith('video') ? 'video' : mime.startsWith('audio') ? 'audio' : 'image'; return text(await client().uploadAsset(`data:${mime};base64,${readFileSync(path).toString('base64')}`, kind, label || basename(path))) }))
|
|
32
|
+
server.registerTool('yesilcam_create_image', { description: 'Generate an image from a prompt (Film Studio). Costs tokens. Set wait=true to block until the result URL is ready.', inputSchema: { prompt: z.string().min(3), tier: z.enum(TIERS).optional(), aspect: z.enum(ASPECTS).optional(), framing: z.enum(['waist_up', 'waist_down', 'full_body']).optional(), wait: z.boolean().optional() } },
|
|
33
|
+
guarded(async ({ prompt, tier, aspect, framing, wait }) => run(client(), imageJob({ prompt, tier: tier || 'draft', aspect: aspect || '16:9', framing }), wait)))
|
|
34
|
+
server.registerTool('yesilcam_create_video', { description: 'Generate a video from a prompt (text2video) or animate an image asset (image2video, give source_asset_id). Costs tokens.', inputSchema: { prompt: z.string().optional(), source_asset_id: z.string().optional(), tier: z.enum(TIERS).optional(), aspect: z.enum(ASPECTS).optional(), duration: z.number().int().optional(), resolution: z.enum(['480p', '720p', '1080p']).optional(), generate_audio: z.boolean().optional(), wait: z.boolean().optional() } },
|
|
35
|
+
guarded(async (a) => run(client(), videoJob({ prompt: a.prompt || '', tier: a.tier || 'draft', aspect: a.aspect || '16:9', sourceAssetId: a.source_asset_id, duration: a.duration, resolution: a.resolution, generateAudio: a.generate_audio }), a.wait)))
|
|
36
|
+
server.registerTool('yesilcam_tryon', { description: 'Fashion Try-On: put a garment (image asset) on a person (image asset) or a saved character. Costs tokens.', inputSchema: { garment_asset_id: z.string(), person_asset_id: z.string().optional(), character_id: z.string().optional(), prompt: z.string().optional(), wait: z.boolean().optional() } },
|
|
37
|
+
guarded(async (a) => { if (!a.person_asset_id && !a.character_id) throw new Error('person_asset_id or character_id is required'); return run(client(), tryonJob({ garmentAssetId: a.garment_asset_id, personAssetId: a.person_asset_id, characterId: a.character_id, prompt: a.prompt }), a.wait) }))
|
|
38
|
+
server.registerTool('yesilcam_job_status', { description: 'Status and result URL of a generation job.', inputSchema: { job_id: z.string(), wait: z.boolean().optional() } },
|
|
39
|
+
guarded(async ({ job_id, wait }) => { const c = client(); const job = wait ? await waitForJob(c, job_id) : (await c.jobStatus(Number(job_id))).job; return text(describeJob(job)) }))
|
|
40
|
+
server.registerTool('yesilcam_list_jobs', { description: 'Recent generation jobs.', inputSchema: { limit: z.number().int().min(1).max(100).optional() } },
|
|
41
|
+
guarded(async ({ limit }) => text(await client().jobs({ limit: limit || 20 }))))
|
|
42
|
+
server.registerTool('yesilcam_list_characters', { description: 'Saved characters (for consistent faces across scenes).', inputSchema: {} },
|
|
43
|
+
guarded(async () => text(await client().characters())))
|
|
44
|
+
server.registerTool('yesilcam_maket_analyze', { description: 'Analyze a local DXF or JSON floor plan (Maket): walls, doors, windows, rooms, areas, layer roles. Free, runs locally.', inputSchema: { path: z.string() } },
|
|
45
|
+
guarded(async ({ path }) => text(analyzePlanText(readFileSync(path, 'utf8'), basename(path)))))
|
|
46
|
+
server.registerTool('yesilcam_maket_projects', { description: 'Saved Maket projects of the account.', inputSchema: {} },
|
|
47
|
+
guarded(async () => text(await client().maketList())))
|
|
48
|
+
server.registerTool('yesilcam_maket_save', { description: `Save a local floor plan as a Maket project; open it at ${SITE_URL}/app/maket.`, inputSchema: { path: z.string(), title: z.string().optional() } },
|
|
49
|
+
guarded(async ({ path, title }) => { const t = readFileSync(path, 'utf8'); const a = analyzePlanText(t, basename(path)); return text(await client().maketCreate({ title: title || basename(path, extname(path)), source_kind: a.source_kind, source_text: t, settings: { overrides: {}, wallHeight: 2.9 }, stats: a.stats })) }))
|
|
50
|
+
return server
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function run(c, payload, wait) {
|
|
54
|
+
const r = await c.createJob(payload)
|
|
55
|
+
const jobId = r?.job_id || r?.id || r?.job?.id
|
|
56
|
+
if (!jobId) return text(r)
|
|
57
|
+
if (!wait) return text({ job_id: String(jobId), status: 'queued', hint: 'Call yesilcam_job_status with wait=true to get the result.' })
|
|
58
|
+
return text(describeJob(await waitForJob(c, jobId)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function startStdio() {
|
|
62
|
+
const server = createServer()
|
|
63
|
+
await server.connect(new StdioServerTransport())
|
|
64
|
+
}
|