Яйцебот 2

 

290312 002

3D принтер "Люмен"

 

lumen sm

3D сканер

 

3dscan

ЧПУ Выжигатель

 

woodburner

Контроллер Lumentino

 

lumentino sm

Устройство и теория

Пришло время собрать 3D сканер...

На данный момент в интернете достаточно много вариантов конструкций сканеров, но принцип действия большинства из них одинаков. Поэтому начать следует, как мне кажется, с принципа работы данного устройства. А уже потом, в других разделах, предстоит сделать выбор, какую конструкцию выбрать, какую использовать электронику, программное обеспечение и т.д.

Нашел хорошую статью, где достаточно подробно описан принцип работы. Привожу здесь ее перевод с некоторыми дополнениями. Для сканирования потребуется линейный лазер (лазер, формирующий линию), камера, вращающаяся платформа и сам объект.

howitworks

 

Как это работает?

Мы должны найти декартовы координаты (в некотором пространстве) точек, которые принадлежат сканируемому объекту.
В общем, мы ищем расстояние от оси вращения до точки, отмеченной красным лазером ("" на рисунке). Чтобы найти его, мы должны измерить, сколько пикселей между оптической осью камеры и точкой, отмеченной лазерным лучом. На картинке это расстояние помечено как "b". Когда мы получим эту информацию, мы должны преобразовать его в миллиметры (для этого надо знать, сколько пикселей приходится на миллиметр). Угол между лазером и камерой постоянен и равен "alpha". С помощью простых тригонометрических вычислений, мы можем вычислить "ro":
sin(alpha) = b / ro, что означает, что ro = b / sin(alpha)
Эта операция повторяется для каждого слоя. Количество слоев зависит от разрешения камеры. Если камера 640х480, то слоев 480. Затем платформа поворачивается на какой-то угол и вся операция повторяется.

 

polar coordinat system

Предыдущие операции дали нам координаты в полярной системе координат. В полярной цилиндрической системе каждая точка представлена следующими аргументами:

P = (ro, fi, z), где

  • ro - расстояние от точки до оси Z. Это наша расстояние, измеренное в предыдущей операции;
  • fi - угол между точкой и осью X. Это угол вращения платформы. Он растет на постоянную величину каждый раз при повороте платформы. Эта постоянная величина равна: 360 градусов / количество сканирований. Т.е. для 120 профилей вокруг объекта, платформа поворачивается на: 360 градусов / 120 = 3 градуса. Таким образом, после первого хода, fi = 3, после второго fi = 6, после третьего fi = 9 и так далее.
  • z - координата Z точки. Это значение имеет то же значение Z в декартовой системе.

 

Преобразование полярных координат в декартовы очень простое:
x = ro * cos( fi )
y = ro * sin( fi )
z = z

 

Конструкция 3D сканера

Выбор предпочтительной конструкции для 3D сканера был нетрудным, хотя вариантов есть несколько. Мне больше понравился тот, где все детали печатаются на 3D принтере. Видно, что данная конструкция дает возможность достаточно просто менять некоторые параметры сканирования:

  • угол между камерой и лазером может быть установлен в 30 или 45 градусов (нужна доп. деталь).
  • возможность использования двух камер (нужны доп. детали).
  • возможность изменения расстояния от оси вращения предмета до камеры (и лазера), а также высоты установки камеры и лазера для настройки под различные размеры объектов сканирования.

 foto0342

Скачать 3d модели деталей и подробнее ознакомиться с данной конструкцией можно у первоисточника.

foto0341

 

Электроника 3D сканера

 Электроника 3D сканера в общем случае представляет из себя контроллер (типа того же Arduino, например) с USB преобразователем, а также драйвер одного или двух шаговых двигателей и несколькими цифровыми выходами для управления лазером. Вариантов использования тех или иных готовых электронных узлов может быть несколько. Это касается как выбора контроллера, так и используемых драйверов.

Мне показалось более интересным собрать собственный контроллер, базирующийся на том же Arduino с использованием драйверов Pololu A4988. Назовем эту плату, к примеру, "Моторино".

 

 DSC05127 smDSC05125 sm

Размер платы соответствует размеру плат Arduino (NG и т.д.).

 scanner scheme

 Дополнительные разъемы с неиспользуемыми выводами XS4 и XS6 на плату не влезли, так что пока их не ищите.

 scanner sborka new

 На сборке показаны основные разъемы для подключения цепей питания, лазера, а также прописаны пины, как они обозначаются в ситеме Arduino.

