The day started off early with a meeting. I have a few more throughout the day. Here is where I’ve left off with the 3D modeling app
- Density Maps
- Changing Model doesn’t rebuild texture/flipping/offsets/rotation
- Large gaps at top/bottom/right
- Diagonal lines and top triangle on last column in wrong orientation
- Bottom triangles going to pole
- Large triangle on bottom right side
- Model Texture appears rotated 90 degrees by default
- Verify read/write correct pixels
- Squished cube swapping between unused pixel formats
- Live view for 128×128 textures drops FPS down to 50 vs 60 FPS for 64×64
- Color of selected index not changing in scene
- Features
- Morph between models
- Create simple geometric shapes
- Wire up transform controls on a vector in the scene
- More dynamic maps
- Distance of face from center
- Distance of vertex from center
- Direction face is facing x/y/z or 360? top/bottom black/white
- Upcoming projects – communication
- Mechanical
- Audio API – microphone/sound
- Electromagnetic
- Visible Light – camera/screen/paper
- QR Code Reader
- Text recognition
- Radio
- Near Field Communication (NFC)
- Low Energy Bluetooth
- WiFi / Personal Hot Spot
- Air Drop
- Cellular
- Visible Light – camera/screen/paper
- Mechanical
Let’s look at why the dynamic maps are not updating.
- Selecting a new texture – fixed. I wasn’t passing in the name of the dynamic map to generate.
- Changing between models – fixed by prior fix.
- Changing a vertex position – fixed with debounce
Holy smokes. I just added support for multiple weighted mediums – the colors are distinct. Rather than a gradual color difference from the single median value to the min/max values, I have 3 separate medians that divide the sizes into four visually distinct groups.

