本人对于 BIM 一无所知,笔记中都是搜索引擎查找+猜测+总结的成果,一些结论和断言会有不准确和不专业之处,请多多见谅。
背景
工作的项目中需要在页面中展示 BIM。
BIM 有很多解释,这里指的是 Building Information Model - 建筑信息模型,简单说就是为建筑工程绘制的 2D/3D 模型。
功能需求是:将提供的 .rvt
文件的模型展示在 web 页面中。
经过一番研究:
- BIM 模型文件有多种格式,
.rvt
格式是 Autodesk Revit 开发的项目文件。 - 前端无法直接使用
.rvt
文件,three.js 可以通过加载.rvt
文件转化的.json
文件展示模型内容。 - 要将
.rvt
转化成.json
(实际上是一堆.json
和其他资源文件),要用 revit 软件,例如,付费软件 Autodesk Revit,试用版 16GB… - Revit 支持二次开发,其中包括转化文件的 API,似乎用 ASP.NET 开发的,但是也不是一键转化,似乎需要一些操作和知识(不懂)。
- 一些平台实现了二次开发,并提供包括上传、转化、展示等一条龙服务,当然这都是收费的,例如广联达的 BIMFACE、Autodesk 官方团队的 Autodesk Forge(本人主要研究的后者)。
.rvt
测试文件不好找,转换完的.json
文件也不好找,本人没找到,测试用的文件是客户给的,所以不方便提供。Autodesk Forge 官方提供了一些示例文件,不过我没下载成功,你可以试试: Revit 示例项目文件
Forge
指引
Autodesk Forge 提供了很多 API 用于开发和管理模型数据,还可以使用 Autodesk Forge Viewer 在页面上加载展示模型。
Autodesk Forge 的工作人员提供了一个 《Autodesk Forge中文帮助中心》,里面包括教程文档、Forge 进阶、常见问题、在线工具等很多内容,阅读它们是最快掌握 Forge 的途径,建议至少阅读完 《Autodesk Forge 学习简谈系列》。
系列中有称为 learnforge 的新手案例教程,包括视频教程《Forge Viewer案例从搭建到部署》和视频中的《参考文档》,具体的搭建和演示本人就不重复了,建议自己搭一遍,熟悉简单的流程。
流程
我跟着 Nodejs 案例搭建了一遍,一直到成功展示到页面,至此我的需求就基本满足了,所以就没有继续看后面的扩展。
案例的大致流程是:
1、注册登录
官方注册账号并登录,开启试用
2、身份验证
创建 App,主要用来获取开发用的 Client ID 和 Client Secret,基于它们生成具体访问指定范围权限的 token,调用 API 时需将 token 传递过去。
3、数据管理
Forge 将数据存储在云端的名叫 bucket 的容器(抽象)中。
- 首先创建一个 bucket
- 然后上传支持格式的模型文件,例如
.rvt
- 然后执行转换操作,将模型文件转换成 Forge Viewer 可以加载的数据格式。
4、页面展示
Forge 可以任意支持的模型文件转化成 SVF 格式(.svf
),.svf
文件记录了模型中使用到了哪些资源,通过它可以得知需要加载哪些文件,包括 .json
、图片、.gz
、.bin
等。
案例中通过 Viewer 加载远程的 svf 文件进而加载相关资源文件,最终渲染整个模型。
svf 包提取器
关于 SVF
SVF 不是单一文件,是一个数据包,包括了构建集合信息,属性包,有一个
.svf
的清单文件(二维模型时.f2d
)。而 Forge Viewer 的 JavaScript 库对此数据进行解析和渲染。目前 SVF 数据格式并没有文档说明,也无官方端口直接下载数据包。不过可以按清单文件下载到这些文件。
注意:SVF2 暂时还不支持下载数据包。
Forge Viewer 支持 SVF 格式是内部格式,作为入口的 .svf
文件实际上就是个压缩包,可以通过压缩软件解压。其中包含的 manifest.json
文件记录了模型所需的相关资源的文件地址,例如 .json
、图片、.gz
、.bin
等。
下图是 {3D}.svf
文件解压结果:
功能
通过搭建案例已经实现基本的展示需求,但实际上,转化的数据都存储在 Forge 云端服务器,这样对于数据管理和网络请求都是不友好的,所以还需要将其下载到自己的私有服务器进行存储。
可是 Forge 并没有提供傻瓜式的一键下载 SVF 包的功能。
好在官方开发人员提出了可行的方案,就是根据获取到的文件清单,批量进行手动下载打包,见《Autodesk Forge 学习简谈 - 4》。
团队中也有人(PHILIPPE LEEFSMA)从已废弃的官方在线工具中解耦了 “下载离线 SVF 包” 的功能:《Forge SVF Extractor in Node.js》
官方还开发了支持一键下载的 vscode 插件,也可以参考源码开发自己的下载功能。
方案
.svf
文件和其记录的相关资源统称为 SVF,Forge 称呼这些转化后的结果为 Derivatives - SVF 衍生(或派生)文件。
通过 DerivativesApi
和 URN 可以提取模型的 manifest 数据,其中存储了这个模型的 Derivatives 信息,再通过 Derivatives 信息中的路径手动拼接官方地址就是 Viewer 加载的文件地址。
将这些衍生文件的路径全部整合好,进行批量下载并存储到本地就实现了 SVF 包的下载。
URN:Uniform Resource Name(统一资源名称)不同与 URL(统一资源定位符)的标准格式的URI,以唯一名称的方式指向资源,而不是资源的所在地。更多请参考《HTTP 权威指南》中 URN 的介绍。
代码
本人使用的 PHILIPPE 的解耦代码,并对代码进行了一些调整以使其可以直接使用:
- 删除代码中保留的一些 PHILIPPE 自己项目中的模块
- 一些 API 的过时使用方式,导致调用报错
- 一些路径分隔符不一致导致的报错
- 本人将代码放在了 Nodejs 环境运行,所以改用 CommonJS 方式加载模块
- 环境搭建和 token 生成沿用 Learn Forge 案例
最终结果:
// ExtractorSvc.js
const archiver = require('archiver')
const { DerivativesApi } = require('forge-apis')
const request = require('request')
const mkdirp = require('mkdirp')
const Zip = require('node-zip')
const Zlib = require('zlib')
const path = require('path')
const fs = require('fs')
const _ = require('lodash')
// 转化路径中的分隔符
// path 会将路径中的路径分隔符全部转化为当前环境的默认格式,可能会是 `\`,在拼接其它路径时可能会有问题,这里进行了统一替换
function formatSep(str, sep = path.sep) {
const reg = new RegExp(sep === '/' ? '\\\\' : '/', 'g')
return str.replace(reg, sep)
}
// Extractor Service 类
class ExtractorSvc {
// 构造函数
constructor() {
// 初始化 API 实例
this.derivativesAPI = new DerivativesApi()
}
// 实例名成
name() {
return 'ExtractorSvc'
}
/**
* 【对外提供的 API】
* 将 SVF 文件全部下载到服务器,并返回所有文件的路径
* @param {AuthClient} oauth2Client 案例中定义的 getClient 方法生成的 token
* @param {AuthToken} credentials 案例中定义的 getInternalToken 方法生成的 token
* @param {string} urn 案例中 new ObjectsApi().getObjects API获取的 objectId
* @param {string} directory 存储资源的目标绝对路径
* @returns
*/
download(oauth2Client, credentials, urn, directory) {
return new Promise(async (resolve, reject) => {
// mkdirp:mkdir 的 promise 分装
// 创建目标目录,确保目录确实存在
await mkdirp(directory)
// 根据 URL 获取顶层的 manifest
const manifest = await this.derivativesAPI.getManifest(urn, {}, oauth2Client, credentials)
// 整合要获取的全部资源
const derivatives = await this.getDerivatives(oauth2Client, credentials, manifest.body)
// 格式化资源信息,提取必要字段
const nestedDerivatives = derivatives.map(item => {
return item.files.map(file => {
const localPath = formatSep(path.resolve(directory, item.localPath))
return {
basePath: item.basePath,
guid: item.guid,
mime: item.mime,
fileName: file,
urn: item.urn,
localPath
}
})
})
// 将多维数组拍平,转化为一维数组
const derivativesList = _.flattenDeep(nestedDerivatives)
// 为每个资源文件创建异步下载任务
const downloadTasks = derivativesList.map(derivative => {
return new Promise(async resolve => {
// 由于要拼接 HTTP 地址,所以强制使用 `/` 分隔符
const urn = formatSep(path.join(derivative.basePath, derivative.fileName), '/')
// 下载每个文件
const data = await this.getDerivative(oauth2Client, credentials, urn)
const filename = formatSep(path.resolve(derivative.localPath, derivative.fileName))
// 保存文件
await this.saveToDisk(data, filename)
resolve(filename)
})
})
// 等待所有文件下载
const files = await Promise.all(downloadTasks)
resolve(files)
})
}
// 解析 manifest 全部要获取的资源,提取 guid mime 和解析URN生成的路径信息对象
parseManifest(manifest) {
const items = []
const parseNodeRec = node => {
const roles = [
'Autodesk.CloudPlatform.DesignDescription',
'Autodesk.CloudPlatform.PropertyDatabase',
'Autodesk.CloudPlatform.IndexableContent',
'leaflet-zip',
'thumbnail',
'graphics',
'preview',
'raas',
'pdf',
'lod'
]
if (roles.includes(node.role)) {
const item = {
guid: node.guid,
mime: node.mime
}
// 解析 URN 生成的路径信息对象
const pathInfo = this.getPathInfo(node.urn)
items.push(Object.assign({}, item, pathInfo))
}
if (node.children) {
node.children.forEach(child => {
parseNodeRec(child)
})
}
}
parseNodeRec({
children: manifest.derivatives
})
return items
}
// 收集 SVF 资源
getSVFDerivatives(oauth2Client, credentials, item) {
return new Promise(async (resolve, reject) => {
try {
const svfPath = item.urn.slice(item.basePath.length)
// 记录 svf 文件路径
const files = [svfPath]
// 通过 request 获取 SVF 文件信息(Buffer)
const data = await this.getDerivative(oauth2Client, credentials, item.urn)
// 将 SVF Buffer 数据转化成压缩包对象格式
const pack = new Zip(data, {
checkCRC32: true,
base64: false
})
// 获取里面的 manifest.json 信息
const manifestData = pack.files['manifest.json'].asNodeBuffer()
const manifest = JSON.parse(manifestData.toString('utf8'))
// 如果 manifest 还记录了资源文件,则一并记录
if (manifest.assets) {
manifest.assets.forEach(asset => {
// 跳过 SVF 嵌入资源
if (asset.URI.indexOf('embed:/') === 0) {
return
}
files.push(asset.URI)
})
}
return resolve(
Object.assign({}, item, {
files
})
)
} catch (ex) {
reject(ex)
}
})
}
// 收集 F2D 资源(示例文件中没有此类型,所以不做介绍)
getF2dDerivatives(oauth2Client, credentials, item) {
return new Promise(async (resolve, reject) => {
try {
const files = ['manifest.json.gz']
const manifestPath = item.basePath + 'manifest.json.gz'
const data = await this.getDerivative(oauth2Client, credentials, manifestPath)
// 解压缩 Gzip
const manifestData = Zlib.gunzipSync(data)
const manifest = JSON.parse(manifestData.toString('utf8'))
if (manifest.assets) {
manifest.assets.forEach(asset => {
// 跳过 SVF 嵌入资源
if (asset.URI.indexOf('embed:/') === 0) {
return
}
files.push(asset.URI)
})
}
return resolve(
Object.assign({}, item, {
files
})
)
} catch (ex) {
reject(ex)
}
})
}
// 整合顶层 manifest 中要获取的资源
getDerivatives(oauth2Client, credentials, manifest) {
return new Promise(async (resolve, reject) => {
// 解析整合 manifest 全部资源的必要信息
const items = this.parseManifest(manifest)
const derivativeTasks = items.map(item => {
// 根据 mime 处理
switch (item.mime) {
case 'application/autodesk-svf':
// 如果是 SVF 则获取文件并解析文件中的资源地址列表
return this.getSVFDerivatives(oauth2Client, credentials, item)
case 'application/autodesk-f2d':
// 如果是 F2D 则获取文件并解析文件中的资源地址列表
return this.getF2dDerivatives(oauth2Client, credentials, item)
case 'application/autodesk-db':
// 固定 gz 文件
return Promise.resolve(
Object.assign({}, item, {
files: [
'objects_attrs.json.gz',
'objects_vals.json.gz',
'objects_offs.json.gz',
'objects_ids.json.gz',
'objects_avs.json.gz',
item.rootFileName
]
})
)
default:
// 其它类型文件,如 jpg 等
return Promise.resolve(
Object.assign({}, item, {
files: [item.rootFileName]
})
)
}
})
const derivatives = await Promise.all(derivativeTasks)
return resolve(derivatives)
})
}
// 解析 URN 生成路径相关信息
getPathInfo(encodedURN) {
const urn = decodeURIComponent(encodedURN)
const rootFileName = urn.slice(urn.lastIndexOf('/') + 1)
const basePath = urn.slice(0, urn.lastIndexOf('/') + 1)
const localPathTmp = basePath.slice(basePath.indexOf('/') + 1)
const localPath = localPathTmp.replace(/^output\//, '')
return {
rootFileName,
localPath,
basePath,
urn
}
}
// 通过 URN 获取资源数据(Buffer)
getDerivative(oauth2Client, credentials, urn) {
return new Promise(async (resolve, reject) => {
// 拼接官方固定的地址
const baseUrl = 'https://developer.api.autodesk.com/'
// url 是拼接好的文件资源路径
// URN包含文件路径信息,可能有中文,需要编码
const url = baseUrl + `derivativeservice/v2/derivatives/${encodeURIComponent(urn)}`
// 使用 request 请求资源文件
request(
{
url,
method: 'GET',
headers: {
// 注意添加 token
Authorization: 'Bearer ' + credentials.access_token,
'Accept-Encoding': 'gzip, deflate'
},
encoding: null
},
(err, response, body) => {
if (err) {
return reject(err)
}
if (body && body.errors) {
return reject(body.errors)
}
if ([200, 201, 202].indexOf(response.statusCode) < 0) {
return reject(response)
}
return resolve(body || {})
}
)
})
}
// 将文件写入到本地
saveToDisk(data, filename) {
return new Promise(async (resolve, reject) => {
// 创建原有的文件所在目录
await mkdirp(path.dirname(filename))
// 创建写入流
const wstream = fs.createWriteStream(filename)
const ext = path.extname(filename)
wstream.on('finish', () => {
resolve()
})
// 写入数据
if (typeof data === 'object' && ext === '.json') {
wstream.write(JSON.stringify(data))
} else {
wstream.write(data)
}
wstream.end()
})
}
/**
* 【对外提供的 API】
* 将指定的文件全部打包成一个 zip 压缩包
* @param {*} rootDir 要打包的文件所在目录(绝对路径)
* @param {*} zipfile 生成压缩包的完整绝对路径
* @param {*} zipRoot 压缩包中根目录文件名
* @param {*} files 要打包的文件地址列表
* @returns
*/
createZip(rootDir, zipfile, zipRoot, files) {
// 统一路径分隔符
rootDir = formatSep(rootDir)
zipfile = formatSep(zipfile)
zipRoot = formatSep(zipRoot)
return new Promise((resolve, reject) => {
try {
// 创建写入流
const output = fs.createWriteStream(zipfile)
//生成 archiver 对象,打包类型为zip
const archive = archiver('zip')
// 写入流关闭(打包完成)即决议
output.on('close', () => {
resolve()
})
archive.on('error', err => {
reject(err)
})
// 将打包对象与写入流关联
archive.pipe(output)
if (files) {
files.forEach(file => {
try {
// 读取文件内容
const rs = fs.createReadStream(file)
// 将被打包文件的流添加进 archiver 对象中
archive.append(rs, {
// 压缩包中文件路径
name: `${zipRoot}/${formatSep(file).replace(rootDir, '')}`
})
} catch (ex) {
console.log(ex)
}
})
} else {
// 如果未指定文件,则打包指定 rootDir 目录下全部文件
archive.glob(rootDir + '/*')
}
// 完成归档
archive.finalize()
} catch (ex) {
reject(ex)
}
})
}
}
module.exports = {
ExtractorSvc
}
路由接口:
// ...
const { ExtractorSvc } = require('./common/extractor')
router.post('/download', async (req, res, next) => {
// 统一名称:下载 SVF 存放的文件夹名称和生成的压缩包名称
// const name = req.body.name
const name = 'MyForgeModel'
// 案例中 new ObjectsApi().getObjects API获取的 objectId
const urn = req.body.objectName
// 创建 Extractor service 实例
const extractorSvc = new ExtractorSvc()
// 下载 SVF 存放的目标绝对路径
// 当前同接口放在同一路径
const dir = path.resolve(__dirname, name)
// 执行下载
// oauth_client oauth_token 是案例中生成的 token
const files = await extractorSvc.download(req.oauth_client, req.oauth_token, urn, dir)
// 压缩包完整绝对路径
const zipfile = dir + '.zip'
// 打包全部文件
await extractorSvc.createZip(dir, zipfile, name, files)
// 删除下载的资源
// rmdir(dir)
// 将压缩包地址返回客户端
res.status(200).end(zipfile)
})
module.exports = router
效果
调用 前置路径/download
接口后,服务器就会生成一个存储 SVF 衍生文件的文件夹和一个将它们打包后的压缩包。
就好像:
├─ MyForgeModel # 存储 SVF 衍生文件的文件夹
│ ├─ Resource
│ │ ├─ 三维视图
│ │ │ └─ {3D} 96277
│ │ │ ├─ 3
│ │ │ │ └─ mats
│ │ │ │ ├─ concrete_aggregate_polished_color.jpg
│ │ │ │ ├─ concrete_aggregate_polished_color_svf_tex_mod.jpg
│ │ │ │ ├─ concrete_aggregate_polished_refl.jpg
│ │ │ │ ├─ concrete_aggregate_polished_refl_svf_tex_mod.jpg
│ │ │ │ ├─ concrete_aggregate_polished_rough.jpg
│ │ │ │ ├─ grass_ao_mult_h.jpg
│ │ │ │ ├─ grass_ao_mult_h_svf_tex_mod.jpg
│ │ │ │ ├─ grass_color.jpg
│ │ │ │ ├─ grass_color_svf_tex_mod.jpg
│ │ │ │ ├─ grass_norm_mod.jpg
│ │ │ │ └─ grass_norm_mod_svf_tex_mod.jpg
│ │ │ ├─ 0.pf
│ │ │ ├─ CameraDefinitions.bin
│ │ │ ├─ CameraList.bin
│ │ │ ├─ FragmentList.pack
│ │ │ ├─ GeometryMetadata.pf
│ │ │ ├─ InstanceTree.bin
│ │ │ ├─ LightDefinitions.bin
│ │ │ ├─ LightList.bin
│ │ │ ├─ Materials.json.gz
│ │ │ ├─ ProteinMaterials.json.gz
│ │ │ ├─ Set.bin
│ │ │ ├─ {3D}.svf # 这是用于提供给 Viewer 解析的文件
│ │ │ ├─ {3D}1.png
│ │ │ ├─ {3D}2.png
│ │ │ └─ {3D}4.png
│ │ ├─ model.sdb
│ │ ├─ objects_attrs.json.gz
│ │ ├─ objects_avs.json.gz
│ │ ├─ objects_ids.json.gz
│ │ ├─ objects_offs.json.gz
│ │ ├─ objects_vals.json.gz
│ │ └─ objects_viewables.json.gz
│ ├─ preview1.png
│ ├─ preview2.png
│ └─ preview4.png
└─ MyForgeModel.zip # 打包 MyForgeModel 后的压缩包
不足
- 需要判断 MIME 目前只有3种,不确定是否还有其它类型
- 没有对一些异常情况作处理,例如网络请求失败、写入文件失败、解析失败等
- 其它可完善和扩展的地方