Собственно, эту плату можно использовать для управления и другими моторизированными устройствами с одним или двумя шаговыми моторами, а также дополнительно управлять тремя сервоприводами. Пример такого устройства - тот же Яйцебот.

 В качестве микроконтроллера можно использовать недорогие ATMEGA8A, ATMEGA168 и т.д. в соответсвующем корпусе.

В качестве бутлоадеров можно использовать идущие вместе с Arduino загрузчики (лежат в соответствующих папках ПО Arduino) :

  • ATmegaBOOT/hex (ATmegaBOOT-prod-firmware-2009-11-07.hex) - для Atmega8 (Arduino NG)
  • ATmegaBOOT_168_diecimila.hex - для Atmega168 (Arduino Diecimila).

 Вариант печатной платы:

scanner milling

 Данный контроллер можно с успехом применять и вдругих устройствах, помимо 3D сканера. Например, им можно управлять яйцеботом, ЧПУ выжигателем и т.п.

 

Прошивка

Прошивка, как вы понимаете, неразрывно связана с программой управления на компьютере, так как по сути все вычисления осуществяются именно на нем, а контроллер лишь отрабатывает команды на включение-выключение лазера и вращение поворотного столика.

Среди нескольких вариантов сканеров, представленных в инете, меня зинтересовал Fabscan. Это довольно продвинутый OpenSource проект, есть исходники прошивки и ПО для компьютера. Однако с Win версией ПО существуют проблемы, из-за которых мне не удалось пока наладить отправку команд на сканер. Хотя сама прошивка с некоторыми корректировками очень даже неплохо подойдет. В теории Fabscan кроме сканирования непосредственно координат объекта, также на втором этапе сканирует его текстуру для последующего наложения.

 После старта контроллер подает сигнал на включения лазера (5 и 10(второй лазер - опция)), после чего переходит в режим ожидания команд.

 

// FabScan - http://hci.rwth-aachen.de/fabscan
//
//  Created by Francis Engelmann on 7/1/11.
//  Copyright 2011 Media Computing Group, RWTH Aachen University. All rights reserved.
//  
//  Chngelog:
//  
//  R. Bohne 29.01.2013: changed pin mapping to Watterott FabScan Arduino Shield
//  R. Bohne 30.12.2013: added pin definitions for stepper 4 --> this firmware supports the new FabScan Shield V1.1, minor syntax changes. Steppers are now disabled at startup.
//  R. Bohne 12.03.2014: renamed the pins 14..19 to A0..A5 (better abstraction for people who use Arduino MEGA, etc.)
//  cnc.maket-city.ru: Упрощаем и корректируем пины для платы "Моторино", + вариант для двухлазерного сканера.

 

#define LIGHT_PIN 9
#define LASER_PIN 5
#define LASER2_PIN 10

//Stepper 1 as labeled on Shield, Turntable
#define ENABLE_PIN_0  2
#define STEP_PIN_0    3
#define DIR_PIN_0     4

//Stepper 2, Laser Stepper
#define ENABLE_PIN_1  2
#define STEP_PIN_1    6
#define DIR_PIN_1     7
 
#define TURN_LASER_OFF      200
#define TURN_LASER_ON       201
#define PERFORM_STEP        202
#define SET_DIRECTION_CW    203
#define SET_DIRECTION_CCW   204
#define TURN_STEPPER_ON     205
#define TURN_STEPPER_OFF    206
#define TURN_LIGHT_ON       207
#define TURN_LIGHT_OFF      208
#define ROTATE_LASER        209
#define FABSCAN_PING        210
#define FABSCAN_PONG        211
#define SELECT_STEPPER      212
#define TURN_LASER2_OFF      213  //Вариант с двумя лазерами
#define TURN_LASER2_ON       214  //Вариант с двумя лазерами
#define LASER_STEPPER       11
#define TURNTABLE_STEPPER   10
//the protocol: we send one byte to define the action what to do.
//If the action is unary (like turnung off the light) we only need one byte so we are fine.
//If we want to tell the stepper to turn, a second byte is used to specify the number of steps.
//These second bytes are defined here below.

#define ACTION_BYTE         1    //normal byte, first of new action
#define LIGHT_INTENSITY     2
#define TURN_TABLE_STEPS    3
#define LASER1_STEPS        4
#define LASER2_STEPS        5
#define LASER_ROTATION      6
#define STEPPER_ID          7

