Piloter CATIA V5 depuis .NET en liaison tardive
jeudi 9 juillet 2026CATIA V5 expose une API d'automatisation COM, conçue à la fin des années 90 pour VB6, VBA et VBScript. On peut donc en théorie le piloter depuis .NET de deux façons : en liaison anticipée (early binding), avec les interops générées depuis les bibliothèques de types (INFITF, MECMOD, DRAFTING…), ou en liaison tardive (late binding), sans aucune référence à la compilation.
La liaison tardive est séduisante quand CATIA n'est pas installé sur les machines de build, ou quand on veut supporter plusieurs versions de CATIA avec un seul binaire. Avec la plupart des serveurs COM, c'est trivial en C# :
object catia = Marshal.GetActiveObject("CATIA.Application");
dynamic application = catia;
Console.WriteLine(application.Name);
Sauf qu'avec CATIA, ce code échoue. Systématiquement. Sur tous les membres, même une propriété aussi inoffensive que Name, avec l'inusable COMException 0x80004005 (E_FAIL), sans autre explication.
Et le plus troublant : le même appel en VBScript fonctionne parfaitement.
Set catia = GetObject(, "CATIA.Application")
WScript.Echo catia.Name ' Affiche CNEXT
Le coupable : GetTypeInfoCount
L'interface IDispatch, au cœur de la liaison tardive COM, expose quatre méthodes : GetTypeInfoCount, GetTypeInfo, GetIDsOfNames et Invoke. La spécification est claire : un objet qui ne fournit pas d'information de type doit répondre S_OK à GetTypeInfoCount avec un compte de zéro.
L'implémentation IDispatch de CATIA V5, écrite maison au-dessus de son modèle objet, répond E_FAIL. Curieusement, GetTypeInfo(0) répond S_OK avec un pointeur valide : l'information de type existe bel et bien, c'est la méthode qui annonce son existence qui est cassée.
Pourquoi personne ne s'en est jamais aperçu chez Dassault Systèmes ? Parce qu'aucun des clients supportés n'appelle cette méthode :
- VBScript va directement à
GetIDsOfNamespuisInvoke, sans jamais consulter l'information de type ; - VBA et VB6 en liaison anticipée passent par la vtable des interfaces duales, sans toucher à
IDispatch.
Les seuls clients au monde qui interrogent GetTypeInfoCount avant chaque invocation sont arrivés bien après la conception de cette API : le DLR (le mot-clé dynamic de C#), le binder COM du CLR (Type.InvokeMember) et l'adaptateur COM de PowerShell. Tous les trois considèrent cet échec comme fatal et abandonnent — d'où le E_FAIL sur tous les membres, y compris les plus simples. Un bug dans un chemin de code que l'éditeur n'exerce jamais peut survivre 25 ans et une trentaine de releases.
La solution : faire ce que fait VBScript
Puisque VBScript réussit en appelant GetIDsOfNames et Invoke directement, faisons pareil : déclarons l'interface IDispatch nous-mêmes et invoquons-la à la main, sans passer par les couches du CLR qui consultent l'information de type.
On commence par déclarer l'interface. Attention : ne déclarez cette interface qu'une seule fois dans tout le processus — deux interfaces [ComImport] portant le même IID, c'est un comportement non spécifié.
[ComImport]
[Guid("00020400-0000-0000-C000-000000000046")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IDispatch
{
[PreserveSig] int GetTypeInfoCount(out uint typeInfoCount);
[PreserveSig] int GetTypeInfo(uint typeInfoIndex, uint localeId, out IntPtr typeInfo);
[PreserveSig] int GetIDsOfNames(
ref Guid interfaceId,
[MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.LPWStr)] string[] names,
uint nameCount,
uint localeId,
[MarshalAs(UnmanagedType.LPArray)] int[] dispatchIds);
[PreserveSig] int Invoke(
int dispatchId,
ref Guid interfaceId,
uint localeId,
ushort flags,
ref DispatchParameters parameters,
IntPtr result,
IntPtr exceptionInfo,
IntPtr argumentError);
}
// La structure DISPPARAMS de COM.
[StructLayout(LayoutKind.Sequential)]
public struct DispatchParameters
{
public IntPtr Arguments;
public IntPtr NamedArgumentIds;
public uint ArgumentCount;
public uint NamedArgumentCount;
}
Deux détails comptent énormément ici :
[PreserveSig]: on récupère le HRESULT brut au lieu de laisser le CLR le transformer en exception, ce qui permet de traiter finement les cas d'erreur ;Invokereçoit le VARIANT de résultat enIntPtr: c'est nous qui allouons, initialisons et convertissons ce VARIANT, pas le marshaleur du CLR.
Le cœur : l'appel à Invoke
Voici le helper complet. Il gère les trois opérations de base — lire une propriété, affecter une propriété, appeler une méthode — avec les conventions exactes qu'attend COM :
public static class CatiaDispatch
{
const ushort DISPATCH_METHOD = 1;
const ushort DISPATCH_PROPERTYGET = 2;
const ushort DISPATCH_PROPERTYPUT = 4;
// Argument nommé réservé désignant la valeur affectée à une propriété.
const int DISPID_PROPERTYPUT = -3;
const int DISP_E_EXCEPTION = unchecked((int)0x80020009);
// Un VARIANT occupe 16 octets en 32 bits et 24 en 64 bits.
static readonly int _variantSize = IntPtr.Size == 8 ? 24 : 16;
[DllImport("oleaut32.dll")]
private static extern int VariantClear(IntPtr variant);
// Lecture d'une propriété : les deux drapeaux, comme VBScript sur une expression
// sans argument, car certains membres ne sont exposés que sous l'une des deux formes.
public static object GetProperty(object target, string name)
=> Invoke(target, name, DISPATCH_PROPERTYGET | DISPATCH_METHOD, [], wantResult: true);
public static void SetProperty(object target, string name, object value)
=> Invoke(target, name, DISPATCH_PROPERTYPUT, [value], wantResult: false);
public static void CallMethod(object target, string name, params object[] arguments)
=> Invoke(target, name, DISPATCH_METHOD, arguments, wantResult: false);
public static object CallMethodWithResult(object target, string name, params object[] arguments)
=> Invoke(target, name, DISPATCH_METHOD, arguments, wantResult: true);
static object Invoke(object target, string name, ushort flags, object[] arguments, bool wantResult)
{
if (target is not IDispatch dispatch)
throw new InvalidOperationException($"L'objet n'expose pas IDispatch : « {name} » est inappelable.");
int dispatchId = GetDispatchId(dispatch, name);
IntPtr argumentsBuffer = IntPtr.Zero;
IntPtr namedArgumentsBuffer = IntPtr.Zero;
IntPtr resultBuffer = IntPtr.Zero;
IntPtr exceptionInfoBuffer = IntPtr.Zero;
try
{
var parameters = new DispatchParameters();
if (arguments.Length > 0)
{
argumentsBuffer = Marshal.AllocCoTaskMem(_variantSize * arguments.Length);
// Les arguments sont fournis en ordre INVERSE : c'est la convention de DISPPARAMS.
for (int index = 0; index < arguments.Length; index++)
Marshal.GetNativeVariantForObject(
arguments[arguments.Length - 1 - index],
IntPtr.Add(argumentsBuffer, index * _variantSize));
parameters.Arguments = argumentsBuffer;
parameters.ArgumentCount = (uint)arguments.Length;
}
// Une affectation de propriété exige que la valeur soit passée comme
// argument nommé DISPID_PROPERTYPUT.
if ((flags & DISPATCH_PROPERTYPUT) != 0)
{
namedArgumentsBuffer = Marshal.AllocCoTaskMem(sizeof(int));
Marshal.WriteInt32(namedArgumentsBuffer, DISPID_PROPERTYPUT);
parameters.NamedArgumentIds = namedArgumentsBuffer;
parameters.NamedArgumentCount = 1;
}
if (wantResult) resultBuffer = AllocateZeroed(_variantSize);
// CATIA range le motif réel de ses échecs dans l'EXCEPINFO (par exemple
// « CATIAApplication : The method ActiveDocument failed ») ; sans lui,
// on ne recevrait qu'un E_FAIL inexploitable.
exceptionInfoBuffer = AllocateZeroed(
Marshal.SizeOf(typeof(System.Runtime.InteropServices.ComTypes.EXCEPINFO)));
var nullInterfaceId = Guid.Empty;
int result = dispatch.Invoke(dispatchId, ref nullInterfaceId, 0, flags,
ref parameters, resultBuffer, exceptionInfoBuffer, IntPtr.Zero);
if (result == DISP_E_EXCEPTION)
{
string description = ReadExceptionDescription(exceptionInfoBuffer);
if (description != null)
throw new InvalidOperationException($"CATIA a rejeté l'appel de « {name} » : {description}");
}
if (result != 0) Marshal.ThrowExceptionForHR(result);
if (!wantResult) return null;
return Marshal.GetObjectForNativeVariant(resultBuffer);
}
finally
{
if (argumentsBuffer != IntPtr.Zero)
{
for (int index = 0; index < arguments.Length; index++)
VariantClear(IntPtr.Add(argumentsBuffer, index * _variantSize));
Marshal.FreeCoTaskMem(argumentsBuffer);
}
if (namedArgumentsBuffer != IntPtr.Zero) Marshal.FreeCoTaskMem(namedArgumentsBuffer);
if (resultBuffer != IntPtr.Zero)
{
VariantClear(resultBuffer);
Marshal.FreeCoTaskMem(resultBuffer);
}
if (exceptionInfoBuffer != IntPtr.Zero) Marshal.FreeCoTaskMem(exceptionInfoBuffer);
}
}
static int GetDispatchId(IDispatch dispatch, string name)
{
var nullInterfaceId = Guid.Empty;
int[] dispatchIds = new int[1];
int result = dispatch.GetIDsOfNames(ref nullInterfaceId, [name], 1, 0, dispatchIds);
if (result != 0) Marshal.ThrowExceptionForHR(result);
return dispatchIds[0];
}
static IntPtr AllocateZeroed(int size)
{
IntPtr buffer = Marshal.AllocCoTaskMem(size);
for (int index = 0; index < size; index++) Marshal.WriteByte(buffer, index, 0);
return buffer;
}
static string ReadExceptionDescription(IntPtr exceptionInfo)
{
if (exceptionInfo == IntPtr.Zero) return null;
var information = (System.Runtime.InteropServices.ComTypes.EXCEPINFO)Marshal.PtrToStructure(
exceptionInfo, typeof(System.Runtime.InteropServices.ComTypes.EXCEPINFO));
if (string.IsNullOrEmpty(information.bstrDescription)) return null;
if (!string.IsNullOrEmpty(information.bstrSource))
return $"{information.bstrSource} : {information.bstrDescription}";
return information.bstrDescription;
}
}
Les pièges concentrés dans ce code, dans l'ordre où ils mordent :
- Les arguments positionnels sont en ordre inverse dans
DISPPARAMS.ExportData(chemin, "pdf")se transmet avec"pdf"en premier. Si vous l'oubliez, vous obtiendrez au mieuxDISP_E_TYPEMISMATCH, au pire un comportement silencieusement faux. - Une affectation de propriété n'est pas un appel comme les autres : la valeur doit être passée comme argument nommé avec l'identifiant réservé
DISPID_PROPERTYPUT(-3). Sans cela,DISP_E_PARAMNOTFOUND. - Lire une propriété demande les deux drapeaux
DISPATCH_PROPERTYGET | DISPATCH_METHOD— c'est ce qu'envoie VBScript pour toute expression sans argument, et certains membres de CATIA ne répondent qu'à l'une des deux formes. - Le VARIANT fait 24 octets en x64 (16 en x86). Une taille fausse et vous corrompez la mémoire.
- Chaque VARIANT doit être nettoyé avec
VariantClearavant de libérer le tampon, sinon vous divulguez lesBSTRet les références COM qu'il contient. - Fournissez l'
EXCEPINFO: quand un appel échoue légitimement (document inexistant, méthode non applicable), CATIA y dépose un vrai message d'erreur. C'est la différence entre «E_FAIL» et «CATIAApplication : The method ActiveDocument failed» dans vos journaux.
À l'usage
Une fois le helper en place, le code applicatif redevient lisible. Par exemple, exporter un plan en PDF :
object catia = Marshal.GetActiveObject("CATIA.Application");
CatiaDispatch.SetProperty(catia, "DisplayFileAlerts", false);
object documents = CatiaDispatch.GetProperty(catia, "Documents");
object drawing = CatiaDispatch.CallMethodWithResult(documents, "Open", @"C:\Plans\MonPlan.CATDrawing");
CatiaDispatch.CallMethod(drawing, "ExportData", @"C:\Plans\MonPlan.pdf", "pdf");
CatiaDispatch.CallMethod(drawing, "Close");
Une mise en garde qui découle de tout ce qui précède : une fois l'objet CATIA entre vos mains, ne le laissez jamais approcher par les mécanismes standard de .NET. Un dynamic oublié, un Type.InvokeMember, un ToString() dans une trace, l'inspecteur du débogueur, l'adaptateur COM de PowerShell — chacun de ces accès consulte l'information de type et lèvera E_FAIL. Ça ne corrompt pas la session (l'appel suivant par le helper fonctionne), mais c'est déroutant à déboguer.
Une dernière précaution, sur un tout autre plan : Marshal.GetActiveObject a disparu de .NET (Core). Si vous ciblez .NET 5 ou une version ultérieure, il faut le réimporter vous-même via CLSIDFromProgID et GetActiveObject d'oleaut32. Sur .NET Framework, il reste disponible directement.
Et la liaison anticipée, alors ?
Elle fonctionne, elle. Un appel par la vtable des interfaces duales ne consulte jamais GetTypeInfoCount : les interops générées depuis les bibliothèques de types de CATIA (avec tlbimp ou une référence COM dans Visual Studio) pilotent CATIA sans encombre, retours d'objets compris.
Le choix entre les deux se résume donc à une question de dépendances :
- Liaison anticipée : code typé, IntelliSense, erreurs à la compilation — mais vos machines de build doivent disposer des bibliothèques de types, et le binaire est lié à leur version ;
- Liaison tardive : aucune dépendance à la compilation, un binaire unique pour toutes les versions de CATIA — au prix du helper ci-dessus et de chaînes de caractères non vérifiées.
Ce qu'il faut retenir, c'est que le chemin intermédiaire — la liaison tardive via dynamic ou Type.InvokeMember, si confortable avec Excel ou Word — est précisément celui que CATIA interdit. Avec lui, c'est tout ou rien : les interops, ou l'IDispatch à la main.
Besoin d'un développement CATIA (.NET, VBA, CATScript) ? Contactez-moi pour un devis gratuit.