The updated code adopts a slightly different approach: it creates the solid in segments, creating a new segment whenever there’s an error encountered or when the current segment exceeds a certain length.
I’ve also added the capability to resize the profile to be swept dynamically by moving one’s hands closer together or further apart.
I was having some trouble with solids being disposed of by a background thread, as I was implementing this new version, so I also decided to reduce the number of objects I actually kept in memory (the cursor sphere was one, for instance). The modified approach creates them when needed – probably incurring a modest overhead, but worth the increased stability, on balance.
using Autodesk.AutoCAD.ApplicationServices;
using Autodesk.AutoCAD.DatabaseServices;
using Autodesk.AutoCAD.EditorInput;
using Autodesk.AutoCAD.Geometry;
using Autodesk.AutoCAD.Runtime;
using AcGi = Autodesk.AutoCAD.GraphicsInterface;
using System.Runtime.InteropServices;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.IO;
using System;
using Microsoft.Research.Kinect.Nui;
namespace KinectIntegration
{
// Our own class duplicating the one implemented by nKinect
// to aid with porting
public class ColorVector3
{
public double X, Y, Z;
public int R, G, B;
}
public class KinectJig : DrawJig
{
[DllImport("acad.exe", CharSet = CharSet.Auto,
CallingConvention = CallingConvention.Cdecl,
EntryPoint = "?acedPostCommand@@YAHPB_W@Z"
)]
extern static private int acedPostCommand(string strExpr);
// Our transient solids (cursor sphere & tube) are yellow
const short transSolColor = 2;
// Our final solids will be green
const short finalSolColor = 3;
// A transaction and database to add solids
private Transaction _tr;
private Document _doc;
// We need our Kinect sensor
private Runtime _kinect = null;
// With the images collected by it
private ImageFrame _depth = null;
private ImageFrame _video = null;
// A list of points captured by the sensor
// (for eventual export)
private List<ColorVector3> _vecs;
// A list of points to be displayed
// (we use this for the jig)
private Point3dCollection _points;
// A list of vertices to draw between
// (we use this for the final polyline creation)
private Point3dCollection _vertices;
private int _lastDrawnVertex;
// Entities to create our solid
private DBObjectCollection _created;
// The radius of the profile circle to create
private double _profRad;
// The location at which to draw a sphere when resizing
private Point3d _resizeLocation;
// The approximate length of each swept segment
// (as a multiple of the radius)
private double _segFactor;
// An offset value we use to move the mouse back
// and forth by one screen unit
private int _offset;
// Flags to indicate Kinect gesture modes
private bool _resizing; // Drawing mode active
private bool _drawing; // Drawing mode active
private bool _finished; // Finished - want to exit
public bool Finished
{
get { return _finished; }
}
public KinectJig(
Document doc, Transaction tr, double profRad, double factor
)
{
// Initialise the various members
_doc = doc;
_tr = tr;
_points = new Point3dCollection();
_vertices = new Point3dCollection();
_lastDrawnVertex = -1;
_offset = 1;
_resizing = false;
_drawing = false;
_finished = false;
_created = new DBObjectCollection();
_profRad = profRad;
_segFactor = factor;
// Create our sensor object - the constructor takes
// three callbacks to receive various data:
// - skeleton movement
// - rgb data
// - depth data
_kinect = new Runtime();
_kinect.SkeletonFrameReady +=
new EventHandler<SkeletonFrameReadyEventArgs>(
OnSkeletonFrameReady
);
_kinect.VideoFrameReady +=
new EventHandler<ImageFrameReadyEventArgs>(
OnVideoFrameReady
);
_kinect.DepthFrameReady +=
new EventHandler<ImageFrameReadyEventArgs>(
OnDepthFrameReady
);
}
void OnDepthFrameReady(
object sender, ImageFrameReadyEventArgs e
)
{
_depth = e.ImageFrame;
}
void OnVideoFrameReady(
object sender, ImageFrameReadyEventArgs e
)
{
_video = e.ImageFrame;
}
void OnSkeletonFrameReady(
object sender, SkeletonFrameReadyEventArgs e
)
{
SkeletonFrame s = e.SkeletonFrame;
if (!_finished)
{
foreach (SkeletonData data in s.Skeletons)
{
if (SkeletonTrackingState.Tracked == data.TrackingState)
{
Point3d leftHip =
PointFromVector(
data.Joints[JointID.HipLeft].Position
);
Point3d leftHand =
PointFromVector(
data.Joints[JointID.HandLeft].Position
);
Point3d rightHand =
PointFromVector(
data.Joints[JointID.HandRight].Position
);
if (
leftHand.DistanceTo(Point3d.Origin) > 0 &&
rightHand.DistanceTo(Point3d.Origin) > 0 &&
leftHand.DistanceTo(rightHand) < 0.05)
{
// Hands are less than 5cm from each other
_drawing = false;
_resizing = false;
_finished = true;
}
else
{
// Hands are within 10cm of each other vertically and
// both hands are above the waist, so we resize the
// profile radius
_resizing =
(leftHand.Z > leftHip.Z &&
rightHand.Z > leftHip.Z &&
Math.Abs(leftHand.Z - rightHand.Z) < 0.1);
// If the left hand is below the waist, we draw
_drawing = (leftHand.Z < leftHip.Z);
}
if (_resizing)
{
// If resizing, set some data to help draw
// a sphere where we're resizing
Vector3d vec = (leftHand - rightHand) / 2;
_resizeLocation = rightHand + vec;
_profRad = vec.Length;
}
if (_drawing)
{
// If we have at least one prior vertex...
if (_vertices.Count > 0)
{
// ... check whether we're a certain distance away
// from the last one before adding it (this smooths
// off the jitters of adding every point)
Point3d lastVert = _vertices[_vertices.Count - 1];
if (lastVert.DistanceTo(rightHand) > _profRad * 4)
{
// Add the new vertex to our list
_vertices.Add(rightHand);
}
}
else
{
// Add the first vertex to our list
_vertices.Add(rightHand);
}
}
break;
}
}
}
}
public void StartSensor()
{
if (_kinect != null)
{
_kinect.Initialize(
RuntimeOptions.UseDepth |
RuntimeOptions.UseColor |
RuntimeOptions.UseSkeletalTracking
);
_kinect.VideoStream.Open(
ImageStreamType.Video, 2,
ImageResolution.Resolution640x480,
ImageType.Color
);
_kinect.DepthStream.Open(
ImageStreamType.Depth, 2,
ImageResolution.Resolution640x480,
ImageType.Depth
);
}
}
public void StopSensor()
{
if (_kinect != null)
{
_kinect.Uninitialize();
_kinect = null;
}
}
public void Cleanup()
{
_vertices.Clear();
foreach (DBObject obj in _created)
{
obj.Dispose();
}
_created.Clear();
}
public void UpdatePointCloud()
{
_vecs = GeneratePointCloud(1, true);
}
private List<ColorVector3> GeneratePointCloud(
int sampling, bool withColor = false
)
{
// We will return a list of our ColorVector3 objects
List<ColorVector3> res = new List<ColorVector3>();
// Let's start by determining the dimensions of the
// respective images
int depHeight = _depth.Image.Height;
int depWidth = _depth.Image.Width;
int vidHeight = _video.Image.Height;
int vidWidth = _video.Image.Width;
// For the sake of this initial implementation, we
// expect them to be the same size. But this should not
// actually need to be a requirement
if (vidHeight != depHeight || vidWidth != depWidth)
{
Application.DocumentManager.MdiActiveDocument.
Editor.WriteMessage(
"\nVideo and depth images are of different sizes."
);
return null;
}
// Depth and color data for each pixel
Byte[] depthData = _depth.Image.Bits;
Byte[] colorData = _video.Image.Bits;
// Loop through the depth information - we process two
// bytes at a time
for (int i = 0; i < depthData.Length; i += (2 * sampling))
{
// The depth pixel is two bytes long - we shift the
// upper byte by 8 bits (a byte) and "or" it with the
// lower byte
int depthPixel = (depthData[i + 1] << 8) | depthData[i];
// The x and y positions can be calculated using modulus
// division from the array index
int x = (i / 2) % depWidth;
int y = (i / 2) / depWidth;
// The x and y we pass into DepthImageToSkeleton() need to
// be normalised (between 0 and 1), so we divide by the
// width and height of the depth image, respectively
// As we're using UseDepth (not UseDepthAndPlayerIndex) in
// the depth sensor settings, we also need to shift the
// depth pixel by 3 bits
Vector v =
_kinect.SkeletonEngine.DepthImageToSkeleton(
((float)x) / ((float)depWidth),
((float)y) / ((float)depHeight),
(short)(depthPixel << 3)
);
// A zero value for Z means there is no usable depth for
// that pixel
if (v.Z > 0)
{
// Create a ColorVector3 to store our XYZ and RGB info
// for a pixel
ColorVector3 cv = new ColorVector3();
cv.X = v.X;
cv.Y = v.Z;
cv.Z = v.Y;
// Only calculate the colour when it's needed (as it's
// now more expensive, albeit more accurate)
if (withColor)
{
// Get the colour indices for that particular depth
// pixel. We once again need to shift the depth pixel
// and also need to flip the x value (as UseDepth means
// it is mirrored on X) and do so on the basis of
// 320x240 resolution (so we divide by 2, assuming
// 640x480 is chosen earlier), as that's what this
// function expects. Phew!
int colorX, colorY;
_kinect.NuiCamera.
GetColorPixelCoordinatesFromDepthPixel(
_video.Resolution, _video.ViewArea,
320 - (x/2), (y/2), (short)(depthPixel << 3),
out colorX, out colorY
);
// Make sure both indices are within bounds
colorX = Math.Max(0, Math.Min(vidWidth - 1, colorX));
colorY = Math.Max(0, Math.Min(vidHeight - 1, colorY));
// Extract the RGB data from the appropriate place
// in the colour data
int colIndex = 4 * (colorX + (colorY * vidWidth));
cv.B = (byte)(colorData[colIndex + 0]);
cv.G = (byte)(colorData[colIndex + 1]);
cv.R = (byte)(colorData[colIndex + 2]);
}
else
{
// If we don't need colour information, just set each
// pixel to white
cv.B = 255;
cv.G = 255;
cv.R = 255;
}
// Add our pixel data to the list to return
res.Add(cv);
}
}
return res;
}
private bool GenerateTube(
double profRad, Point3dCollection pts, out Solid3d sol
)
{
bool readyToBreak;
// Let's start by creating our spline path
using (Spline path = new Spline(pts, 0, 0.0))
{
double pathLen = path.GetDistanceAtParameter(path.EndParam);
readyToBreak = (pathLen > _profRad * _segFactor);
// And our sweep profile
Circle profile =
new Circle(pts[0], pts[1] - pts[0], profRad);
using (profile)
{
// Then our sweep options
SweepOptionsBuilder sob = new SweepOptionsBuilder();
// Align the entity to sweep to the path
sob.Align =
SweepOptionsAlignOption.AlignSweepEntityToPath;
// The base point is the start of the path
sob.BasePoint = path.StartPoint;
// The profile will rotate to follow the path
sob.Bank = true;
using (SweepOptions sweepOpts = sob.ToSweepOptions())
{
sol = new Solid3d();
// Sweep our profile along our path
sol.CreateSweptSolid(profile, path, sweepOpts);
}
}
}
_lastDrawnVertex = pts.Count - 1;
return readyToBreak;
}
private Point3d PointFromVector(Vector v)
{
// Rather than just return a point, we're effectively
// transforming it to the drawing space: flipping the
// Y and Z axes (which makes it consistent with the
// point cloud, and makes sure Z is actually up - from
// the Kinect's perspective Y is up), and reversing
// the X axis (which is the result of choosing UseDepth
// rather than UseDepthAndPlayerIndex)
return new Point3d(-v.X, v.Z, v.Y);
}
protected override SamplerStatus Sampler(JigPrompts prompts)
{
// We don't really need a point, but we do need some
// user input event to allow us to loop, processing
// for the Kinect input
PromptPointResult ppr =
prompts.AcquirePoint("\nClick to capture: ");
if (ppr.Status == PromptStatus.OK)
{
if (_finished)
{
acedPostCommand("CANCELCMD");
return SamplerStatus.Cancel;
}
// If not finished, but stopped drawing, add the
// geometry that was previously drawn to the database
if (!_drawing &&
(_created.Count > 0 || _vertices.Count > 0)
)
{
AddSolidOrPath();
}
// Generate a point cloud
try
{
if (_depth != null && _video != null)
{
// Use a sampling of one in 50 points for the jig
_vecs = GeneratePointCloud(50);
// We just need the point coordinates for jigging
// (no colours)
_points.Clear();
foreach (ColorVector3 vec in _vecs)
{
_points.Add(
new Point3d(vec.X, vec.Y, vec.Z)
);
}
// Let's move the mouse slightly to avoid having
// to do it manually to keep the input coming
System.Drawing.Point pt =
System.Windows.Forms.Cursor.Position;
System.Windows.Forms.Cursor.Position =
new System.Drawing.Point(
pt.X, pt.Y + _offset
);
_offset = -_offset;
}
}
catch {}
return SamplerStatus.OK;
}
return SamplerStatus.Cancel;
}
// Helper functions to extract/blank portions of our
// vertex list (when we want to draw the beginning of it)
private void ClearAllButLast(Point3dCollection pts, int n)
{
while (pts.Count > n)
{
pts.RemoveAt(0);
}
_lastDrawnVertex = -1;
}
private Point3dCollection GetAllButLast(
Point3dCollection pts, int n
)
{
Point3dCollection res = new Point3dCollection();
for (int i = 0; i < pts.Count - n; i++)
{
res.Add(pts[i]);
}
return res;
}
protected override bool WorldDraw(AcGi.WorldDraw draw)
{
short origCol = draw.SubEntityTraits.Color;
// This simply draws our points
draw.Geometry.Polypoint(_points, null, null);
if (_resizing)
{
using (Solid3d sphere = new Solid3d())
{
try
{
sphere.CreateSphere(_profRad);
if (sphere != null)
{
sphere.TransformBy(
Matrix3d.Displacement(
_resizeLocation - Point3d.Origin
)
);
// Draw the cursor
draw.SubEntityTraits.Color = transSolColor;
sphere.WorldDraw(draw);
}
}
catch { }
finally
{
draw.SubEntityTraits.Color = origCol;
}
}
return true;
}
// If we're currently drawing...
if (_drawing)
{
Solid3d sol = null;
try
{
// If we have vertices that haven't yet been drawn...
if (_vertices.Count > 1 &&
_vertices.Count - 1 > _lastDrawnVertex
)
{
// ... generate a tube
if (GenerateTube(_profRad, _vertices, out sol))
{
// If it was created, add it to our list to draw
_created.Add(sol);
sol = null;
// Clear all but the last two vertices to draw from
// next time
ClearAllButLast(_vertices, 2);
}
}
}
catch
{
// If the tube generation failed...
if (sol != null)
{
sol.Dispose();
}
// Loop, creating the most recent successful tube we can
bool succeeded = false;
int n = 1;
do
{
try
{
// Generate the previous, working tube using all
// but the last points (if it fails, one more is
// excluded per iteration, until we get a working
// tube)
GenerateTube(
_profRad, GetAllButLast(_vertices, n++), out sol
);
_created.Add(sol);
sol = null;
succeeded = true;
}
catch { }
}
while (!succeeded && n < _vertices.Count);
if (succeeded)
{
ClearAllButLast(_vertices, n - 1);
if (_vertices.Count > 1)
{
try
{
// And generate a tube for the remaining vertices
GenerateTube(_profRad, _vertices, out sol);
}
catch
{
succeeded = false;
}
}
}
if (!succeeded && sol != null)
{
sol.Dispose();
sol = null;
}
}
// Draw our solid(s)
draw.SubEntityTraits.Color = transSolColor;
if (sol != null)
{
try
{
sol.WorldDraw(draw);
}
catch
{}
}
foreach (DBObject obj in _created)
{
Entity ent = obj as Entity;
if (ent != null)
{
try
{
ent.WorldDraw(draw);
}
catch
{}
}
}
if (_vertices.Count > 0)
{
Point3d lastPt = _vertices[_vertices.Count - 1];
// Create a cursor sphere
using (Solid3d cursor = new Solid3d())
{
try
{
cursor.CreateSphere(_profRad);
if (cursor != null)
{
cursor.TransformBy(
Matrix3d.Displacement(lastPt - Point3d.Origin)
);
// Draw the cursor
draw.SubEntityTraits.Color = transSolColor;
cursor.WorldDraw(draw);
}
}
catch { }
}
}
if (sol != null)
{
sol.Dispose();
}
}
draw.SubEntityTraits.Color = origCol;
return true;
}
public void AddSolidOrPath()
{
Solid3d sol = null;
try
{
GenerateTube(_profRad, _vertices, out sol);
}
catch
{
if (sol != null)
{
sol.Dispose();
sol = null;
}
}
if (_created.Count > 0 || sol != null)
{
if (sol != null)
{
_created.Add(sol);
}
BlockTableRecord btr =
(BlockTableRecord)_tr.GetObject(
_doc.Database.CurrentSpaceId,
OpenMode.ForWrite
);
foreach (DBObject obj in _created)
{
Entity ent = obj as Entity;
if (ent != null)
{
ent.ColorIndex = finalSolColor;
btr.AppendEntity(ent);
_tr.AddNewlyCreatedDBObject(ent, true);
}
}
_created.Clear();
}
Cleanup();
_vertices.Clear();
}
public void ExportPointCloud(string filename)
{
if (_vecs.Count > 0)
{
using (StreamWriter sw = new StreamWriter(filename))
{
// For each pixel, write a line to the text file:
// X, Y, Z, R, G, B
foreach (ColorVector3 pt in _vecs)
{
sw.WriteLine(
"{0}, {1}, {2}, {3}, {4}, {5}",
pt.X, pt.Y, pt.Z, pt.R, pt.G, pt.B
);
}
}
}
}
}
public class Commands
{
[CommandMethod("ADNPLUGINS", "KINEXT", CommandFlags.Modal)]
public void ImportFromKinect()
{
Document doc =
Autodesk.AutoCAD.ApplicationServices.
Application.DocumentManager.MdiActiveDocument;
Editor ed = doc.Editor;
Transaction tr =
doc.TransactionManager.StartTransaction();
// Pass in a default radius of 5cm and a segment length
// of 10 times that
KinectJig kj = new KinectJig(doc, tr, 0.05, 10);
try
{
kj.StartSensor();
}
catch (System.Exception ex)
{
ed.WriteMessage(
"\nUnable to start Kinect sensor: " + ex.Message
);
tr.Dispose();
return;
}
PromptResult pr = ed.Drag(kj);
if (pr.Status != PromptStatus.OK && !kj.Finished)
{
kj.StopSensor();
kj.Cleanup();
tr.Dispose();
return;
}
// Generate a final point cloud with color before stopping
// the sensor
kj.UpdatePointCloud();
kj.StopSensor();
kj.AddSolidOrPath();
tr.Commit();
// Manually dispose to avoid scoping issues with
// other variables
tr.Dispose();
// We'll store most local files in the temp folder.
// We get a temp filename, delete the file and
// use the name for our folder
string localPath = Path.GetTempFileName();
File.Delete(localPath);
Directory.CreateDirectory(localPath);
localPath += "\\";
// Paths for our temporary files
string txtPath = localPath + "points.txt";
string lasPath = localPath + "points.las";
// Our PCG file will be stored under My Documents
string outputPath =
Environment.GetFolderPath(
Environment.SpecialFolder.MyDocuments
) + "\\Kinect Point Clouds\\";
if (!Directory.Exists(outputPath))
Directory.CreateDirectory(outputPath);
// We'll use the title as a base filename for the PCG,
// but will use an incremented integer to get an unused
// filename
int cnt = 0;
string pcgPath;
do
{
pcgPath =
outputPath + "Kinect" +
(cnt == 0 ? "" : cnt.ToString()) + ".pcg";
cnt++;
}
while (File.Exists(pcgPath));
// The path to the txt2las tool will be the same as the
// executing assembly (our DLL)
string exePath =
Path.GetDirectoryName(
Assembly.GetExecutingAssembly().Location
) + "\\";
if (!File.Exists(exePath + "txt2las.exe"))
{
ed.WriteMessage(
"\nCould not find the txt2las tool: please make sure " +
"it is in the same folder as the application DLL."
);
return;
}
// Export our point cloud from the jig
ed.WriteMessage(
"\nSaving TXT file of the captured points.\n"
);
kj.ExportPointCloud(txtPath);
// Use the txt2las utility to create a .LAS
// file from our text file
ed.WriteMessage(
"\nCreating a LAS from the TXT file.\n"
);
ProcessStartInfo psi =
new ProcessStartInfo(
exePath + "txt2las",
"-i \"" + txtPath +
"\" -o \"" + lasPath +
"\" -parse xyzRGB"
);
psi.CreateNoWindow = false;
psi.WindowStyle = ProcessWindowStyle.Hidden;
// Wait up to 20 seconds for the process to exit
try
{
using (Process p = Process.Start(psi))
{
p.WaitForExit();
}
}
catch
{ }
// If there's a problem, we return
if (!File.Exists(lasPath))
{
ed.WriteMessage(
"\nError creating LAS file."
);
return;
}
File.Delete(txtPath);
ed.WriteMessage(
"Indexing the LAS and attaching the PCG.\n"
);
// Index the .LAS file, creating a .PCG
string lasLisp = lasPath.Replace('\\', '/'),
pcgLisp = pcgPath.Replace('\\', '/');
doc.SendStringToExecute(
"(command \"_.POINTCLOUDINDEX\" \"" +
lasLisp + "\" \"" +
pcgLisp + "\")(princ) ",
false, false, false
);
// Attach the .PCG file
doc.SendStringToExecute(
"_.WAITFORFILE \"" +
pcgLisp + "\" \"" +
lasLisp + "\" " +
"(command \"_.-POINTCLOUDATTACH\" \"" +
pcgLisp +
"\" \"0,0\" \"1\" \"0\")(princ) ",
false, false, false
);
doc.SendStringToExecute(
"_.-VISUALSTYLES _C _Realistic ",
false, false, false
);
}
// Return whether a file is accessible
private bool IsFileAccessible(string filename)
{
// If the file can be opened for exclusive access it means
// the file is accesible
try
{
FileStream fs =
File.Open(
filename, FileMode.Open,
FileAccess.Read, FileShare.None
);
using (fs)
{
return true;
}
}
catch (IOException)
{
return false;
}
}
// A command which waits for a particular PCG file to exist
[CommandMethod(
"ADNPLUGINS", "WAITFORFILE", CommandFlags.NoHistory
)]
public void WaitForFileToExist()
{
Document doc =
Application.DocumentManager.MdiActiveDocument;
Editor ed = doc.Editor;
HostApplicationServices ha =
HostApplicationServices.Current;
PromptResult pr = ed.GetString("Enter path to PCG: ");
if (pr.Status != PromptStatus.OK)
return;
string pcgPath = pr.StringResult.Replace('/', '\\');
pr = ed.GetString("Enter path to LAS: ");
if (pr.Status != PromptStatus.OK)
return;
string lasPath = pr.StringResult.Replace('/', '\\');
ed.WriteMessage(
"\nWaiting for PCG creation to complete...\n"
);
// Check the write time for the PCG file...
// if it hasn't been written to for at least half a second,
// then we try to use a file lock to see whether the file
// is accessible or not
const int ticks = 50;
TimeSpan diff;
bool cancelled = false;
// First loop is to see when writing has stopped
// (better than always throwing exceptions)
while (true)
{
if (File.Exists(pcgPath))
{
DateTime dt = File.GetLastWriteTime(pcgPath);
diff = DateTime.Now - dt;
if (diff.Ticks > ticks)
break;
}
System.Windows.Forms.Application.DoEvents();
if (HostApplicationServices.Current.UserBreak())
{
cancelled = true;
break;
}
}
// Second loop will wait until file is finally accessible
// (by calling a function that requests an exclusive lock)
if (!cancelled)
{
int inacc = 0;
while (true)
{
if (IsFileAccessible(pcgPath))
break;
else
inacc++;
System.Windows.Forms.Application.DoEvents();
if (HostApplicationServices.Current.UserBreak())
{
cancelled = true;
break;
}
}
ed.WriteMessage("\nFile inaccessible {0} times.", inacc);
try
{
CleanupTmpFiles(lasPath);
}
catch
{ }
}
}
internal void CleanupTmpFiles(string txtPath)
{
if (File.Exists(txtPath))
File.Delete(txtPath);
Directory.Delete(
Path.GetDirectoryName(txtPath)
);
}
}
}
When we run the KINEXT command and move our hands when at the same level (we’ll drop the left hand to draw with the right), we see a sphere being displayed representing the size of the tube we’ll be sweeping:
And after dropping our left hand we can proceed with drawing with our right, culminating in a series of connected (and disjoint) tubes:
Recent Comments
Archives
More...