int incomingByte = 0;
int byteType = 1;
int currStepper;


//current motor: turn a single step
void step()
{
 if(currStepper == TURNTABLE_STEPPER){
   digitalWrite(STEP_PIN_0, HIGH);
 }
 //else if(currStepper == LASER_STEPPER){
 //  digitalWrite(STEP_PIN_1, HIGH);
 //}

 delay(3);
 if(currStepper == TURNTABLE_STEPPER){
   digitalWrite(STEP_PIN_0, LOW);
 }
 //else if(currStepper == LASER_STEPPER){
 //  digitalWrite(STEP_PIN_1, LOW);
 //}
 delay(3);
}

//step the current motor for <count> times
void step(int count)
{
  for(int i=0; i<count; i++){
    step();
  }
}

void setup()
{
  // initialize the serial port
   Serial.begin(9600);
   pinMode(LASER_PIN, OUTPUT);
   pinMode(LASER2_PIN, OUTPUT);
   pinMode(LIGHT_PIN, OUTPUT);

  pinMode(ENABLE_PIN_0, OUTPUT);
  pinMode(DIR_PIN_0, OUTPUT);
  pinMode(STEP_PIN_0, OUTPUT);

 // pinMode(ENABLE_PIN_1, OUTPUT);
 // pinMode(DIR_PIN_1, OUTPUT);
 // pinMode(STEP_PIN_1, OUTPUT);

 //disable all steppers at startup
 digitalWrite(ENABLE_PIN_0, HIGH);  //HIGH to turn off
// digitalWrite(ENABLE_PIN_1, HIGH);  //HIGH to turn off
 
 digitalWrite(LIGHT_PIN, LOW); //turn light off
 digitalWrite(LASER_PIN, HIGH); //turn laser 1 on
 digitalWrite(LASER2_PIN, HIGH); //turn laser 2 on
 delay(3000);
 digitalWrite(LASER_PIN, LOW); //turn laser off
 digitalWrite(LASER2_PIN, LOW); //turn laser off
 
 Serial.write(FABSCAN_PONG); //send a pong back to the computer so we know setup is done and that we are actually dealing with a FabScan
 
 currStepper = TURNTABLE_STEPPER;  //turntable is default stepper
}

void loop()
{
  if(Serial.available() > 0){
    
    incomingByte = Serial.read();
   
    switch(byteType){
      case ACTION_BYTE:
      
          switch(incomingByte){    //this switch always handles the first byte
            //Laser
            case TURN_LASER_OFF:
              digitalWrite(LASER_PIN, LOW);    // turn the LASER off
              break;
            case TURN_LASER_ON:
              digitalWrite(LASER_PIN, HIGH);   // turn the LASER on
              break;
            case TURN_LASER2_OFF:
              digitalWrite(LASER2_PIN, LOW);    // turn the LASER 2 off
              break;
            case TURN_LASER2_ON:
              digitalWrite(LASER2_PIN, HIGH);   // turn the LASER 2 on
              break;
            case ROTATE_LASER: //unused
              byteType = LASER_ROTATION;
              break;
            //TurnTable
            case PERFORM_STEP:
              byteType = TURN_TABLE_STEPS;
              break;
            case SET_DIRECTION_CW:
              if(currStepper == TURNTABLE_STEPPER){
                digitalWrite(DIR_PIN_0, HIGH);
              //}else if(currStepper == LASER_STEPPER){
              //  digitalWrite(DIR_PIN_1, HIGH);
              }
              break;
            case SET_DIRECTION_CCW:
              if(currStepper == TURNTABLE_STEPPER){
                digitalWrite(DIR_PIN_0, LOW);
              //}else if(currStepper == LASER_STEPPER){
              //  digitalWrite(DIR_PIN_1, LOW);
              }
              break;
            case TURN_STEPPER_ON:
              if(currStepper == TURNTABLE_STEPPER){
                digitalWrite(ENABLE_PIN_0, LOW);
              //}else if(currStepper == LASER_STEPPER){
              //  digitalWrite(ENABLE_PIN_1, LOW);
              }
              break;
            case TURN_STEPPER_OFF:
              if(currStepper == TURNTABLE_STEPPER){
                digitalWrite(ENABLE_PIN_0, HIGH);
              //}else if(currStepper == LASER_STEPPER){
              //  digitalWrite(ENABLE_PIN_1, HIGH);
              }
              break;
            case TURN_LIGHT_ON:
              byteType = LIGHT_INTENSITY;
              break;
            case TURN_LIGHT_OFF:
              digitalWrite(LIGHT_PIN, LOW);
              break;
            case FABSCAN_PING:
              delay(1);
              Serial.write(FABSCAN_PONG);
              break;
            case SELECT_STEPPER:
              byteType = STEPPER_ID;
              break;
            }
      
          break;
       case LIGHT_INTENSITY:       //after this point we take care of the second byte if one is sent
          analogWrite(LIGHT_PIN, incomingByte);
          byteType = ACTION_BYTE;  //reset byteType
          break;
        case TURN_TABLE_STEPS:
          step(incomingByte);
          byteType = ACTION_BYTE;
          break;
        case STEPPER_ID:
          Serial.write(incomingByte);
          currStepper = incomingByte;
          byteType = ACTION_BYTE;
          break;
    }
  }
}