function getWeight(value, {min, max, medians}) {
if(value <= min) return 0;
if(value >= max) return 1;
const SEGMENT_SIZE = 1 / medians.length;
for(let i = 0; i <= medians.length; i++) {
const median = i === medians.length ? max : medians[i];
if(value <= median) {
const minValue = i === 0 ? min : medians[i-1];
const range = median - minValue;
const v = value - minValue;
return ((v/range) * SEGMENT_SIZE) + (SEGMENT_SIZE * i);
}
}
return 1;
}
const sortedAreas = areas.slice().sort((a, b) => a-b);
const medianCount = 3;
const medians = [];
for(let i = 0; i < medianCount; i++) {
const areaIndex = Math.floor(i * (areas.length/(medianCount + 1)));
medians[i] = sortedAreas[areaIndex];
}
for(let i = 0; i < trianglesXy.length; i++) {
const [xy1, xy2, xy3] = trianglesXy[i];
const area = areas[i];
const weight = getWeight(area, {min, max, medians});
const hue = Math.floor(weight * 240); // red to blue
const color = `hsl(${hue}, 100%, 50%)`;
Although nice… It’s still a bit unclear as to what is closer to the average size. Let’s see if we can keep most of the model white, and only highlight the small and large faces with red and blue. In a general sense, I want three median values so the color range goes from Red to White, White to White, White to White, and White to Blue

function getWeightedColor(weight) {
const HUE_RED = 0;
const HUE_BLUE = 240;
const LUMINANCE_WHITE = 100;
const LUMINANCE_GREY = LUMINANCE_WHITE / 2;
// Red to blue (note: unused, will be overwritten)
let hue = Math.abs(HUE_RED + Math.floor(weight * (HUE_BLUE - HUE_RED)));
let saturation = 100;
let luminance = 50;
const SIZE = 1/4;
if(weight < SIZE) {
hue = HUE_RED;
// fade hue to white
luminance = LUMINANCE_GREY + Math.floor(weight * LUMINANCE_GREY);
} else if(weight < 1 - SIZE) {
// middle range - 25% to 75%
// override hue with white
luminance = LUMINANCE_WHITE;
} else {
// white to blue
hue = HUE_BLUE;
// fade white to hue
luminance = LUMINANCE_WHITE - Math.floor((weight - 0.75) * LUMINANCE_GREY)
}
return `hsl(${hue}, ${saturation}%, ${luminance}%)`;
}
I expected the blue to be darker. I also expected the top to have a lot of red. I’m wondering what is going on. Perhaps I should be using gray instead of white.
I figured it out. I had many things wrong in many places. However, I’ve got it working.

The medians were off as they started at zero as the first index. This caused only the triangles with the minimum size to be red. This was fixed to add 1 to the index before multiplying by the area size:
const sortedAreas = areas.slice().sort((a, b) => a-b);
const medianCount = 3;
const medians = [];
const areaSize = areas.length / (medianCount + 1);
for(let i = 0; i < medianCount; i++) {
const areaIndex = Math.floor((i + 1) * areaSize);
medians[i] = sortedAreas[areaIndex];
}
My function to get the weight of a color needed to add 1 to the median length to determine the segment size, since there is an extra segment in addition to the medians. This was causing weights greater than 100%.
function getWeight(value, {min, max, medians}) {
if(value <= min) return 0;
if(value >= max) return 1;
const SEGMENT_SIZE = 1 / (medians.length + 1);
for(let i = 0; i <= medians.length; i++) {
const median = i === medians.length ? max : medians[i];
if(value <= median) {
const minValue = i === 0 ? min : medians[i-1];
const range = median - minValue;
const v = value - minValue;
return ((v/range) * SEGMENT_SIZE) + (SEGMENT_SIZE * i);
}
}
return 1;
}
The last part was how I calculated hue/saturation. Although I was offsetting the weights for the section of color I was applying, I didn’t scale it up to match the 1% to 100% range for that section. This resulted in washed out colors. There were many changes to the function as I hadn’t realized the other issues affecting my colors until after the following code was confirmed to be giving the correct results. The main part was inflating the deltas for each section to be within 0 to 1, and then adding/subtracting appropriately.
function getWeightedColor(weight) {
weight = Math.min(1, Math.max(weight, 0));
const HUE_RED = 0;
const HUE_BLUE = 240;
const LUMINANCE_WHITE = 100;
const LUMINANCE_GREY = LUMINANCE_WHITE / 2;
// Red to blue (note: unused, will be overwritten)
let hue = Math.abs(HUE_RED + Math.floor(weight * (HUE_BLUE - HUE_RED)));
let saturation = 100;
let luminance = 50;
const COUNT = 4;
const SIZE = 1/COUNT;
if(weight < SIZE) {
hue = HUE_RED;
const delta = weight * COUNT;
// full saturation to half
saturation = 50 + Math.floor((1-delta) * 50);
// fade hue to white
luminance = LUMINANCE_GREY + Math.floor(delta * LUMINANCE_GREY);
} else if(weight < 1 - SIZE) {
hue = HUE_RED;
// middle range - 25% to 75%
// override hue with white
saturation = 0;
luminance = LUMINANCE_WHITE;
} else {
// translate weight for section
const delta = (weight - (SIZE * (COUNT - 1))) * COUNT;
// white to blue
hue = HUE_BLUE;
saturation = 50 + Math.floor((delta) * 50);
// fade white to hue
luminance = LUMINANCE_GREY + Math.floor((1-delta) * LUMINANCE_GREY)
}
return `hsl(${hue}, ${saturation}%, ${luminance}%)`;
}
Since the weights are updated in the proper order now, the colorful heat-map texture looks better as well. Here are the two side-by-side for comparison as well as one in grey scale.



I actually like them all. I decided to introduce a method that lets the user choose the color scale applied.

I’ve modified the Rainbow scale so you are mostly seeing green on average with red/blue at the ends. Having yellow and cyan in the mix just added a bit of confusion on what the median distribution was.

With this, I introduced the ability to pass an array of RGB long color values to the method to get a color. Since the medians represent the values between the min/max values, they are two less than the color count. If I have 3 colors, I should have 1 (center) median value. 4 colors have 2 medians, 5 have 3 and so on… The medians are weighted representations tied to the colors. If I have 1 or 2 colors, I force the medians to have at least 1 value to help distribute the colors evenly. Although it’s silly to do it with 1 color as there are no transitioning colors.
function dynamicColorScheme() {
const scheme = document.getElementById('dynamic-colors').value;
switch(scheme) {
case 'red-white-blue':
return [0xFF0000, 0xFFFFFF, 0xFFFFFF, 0xFFFFFF, 0x0000FF];
case 'rainbow':
return [0xFF0000, 0x00FF00, 0x00FF00, 0x00FF00, 0x0000FF]
case 'grey-scale':
return [0x000000, 0x808080, 0x808080, 0x808080, 0xFFFFFF];
default:
return [0x000000, 0xFFFFFF];
}
}
function getWeightedIntColor(weight, colors) {
const count = colors.length;
if(weight <= 0) return colors[0];
if(weight >= 1) return colors[count - 1];
const segmentSize = 1 / (count - 1);
const index = Math.floor(weight * (count - 1));
const fromColor = colors[index];
const toColor = colors[Math.min(index+1, count - 1)];
const offset = segmentSize * index;
const delta = weight - offset;
const percent = delta / segmentSize;
return intColorBetween(fromColor, toColor, percent);
}
const colors = dynamicColorScheme();
const medianCount = Math.max(1, colors.length - 2);
const medians = [];
const areaSize = areas.length / (medianCount + 1);
for(let i = 0; i < medianCount; i++) {
const areaIndex = Math.floor((i + 1) * areaSize);
medians[i] = sortedAreas[areaIndex];
}
for(let i = 0; i < trianglesXy.length; i++) {
const [xy1, xy2, xy3] = trianglesXy[i];
const area = areas[i];
const weight = getWeight(area, {min, max, medians});
const color = intColorAsHex(getWeightedIntColor(weight, colors));
And with that, I crated a few helper functions to transition color values and build hex strings.
function intColorAsHex(color) {
return '#' + Math.min(Math.max(color, 0x000000), 0xFFFFFF).toString(16).padStart(6, '0');
}
function intColorBetween(color1, color2, percent) {
const rgb1 = rgbIntToRgb(color1);
const rgb2 = rgbIntToRgb(color2);
const r = intValueBetween(rgb1.r, rgb2.r, percent);
const g = intValueBetween(rgb1.g, rgb2.g, percent);
const b = intValueBetween(rgb1.b, rgb2.b, percent);
return (r << 16) | (g << 8) | b;
}
function intValueBetween(start, end, percent) {
return Math.floor(start + (end - start) * percent);
}
function rgbIntToRgb(color) {
return {
r: (color >> 16) & 0xFF,
g: (color >> 8) & 0xFF,
b: color & 0xFF
};
}
From this point on, it’s going to be fairly easy to add a new set of gradients for the user to choose from without any drastic changes to the code.
Now for the top hat. When I created it, I set up the faces to be a high density intentionally so that I could add details around the band at its base.

Well that’s unexpected. It turns out that my logic thinks that many of the faces of the hat do not have any volume. At least you can see that the band at the base of the hat has a different density. But the blue color means that it’s on the larger side of density. Odd. Time to embrace the theorem of Pythagoras and evaluate some triangles.
{
"areaZero": 0,
"vectorMatch": 976,
"indexMatch": 66,
"wrappingCount": 29
}
We have lots of vectors matching each other. That’s too many. I need to evaluate how they are being compared.
if(
vectorsAreEqual(vector1, vector2) ||
vectorsAreEqual(vector1, vector3) ||
vectorsAreEqual(vector2, vector3)
) {
vectorMatch++;
// no area
continue;
}
function vectorsAreEqual(vector1, vector2) {
return vector1.x === vector2.x &&
vector1.y === vector2.y &&
vector1.z === vector2.z;
}
Looking at the code, nothing is jumping out at me. There shouldn’t be so many vectors matching. The UFO has 64 matching vectors, and I suspect those are from the poles. The Hat and UFO were both made in Rokuro. They were made in the same way in which I created an outline of one side of the object, and the software spun the outline around the z axis to create the geometry.
{
"indexes": [
99,
131,
132
],
"vector1": {
"x": 0.15490196078431373,
"y": -0.44509803921568625,
"z": 0.23333333333333328
},
"vector2": {
"x": 0.15490196078431373,
"y": -0.44509803921568625,
"z": 0.23333333333333328
},
"vector3": {
"x": 0.19803921568627447,
"y": -0.44509803921568625,
"z": 0.19803921568627447
}
}
Although the indexes are different, two of the vectors have the same value. This is odd. The full top hat appears when using regular textures.

Perhaps it’s something with how the vectors are translated. It’s a simple call. const vectors = pixels.map(rgbAsVector);Tracing that, we have our rgbAsVector function.
function rgbAsVector(rgb) {
return new THREE.Vector3(
mapByteToControlVectorValue(axisValueOfRgb(rgb, 'x')),
mapByteToControlVectorValue(axisValueOfRgb(rgb, 'y')),
mapByteToControlVectorValue(axisValueOfRgb(rgb, 'z'))
);
}
This function maps the rgb values to the x, y, and z axes, converts the byte value of 0 to 255 into a vector value from -0.5 to 0.5. Let’s confirm that each pixel used in this triangle has the same rgb values.
[
{
"r": 187,
"g": 167,
"b": 14
},
{
"r": 187,
"g": 167,
"b": 14
},
{
"r": 178,
"g": 178,
"b": 14
}
]
They do indeed match. How am I normally seeing this face on the model if the vertexes match? Maybe I’m getting the wrong indexes?
for(let i = 0; i < trianglePositionIndexes.length; i+= 3) {
const index1 = trianglePositionIndexes[i];
const index2 = trianglePositionIndexes[i + 1];
const index3 = trianglePositionIndexes[i + 2];
This looks normal to me. Each triangle has three indexes. I’m skipping over every second and third index. What if I include the triangle anyway?

The faces appear… they are red as an area with zero is the minimum value. Why am I getting matching vectors when creating the texture? The triangle indexes come from a call to createSphericalControlTriangles. This specific function is used to build the wireframe geometry. The wireframe geometry looks intact.

function createSphericalControlTriangles(options) {
var indexedTriangles = [];
for(let column = 0; column < horizontalSegments; column++) {
for(let row = 0; row < verticalSegments; row++) {
const centerIndex = rowColumnToIndexOfVertex(row, column, options);
const bottomRightIndex = rowColumnToIndexOfVertex(row + 1, column + 1, options);
const bottomIndex = rowColumnToIndexOfVertex(row + 1, column, options);
const rightIndex = rowColumnToIndexOfVertex(row, column + 1, options);
// Add triangles in counter-clockwise order
if(row === 0) {
// triangles at top pole
indexedTriangles.push(centerIndex, bottomIndex, bottomRightIndex);
} else if(row === verticalSegments - 1) {
// triangles at bottom pole
indexedTriangles.push(centerIndex, bottomRightIndex, rightIndex);
} else {
// quads in the middle of poles
indexedTriangles.push(centerIndex, bottomRightIndex, rightIndex);
indexedTriangles.push(centerIndex, bottomIndex, bottomRightIndex);
}
}
}
return indexedTriangles;
}
I think I found the problem here. We are still referencing horizontalSegments and verticalSegments instead of using options.rows and options.columns. Nope. The change had no effect. It probably shouldn’t have since the aspect ration between the Top Hat and UFO are the same. They even have the same image dimensions of 128×128. Let’s see how our specific triangle is built. From what I see, it’s triangle index number 396. So for all points, it’s 396, 397, and 398.
This is what’s being evaluated:
{
"index": 393,
"row": 4,
"column": 2,
"centerIndex": 99,
"bottomIndex": 131,
"bottomRightIndex": 132,
"rightIndex": 100
}
From this, we are building the second part of the quad with centerIndex 99, bottomIndex 131, and bottomRightIndex 132. Yes. Those are the numbers we are getting back. They are in a counterclockwise pattern, so we should see the face facing us. The indexes for each value are being resolved with the proper row/column arguments when calling rowColumnToIndexOfVertex
| location | row =4 | column = 2 | Index |
|---|---|---|---|
| center | row | column | 99 |
| bottom right | row + 1 | column + 1 | 132 |
| bottom | row + 1 | column | 131 |
| right | row | column + 1 | 100 |
Yes – 100 comes after 99. And 132 comes after 131. Is 131 below 99? Well, it’s easier to add 32 columns to 100, so yes these are correct indexes – if 99 is correct to begin with.
At column 2, we aren’t even working with stitching, so it’s not an issue with jumping from the right of the vertices grid to the left. Row 4 is not a pole or next to a pole, so that’s not an issue either.
Why is the wireframe unaffected?
Let’s verify those indexes match whats coming back for pixel values. I pulled the vectors out of the positions.array of values, as well as the NURBS control points. Everything says two of these vertexes occupy the same location in space. How am I seeing the wireframe for these faces?
const positions = createSphericalVertices(nurbsControlVertices, options.columns, options.rows);
console.log('positions', {
nurbs1: nurbsControlVertices[index1],
nurbs2: nurbsControlVertices[index2],
nurbs3: nurbsControlVertices[index3],
position1: {
x: positions.array[index1 * 3],
y: positions.array[(index1 * 3) + 1],
z: positions.array[(index1 * 3) + 2],
},
position2: {
x: positions.array[index2 * 3],
y: positions.array[(index2 * 3) + 1],
z: positions.array[(index2 * 3) + 2],
},
position3: {
x: positions.array[index3 * 3],
y: positions.array[(index3 * 3) + 1],
z: positions.array[(index3 * 3) + 2],
},
})
{
"nurbs1": {
"color": 12297998,
"vector": {
"x": 0.15490196078431373,
"y": -0.44509803921568625,
"z": 0.23333333333333328
},
"x": 0.15490196078431373,
"y": -0.44509803921568625,
"z": 0.23333333333333328
},
"nurbs2": {
"color": 12297998,
"vector": {
"x": 0.15490196078431373,
"y": -0.44509803921568625,
"z": 0.23333333333333328
},
"x": 0.15490196078431373,
"y": -0.44509803921568625,
"z": 0.23333333333333328
},
"nurbs3": {
"color": 11710990,
"vector": {
"x": 0.19803921568627447,
"y": -0.44509803921568625,
"z": 0.19803921568627447
},
"x": 0.19803921568627447,
"y": -0.44509803921568625,
"z": 0.19803921568627447
},
"position1": {
"x": 0.15490196645259857,
"y": -0.44509804248809814,
"z": 0.23333333432674408
},
"position2": {
"x": 0.15490196645259857,
"y": -0.44509804248809814,
"z": 0.23333333432674408
},
"position3": {
"x": 0.1980392187833786,
"y": -0.44509804248809814,
"z": 0.1980392187833786
}
}
Let’s stop discarding the faces where vertices match and color this specific face a different color to locate it visually on the model.
But first!
British Invasion
Well, the library is having it’s annual fundraiser tonight. Since I’m on the board of trustees, I need to make an appearance and rub some elbows. The theme for this years “taste of books” event is “British Invasion”. I hear many local business owners and doing pop culture stuff like Beetles themed food and costumes at their workstations.
Back to Work!
Well, I had some fun at the event and met a few people. Now it’s back to work. I setup the density map so that my special face will show up as red, and all faces with zero area will show up as yellow. I can see the red spot on the texture map, but no matter how hard I try, I’m not fining it on the model itself. I think it’s time to align the texture map so that it lines up with the mesh.


The dynamic texture is now taking the full height and width of the image. I had a scaling function already that I needed to add offsets and adjust the scale. I mainly get my min Y value from the first index and my max Y value from the last index. Since those two are poles in the center of the image, I just used the first cell in row 1 for my min X value, and the last cell in row 1 for my max X value. I got a bit fancy and went after the index of the first column in row 2 and subtracted 1 from it.
function scaleCoordinateWithOffsets(xy, source, target) {
return {
x: ((xy.x - source.minX) / (source.maxX - source.minX)) * target.width,
y: ((xy.y - source.minY) / (source.maxY - source.minY)) * target.height,
};
}
function getModelReadOptions(size = image2D) {
// ... logic
const options = {
width,
height,
hDown: segments.horizontalDownsample,
vDown: segments.verticalDownsample,
rows: segments.vertical,
columns: segments.horizontal,
mapping
};
options.minY = indexOfImageDataToImageXy(0, options).y;
options.minX = indexOfImageDataToImageXy(rowColumnToIndexOfVertex(1, 0, options), options).x;
options.maxX = indexOfImageDataToImageXy(rowColumnToIndexOfVertex(2, 0, options)-1, options).x;
options.maxY = indexOfImageDataToImageXy(mapping.dataCount-1, options).y;
Did it work? Yes – but no. Although the texture is now taking the full area available to it on the texture map, the red triangle proves elusive. I think part of the problem is that my model appears to have faces overlapping each other. The wireframe mesh has wireframes in different positions.

Looking at the UFO, I can see the actual seam instead of transparency. I also see some odd behavior though. This has to do with something I spotted earlier where the last columns quads were going in the wrong direction. I think it’s about time that I go back to the model logic and see whats going on with the vectors being read.

Well, well, well… I decided that while looping through the indexes to translate them to rows & columns, I should map them back and verify that they resolve back into themselves. Guess what – the last column doesn’t map back.
for(let i = 0; i < options.mapping.dataCount; i++) {
const rc = indexOfImageDataToRowAndColumn(i, options);
const backToIndex = rowColumnToIndexOfVertex(rc.row, rc.column, options);
if(i !== backToIndex) {
console.log('Index %s (Row %s Column %s) mismatch = %s',
i, rc.row, rc.column, backToIndex
);
}
}
Index 32 (Row 1 Column 31) mismatch = 0
Index 64 (Row 2 Column 31) mismatch = 32
Index 96 (Row 3 Column 31) mismatch = 64
This seams to be a problem with rowColumnToIndexofVertex related to the pole offset, or wrapping. Let’s look at what’s there.
function rowColumnToIndexOfVertex(row, column, options) {
// handle poles
if(row <= 0) return 0;
if(row >= options.rows - 1) return options.mapping.dataCount -1;
// offset for top pole
column++;
// stitch left/right
column = column % options.columns;
return ((row - 1) * options.columns) + column;
}
I had to fiddle with it a bit. First, I shouldn’t have been resolving anything equal or greater than rows – 1. The last row is actually rows. The column offset for the pole is unnecessary. It gets handled with mod options.columns + 1. The thing that threw me off is that I though rows/columns were the column/row counts. They are not. They are actually the last row/column index. I need to rename them accordingly. For now, here are the fixes.
function rowColumnToIndexOfVertex(row, column, options) {
// handle poles
if(row <= 0) return 0;
if(row >= options.rows) return options.mapping.dataCount -1;
// offset for top pole
column;
// stitch left/right
column = column % options.columns + 1;
return ((row - 1) * options.columns) + column;
}
So all of our rows & columns that we got from an index resolve back to the same index. Is the UFO fixed? Is the Hat Fixed?


Yes and No. The last column for the UFO has diagonals going in the correct direction, and it no longer jumps to the previous row. It looks like the UV mapping is off as I am only seeing half of the last cell. This could be due to the hidden column of vertices used for stitching.
The top hat has changed what faces it thinks are transparent. It feels like I’m still off somewhere with evaluating which vertices to read.
Looking close at the UFO, I saw some flickering/overlap. The last column overlaps the first column and tries to connect to the second column of vertices. This is more of a geometry problem rather than a UV problem.

The model uses parametric geometry and has a call to get a point based on a UV value.
function getPoint(u, v, target) {
let column = Math.floor(u * (options.columns + 1));
let row = Math.floor((1 - v) * (options.rows + 1));
const index = rowColumnToIndexOfVertex(
row,
column,
options
);
if(index > options.mapping.dataCount) {
console.error('Attempting to getPoint(u: %s, v: %s) for row %s col %s, but %s out of range (%s max)',
u.toFixed(2),
v.toFixed(2),
row,
column,
index,
options.mapping.dataCount - 1
);
target.set(0, 0, 0);
}
const xyz = controlVertices[index];
if(!xyz) {
console.error('Control Vertices Index %s returned nothing', index, controlVertices)
}
target.set(xyz.x, xyz.y, xyz.z);
}
I need to switch back to the alignment grid for this problem. Sure enough, I see the stretched texture. Similar to before, I’m finding that my logic was treating the column as if it were a column count rather than the last column. The fix was to remove the +1.


There are no discernable changes to the top hats transparency for the Density Map. Let’s take a look at the weighted texture map that was generated.

I see a few problems.
- Every other row of cells is transparent.
- The bottom row centers in on the center pixel, unlike the top row with even triangles lined up beside eachother.
- Just below the center, we have a pattern of four where two diagonal cells are transparent.
The diagonal cells are the outlier. They should have been squares. Although there is a lot of pink and red, the model doesn’t have any visible pink or red in the scene. I’ve looked both inside and outside of the hat, and along the edges. There is only one dark blue band of color, and next to it should be a pink band of color. Instead, I see white.

Let’s throw the alignment map on and see what’s going on. Vertically, it’s jumping from A5 to A9, and then to A13. Each one is skipping 3 cells – two transparent rows, and the pink row. The area is so small, I can’t see that well. No wonder I couldn’t find the red triangle.

I was pretty certain that small band at the bottom was made of a few rows – not just one. The next step is to look at the original image in Totora. If it reveals that three data points are stuck together in one place, then this models geometry and Density Map is accurate. If not – then I need to diagnose why that is.

I had to go down into the basement to load up my old Windows computer. Tatara 7 for Mac doesn’t work on the latest version of Mac. Opening the file, I found a familiar sight.

The hat has a missing wedge. The sculpt type is set to plane, so I suspect that is the reason for it. I don’t see an option to change the sculpt type to a sphere. The most important bit is that the density around the band seems to match what I see in my web page. Lets take a look at the points.

There are two points at the top of the hats band. Actually – there are four. As I dragged each point, another remained.

The same thing happened at the base of the hat.

All of the right angles consisted of 3 or more points, or have points within 1 “snap” from them. The whole reason for this is simple. NURBS! A model may look nice and square in your editor, but as soon as you upload to Second Life, the hard edges became round. To have hard edges, you had to fight NURBS by tripling your control points. Not only that, but tripling up on the points also helped the hat to appear normal from far away as the level of detail (LOD) was reduced.
Here is the same image in Rokuro 4. The sculpt type is set to sphere, so the full hat is rendered. You can make out the hollow shape in the profile view much easier now.


Notice how each area has two squares, 1 grid space apart. Those are what creates the red bits in my density maps!
Ok, so things are starting to make sense. Our density map appears to be accurate for how the sculpted prim was designed. So why does it look funny? What’s with all of the transparency in our web page?

Let’s increase the vertical height of our density map. Maybe it’s due to the stretched edges.

I increased the texture dimensions to 4,096 x 16,384 pixels. We are starting to see a crisp border with that stretched texture. The vertical alignment seems off. Looking at the brim of the hat, the texture doesn’t stretch fully to cover it all. My offset and repeat controls only have sliders, so it’s hard to incrementally lower the repeat and change the offset.


Well that was fun. To simplify the process and follow DRY (do not repeat yourself), I created a function to wire up pairs of input controls. The one for radians needed a little bit of extra logic to convert between degrees and radians, but that was previously worked out for rotating objects.
bindRadianAndDegreeInput(
'texture-rotation',
'texture-rotation-value',
handleTextureOrientation
);
bindRangeAndNumericInput(
'texture-horizontal-offset',
'texture-horizontal-offset-value',
handleTextureOrientation
);
function bindRangeAndNumericInput(rangeId, numericId, callback) {
const range = document.getElementById(rangeId);
const numeric = document.getElementById(numericId);
numeric.value = parseFloat(numeric.value).toFixed(2);
range.value = parseFloat(numeric.value).toFixed(2);
const handler = debounce(callback, 100);
range.addEventListener('input', () => {
numeric.value = parseFloat(range.value).toFixed(2);
handler();
});
numeric.addEventListener('input', () => {
range.value = parseFloat(numeric.value).toFixed(2);
handler();
});
}
function bindRadianAndDegreeInput(radianId, degreeId, callback) {
const radian = document.getElementById(radianId);
const degree = document.getElementById(degreeId);
degree.min = 0;
degree.max = 359.95;
degree.step = 0.05;
radian.min = -Math.PI;
radian.max = Math.PI;
degree.value = parseFloat(degree.value).toFixed(2);
radian.value = degreesToRadians(parseFloat(degree.value));
const handler = debounce(callback, 100);
radian.addEventListener('input', () => {
const value = radiansToDegrees(parseFloat(radian.value));
degree.value = (Math.floor(value * 20) / 20).toFixed(2);
handler();
});
degree.addEventListener('input', () => {
radian.value = degreesToRadians(parseFloat(degree.value));
handler();
});
}
Back to the top hat! Let’s try to line up that bottom brim.
- Rotate 179.80
- Flip Horizontally & Vertically
- Offset 0.03 Vertically
- Repeat 0.93 Vertically
The hats brim is lined up vertically with the texture. However, other parts of the model are not aligned.

This is telling me that my image map has a bit too much vertical data, or that I don’t have enough rows of vertices. Let’s move over to alignment map 1024. It has a 32×32 grid of cells. Columns go from A to Z, AA to AF. Rows go from 1 to 32.
The top of the hat has the bottom row of cells ending in 32. Before that, we see row 28. Rows 29, 30, and 31 are hidden due to NURBS and LOD optimization.

Now if I do my usual texture alignment when displaying the density map (Rotate 180, Flip H & V), I still see the top of the hat ends with row 32. Lets take a look at the hat from the bottom.

Looking from below is a bit more difficult. We see A12 on the brim. The triangles joining at the sphere don’t give us much detail about the first cell displayed. Let’s look at the bottom from another angle.

Interesting. I thought my eyes deceived me. The inner wall jumps from row 6 to 8. Looking back at the Rokuro screenshot, I do see a point in the middle. It seems unnecessary to have a point there, let alone two. However, we see its effect jumping between cells. The big question is, how many points between the top row and row 6. If we have four points, then… something doesn’t add up. Wait a sec. I just added the ability to offset the texture with the numeric input.

Man… that came in handy. Now I need to confirm something. Did I double up the vector on the pole? If I did, then this makes sense. If not, then I’m missing an extra row. Back to the basement!
Yes! The model had two points in the center. This model has many issues and keeps sending me down rabbit holes just to find out that the model is the problem. I cleaned it up and created another version. All points that “should” be doubled up are now doubled up. The inside of the hats wall is no longer doubled up. Instead the points are distributed equally along the inside wall. The center point is no longer doubled up as well. I moved the extra point equally between the center and the inside wall. Here is the new model as a sculpted prim image.

Wait a sec… why is the size 178×178? That is not a base 2 dimension. Back to the basement. It turns out that an online converter from TGA to PNG resized the image. Here is the 64×64 image of the new and improved top hat.

What… in… the… world? I’m having lots of issues.
- The top of the model has a central point on the edge of the hat.
- The side of the brim is full of diamond faces instead of a consitent vertical wall all the way around.
- The inside of the wall is twisted
- The center of the inside has overlapping geometry flickering faces




Looking at the earlier and new PNG images, this new one looks a bit dithered. Here is a close up of the two side-by-side in the wordpress media manager.

It looks as if I may need to head to the basement again to find another way to convert the TGA file to PNG. The original color values need to be preserved. Otherwise the model data is corrupted.
The one highlight of this latest file is that the doubling up of points seems to have had an effect on the density map. I’m now seeing shades of pink since the micro-sized faces have been reduced to an area of 0.

At this point, it’s getting late. I need to wrap this stuff up.
