2021년 2월 4일 목요일

Autodesk Forge Digital Twin 코드 분석 및 UML 구조 역설계

이 글은 Autodesk Forge Digital Twin 소스 예제를 간단히 분석한다. 쉬운 이해를 위해, 구조와 소스코드를 먼저 분석하고, 이를 UML로 표현해 구조를 역설계 한다. 이를 통해, 실무적 관점에서 플랫폼 기반 디지털 트윈 서비스 개발 절차 및 방법, 고려사항 등을 확인해 봅니다.

개요
이 프로그램은 디지털 트윈의 특징 중 물리모델과 디지털모델의 연결, 실시간 IoT 센서 정보 반영, 시뮬레이션, 클라우드와 같은 기능을 구현한 것이다. 오토데스크 포지에 대한 설명은 다음 링크를 참고한다.
오토데스크 포지 디지털트윈 소프트웨어 아키텍처 분석 방법은 먼저 소스코드와 GUI 뷰를 분석한 후, 객체 및 클래스 정적 구조, 시퀀스 동적 구조를 역설계하는 순서로 진행한다. 

컴포넌트 구조 및 실행 방법
이 소프트웨어는 Autodesk Forge, Node.JS 자바스크립트 런타임 플랫폼, express 웹서버, Bootstrap UI 테마, MongoDB 데이터베이스를 사용하고 있다.

Autodesk Forge에 가입하고, 앞에 언급된 컴포넌트를 모두 설치한 후 다음 명령을 실행해 웹서버를 구동한다. 
git clone https://github.com/Autodesk-Forge/forge-digital-twin
npm install
export FORGE_CLIENT_ID=<client-id>
export FORGE_CLIENT_SECRET=<client-secret>
export FORGE_MODEL_URN=<model-urn>
export MONGODB_URL=<mongodb-connection-string>
npm start

3차원 디지털 모델은 아래 예제를 다운받아 SVF포맷으로 변환한 후 앞의 FORGE_MODEL_URN에 URN형식으로 경로를 제공한다.
View 구조 분석
HTML 뷰 렌더링 구조는 index.pug 에서 시작한다. 참고로, PUG(Python User Group)는 파이썬과 유사한 문법으로 HTML을 기술하는 HTML 렌더링 템플릿 미들웨어로 HTML을 간략한 문법으로 정의하고, 데이터베이스 객체와 바인딩을 쉽게 처리한다.
pug파일은 views 폴더에 저장되어 있다. 해당 pug 뷰를 분석하면 다음과 같은 웹 레이아웃을 구성하고 있는 것을 알 수 있다.
뷰 레이아웃 구조 분석

각 뷰를 정의한 pug는 script 함수를 통해 js 코드를 실행하게 되고, 실행된 js 코드에 의해 database 객체 데이터가 획득되어, 화면에 데이터를 출력한다. 비지니스 로직 최초 진입 부분은 main.js 의 Autodesk.Viewing.Initializer() 콜백 함수이고, 여기서 3차원 모델 로딩, 데이터 로딩 및 렌더링, 이벤트 등록 함수 호출이 실행된다.

소프트웨어 아키텍처 역설계
1. 정적구조 및 객체 정보모델 역설계
소스코드 분석 내용을 바탕으로 구조를 UML로 역설계한다. 주요 구조는 다음과 같다. 전형적인 MVC 디자인 패턴 구조이다. 
Digital Twin MVC 구조

아래와 같이 DATABASE는 MONGODB 스키마에서 객체 속성값을 로드하고 필요한 곳에 제공하는 역할을 한다. 
데이터베이스 model 구조

script 는 비지니스 로직 역할을 수행한다. 데이터베이스와 UI를 중간에서 연결하며, CRUD에 대한 로직을 처리한다.
script 구조

routes는 URL에 따른 페이지 뷰를 렌더링할 때 라우팅하는 역할을 한다.
routes 구조

Forge 뷰어의 객체 정보 모델 데이터 구조는 Autodesk 공식 문서에 설명되지 않아 사용 및 이해에 불편하다. 정보 모델 구조 분석을 위해 다음과 같이 DOM 구조를 덤프해 보았다. 
객체 정보 모델 구조 분석

