diff --git a/plugins_src/import_export/Makefile b/plugins_src/import_export/Makefile
index 478aca02..1fe6b68b 100644
--- a/plugins_src/import_export/Makefile
+++ b/plugins_src/import_export/Makefile
@@ -30,6 +30,7 @@ endif
MODULES= \
wpc_3ds \
+ wpc_3mf \
wpc_ai \
wpc_bzw \
wpc_collada \
diff --git a/plugins_src/import_export/wpc_3mf.erl b/plugins_src/import_export/wpc_3mf.erl
new file mode 100644
index 00000000..e4e9bb27
--- /dev/null
+++ b/plugins_src/import_export/wpc_3mf.erl
@@ -0,0 +1,1880 @@
+%%
+%% wpc_3mf.erl --
+%%
+%% 3D Manufacturing File (.3mf) import/export.
+%%
+%% Copyright (c) 2024 Edward Blake
+%%
+%% See the file "license.terms" for information on usage and redistribution
+%% of this file, and for a DISCLAIMER OF ALL WARRANTIES.
+%%
+%% $Id$
+%%
+
+-module(wpc_3mf).
+
+-export([init/0,menu/2,command/2]).
+-export([t/0,t2/0,t_o/0]).
+
+-include_lib("wings/e3d/e3d.hrl").
+-include_lib("wings/e3d/e3d_image.hrl").
+-include_lib("wings/intl_tools/wings_intl.hrl").
+
+-include_lib("stdlib/include/zip.hrl").
+
+init() ->
+ wpa:pref_set_default(?MODULE, swap_y_z, true),
+ true.
+
+menu({file,import}, Menu) ->
+ menu_entry(Menu);
+menu({file,export}, Menu) ->
+ menu_entry(Menu);
+menu({file,export_selected}, Menu) ->
+ menu_entry(Menu);
+menu(_, Menu) -> Menu.
+
+command({file,{import,{tmf_model,Ask}}}, St) ->
+ do_import(Ask, St);
+command({file,{export,{tmf_model,Ask}}}, St) ->
+ Exporter = fun(Ps, Fun) -> wpa:export(Ps, Fun, St) end,
+ do_export(Ask, export, Exporter, St);
+command({file,{export_selected,{tmf_model,Ask}}}, St) ->
+ Exporter = fun(Ps, Fun) -> wpa:export_selected(Ps, Fun, St) end,
+ do_export(Ask, export_selected, Exporter, St);
+command(_, _) ->
+ next.
+
+
+menu_entry(Menu) ->
+ Menu ++ [{?__(1,"3D Manufacturing (.3mf)..."),tmf_model,[option]}].
+
+props() ->
+ [{ext,".3mf"},{ext_desc,?__(1,"3D Manufacturing File")}].
+
+units() ->
+ units(true).
+units(W) ->
+ [{?__(1,"Meters"),meter}] ++
+ if W ->
+ [{?__(2,"Decimeters"),dm}];
+ true -> []
+ end ++
+ [{?__(3,"Centimeters"),cm},
+ {?__(4,"Millimeters"),mm},
+ {?__(5,"Microns"),micron}] ++
+ if W ->
+ [{?__(6,"Yards"),yd}];
+ true -> []
+ end ++
+ [{?__(7,"Feet"),ft},
+ {?__(8,"Inches"),in}].
+
+%%%
+%%% Import.
+%%%
+
+do_import(Ask, _St) when is_atom(Ask) ->
+ ModelUnit = wpa:pref_get(?MODULE, model_units, cm),
+ Dialog = [
+ {hframe,[
+ {label,?__(2,"Model units (for Wings3D Unit):")},
+ {menu, units(), ModelUnit, [{key,model_units}]} ]}
+ ] ++ common_mesh_options(import),
+ wpa:dialog(Ask, ?__(1,"3D Manufacturing Import Options"), Dialog,
+ fun(Res) ->
+ {file,{import,{tmf_model,Res}}}
+ end);
+do_import(Attr, St) ->
+ wpa:import(props(), import_fun(Attr), St).
+
+set_pref(KeyVals) ->
+ wpa:pref_set(?MODULE, KeyVals).
+
+import_transform(E3dFile, KeyVals) ->
+ Mat = wpa:import_matrix(KeyVals),
+ e3d_file:transform(E3dFile, Mat).
+
+import_fun(Attr) ->
+ fun(Filename) ->
+ set_pref(Attr),
+ ModelUnits = proplists:get_value(model_units, Attr, cm),
+ case open_3mf_file(ModelUnits, Filename) of
+ {ok, {Objs, Mats}} ->
+ {ok, import_transform(#e3d_file{objs=Objs,mat=Mats}, Attr)};
+ {error, Err} ->
+ {error, Err}
+ end
+ end.
+
+%%%
+%%% Export.
+%%%
+
+more_info() ->
+ [?__(1,
+ "
Preparation
"
+ "If an object has multiple materials the object should be cut apart \n"
+ "by material (use Loop Cut tool).\n\n"
+ "Units
"
+ "The model units specifies what the Wings3D unit (WU) is "
+ "equivalent to. The Convert in 3MF specifies what unit "
+ "the exported 3MF file should be in\n\n"
+ "The reason is you may have an object modeled with the units in "
+ "decimeters, but you want to export the file in millimeters, "
+ "possibly because a specific unit is required in the 3MF delivered. "
+ "In this case the object will be rescaled automatically to fit "
+ "millimeters (x100). 3MF allows 5 units (meter, centimeter, "
+ "millimeter, micron, ft, in), "
+ "when same as above is used, the nearest supported unit is "
+ "chosen.\n\n")].
+
+info_button() ->
+ Title = ?__(1,"3MF Export Information"),
+ {help, Title, fun () -> more_info() end}.
+
+
+do_export(Ask, Op, _Exporter, _St) when is_atom(Ask) ->
+ ModelUnit = wpa:pref_get(?MODULE, model_units, cm),
+ TMFUnit = wpa:pref_get(?MODULE, tmf_units, mm),
+
+ Dialog = [
+ {label_column, [
+ {?__(3,"Model units (for Wings3D Unit):"),
+ {menu, units(), ModelUnit, [{key,model_units}]} },
+ {?__(4,"Convert in 3MF to:"),
+ {menu, [{"Same as above",same}] ++ units(false),
+ TMFUnit, [{key,tmf_units}]} }
+ ]}
+ ] ++ common_mesh_options(export) ++ [
+ {hframe,[info_button()]}
+ ],
+
+ wpa:dialog(Ask, ?__(1,"3D Manufacturing (.3mf) Export Options"), Dialog,
+ fun(Res) ->
+ {file,{Op,{tmf_model,Res}}}
+ end);
+do_export(Attr, _Op, Exporter, _St) when is_list(Attr) ->
+
+ SubDivs = proplists:get_value(subdivisions, Attr, 0),
+ Uvs = proplists:get_bool(include_uvs, Attr),
+
+ Ps = [{include_uvs,Uvs},
+ {tesselation, triangulate},
+ {include_hard_edges, true},
+ {subdivisions,SubDivs}|props()],
+ Exporter(Ps, export_fun(Attr)).
+
+export_transform(E3dFile, KeyVals) ->
+ Mat = wpa:export_matrix(KeyVals),
+ e3d_file:transform(E3dFile, Mat).
+
+export_fun(Attr) ->
+ fun(Filename, Contents_0) ->
+ set_pref(Attr),
+ E3DFile=export_transform(Contents_0, Attr),
+
+ ModelUnits = proplists:get_value(model_units, Attr, cm),
+ TMFUnits = proplists:get_value(tmf_units, Attr, mm),
+
+ wr_3mf(Filename, units_tuple(ModelUnits, TMFUnits), E3DFile)
+ end.
+
+units_tuple(dm, same) ->
+ units_tuple(dm, cm);
+units_tuple(yd, same) ->
+ units_tuple(yd, ft);
+units_tuple(ModelUnits, same)
+ when ModelUnits =:= meter;
+ ModelUnits =:= cm;
+ ModelUnits =:= mm;
+ ModelUnits =:= micron;
+ ModelUnits =:= ft;
+ ModelUnits =:= in ->
+ units_tuple(ModelUnits, ModelUnits);
+units_tuple(ModelUnits, TMFUnits) ->
+ {ModelUnits, TMFUnits}.
+
+
+common_mesh_options(Type) ->
+ T = wpa:dialog_template(?MODULE, Type, [
+ include_normals,
+ include_colors,
+ default_filetype
+ ]),
+ T_1 = common_mesh_options_remove_filetype(T),
+ [T_1].
+common_mesh_options_remove_filetype({vframe, L}) ->
+ L_1 = common_mesh_options_remove_filetype_2(lists:reverse(L)),
+ {vframe, lists:reverse(L_1)}.
+common_mesh_options_remove_filetype_2([panel|L]) ->
+ common_mesh_options_remove_filetype_2(L);
+common_mesh_options_remove_filetype_2([{vframe, [{menu,_,_,PL}],_}|L]=L1) ->
+ case proplists:get_value(key, PL) of
+ default_filetype ->
+ common_mesh_options_remove_filetype_2(L);
+ _ ->
+ L1
+ end;
+common_mesh_options_remove_filetype_2(L) ->
+ L.
+
+
+%%
+%% Write 3MF
+%%
+
+%% An object with just a material
+-record(notexprop, {
+ obj,
+ mtl
+}).
+
+%% An object with a material and a texture
+-record(texprop, {
+ obj,
+ mtl,
+ t2dg,
+ mprop,
+ tex
+}).
+
+
+wr_3mf(Filename, Units, E3DFile) ->
+ Name = filename:basename(filename:rootname(Filename)),
+ Ret = try
+ wr_3mf_1(Name, Units, E3DFile)
+ catch _:one_material_per_volume ->
+ {error, {one_material_per_volume, any}}
+ end,
+ case Ret of
+ {Ret_1, TexImgs} when is_list(Ret_1) ->
+ TMFContents = iolist_to_binary(Ret_1),
+ ok = wr_zip(Filename, TMFContents, TexImgs),
+ ok;
+ {error, {one_material_per_volume, A}} ->
+ {error,
+ "Only one material per volume allowed, use the "
+ "\"loop cut\" tool to split objects and apply "
+ "one material to each whole object afterwards." ++
+ case A of
+ any ->
+ "";
+ "" ->
+ "";
+ ObjName when is_list(ObjName) ->
+ lists:flatten(io_lib:format(
+ "~n~n'~s' must be single material.",
+ [ObjName]))
+ end }
+ end.
+
+wr_3mf_1(_Name, {_WU, TMFUnit}=Units, #e3d_file{objs=Objs_0,mat=Mats,creator=Creator}) ->
+ case close_objs_single_mat(Objs_0) of
+ {ok, Objs_1} ->
+ %% Combine objects with same name
+ ScaleF = scale_from_units(export, Units),
+ Objs_2 = scale_objects(Objs_1, ScaleF),
+ Objs_3 = Objs_2,
+ {MtlIndices, TexImgs, IdxCount} = index_mtls(Mats),
+ Objs = number_objs(Objs_3, MtlIndices, IdxCount),
+ Xml=[
+ <<"\n">>,
+ to_bin(io_lib:format(
+ "\n" ++
+ "\n",
+ [Creator, to_3mf_unit(TMFUnit)])),
+ <<"\n">>,
+ [ wr_3mf_mat(Mt, MtlIndices)
+ || Mt <- Mats],
+ [ wr_3mf_obj(Obj, MtlIndices, ObjNum)
+ || {Obj, ObjNum} <- Objs ],
+ <<"\n">>,
+ <<"\n">>,
+ [ to_bin(io_lib:format("- \n", [obj_num_only(ObjNum)]))
+ || {_, ObjNum} <- Objs ],
+ <<"
\n">>,
+ <<"\n">>
+ ],
+ {Xml, TexImgs};
+ {error, Err} ->
+ {error, Err}
+ end.
+
+obj_num_only(#notexprop{obj=ObjNum})
+ when is_integer(ObjNum) ->
+ ObjNum;
+obj_num_only(#texprop{obj=ObjNum})
+ when is_integer(ObjNum) ->
+ ObjNum.
+
+
+wr_zip(Filename, TMFContents, TexImgs) ->
+ Options = [],
+ Rels = [
+ "",
+ "",
+ "",
+ ""],
+ ContentTypes =
+ ["",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""],
+ {ok, _} = zip:zip(Filename, [
+ {"3D/3dmodel.model", to_bin(TMFContents)},
+ {"_rels/.rels", to_bin(Rels)},
+ {"[Content_Types].xml", to_bin(ContentTypes)}
+ ] ++ TexImgs, Options),
+ ok.
+
+
+%% 3MF Write Objects
+-record(tmfwrobj, {
+ name = [],
+ vs = [],
+ vc = [],
+ tx = [],
+ vl = []
+}).
+-record(tmfwrvl, {
+ name = [],
+ mat = none,
+ fs = []
+}).
+
+
+%% Assign a number for each object
+%%
+number_objs(Objs, MtlIndices, Num)
+ when is_integer(Num) ->
+ number_objs(Objs, MtlIndices, Num, []).
+number_objs([O|Objs], MtlIndices, Num, OL) ->
+ {O_1, Mat} = number_objs_1(O),
+ case gb_trees:get(Mat, MtlIndices) of
+ {mat, MtlId, none} ->
+ ObjId = Num+1,
+ number_objs(Objs, MtlIndices, Num+1,
+ [{O_1,#notexprop{obj=ObjId,mtl=MtlId}}|OL]);
+ {mat, MtlId, {TexId}} ->
+ ObjId = Num+1,
+ T2DGId = Num+2,
+ MPId = Num+3,
+ number_objs(Objs, MtlIndices, Num+3,
+ [{O_1,#texprop{obj=ObjId,mtl=MtlId,t2dg=T2DGId,mprop=MPId,tex=TexId}}|OL])
+ end;
+number_objs([], _MtlIndices, _Num, OL) ->
+ lists:reverse(OL).
+number_objs_1(#e3d_object{name=Name,obj=#e3d_mesh{vs=Vs,vc=Vc,tx=Tx,fs=EFs}=_}=_) ->
+ [#e3d_face{mat=[Mat]}|_]=EFs,
+ VL = #tmfwrvl{name=Name,mat=Mat,fs=EFs},
+ {#tmfwrobj{name=Name,vs=Vs,vc=Vc,tx=Tx,vl=[VL]}, Mat}.
+
+
+
+
+%% Try to split and triangulate along the seam of
+%% multi-material objects to turn them into valid
+%% single material volumes, or return an error.
+%%
+close_objs_single_mat(Objs_0) ->
+ %% TODO
+ close_objs_single_mat_1(Objs_0).
+close_objs_single_mat_1(Objs) ->
+ Objs_M =
+ [ ordsets:from_list(lists:append([ ML || #e3d_face{mat=ML} <- Efs]))
+ || #e3d_object{obj=#e3d_mesh{fs=Efs}} <- Objs],
+ case lists:all(fun (A) -> length(A) =:= 1 end, Objs_M) of
+ true ->
+ {ok, Objs};
+ false ->
+ ObjName = first_obj_name(Objs, [length(B) =/= 1 || B <- Objs_M]),
+ {error, {one_material_per_volume, ObjName}}
+ end.
+
+
+first_obj_name([_|Objs], [false|L2]) ->
+ first_obj_name(Objs, L2);
+first_obj_name([#e3d_object{name=Name}|_], [true|_]) ->
+ Name;
+first_obj_name(_, []) ->
+ "".
+
+
+wr_3mf_obj(#tmfwrobj{name=ObjName,vs=Vs,vc=_Vc,tx=VTx,vl=Volumes}, MtlIndices, ObjNum) ->
+ [#tmfwrvl{mat=MatName}|_]=Volumes,
+ case ObjNum of
+ #texprop{obj=ObjId,t2dg=T2DGId,mprop=MPId}
+ when is_integer(ObjId),
+ is_integer(T2DGId),
+ is_integer(MPId) ->
+ {mat, MtlId, {Tex2DId}} = gb_trees:get(MatName, MtlIndices),
+ [
+ wr_3mf_tex2dg(T2DGId, Tex2DId, VTx),
+ wr_3mf_multiprop(MPId, {MtlId, T2DGId}, VTx),
+ to_bin(io_lib:format(
+ "