From 0de16af87bb544f8356d29db0db977785b358a19 Mon Sep 17 00:00:00 2001 From: yujiaxu-ops Date: Thu, 15 Oct 2026 22:39:56 +0800 Subject: [PATCH] feat(cli): default Smart Topology to Meshy T2.5 --- docs/skill-parity/decisions.md | 17 ++++++++++ docs/skill-parity/endpoint-contracts.json | 4 +-- docs/skill-parity/migration-notes.md | 8 +++++ docs/usage.md | 9 +++--- skills/meshy-cli/SKILL.md | 4 +-- src/cmd/image-to-3d.ts | 18 +++++------ src/cmd/text-to-3d.ts | 15 +++++---- src/internal/smart-topology.ts | 25 +++++++-------- tests/api-alignment-features.test.ts | 38 +++++++++++++++++++---- tests/surface.test.ts | 1 + 10 files changed, 94 insertions(+), 45 deletions(-) diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 68c2c9e..bc4f443 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -1102,3 +1102,20 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu - Credits are meshy-t2's: 5 for a mesh only; image-to-3d with 2K or 4K textures 15, with 8K textures 20. A text-to-3d refine of a meshy-t2.5 preview works like one of a meshy-t2 preview. + +## D-079 Smart Topology defaults to Meshy T2.5 + +- Supersedes the omitted-model and flag-only polycount defaults in D-076 / D-078. + On image-to-3d and text-to-3d preview, `model_type: "smart-topology"` now + sends `ai_model: "meshy-t2.5"` when no model was supplied. This aligns the + CLI with 3D Agent's Smart Topology generation. +- Conditional defaults run after defaults, raw JSON and flags have merged, + so `--data` and flags select models consistently. Explicit model and + polycount values survive; flags continue to take precedence over JSON. +- T2.5 leaves `target_polycount` omitted for the API's 15,000 default and + accepts 100–30,000. Explicit `meshy-t2` retains the CLI's 10,000 default + and 100–15,000 range. Standard generation and standalone refine retain + their existing model defaults. +- Keep the public API's canonical model ID `meshy-t2.5`; the CLI does not + introduce a renamed alias. Public API model names need not follow billing + SKU conventions from the image-generation endpoints. diff --git a/docs/skill-parity/endpoint-contracts.json b/docs/skill-parity/endpoint-contracts.json index 75b5375..ce8a7de 100644 --- a/docs/skill-parity/endpoint-contracts.json +++ b/docs/skill-parity/endpoint-contracts.json @@ -49,11 +49,11 @@ ], "smart_topology": { "applies_to": ["image-to-3d", "text-to-3d (preview)"], - "request": { "model_type": "smart-topology (required for these models)", "ai_model": { "enum": ["meshy-t2", "meshy-t2.5"], "default": "omitted: the API picks meshy-t2", "cli_flag": "--ai-model" }, "topology": "triangle only; quad is refused" }, + "request": { "model_type": "smart-topology (required for these models)", "ai_model": { "enum": ["meshy-t2", "meshy-t2.5"], "default": "CLI sends meshy-t2.5; API omission still picks meshy-t2", "cli_flag": "--ai-model" }, "topology": "triangle only; quad is refused" }, "target_polycount": { "meshy-t2": { "range": "[100,15000]", "api_default": 4000, "cli_default": 10000 }, "meshy-t2.5": { "range": "[100,30000]", "api_default": 15000, "cli_default": "omitted (the API's 15000)" } }, "credits": "both models: 5 mesh only; image-to-3d with 2K or 4K textures 15, with 8K textures 20", "refine": "a text-to-3d refine of a meshy-t2.5 preview works like one of a meshy-t2 preview", - "decision": "D-078" + "decision": "D-079" }, "creative_lab_products": { "figure": { diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index 446ee5e..e271b02 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -210,6 +210,14 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | smart-topology polycount default with `--ai-model meshy-t2.5` | — | none sent (the API's 15,000); meshy-t2 still gets 10,000 | D-078 | | a Smart Topology `ai_model` without smart-topology, or smart-topology with `topology: "quad"` (through `--data`) | sent, refused by the API | refused locally (exit 2) | D-078 | +### 3.10 Unreleased: Smart Topology defaults to T2.5 + +`image-to-3d` and `text-to-3d` preview now send `ai_model: "meshy-t2.5"` +when `--model-type smart-topology` has no explicit model. The default face +count is the API's 15,000 (allowed range 100–30,000). To keep T2 and its CLI +10,000 default, pass `--ai-model meshy-t2`. Models supplied through `--data` +receive the same conditional defaults as flags. See D-079. + New global flags: `--output-schema`, `--api-key-file`, `--workspace`, `--no-update-check`, `--base-url-creative-lab`. New per-command flags on task verbs: `--save-json`, `--include-raw`, `--project`, `--stage`, `--operation-id`, `--stop-after-first` (make), diff --git a/docs/usage.md b/docs/usage.md index 28f7ad6..c3fbeaa 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -110,11 +110,12 @@ meshy-cli image-to-3d create --image-url https://example.com/cat.png --geometry- meshy-cli text-to-motion create --prompt "a character waving" --duration 3 # smart topology: component-aware low-poly with a native polycount (triangles only). -# meshy-t2 (the API's choice when --ai-model is omitted): 100-15000, CLI default 10000; -# meshy-t2.5: 100-30000, default the API's 15000. Credits for either: 5 mesh only, +# meshy-t2.5 (CLI default): 100-30000, default the API's 15000; +# --ai-model meshy-t2: 100-15000, CLI default 10000. Credits for either: 5 mesh only, # 15 textured at 2k/4k, 20 at 8k. A meshy-t2.5 preview refines like any other. -meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --target-polycount 10000 -meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --ai-model meshy-t2.5 --target-polycount 25000 +meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology +meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --target-polycount 25000 +meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --ai-model meshy-t2 meshy-cli text-to-3d create --mode preview --prompt "a toy robot" --model-type smart-topology --target-polycount 8000 # printability with a print size: also checks (analyze) or thickens (repair) thin walls diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index ef5d005..89779e6 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -166,8 +166,8 @@ These are API rules, not preferences — ignoring them produces failed tasks: text-to-3d: preview only; multi-image: `2k` only; not with smart-topology); `--ultra-mode` on image-to-3d is its deprecated spelling of `2k`. `--model-type smart-topology` (text-to-3d and image-to-3d) has a native - `--target-polycount`: meshy-t2 100-15000 (default 10000); `--ai-model - meshy-t2.5` up to 30000 (default 15000). `retexture`'s + `--target-polycount`: meshy-t2.5 is the CLI default, 100-30000 (default + 15000); `--ai-model meshy-t2` selects T2, 100-15000 (default 10000). `retexture`'s `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of **the same object**, not style references. - Printing: `analyze-`/`repair-printability` take `--printer-type diff --git a/src/cmd/image-to-3d.ts b/src/cmd/image-to-3d.ts index 7980fca..0458ef1 100644 --- a/src/cmd/image-to-3d.ts +++ b/src/cmd/image-to-3d.ts @@ -4,7 +4,7 @@ * The mode picks the model: * - standard → meshy-7.1 (server default "latest") — full-detail * generation; polycount is remesh's job afterwards. - * - smart-topology → meshy-t2 (server default), or meshy-t2.5 with + * - smart-topology → meshy-t2.5 (CLI default), or meshy-t2 with * --ai-model — component-aware low-poly with a native * --target-polycount; the budget pick for game-ready geometry. * @@ -17,7 +17,7 @@ import { Option } from "commander"; import { parseBool, parseCsv, parseInt10 } from "../internal/flags.js"; import { UsageError } from "../internal/errors.js"; import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; -import { checkSmartTopology, defaultPolycount, SMART_TOPOLOGY_MODELS } from "../internal/smart-topology.js"; +import { prepareSmartTopologyPayload, SMART_TOPOLOGY_MODELS } from "../internal/smart-topology.js"; const spec: ResourceCommandSpec = { name: "image-to-3d", @@ -39,13 +39,13 @@ const spec: ResourceCommandSpec = { .addOption( new Option( "--model-type ", - "standard (default; meshy-7.1 full-detail generation) | smart-topology (component-aware low-poly: clean topology, separated parts; meshy-t2, or --ai-model meshy-t2.5)", + "standard (default; meshy-7.1 full-detail generation) | smart-topology (component-aware low-poly: clean topology, separated parts; meshy-t2.5 by default)", ).choices(["standard", "smart-topology"]), ) .addOption( new Option( "--ai-model ", - "smart-topology only: meshy-t2 (the API's choice when omitted) | meshy-t2.5 (polycount up to 30000)", + "smart-topology only: meshy-t2.5 (default; polycount up to 30000) | meshy-t2", ).choices([...SMART_TOPOLOGY_MODELS]), ) .option( @@ -159,10 +159,9 @@ const spec: ResourceCommandSpec = { // texturing is its own step, and the API rejects texture knobs on // untextured runs, so they ride only when texturing is on — then the // game-ready defaults apply: full PBR map set, 4k base color. The - // model follows the mode (standard → meshy-7.1, smart-topology → - // meshy-t2, both server defaults) unless --ai-model picks meshy-t2.5. - // meshy-t2 gets the rigging-friendly 10k polycount; meshy-t2.5 keeps - // the API's 15k (D-078). The Ultra pass stays off + // standard follows the server's meshy-7.1 default. Smart Topology's + // model and polycount defaults are applied after payload merging. + // The Ultra pass stays off // unless asked for: it bills extra. GLB-only output — omitting target_formats // makes the API produce every format. const texturing = opts.shouldTexture === true; @@ -170,12 +169,11 @@ const spec: ResourceCommandSpec = { should_texture: false, enable_pbr: texturing ? true : undefined, texture_resolution: texturing ? "4k" : undefined, - target_polycount: defaultPolycount(opts), target_formats: ["glb"], }; }, validatePayload(payload) { - checkSmartTopology(payload); + prepareSmartTopologyPayload(payload); // Smart topology has neither knob; v1 answers 400 for either (it does not ignore them). if (payload.model_type === "smart-topology") { for (const [flag, key] of [["--remove-lighting", "remove_lighting"], ["--image-enhancement", "image_enhancement"]] as const) { diff --git a/src/cmd/text-to-3d.ts b/src/cmd/text-to-3d.ts index 3e59508..cdc8f6a 100644 --- a/src/cmd/text-to-3d.ts +++ b/src/cmd/text-to-3d.ts @@ -8,7 +8,7 @@ * remesh's job (except smart topology's native polycount), resizing is * resize's job, deprecated parameters (symmetry_mode, lowpoly, ultra_mode) * are gone, and the mode picks the model: standard runs the server default - * "latest" (Meshy 7.1), smart-topology runs meshy-t2, or meshy-t2.5 with + * "latest" (Meshy 7.1), smart-topology runs meshy-t2.5, or meshy-t2 with * --ai-model. The `--data` escape hatch still reaches anything the API accepts. */ @@ -16,7 +16,7 @@ import { Option } from "commander"; import { parseBool, parseCsv, parseInt10 } from "../internal/flags.js"; import { UsageError } from "../internal/errors.js"; import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; -import { checkSmartTopology, defaultPolycount, SMART_TOPOLOGY_MODELS } from "../internal/smart-topology.js"; +import { prepareSmartTopologyPayload, SMART_TOPOLOGY_MODELS } from "../internal/smart-topology.js"; const spec: ResourceCommandSpec = { name: "text-to-3d", @@ -45,13 +45,13 @@ const spec: ResourceCommandSpec = { .addOption( new Option( "--model-type ", - "preview only. standard (default; full-detail generation) | smart-topology (clean triangle topology, separated parts, native --target-polycount; meshy-t2, or --ai-model meshy-t2.5)", + "preview only. standard (default; full-detail generation) | smart-topology (clean triangle topology, separated parts, native --target-polycount; meshy-t2.5 by default)", ).choices(["standard", "smart-topology"]), ) .addOption( new Option( "--ai-model ", - "preview, smart-topology only: meshy-t2 (the API's choice when omitted) | meshy-t2.5 (polycount up to 30000)", + "preview, smart-topology only: meshy-t2.5 (default; polycount up to 30000) | meshy-t2", ).choices([...SMART_TOPOLOGY_MODELS]), ) .option( @@ -130,16 +130,15 @@ const spec: ResourceCommandSpec = { // removed on meshy-6/7) apply otherwise. `make` still asks for 4k: it // made the preview itself, on latest. // GLB-only output — omitting target_formats makes the API produce - // every format. Smart topology defaults like image-to-3d: 10k on - // meshy-t2, the API's 15k on meshy-t2.5 (D-078). + // every format. Smart Topology's model and polycount defaults are + // applied after payload merging, as on image-to-3d. const refine = opts.mode === "refine"; return { enable_pbr: refine ? true : undefined, - target_polycount: defaultPolycount(opts), target_formats: ["glb"], }; }, - validatePayload: checkSmartTopology, + validatePayload: prepareSmartTopologyPayload, }, endpointOf: (c) => c.textTo3d, }; diff --git a/src/internal/smart-topology.ts b/src/internal/smart-topology.ts index da5124b..e11a611 100644 --- a/src/internal/smart-topology.ts +++ b/src/internal/smart-topology.ts @@ -2,29 +2,28 @@ * Smart Topology models, shared by image-to-3d and text-to-3d preview so the * two commands cannot drift apart. * - * Without --ai-model the request carries no ai_model and the API picks - * meshy-t2. meshy-t2.5 takes a polycount up to 30,000 (meshy-t2: 15,000). + * The CLI pins meshy-t2.5 when Smart Topology has no explicit ai_model. + * meshy-t2.5 takes a polycount up to 30,000 (meshy-t2: 15,000). * Both need model_type "smart-topology", and both output triangles only. */ import { UsageError } from "./errors.js"; export const SMART_TOPOLOGY_MODELS = ["meshy-t2", "meshy-t2.5"] as const; +export const DEFAULT_SMART_TOPOLOGY_MODEL = "meshy-t2.5"; const MAX_POLYCOUNT: Readonly> = { "meshy-t2": 15_000, "meshy-t2.5": 30_000 }; -/** - * The CLI's polycount default (D-078): 10,000 for meshy-t2 (D-076); meshy-t2.5 - * is left to the API's own 15,000. Follows --ai-model only. - */ -export function defaultPolycount(opts: Record): number | undefined { - return opts.modelType === "smart-topology" && opts.aiModel !== "meshy-t2.5" ? 10_000 : undefined; -} - -/** Checks the merged payload, so values sent through --data meet the same rules. */ -export function checkSmartTopology(payload: Record): void { - const model = typeof payload.ai_model === "string" ? payload.ai_model : undefined; +/** Applies conditional defaults and validates after --data and flags have merged. */ +export function prepareSmartTopologyPayload(payload: Record): void { const smart = payload.model_type === "smart-topology"; + if (smart) { + payload.ai_model ??= DEFAULT_SMART_TOPOLOGY_MODEL; + // Keep the explicit T2 option's rigging-friendly 10k default; T2.5 uses + // the API's own 15k. Resolve this after merging so --data works like flags. + if (payload.ai_model === "meshy-t2") payload.target_polycount ??= 10_000; + } + const model = typeof payload.ai_model === "string" ? payload.ai_model : undefined; if (model && model in MAX_POLYCOUNT && !smart) { throw new UsageError(`${model} is a Smart Topology model: pass --model-type smart-topology`); } diff --git a/tests/api-alignment-features.test.ts b/tests/api-alignment-features.test.ts index dd75df8..1ba90f6 100644 --- a/tests/api-alignment-features.test.ts +++ b/tests/api-alignment-features.test.ts @@ -303,13 +303,13 @@ test("generation: --geometry-resolution on text-to-3d preview and image-to-3d; t assert.equal(st.code, 0, st.stderr); assert.equal(body(api, 1)["model_type"], "smart-topology"); assert.equal(body(api, 1)["target_polycount"], 8000); - assert.ok(!("ai_model" in body(api, 1)), "the server picks meshy-t2 for smart-topology"); + assert.equal(body(api, 1)["ai_model"], "meshy-t2.5"); const i = await runCli(["image-to-3d", "create", "--image-url", "https://example.invalid/a.png", "--geometry-resolution", "2k", "--async", "--data", "{}"], { env: api.env() }); assert.equal(i.code, 2, "the preflight of an unreachable URL fails before the POST"); for (const args of [ ["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--model-type", "smart-topology", "--geometry-resolution", "2k"], ["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--target-polycount", "500"], - ["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--model-type", "smart-topology", "--target-polycount", "20000"], + ["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--model-type", "smart-topology", "--target-polycount", "30001"], ["text-to-3d", "create", "--mode", "refine", "--preview-task-id", "p", "--geometry-resolution", "2k"], ["image-to-3d", "create", "--input-task-id", "img", "--ultra-mode", "true", "--geometry-resolution", "2k"], ["image-to-3d", "create", "--input-task-id", "img", "--model-type", "smart-topology", "--geometry-resolution", "4k"], @@ -323,10 +323,11 @@ test("generation: --geometry-resolution on text-to-3d preview and image-to-3d; t const geo = await runCli(["image-to-3d", "create", "--input-task-id", "img", "--geometry-resolution", "2k", "--async"], { env: api.env() }); assert.equal(geo.code, 0, geo.stderr); assert.equal(body(api, 3)["geometry_resolution"], "2k"); - // Smart topology defaults alike on both commands: 10000 unless asked. + // Smart topology defaults to T2.5 and leaves its 15k polycount to the API. const stDefault = await runCli(["text-to-3d", "create", "--mode", "preview", "--prompt", "a robot", "--model-type", "smart-topology", "--async"], { env: api.env() }); assert.equal(stDefault.code, 0, stDefault.stderr); - assert.equal(body(api, 4)["target_polycount"], 10_000); + assert.equal(body(api, 4)["ai_model"], "meshy-t2.5"); + assert.ok(!("target_polycount" in body(api, 4))); }); }); @@ -352,11 +353,35 @@ test("smart topology meshy-t2.5: --ai-model on image-to-3d and text-to-3d previe assert.equal((req.json as Record)["target_polycount"], 30_000); // Without --target-polycount meshy-t2.5 keeps the API's default (15000); meshy-t2 still gets 10000. for (const base of [img, txt]) { + req = await ok([...base]); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + assert.ok(!("target_polycount" in (req.json as object))); + req = await ok([...base, "--target-polycount", "30000"]); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + assert.equal((req.json as Record)["target_polycount"], 30_000); req = await ok([...base, "--ai-model", "meshy-t2.5"]); assert.ok(!("target_polycount" in (req.json as object)), `${base[0]}: meshy-t2.5 polycount is left to the API`); req = await ok([...base, "--ai-model", "meshy-t2"]); assert.equal((req.json as Record)["ai_model"], "meshy-t2"); assert.equal((req.json as Record)["target_polycount"], 10_000); + // Explicit JSON models and polycounts override the conditional defaults. + req = await ok([...base, "--data", '{"ai_model":"meshy-t2"}']); + assert.equal((req.json as Record)["ai_model"], "meshy-t2"); + assert.equal((req.json as Record)["target_polycount"], 10_000); + req = await ok([...base, "--data", '{"ai_model":"meshy-t2","target_polycount":8000}']); + assert.equal((req.json as Record)["target_polycount"], 8_000); + req = await ok([...base, "--data", '{"ai_model":"meshy-t2"}', "--ai-model", "meshy-t2.5"]); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + assert.ok(!("target_polycount" in (req.json as object))); + } + // Raw JSON and flag-based model_type use the same defaults. + for (const base of [img.slice(0, -2), txt.slice(0, -2)]) { + req = await ok([...base, "--data", '{"model_type":"smart-topology"}']); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + assert.ok(!("target_polycount" in (req.json as object))); + req = await ok([...base]); + assert.ok(!("ai_model" in (req.json as object)), "standard mode keeps the server's model default"); + assert.ok(!("target_polycount" in (req.json as object))); } // A model_type sent through --data counts as smart-topology. req = await ok(["image-to-3d", "create", "--input-task-id", "img", "--data", '{"model_type":"smart-topology"}', "--ai-model", "meshy-t2.5"]); @@ -364,9 +389,10 @@ test("smart topology meshy-t2.5: --ai-model on image-to-3d and text-to-3d previe const posts = api.requests.length; for (const args of [ - [...img, "--target-polycount", "30000"], + [...img, "--target-polycount", "30001"], [...img, "--ai-model", "meshy-t2", "--target-polycount", "30000"], - [...txt, "--target-polycount", "30000"], + [...txt, "--target-polycount", "30001"], + [...img, "--data", '{"ai_model":"meshy-t2","target_polycount":30000}'], [...img, "--ai-model", "meshy-t2.5", "--target-polycount", "30001"], [...txt, "--ai-model", "meshy-t2.5", "--target-polycount", "99"], ["image-to-3d", "create", "--input-task-id", "img", "--ai-model", "meshy-t2.5"], diff --git a/tests/surface.test.ts b/tests/surface.test.ts index 87ec92f..dffe949 100644 --- a/tests/surface.test.ts +++ b/tests/surface.test.ts @@ -91,6 +91,7 @@ test("the mode picks the model: --ai-model on 3D generation offers the Smart Top for (const resource of ["text-to-3d", "image-to-3d"]) { const model = sub(sub(root, resource), "create").options.find((o) => o.long === "--ai-model"); assert.deepEqual(model?.argChoices, ["meshy-t2", "meshy-t2.5"], resource); + assert.match(model?.description ?? "", /meshy-t2\.5 \(default/, resource); } assert.ok(!createFlags("multi-image-to-3d").has("--ai-model"), "multi-image-to-3d has --ai-model"); const modeType = sub(sub(root, "text-to-3d"), "create").options.find((o) => o.long === "--model-type");