ПО компьютера

Как упоминалось ранее, в статье о прошивке, в Fabscan достаточно проработанное ПО, которое также позволяет выполнять настройку камеры перед сканированием. Как оказалось, это весьма важный процесс, поскольку если отнестись к нему несерьезно, то будут значительные погрешности сканирования.

 Но после нескольких неудачных попыток заставить Win-версию этого ПО нормально общаться со сканером (программа ведет себя странно, посылая вместо одного байта команды целую посылку левых байтов, вводя контроллер в ступор. Можно, конечно, попробовать вычленить коды команд из этих посылок, но тут явно проблемы ПО, а не контроллера), пришел к выводу, что нужно найти какой-то другой временный способ протестировать сканер. Ответ был найден на Instructables

Если для прошивки мы используем Wiring среду Arduino, то для написания ПО компьютера воспользуемся Processing`ом. Поверьте, после Arduino это не будет столь сложным, как кажется. Тем более исходный код у нас есть. Как обычно, корректируем код под нашу прошивку, используем те же команды, что и Fabscan, и у нас получается программка, приведенная ниже. Для ее работы потребуется библиотека работы с камерой: GSVideo.

Суть того, что делает это программа: создает последовательность снимков нашего объекта с камеры, после чего выделяет лазерный луч на этих картинках, делает соответствующие вычисления и записывает полученный результат в файл с расширением *.asc. Этот файл содержит облако точек нашего объекта. Затем этот файл можно открыть для преобразования в программе MeshLab.

Processing - штука развивающаяся и не до конца отработанная, поэтому есть некоторые недоработки. Что первое бросается в глаза - не работает "живое" отображение с камеры во время сканирования.

 

import codeanticode.gsvideo.*;
import processing.serial.*;

//scanner parameters
float odl = 210;  //distance between webcam and turning axle, [milimeter], not used yet
int etap = 200;  //number of phases profiling per revolution
float katLaser1 = 32.5*PI/180;  //angle between laser 1 and camera [radian]
float katLaser2 = 30.0*PI/180;  //angle between laser 2 and camera [radian]
int numLaser = 2;  //1 or 2 lasers
int average = 1;  //0(not used) or 1(used)
float pxmm_x = 4.36; //pixels per milimeter horizontally
float pxmm_y = 4.36; //pixels per milimeter vertically

//objects
PFont f;
GSCapture cam;
Serial myPort;
PrintWriter output1;
PrintWriter output2;

//colors
color black=color(0);
color white=color(255);
int cut=50;

//variables
int itr; //iteration
int laser; //work laser number
float pixBright;
float maxBright=0;
int maxBrightPos=0;
int prevMaxBrightPos;
int cntr=1;
int row;
int col;
int start;

//coordinates
float x, y, z;  //cartesian cords., [milimeter]
float ro;  //first of polar coordinate, [milimeter]
float fi; //second of polar coordinate, [radian]
float b; //distance between brightest pixel and middle of photo [pixel]
float katOperacji=2*PI/etap;  //angle between 2 profiles [radian]

//================= CONFIG ===================

void setup() {
  size(640,480);
  strokeWeight(1);
  smooth();
  background(0);
  //fonts
  f=createFont("Arial",16,true);
  //camera conf.
  String[] avcams=GSCapture.list();
  if (avcams.length==0){
    println("There are no cameras available for capture.");
    textFont(f,12);
    fill(255,0,0);
    text("Camera not ready",680,32);
  }
  else{
    println("Available cameras:");
    for (int i = 0; i < avcams.length; i++) {
      println(avcams[i]);
    }
    textFont(f,12);
    fill(0,255,0);
    text("Camera ready",680,32);
    //cam=new GSCapture(this, 1280, 960,avcams[0]);
    cam=new GSCapture(this, 640, 480,avcams[0]);
    cam.start();
    //delay ============================  
    start = millis();
    while ((millis() - start) < 2000)  {
       };       
  }
 
  //Serial (COM) conf.
  println(Serial.list());
  myPort=new Serial(this, Serial.list()[1], 9600);
  //output file
  output1=createWriter("skan1.asc");  //point cloud laser 1 *.asc
  output2=createWriter("skan2.asc");  //point cloud laser 2 *.asc
}

//============== MAIN PROGRAM =================

void draw() {
 
  myPort.write(205); //Motor On
 
  for (laser=0;laser<numLaser;laser++) {
    if (laser==0) {
       myPort.write(201); //Laser 0 ON
       myPort.write(204); //Clockwise
       }
    else {
      myPort.write(214); //Laser 1 ON
      myPort.write(203); //Counter Clockwise  need 203!!!
      }
    //delay ============================  
    start = millis();
    while ((millis() - start) < 2000)  {
       };   
    for (itr=0;itr<etap;itr++) {
       allwork();  //=================== Main calculations here ===================
       }
    if (laser==0) myPort.write(200); //Laser 0 Off
       else myPort.write(213); //Laser 1 Off
  }
  myPort.write(206); //Motor off  
  output1.flush();
  output1.close();  //save point cloud for laser 1 only
  output2.flush();
  output2.close();  //save point cloud for laser 2 only
  println("Scan finished.");
  noLoop();
}

void allwork(){
  PImage img=createImage(cam.width, cam.height, RGB);
  PImage out=createImage(cam.width, cam.height, RGB);
  cam.read();      
  img.loadPixels();
  cam.loadPixels();
  out.loadPixels();
  String picfile="Laser"+nf(laser+1, 1)+"-"+nf(itr+1, 3)+".png";
  String picfile2="Laser"+nf(laser+1, 1)+"-out-"+nf(itr+1, 3)+".png";
  for (int n=0;n<img.pixels.length;n++){
    img.pixels[n]=cam.pixels[n];
    }
  int currentPos;
  fi=itr*katOperacji+laser*(katLaser1+katLaser2); // Offset 60 grad for second laser
  println(fi);  
  int maxBrights = 1;
  int[] maxBrightPoses = new int[img.width];
  for(row=0; row<img.height; row++){  //starting row analysis
    maxBrightPos=0;
    maxBright=0;
    for(col=0; col<img.width; col++){
      currentPos = row * img.width + col;
      pixBright=brightness(img.pixels[currentPos]);
      if(pixBright>maxBright){
        maxBright=pixBright;
        maxBrightPos=currentPos;
        maxBrights=1;
        maxBrightPoses[maxBrights-1]=currentPos;
      }
      if(pixBright==maxBright){
      maxBrights++;
      maxBrightPoses[maxBrights-1]=currentPos;
      }
      out.pixels[currentPos]=black; //setting all pixels black
    }    
    if ((maxBrights > 1)&&(average==1)) {
     maxBrightPos = 0;
     for (int x=0; x < maxBrights; x++) {
       maxBrightPos += maxBrightPoses[x];
     }
     maxBrightPos = maxBrightPos/maxBrights;
    }
    if (maxBright < cut) continue; // cut lines, where laser a not detected (Bright too small)
    out.pixels[maxBrightPos]=white; //setting brightest pixel white    
    //if (laser==0) b=((maxBrightPos+1-row*img.width)-img.width/2)/pxmm_x;
      //else b=(img.width/2-(maxBrightPos+1-row*img.width))/pxmm_x;
    b=((maxBrightPos+1-row*img.width)-img.width/2)/pxmm_x;  
    if (laser==1) b=-b;
    if (laser==0) ro=b/sin(katLaser1); //Laser 1
       else ro=b/sin(katLaser2); //Laser 2
    //output.println(b + ", " + prevMaxBrightPos + ", " + maxBrightPos); //I used this for debugging   
    if (laser==0) x=ro * cos(fi); else x=ro * cos(-fi); //changing polar coords to kartesian
    if (laser==0) y=ro * sin(fi); else y=ro * sin(-fi);
    //x=ro * cos(fi);
    //y=ro * sin(fi);
    z=row/pxmm_y;   
    if( (ro>=-60) && (ro<=60) ){ //printing coordinates in table borders   
        if (laser==0) output1.println(x + "," + y + "," + z);
          else output2.println(x + "," + y + "," + z);        
    }
  }//end of row analysis  
     
  out.updatePixels();
  out.save(picfile2); // save generated picture
  img.updatePixels();
  img.save(picfile);  // save scanned picture
  image(img,0,0,img.width/2,img.height/2);
  //set(0,0,img);
  stepmotor();      
  println("Etap=",itr);
}  

void stepmotor() {  //sending command to turn
  int steps = 400/etap;
  myPort.write(202); //Turn motor
  myPort.write(steps);   //by N step
  //delay ============================  
  start = millis();
  while ((millis() - start) < 50*steps)  {
     };
}

 

Тестируем 3D сканер

DSC05144 sm

В тестах я использовал двухлазерную систему, так как информация, снимаемая с разных углов, является более полной. Хотя для простых объектов, без сильных выступающих частей вполне можно использовать одну пару лазер - камера.

Я взял два лазера и одну камеру, хотя с таким же успехом можно использовать один лазер и две камеры.

Тестовая камера - Logitech С270 - к сожалению плохо фокусируется на близкостоящих предметах. Т.е. выбирая камеру для сканера надо учитывать, что снимаемый объект будет находиться примерно на расстоянии 20 см от камеры. В данном обзоре пока используем то, что есть.

 Дополнительная сложность двойного сканирования состоит в совмещении двух полученных облаков точек. Чтобы полученные облака максимально совпадали, необходимо четко соблюдать установки камеры и лазеров, т.е. выдерживать углы между ними, как можно точнее совмещать ось вращения и оптическую ось камеры.

Перед началом сканирования нужно совместить оптическую ось камеры с осью вращения стола, а также определить разрешение нашей камеры в пикселях на 1 мм. В этом тесте я задал формат камеры 640х480. Подставляем линейку, смотрим через камеру и видим, что по вертикали у нас видно 110 мм. Делим 480 на 110 и получаем разрешение, которое надо занести в программу для того, чтобы в результате размер объекта совпадал с реальным.

 Сканируя объект, программа сохраняет изображения и результат поиска луча в картинки для первого лазера:

 Laser1-001Laser1-out-001

и то же самое для второго лазера:

Laser2-001Laser2-out-001

Количество таких сканирований задается в программе на Processing`e. В данном случае идет 200 сканирований на один оборот для каждого лазера.

 После окончания сканирования мы имеем набор картинок (которые уже не нужны), и два облака scan1.asc и scan2.asc.