포지의 정보 모델 구조는 다음과 같이 Composite 디자인 패턴을 사용하고 있어, 계층적 구조를 지원하며, 각 계층 요소인 node에서 object를 포함하고 있어, 속성을 관리한다. 
Autodesk Forge 객체 정보 모델 구조(UML)

property는 객체의 모든 속성들을 관리하는 데, 유연한 속성집합 관리를 위해, 속성은 {name, value} 구조로 되어 있다. name에 해당하는 displayName에 대한 displayValue는 type에 따라 정수, 실수, 문자열 등으로 해석되어야 한다. units는 값의 단위를 문자열로 정의한다. 각 속성값은 displayCategory로 그룹핑된다. 참고로, 각 객체의 속성 접근은 비동기 콜백 함수를 이용해 획득해야 한다. 다음은 그 예를 보여준다.

            var roomProperties = [];
            viewer.search('Room', function (idArray) {
                $.each(idArray, function(num, dbid) {
                    viewer.model.getProperties(dbid, function(result) {
                        if (result.properties) {
                            roomProperties.push(result);
                            console.log(result);
                            return;
                        }
                        return;
                    });
                });
            }); 

2. 동적구조 역설계
각 컴포넌트와 객체가 서로간에 어떤 순서로 호출을 처리하는 지 동적구조를 역설계한다. 호출방식은 Node.js 및 웹 특성상 웹서버나 미들웨어가 이벤트 기반 실행 처리하는 구조이므로, 직접적으로 드러나 있지는 않다. 분석 시 프로토콜대로 약속된 이벤트에서 호출된다는 것을 고려한다. 특히, 웹 기반 동작 환경(예. 자바스크립트 등)에서는 getProperties() 같은 주요 포지 함수 호출이 비동기 콜백함수로 처리되므로, 객체와 데이터를 연계시키기가 매우 까다롭다. 이 경우, promise, async등을 사용해 실행 시퀀스 구조를 개발해야 한다. 

포지의 큰 실행 흐름은 인증, 모델 버킷 생성, 모델 업로드, 뷰 실행, 비지니스 로직 실행이다. 다음 그림은 이를 보여준다.  

유스케이스 구조는 다음과 같다. 
유스케이스 구조

user가 login하면, authorize하고, 웹서버 실행해 접속을 listen한다. 접속되면, node.js 의 라우터가 index.pug를 렌더링한다. index는 header, viewer, sidebar, footer pug파일을 포함한다. viewer는 3D 모델(예. 기계, 건축, 건설, BIM 모델 파일 등)을 렌더링하는 3D viewer를 포함한다. sidebar는 데이터 계층구조를 나타내는 performance, maintenance 등을 렌더링한다. 도킹되는 패널은 footer.pug에서 포함된 main.js, performance.js, maintenance.js 등을 포함한다. 데이터는 database를 통해 fetch하여 가져온다. 이 중에 주요 시퀀스를 분석해 보자.

login의 server.js 시퀀스: express의 set함수로 웹페이지 렌더링 뷰는 pug로 설정하고, 라우팅을 위해, 기본 페이지 루트('/')를 index 페이지로 설정한다. 이외, 인증 페이지, 구매 및 유지보수 뷰 페이지 라우팅 URL을 설정하고, 서버를 실행(listen)한다. 다음 UML 시퀀스 구조는 이를 표현한다. 

main.js 시퀀스. 이 시퀀스는 다음과 같이 Autodesk 3차원 모델 뷰어를 생성하고 옵션을 설정한다. main.js의 실행 진입점은 Autodesk.Viewing.Initializer() callback 함수이다. 여기서, 모델, 데이터베이스 CRUD를 위한 함수 호출 및 이벤트 등록이 실행된다.

maintenance.js 시퀀스. 이 시퀀스는 탭 패널에 출력할 데이터를 얻어 값을 설정하고, click, active 등 이벤트가 발생되면, 다시 데이터를 업데이트하는 적절한 함수를 실행한다.  

