From 81b79ac479527e087f92f7586b1612eb3c706ff1 Mon Sep 17 00:00:00 2001 From: Arlieeee Date: Tue, 13 Oct 2026 17:57:33 +0800 Subject: [PATCH] [ENG-4166] feat(cli): meshy-t2.5 Smart Topology model on image-to-3d and text-to-3d - --ai-model meshy-t2|meshy-t2.5 on image-to-3d and text-to-3d preview, with --model-type smart-topology only; omitted, the API picks meshy-t2. - target_polycount checked per model on the merged payload: meshy-t2 100-15000, meshy-t2.5 100-30000. meshy-t2 keeps the CLI default 10000; meshy-t2.5 leaves it to the API (15000), D-078. - Smart Topology ai_model without smart-topology, and topology quad with smart-topology, are refused before the request. - Docs: usage, SKILL.md, decisions D-078, migration notes for 0.4.3, endpoint contracts, capability matrix. Version 0.4.3. Co-Authored-By: Claude Opus 5.5 --- docs/skill-parity/capability-matrix.json | 3 +- docs/skill-parity/decisions.md | 29 +++++++++++- docs/skill-parity/endpoint-contracts.json | 8 ++++ docs/skill-parity/migration-notes.md | 12 ++++- docs/usage.md | 6 ++- package.json | 2 +- skills/meshy-cli/SKILL.md | 5 +- src/cmd/image-to-3d.ts | 38 +++++++++------ src/cmd/multi-image-to-3d.ts | 4 +- src/cmd/text-to-3d.ts | 36 +++++++++------ src/internal/smart-topology.ts | 44 ++++++++++++++++++ tests/api-alignment-features.test.ts | 56 +++++++++++++++++++++++ tests/surface.test.ts | 14 +++--- 13 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 src/internal/smart-topology.ts diff --git a/docs/skill-parity/capability-matrix.json b/docs/skill-parity/capability-matrix.json index ef587ee..c9cd123 100644 --- a/docs/skill-parity/capability-matrix.json +++ b/docs/skill-parity/capability-matrix.json @@ -432,7 +432,8 @@ ], "evidence": [ "payload merge defaults < --data < flags with explicit false/0 preserved; tests T-028; surface.test.ts unchanged", - "tests/codex-review-round1.test.ts R04/F08" + "tests/codex-review-round1.test.ts R04/F08", + "0.4.3: smart-topology --ai-model meshy-t2|meshy-t2.5 on image-to-3d and text-to-3d preview, per-model target_polycount range on the merged payload, meshy-t2.5 polycount default left to the API (decisions D-078); tests/api-alignment-features.test.ts, tests/surface.test.ts" ], "intentional_differences": [], "external_verification": "live_passed", diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index e58e786..68c2c9e 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -737,7 +737,7 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu - `engines.node` was raised from `>=20` to `>=24` in `7edf056` ("chore: upgrade Node and dependencies") as a side effect of a dependency bump, not because any - code needed Node 24. Audited on 2026-09-22: the only runtime gate in the tree + code needed Node 24. Checked on 2026-09-22: the only runtime gate in the tree was `REQUIRED_NODE_MAJOR = 24` in `doctor.ts`; `@types/node@^22` typechecks clean and the full suite is 563/563 on Node 22.22.0. The real floor is the strictest dependency, `commander@15` at `>=22.12.0`. @@ -1075,3 +1075,30 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu call. It is now kind `action` (a one-off API call that changes state) with its endpoint, and `counts` gains `action`. The entry keeps its keys and its place at the end of `items`. + +## D-078 `--ai-model` picks the Smart Topology model: meshy-t2 or meshy-t2.5 + +- Smart topology now has two models, so "the mode is the model" no longer + holds for it. image-to-3d and text-to-3d preview take `--ai-model + meshy-t2|meshy-t2.5`, with `--model-type smart-topology` only. Standard mode + keeps no model flag (other models stay reachable through `--data`), and + multi-image-to-3d gets none: the API offers no Smart Topology there. +- Without `--ai-model` nothing changes: the request carries no `ai_model` and + the API picks meshy-t2. +- `target_polycount` is checked per model before the request: meshy-t2 + 100–15,000, meshy-t2.5 100–30,000. image-to-3d had no local range check + before. +- Polycount default: meshy-t2 keeps the CLI's 10,000 (D-076). meshy-t2.5 sends + none and gets the API's 15,000. The 10,000 pin exists because meshy-t2's own + default (4,000) is too low for rigging; meshy-t2.5's is not, and pinning + 10,000 would quietly cap a model picked for denser meshes. The default + follows `--ai-model`; a model set only through `--data` gets the meshy-t2 + default, which meshy-t2.5 also accepts. +- The checks run on the merged payload, so `--data` meets the same rules. A + Smart Topology `ai_model` without `model_type: "smart-topology"`, + `topology: "quad"` with smart-topology (its output is triangle-only; chain + `remesh --topology quad` for quads) and an out-of-range polycount are usage + errors (exit 2). +- 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. diff --git a/docs/skill-parity/endpoint-contracts.json b/docs/skill-parity/endpoint-contracts.json index 5d3c125..75b5375 100644 --- a/docs/skill-parity/endpoint-contracts.json +++ b/docs/skill-parity/endpoint-contracts.json @@ -47,6 +47,14 @@ { "id": "creative-lab.fidget-pixel.build", "commandPath": ["creative-lab", "fidget-pixel", "build"], "base": "creative-lab", "relativePath": "/fidget-pixel/v1/build", "legacyEndpoint": "/openapi/creative-lab/fidget-pixel/v1/build", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [], "taskTypes": ["creative-lab-fidget-pixel-build"], "billing": { "create": "may-charge" } }, { "id": "creative-lab.fidget-collapsible", "commandPath": ["creative-lab", "fidget-collapsible"], "base": "creative-lab", "relativePath": "/fidget-collapsible/v1", "legacyEndpoint": "/openapi/creative-lab/fidget-collapsible/v1", "supports": { "create": true, "get": true, "list": true, "delete": true, "stream": true }, "mediaFields": [ { "path": "image_url", "kind": "image", "many": false, "formats": ["jpg", "jpeg", "png", "webp"] } ], "taskTypes": ["creative-lab-fidget-collapsible"], "billing": { "create": "may-charge" } } ], + "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" }, + "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" + }, "creative_lab_products": { "figure": { "prototype": { diff --git a/docs/skill-parity/migration-notes.md b/docs/skill-parity/migration-notes.md index f12fb9b..446ee5e 100644 --- a/docs/skill-parity/migration-notes.md +++ b/docs/skill-parity/migration-notes.md @@ -201,9 +201,19 @@ Each difference names the legacy behaviour, the CLI behaviour, and the evidence. | `animation-catalog list` | anonymous, local search (name / key / sub-category); result had `fetched`, `total`, `authenticated`, `search_scope: "local"`; items had `rig_type` / `is_free` / `is_default` | needs a key; server-side filters (`--search` matches name / key, `--sub-category` filters); result has `count`, `filters`, `search_scope: "server"`; items as the API returns them | D-073 | | `balance` | `balance` only | also `monthly_credits`, `permanent_credits`, `monthly_resets_at` | D-076 | +### 3.9 Changes in 0.4.3 (visible in both schemas) + +| Behaviour | 0.4.2 and earlier | 0.4.3 | Decision | +| --- | --- | --- | --- | +| `--ai-model` on image-to-3d and text-to-3d preview | no flag (`--data` only) | `meshy-t2` \| `meshy-t2.5`, with `--model-type smart-topology` only; omitted, the API picks meshy-t2 as before | D-078 | +| smart-topology `--target-polycount` | text-to-3d checked 100–15,000; image-to-3d sent any value | checked per model on both: meshy-t2 100–15,000, meshy-t2.5 100–30,000 (exit 2 outside) | D-078 | +| 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 | + 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), `--idle-timeout` (stream). New commands: `uv-unwrap`, `creative-lab`, `animation-catalog`, `showcases`, `download`, `project`, `inspect`, `mesh`, `slicer`, `doctor`; in 0.4.2 -`auto-split` and `usage`. +`auto-split` and `usage`. In 0.4.3: `--ai-model` on image-to-3d and text-to-3d +(smart-topology). diff --git a/docs/usage.md b/docs/usage.md index fe05d17..28f7ad6 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -109,8 +109,12 @@ meshy-cli image-to-3d create --image-url https://example.com/cat.png --geometry- # text → standalone motion clip (Prime/FBX by default; Swift produces BVH) meshy-cli text-to-motion create --prompt "a character waving" --duration 3 -# smart topology: component-aware low-poly with a native polycount +# 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, +# 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 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/package.json b/package.json index 17a9037..d43afb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meshy-cli", - "version": "0.4.2", + "version": "0.4.3", "description": "Official command-line interface for the Meshy AI API — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animate, retexture, 2D images, printability, auto split, multi-color print, Creative Lab, balance and usage — plus selective downloads, project folders, face checks, OBJ print preparation and slicer launch.", "license": "MIT", "type": "module", diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 638b140..ef5d005 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -165,8 +165,9 @@ These are API rules, not preferences — ignoring them produces failed tasks: `--geometry-resolution 2k|4k` runs the Ultra geometry pass (+5 credits; 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` (meshy-t2, text-to-3d and image-to-3d) has a - native `--target-polycount` (default 10000). `retexture`'s + `--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 `--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 08048ab..7980fca 100644 --- a/src/cmd/image-to-3d.ts +++ b/src/cmd/image-to-3d.ts @@ -1,12 +1,12 @@ /** * image-to-3d — https://docs.meshy.ai/en/api/image-to-3d * - * The mode IS the model — there is no model flag: + * 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) — component-aware low-poly - * with a native --target-polycount; the budget pick for game-ready - * geometry. + * - smart-topology → meshy-t2 (server default), or meshy-t2.5 with + * --ai-model — component-aware low-poly with a native + * --target-polycount; the budget pick for game-ready geometry. * * The Ultra geometry pass (--geometry-resolution 2k|4k, or the deprecated * --ultra-mode) rides on standard only: it needs meshy-7.1, so pairing it with @@ -17,6 +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"; const spec: ResourceCommandSpec = { name: "image-to-3d", @@ -38,12 +39,18 @@ const spec: ResourceCommandSpec = { .addOption( new Option( "--model-type ", - "standard (default; meshy-7.1 full-detail generation) | smart-topology (meshy-t2 component-aware low-poly: clean topology, separated parts)", + "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)", ).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)", + ).choices([...SMART_TOPOLOGY_MODELS]), + ) .option( "--target-polycount ", - "smart-topology only: target triangle count, 100-15000 (default: 10000). For standard outputs use remesh", + "smart-topology only: target triangle count — meshy-t2 100-15000 (default: 10000), meshy-t2.5 100-30000 (default: the API's 15000). For standard outputs use remesh", parseInt10, ) .addOption( @@ -105,11 +112,11 @@ const spec: ResourceCommandSpec = { // The Ultra pass needs meshy-7.1, which only standard mode resolves to. if (opts.ultraMode === true && smart) { throw new UsageError( - "--ultra-mode applies to --model-type standard only (it needs meshy-7.1; smart-topology runs meshy-t2)", + "--ultra-mode applies to --model-type standard only (it needs meshy-7.1)", ); } if (smart && opts.geometryResolution !== undefined && opts.geometryResolution !== "standard") { - throw new UsageError("--geometry-resolution 2k/4k applies to --model-type standard only (it needs meshy-7.1; smart-topology runs meshy-t2)"); + throw new UsageError("--geometry-resolution 2k/4k applies to --model-type standard only (it needs meshy-7.1)"); } if (opts.ultraMode !== undefined && opts.geometryResolution !== undefined) { throw new UsageError("--ultra-mode is the deprecated spelling of --geometry-resolution 2k; pass only --geometry-resolution"); @@ -132,6 +139,7 @@ const spec: ResourceCommandSpec = { image_url: opts.imageUrl, input_task_id: opts.inputTaskId, model_type: opts.modelType, + ai_model: opts.aiModel, target_polycount: opts.targetPolycount, ultra_mode: opts.ultraMode, geometry_resolution: opts.geometryResolution, @@ -151,9 +159,10 @@ 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 is not a choice — it follows the mode (standard → meshy-7.1, - // smart-topology → meshy-t2, both server defaults). Smart topology - // gets the rigging-friendly 10k polycount. The Ultra pass stays off + // 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 // unless asked for: it bills extra. GLB-only output — omitting target_formats // makes the API produce every format. const texturing = opts.shouldTexture === true; @@ -161,16 +170,17 @@ const spec: ResourceCommandSpec = { should_texture: false, enable_pbr: texturing ? true : undefined, texture_resolution: texturing ? "4k" : undefined, - target_polycount: opts.modelType === "smart-topology" ? 10_000 : undefined, + target_polycount: defaultPolycount(opts), target_formats: ["glb"], }; }, validatePayload(payload) { - // meshy-t2 has neither knob; v1 answers 400 for either (it does not ignore them). + checkSmartTopology(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) { if (payload[key] !== undefined) { - throw new UsageError(`${flag} applies to --model-type standard only (smart-topology runs meshy-t2, which the API refuses it for)`); + throw new UsageError(`${flag} applies to --model-type standard only (the API refuses it for smart-topology)`); } } } diff --git a/src/cmd/multi-image-to-3d.ts b/src/cmd/multi-image-to-3d.ts index 0f66a1a..fb9a0a6 100644 --- a/src/cmd/multi-image-to-3d.ts +++ b/src/cmd/multi-image-to-3d.ts @@ -1,8 +1,8 @@ /** * multi-image-to-3d — https://docs.meshy.ai/en/api/multi-image-to-3d * - * No model flag, same as the other 3D generation commands: the API default - * "latest" is Meshy 7.1. geometry_resolution "2k" runs the Ultra pass (+5 + * No model flag (the Smart Topology models are not offered here): the API + * default "latest" is Meshy 7.1. geometry_resolution "2k" runs the Ultra pass (+5 * credits; the same geometry setting as the web app's Ultra 2K) and needs * meshy-7.1 / latest; the deprecated ultra_mode boolean is its old spelling. * "4k" is single-image only — the API refuses it here. Only the enum is diff --git a/src/cmd/text-to-3d.ts b/src/cmd/text-to-3d.ts index 8058501..3e59508 100644 --- a/src/cmd/text-to-3d.ts +++ b/src/cmd/text-to-3d.ts @@ -7,15 +7,16 @@ * The flag surface is deliberately minimal: topology and polycount are * remesh's job (except smart topology's native polycount), resizing is * resize's job, deprecated parameters (symmetry_mode, lowpoly, ultra_mode) - * are gone, and there is no model flag — the mode is the model: standard runs - * the server default "latest" (Meshy 7.1), smart-topology runs meshy-t2. The - * `--data` escape hatch still reaches anything the API accepts. + * 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 + * --ai-model. The `--data` escape hatch still reaches anything the API accepts. */ 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"; const spec: ResourceCommandSpec = { name: "text-to-3d", @@ -44,10 +45,20 @@ const spec: ResourceCommandSpec = { .addOption( new Option( "--model-type ", - "preview only. standard (default; full-detail generation) | smart-topology (meshy-t2: clean triangle topology, separated parts, native --target-polycount)", + "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)", ).choices(["standard", "smart-topology"]), ) - .option("--target-polycount ", "preview, smart-topology only: target face count, 100-15000 (default: 10000)", parseInt10) + .addOption( + new Option( + "--ai-model ", + "preview, smart-topology only: meshy-t2 (the API's choice when omitted) | meshy-t2.5 (polycount up to 30000)", + ).choices([...SMART_TOPOLOGY_MODELS]), + ) + .option( + "--target-polycount ", + "preview, smart-topology only: target face count — meshy-t2 100-15000 (default: 10000), meshy-t2.5 100-30000 (default: the API's 15000)", + parseInt10, + ) .addOption( new Option( "--geometry-resolution ", @@ -82,7 +93,7 @@ const spec: ResourceCommandSpec = { throw new UsageError("--mode refine requires --preview-task-id"); } if (mode === "refine") { - for (const [flag, value] of [["--model-type", opts.modelType], ["--target-polycount", opts.targetPolycount], ["--geometry-resolution", opts.geometryResolution]] as const) { + for (const [flag, value] of [["--model-type", opts.modelType], ["--ai-model", opts.aiModel], ["--target-polycount", opts.targetPolycount], ["--geometry-resolution", opts.geometryResolution]] as const) { if (value !== undefined) throw new UsageError(`${flag} applies to --mode preview only (refine inherits the preview's geometry)`); } } @@ -90,11 +101,8 @@ const spec: ResourceCommandSpec = { if (opts.targetPolycount !== undefined && !smart) { throw new UsageError("--target-polycount applies to --model-type smart-topology only; for standard outputs chain `remesh` afterwards"); } - if (opts.targetPolycount !== undefined && ((opts.targetPolycount as number) < 100 || (opts.targetPolycount as number) > 15000)) { - throw new UsageError("--target-polycount must be between 100 and 15000"); - } if (smart && opts.geometryResolution !== undefined && opts.geometryResolution !== "standard") { - throw new UsageError("--geometry-resolution 2k/4k needs meshy-7.1 (standard); smart-topology runs meshy-t2"); + throw new UsageError("--geometry-resolution 2k/4k needs meshy-7.1 (standard), not smart-topology"); } return { mode, @@ -103,6 +111,7 @@ const spec: ResourceCommandSpec = { // "auto" means "let the model decide" — the API's default, expressed by omission. pose_mode: opts.poseMode === "auto" ? undefined : opts.poseMode, model_type: opts.modelType, + ai_model: opts.aiModel, target_polycount: opts.targetPolycount, geometry_resolution: opts.geometryResolution, enable_pbr: opts.enablePbr, @@ -121,15 +130,16 @@ 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 gets image-to-3d's rigging-friendly 10k - // polycount, so the two commands default alike. + // every format. Smart topology defaults like image-to-3d: 10k on + // meshy-t2, the API's 15k on meshy-t2.5 (D-078). const refine = opts.mode === "refine"; return { enable_pbr: refine ? true : undefined, - target_polycount: opts.modelType === "smart-topology" ? 10_000 : undefined, + target_polycount: defaultPolycount(opts), target_formats: ["glb"], }; }, + validatePayload: checkSmartTopology, }, endpointOf: (c) => c.textTo3d, }; diff --git a/src/internal/smart-topology.ts b/src/internal/smart-topology.ts new file mode 100644 index 0000000..da5124b --- /dev/null +++ b/src/internal/smart-topology.ts @@ -0,0 +1,44 @@ +/** + * 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). + * 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; + +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; + const smart = payload.model_type === "smart-topology"; + if (model && model in MAX_POLYCOUNT && !smart) { + throw new UsageError(`${model} is a Smart Topology model: pass --model-type smart-topology`); + } + if (!smart) return; + if (payload.topology === "quad") { + throw new UsageError("smart-topology output is triangle-only; for quads chain `remesh --topology quad` afterwards"); + } + const n = payload.target_polycount; + const max = MAX_POLYCOUNT[model ?? "meshy-t2"]; // another model sent through --data is left to the API + if (max !== undefined && typeof n === "number" && (n < 100 || n > max)) { + throw new UsageError( + model === "meshy-t2.5" + ? "--target-polycount must be between 100 and 30000 on meshy-t2.5" + : "--target-polycount must be between 100 and 15000 on meshy-t2 (--ai-model meshy-t2.5 takes up to 30000)", + ); + } +} diff --git a/tests/api-alignment-features.test.ts b/tests/api-alignment-features.test.ts index 45f721e..dd75df8 100644 --- a/tests/api-alignment-features.test.ts +++ b/tests/api-alignment-features.test.ts @@ -330,6 +330,62 @@ test("generation: --geometry-resolution on text-to-3d preview and image-to-3d; t }); }); +test("smart topology meshy-t2.5: --ai-model on image-to-3d and text-to-3d preview, polycount up to 30000, the API's default polycount", async () => { + await withApi(accept, async (api) => { + const smart = ["--model-type", "smart-topology"]; + const img = ["image-to-3d", "create", "--input-task-id", "img", ...smart]; + const txt = ["text-to-3d", "create", "--mode", "preview", "--prompt", "a robot", ...smart]; + const ok = async (args: string[]) => { + const r = await runCli([...args, "--async"], { env: api.env() }); + assert.equal(r.code, 0, `${args.join(" ")}: ${r.stderr}`); + return api.requests[api.requests.length - 1]!; + }; + + let req = await ok([...img, "--ai-model", "meshy-t2.5", "--target-polycount", "30000"]); + assert.equal(req.path, "/openapi/v1/image-to-3d"); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + assert.equal((req.json as Record)["model_type"], "smart-topology"); + assert.equal((req.json as Record)["target_polycount"], 30_000); + req = await ok([...txt, "--ai-model", "meshy-t2.5", "--target-polycount", "30000"]); + assert.equal(req.path, "/openapi/v2/text-to-3d"); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + 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, "--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); + } + // 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"]); + assert.equal((req.json as Record)["ai_model"], "meshy-t2.5"); + const posts = api.requests.length; + + for (const args of [ + [...img, "--target-polycount", "30000"], + [...img, "--ai-model", "meshy-t2", "--target-polycount", "30000"], + [...txt, "--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"], + ["image-to-3d", "create", "--input-task-id", "img", "--model-type", "standard", "--ai-model", "meshy-t2.5"], + ["image-to-3d", "create", "--input-task-id", "img", "--data", '{"ai_model":"meshy-t2.5"}'], + ["text-to-3d", "create", "--mode", "preview", "--prompt", "x", "--ai-model", "meshy-t2.5"], + ["text-to-3d", "create", "--mode", "refine", "--preview-task-id", "p", "--ai-model", "meshy-t2.5"], + [...img, "--ai-model", "meshy-t2.5", "--data", '{"topology":"quad"}'], + [...txt, "--ai-model", "meshy-t2.5", "--data", '{"topology":"quad"}'], + [...img, "--ai-model", "meshy-6"], + ["multi-image-to-3d", "create", "--input-task-id", "img", "--ai-model", "meshy-t2.5"], + ]) { + const r = await runCli([...args, "--async"], { env: api.env() }); + assert.equal(r.code, 2, `${args.join(" ")}: ${r.stdout}`); + } + assert.equal(api.requests.length, posts, "every refusal happens before the request"); + }); +}); + test("multi-image-to-3d --texture-image-urls: local files are inlined, 1-4 views, needs a textured run, not with --texture-prompt", async () => { const { writeFileSync } = await import("node:fs"); const { join } = await import("node:path"); diff --git a/tests/surface.test.ts b/tests/surface.test.ts index 19c422c..87ec92f 100644 --- a/tests/surface.test.ts +++ b/tests/surface.test.ts @@ -83,14 +83,16 @@ test("image-to-3d gained the smart-topology surface and task chaining", () => { assert.ok(createFlags("multi-image-to-3d").has("--input-task-id")); }); -test("the mode is the model: no model flag on the 3D generation commands", () => { - // Each mode has exactly one model (standard → meshy-7.1 via latest, - // smart-topology → meshy-t2), so --ai-model is gone from 3D generation — - // the 2D image commands keep theirs. text-to-3d's --model-type offers the +test("the mode picks the model: --ai-model on 3D generation offers the Smart Topology models only", () => { + // Standard has one model (meshy-7.1 via latest), so it has no model flag; + // smart-topology has two (meshy-t2, meshy-t2.5). multi-image-to-3d has no + // Smart Topology, so no --ai-model. text-to-3d's --model-type offers the // two modes only (lowpoly was the t1-era engine and stays out). - for (const resource of ["text-to-3d", "image-to-3d", "multi-image-to-3d"]) { - assert.ok(!createFlags(resource).has("--ai-model"), `${resource} still has --ai-model`); + 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.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"); assert.deepEqual(modeType?.argChoices, ["standard", "smart-topology"]); for (const resource of ["text-to-image", "image-to-image"]) {