Открываем их в MeshLab, сначало одно, а затем поверх него другое:

snapshot01snapshot02

Затем следует объединение облаков точек: MeshLab: Align (методом ICP):

snapshot03

Следующий этап - вычисление нормалей облака: Filters -> Point Set -> Compute normals for point sets. Хорошие результаты дает величина параметра "Number of neigbors" порядка 50-70.

Следом идет этап преобразования облака точек в полигональный объект с помощью Surface reconstruction: Poisson. Я использовал параметры 9 7 1 1. Надо сказать, что на последних двух этапах иногда происходит аварийное закрытие программы MeshLab... Тем не менее скан уже почти готов:

 snapshot04

Иногда есть необходимость перевернуть нормали:  Invert Faces Orientation.

Сохраняем объект в STL, и затем его можно открыть в том же Netfabb для контроля:

stlDSC05145

На этом пока все.

А вообще надо еще распечатать этот гриб на 3D принтере...

Ниже в загрузках облака точек с обоих лазеров и итоговая 3D модель.

Вложения:
Доступ по ссылке (http://cnc.maket-city.ru/attachments/article/80/scan_stl.zip)scan_stl.zip[Итоговый STL-файл]0 Kb04/03/14 13:00
Скачать этот файл (skan1.zip)skan1.zip[облако точек от первого лазера]474 Kb03/31/14 11:30
Скачать этот файл (skan2.zip)skan2.zip[облако точек от второго лазера]472 Kb03/31/14 11:31