나머지, performance.js 등도 이와 유사하게 동작한다.

소스 구조 분석
github 폴더 구조는 다음과 같다. 웹서버 시작은 server.js 코드에서 시작된다. 주요 소스는 model, views, public/script 폴더내의 /routes/auth.js, /routes/index.js, /routes/maintenance.js, /routes/procurement.js, /script/main.js, /script/maintenance.js, /script/performance.js, /script/procurement.js 이다.

server.js 에서 클라이언트 접속 listen 대기 후 로그인되면 웹페이지 파일 index.pug 이 렌더링되고, 이 파일에 포함된 header, viewer, footer등 pug파일이 렌더링된다. footer는 실행될 자바스크립트를 포함시켜 인터페이스에 필요한 모델, 데이터 로딩 함수를 호출한다. 다음은 그 주요 코드이다. 
// footer.pug
...
script(src='/scripts/performance.js')
script(src='/scripts/maintenance.js')
...

// main.js
Autodesk.Viewing.Initializer(options, () => {
    mainViewer = new Autodesk.Viewing.Private.GuiViewer3D();
    mainViewer.start();
    loadModel(DEMO_URN /* set by the server-side template engine */);
});

function loadModel(urn) {
    return new Promise(function(resolve, reject) {
    ...
                    initPerformanceTab(mainViewer);
                    initMaintenanceTab(mainViewer);
                    initProcurementTab(mainViewer);
                    initViewer(mainViewer);
                    resolve();
                })
    ...
    });
}

// performance.js
...
function initPerformanceTab(mainViewer) {
...
}

function createEngineSpeedChart() {
        const chart = new Chart(ctx, {
        }
}

server.js 분석
const express = require('express');  // 라이브러리 모듈 가져오기
const path = require('path');
const helmet = require('helmet');
const morgan = require('morgan');

const { FORGE_CLIENT_ID, FORGE_CLIENT_SECRET, FORGE_MODEL_URN, MONGODB_URL } = process.env;  // 환경변수 설정

const db = require('./model/db');   // DB 설정
const app = express();

app.use('/', require('./routes/index'));   // 웹서버 라우팅 주소 설정
app.use('/api/auth', require('./routes/auth'));
app.use('/api/procurement', require('./routes/procurement'));
app.use('/api/maintenance', require('./routes/maintenance'));

const port = process.env.PORT || 3000;
db.connect()
  .then(() => app.listen(port, () => console.log(`Server listening on port ${port}`))) // 서버실행

routes/auth.js 분석
let router = express.Router();
let auth = new AuthenticationClient(process.env.FORGE_CLIENT_ID, process.env.FORGE_CLIENT_SECRET);  // Forge 인증

router.get('/token', async function(req, res, next) {
    try {
        const result = await auth.authenticate(['viewables:read']);
        res.json({ access_token: result });
    } 
});

routes/index.js 분석
router.get('/', function(req, res) {
    res.render('index', { analytics: process.env.GOOGLE_ANALYTICS_TAG, urn: process.env.FORGE_MODEL_URN });
});

routes/maintenance.js 분석
const { Part, Review, Issue } = require('../model/maintenance');  // 유지보수 부품, 리뷰, 이슈 객체 생성
const PartTableLimit = 2048; // note: the jet engine model has cca 1600 parts
const ReviewTableLimit = 256;
const IssueTableLimit = 256;

function countParts() {
    return new Promise(function(resolve, reject) {
        Part.count({}, (err, count) => {        });  // 부품 갯수 
    });
}

function countReviews() {
    return new Promise(function(resolve, reject) {
        Review.count({}, (err, count) => {     });  // 검토 갯수
    });
}

function countIssues() {
    return new Promise(function(resolve, reject) {
        Issue.count({}, (err, count) => { }  // 이슈 갯수
        });
    });
}

function findOrCreatePart(id) {
    return new Promise(function(resolve, reject) {
        Part.findOne({ id }, (err, part) => {
                resolve(part);  // 부품 검색
            }
        });
    });
}

router.get('/issues', function(req, res) {
    let query = {};
    Issue.find(query, (err, issues) => {
            res.json(issues);  // 이슈 검색
        }
    });
});

view/index.pug
pug 형식으로 HTML view 정의하였다. 
doctype html
head
  include partials/head.pug
#container
  #header
    img(src='/images/forge-logo-gray.png' alt='Autodesk Forge Digital Twin Demo')
    h Digital Twin Demo
  #viewer
  #sidebar
    include partials/sidebar.pug
  include partials/footer.pug

view/maintenance.pug
ul#maintenance-tabs.nav.nav-tabs.mb-3.nav-fill(role='tablist')
  li.nav-item
    a#maintenance-revisions-tab.nav-link.active(data-toggle='tab' href='#maintenance-revisions' role='tab' aria-controls='maintenance-revisions' aria-selected='true') Revisions
  li.nav-item
    a#maintenance-issues-tab.nav-link(data-toggle='tab' href='#maintenance-issues' role='tab' aria-controls='maintenance-issues' aria-selected='false') Issues
  li.nav-item
    a#maintenance-statistics-tab.nav-link(data-toggle='tab' href='#maintenance-statistics' role='tab' aria-controls='maintenance-statistics' aria-selected='false') Statistics
  li.nav-item
    a#maintenance-instructions-tab.nav-link(data-toggle='tab' href='#maintenance-instructions' role='tab' aria-controls='maintenance-instructions' aria-selected='false') Docs
#maintenance-tabs-content.tab-content
  #maintenance-revisions.tab-pane.fade.show.active(role='tabpanel' aria-labelledby='maintenance-revisions-tab')
    include ./maintenance-revisions.pug
  #maintenance-issues.tab-pane.fade(role='tabpanel' aria-labelledby='maintenance-issues-tab')
    include ./maintenance-issues.pug
  #maintenance-statistics.tab-pane.fade(role='tabpanel' aria-labelledby='maintenance-statistics-tab')
    include ./maintenance-stats.pug    
  #maintenance-instructions.tab-pane.fade(role='tabpanel' aria-labelledby='maintenance-instructions-tab')
    include ./maintenance-instructions.pug   

public/scripts/main.js 분석
let mainViewer = null;
Autodesk.Viewing.Initializer(options, () => {
    mainViewer = new Autodesk.Viewing.Private.GuiViewer3D(
        document.getElementById('viewer'),
        { extensions: ['HeatmapExtension', 'IssuesExtension', 'AnimationExtension'] }
    );
    mainViewer.start();  // 뷰어 시작
    loadModel(DEMO_URN /* set by the server-side template engine */);  // 모델 로딩
});

function loadModel(urn) {
    return new Promise(function(resolve, reject) {
        function onDocumentLoadSuccess(doc) {
            const node = doc.getRoot().getDefaultGeometry();
            mainViewer.loadDocumentNode(doc, node);
        }
        Autodesk.Viewing.Document.load('urn:' + urn, onDocumentLoadSuccess, onDocumentLoadFailure);  // 모델 로딩
    });
}

function initViewer(viewer) {
    viewer.setQualityLevel(/* ambient shadows */ false, /* antialiasing */ true);  // 뷰어 옵션 설정
    viewer.setGroundShadow(true);
    viewer.setGroundReflection(false);
    viewer.setGhosting(true);
    viewer.setEnvMapBackground(true);
    viewer.setLightPreset(5);
    viewer.setSelectionColor(new THREE.Color(0xEBB30B));
}

public/scripts/maintenance.js 분석
function initMaintenanceTab(mainViewer) {
    let maintenanceViewer = null;

    async function updateRevisions(reload, partIds) {
        updateRevisionsTable();
        updateRevisionsPagination();
    }

    function updateRevisionsTable() // 리비전 테이블 화면 갱신 
    {
        for (let i = currPage * pageSize; i < (currPage + 1) * pageSize && i < list.length; i++) {
            const review = list[i];
            const date = new Date(review.createdAt);
            const $row = $(`
                <tr>
                    <th scope="row">${date.toLocaleDateString()}</th>
                    <td><a href="#" class="part-link">${review.partId}</a></td>
                    <td>${review.author}</td>
                    <td style="color: ${review.passed ? 'green' : 'red'};">${review.passed ? 'Good' : 'Bad'}</td>
                </tr>
            `);
            $tbody.append($row);
        }
    }

    function updateIssuesTable() // 이슈 테이블 화면 갱신 
    {
        for (let i = currPage * pageSize; i < (currPage + 1) * pageSize && i < list.length; i++) {
            const issue = list[i];
            const date = new Date(issue.createdAt);
            const $row = $(`
                <tr>
                    <th scope="row">${date.toLocaleDateString()}</th>
                    <td><a href="#" class="part-link">${issue.partId}</a></td>
                    <td>${issue.author}</td>
                    <td>${issue.text}</td>
                </tr>
            `);
            $tbody.append($row);
        }
    }
}

model/db.js 분석
const mongoose = require('mongoose');  // 몽고 디비 획득

class Database {
    async connect() { 
        try {
            await mongoose.connect(process.env.MONGODB_URL);  // 몽고 디비 연결
        } catch(err) {
        }
    }
}

module.exports = new Database();

model/maintenance.js 분석
const mongoose = require('mongoose');
const validator = require('validator');

// 부품 스키마 정의
const partSchema = new mongoose.Schema({
    id: {
        type: Number,
        required: true
    },
    status: {
        type: String,
        validate: validatePartStatus
    },
    nextReview: {
        type: Date
    }
});

// 리뷰 스키마 정의
const reviewSchema = new mongoose.Schema({
    createdAt: {
        type: Date,
        required: true
    },
    author: {
        type: String,
        required: true,
        validate: validateAuthor
    },
    passed: {
        type: Boolean,
        required: true
    },
    description: {
        type: String,
        validate: validateRevisionDescription
    },
    partId: {
        type: Number,
        required: true
    }
});

module.exports = {
    Part: mongoose.model('Part', partSchema),
    Review: mongoose.model('Review', reviewSchema),
    Issue: mongoose.model('Issue', issueSchema)
};

마무리
Autodesk Forge Digital Twin 웹 서비스는 전형적인 MVC(Model View Control) 디자인 패턴 구조로, 각 폴더의 model, views, public/script는 각각 MVC에 대응된다. 

시퀀스를 보면, start.js는 라우팅 설정을 하고, 웹서버를 우선 실행한다. routes는 인터넷 주소 입력 시 라우팅된 각 뷰 페이지를 redirect해서 렌더링되게 한다. model에서 DB to Object 맵핑을 처리하고, 맵핑된 object를 통해 view에 내용을 렌더링한다. view의 이벤트는 scripts에서 받아 처리한다. 

결론적으로 Autodesk Forge 기반 디지털 트윈 서비스 개발 순서는 다음과 같다.
1. 이해당자사, 요구사항, 유스케이스 및 아키텍처 설계. 예) SRS, BEP, SAD, UML, 의사코드 등
2. 디지털 모델과 데이터베이스 정의, 설계 및 개발. 예) DB Schema, BIM modeling 등 
3. 디지털 데이터 소스 준비. 예) IoT, RFID, Sensor, Agent 등
4. 웹서버 HW 및 SW 컴포넌트 준비. 예) 서버, Node.JS, DB, Express, Dashboard, ETL 등 
5. Autodesk Forge App 생성
6. GUI 뷰 설계. 예) HTML, CSS, PUG 등
7. 비지니스 로직 코딩
1) 모델 로딩 함수
2) 데이터 로딩 및 GUI 출력 함수
3) 데이터 CRUD 함수
4) 각종 이벤트 처리 함수
8. DNS 설정 및 IP 연결
9. 서비스 배포 및 운영

관련 소스는 뷰어를 조작하는 Forge API 사용방법을 충분히 보여주고 있어, 좋은 예제 역할을 하고 있다. 이제, 이를 이용해 BIM 모델, 데이터베이스와 IoT 데이터를 서로 연결해 보면 되겠다.

부록: Forge HTML DOM 구조 분석 및 Forge 뷰어 레이아웃 조정
3차원 모델에 데쉬보드를 추가하기 위해, Forge 화면 DOM 레이아웃 구조를 수정해야 한다. 개발 목적에 따라 모델과 데쉬보드 형식은 모두 다르므로, 오토데스크에서 제공하는 템플릿 코드를 그대로 사용할 수가 없다. 이런 이유로 Forge HTML DOM 구조 분석이 필요하다. 기본 구조는 다음과 같다. 
    
DOM HTML layout

DOM element는 화면에 렌더링될 때 해당 웹페이지에 지정된 css, style에 따라 표시된다. 참고로, html페이지에 설정된 meta, link, script는 다음과 같이 순서 의존 관계가 있으므로, 순서가 달라지면 제대로 표시되지 않을 수 있다.

Revit 3D viewer 옆에 차트를 만들고, 아래에 표를 배치하기 위해, Bootstrap layout 규약에 맞게 class에 row와 col을 적절히 지정한 후, 포지 뷰어가 생성되면 dashboard element안에 차트 canvas를 스크립트에서 생성해 주었다.
     <div class="container-fluid fill">
        <div class="row fill">               
            <div class="col-sm-7 fill">          
                <div id="forgeViewer"> 
                </div>
            </div>   
            <div class="col-sm-12 fill">
                <h4>IoT dataset</h4>
                <div class="table-responsive">
                    <table></table>
                </div>
            </div>
        </div>
     </div>

     <script>
        var row = $(".row").children();
        if(row.length > 0)
            $(row[0]).removeClass('col-sm-7').addClass('col-sm-9 transition-width').after('<div class="col-sm-3 transition-width" id="dashboard"></div>');
     </script> 

하지만, 결과는 제대로 처리되지 않는다. 이유는 지정된 viewer div 엘리먼트 안에서 adsk-viewing-viewer를 Forge가 생성할 때, 스타일을 강제적으로 height, width를 100%로 설정해 버리기 때문이다. 크롬 디버깅을 통해 화면 DOM의 해당 element 스타일을 확인해 보면 다음과 같이, css나 style을 외부에서 정의해 주어도 초기화되는 것을 알 수 있다. 

이런 이유로, 자바스크립트에서 다음과 같이 forge 뷰어가 생성된 후, 해당 element에 강제적으로 높이와 폭을 지정해준다.
    var style3D = "height: 60%; width: 65%; overflow: hidden;";
    $('.adsk-viewing-viewer').attr('style', style3D);

그럼, 다음과 같이 레이아웃이 제대로 표시되는 것을 확인할 수 있다. 
이외 다양한 레이아웃은 레퍼런스를 참고한다.

레퍼런스
추신: Forge와 같이 외부에서 개발된 요소를 가져다 쓸때 DOM을 개발회사 개발자가 강제 설정해 처리하는 경우가 종종 있다. 이 경우에도 이틀 동안 삽질 했는 데, 본인이 Bootstrap 레이아웃 규정에 맞게 DOM element 스타일 등 속성을 정의했음에도 제대로 화면 출력이 안되었다. 결국 어디가 잘못되었는 지 확인하기 위해서, 오토데스크에서 제공되는 해당 오픈소스를 모두 분석해야 했다. 먼가 이상하면, 어디서 속성이 변경될 수 있다는 것을 의심하고, css, style 등을 확인해 보고, 디버깅하여 DOM이 어떻게 변경되는 지를 확인해 보는 것이 좋다. 

추신: Forge의 기본 렌더링 기능은 좋으나, 특별히, 특정 재질이나 형태를 추가하고자 하면, THREE.js 를 직접 접근해 사용해야 하는 불편함이 있다. 이 경우, 아래 링크를 참고한다. 

댓글 2개:

  1. 와~ 교수님 Forge를 이렇게 상세한게 검토하신 한국분은 거의 처음 보았네요. 앞으로 시간될때마다 블로그 들어오겠스빈다!

    답글삭제
    답글
    1. 넵. 도움되신다면 다행입니다. 좋은 하루 되세요~

      삭제