SketchUp UV Projection

This document explains how SketchUp’s texture projection works and how it is parsed by skppy. It covers the binary TLV structure, the projection matrix math, and the UV computation formula.

Overview

SketchUp assigns texture coordinates per face through a 3x3 transform. Most mappings are affine, while legacy projected textures use the homogeneous coordinate as UVQ. Modern files store the transform in TLV records; pre-ZIP files store the equivalent CFaceTextureCoords object in the face’s technical attribute container.

TLV Path

entity_base (0x07D0)
  \- id_wrapper (0x05DC)
       \- ext_payload (0x05DD)
            \- attr_dicts_root (0x36B1)
                 \- attr_dict (0x36B2)
                      \- tex_proj_pair (0x2710)
                           +- tex_proj_front (0x2711)   <- front-face projection
                           \- tex_proj_back  (0x2712)   <- back-face projection

Each side (0x2711 / 0x2712) contains a tex_proj_payload (0x2713) that holds:

Tag

Content

Size

0x2714

enabled flag (compact int, 0 or 1)

variable

0x2715

projection matrix (9 x f64)

72 bytes

0x2716

adjacent vector, retained for inspection

24 bytes

When enabled == 0, the face inherits its UV from its material’s default tiling (untreated planar mapping). When enabled == 1, the matrix below is used.

The Projection Matrix (tag 0x2715)

Nine f64 values are stored row-major and represent a 3 x 3 matrix M:

For observed modern files, M is a 2-D affine transform stored in row-vector form:

[sx sy 1] = [u_raw v_raw 1] * M

where (sx, sy) is the point expressed in SketchUp’s 2-D face-local basis, and (u_raw, v_raw) is the unnormalised texture-space coordinate in inches.

In the common affine case, m[2] and m[5] are zero and m[8] is one:

sx = u_raw * m[0] + v_raw * m[3] + m[6]
sy = u_raw * m[1] + v_raw * m[4] + m[7]

m[6] and m[7] are therefore translation terms in the direct transform, not a 3-D anchor point to subtract from each vertex.

UV Formula

Given a vertex at position P = (px, py, pz) in local SKP inches:

  1. Choose the 2-D face coordinates (sx, sy) in the face-local basis:

    • for non-horizontal faces, s_axis = normalize(cross((0, 0, 1), normal)) and t_axis = cross(normal, s_axis)

    • sx = dot(P, s_axis) and sy = dot(P, t_axis)

    • for horizontal faces, the basis falls back to (x, y)

  2. Invert the 3 x 3 matrix from 0x2715.

  3. Compute:

    [u_raw v_raw q] = [sx sy 1] * inverse(M) u = u_raw / (q * W) v = v_raw / (q * H)

    where W/H are the material texture width/height in inches. Affine transforms have q == 1; the division is significant for projected legacy textures.

Important: Vertex positions must be in the same coordinate space as the matrix - local space, in SketchUp inches. No world transform should be applied before computing UVs.

Code Example

from skppy.data_structure.entities import FaceUVProjection

proj = FaceUVProjection(
    transform=[...],  # 9 floats from tag 0x2715
    origin=(0, 0, 0), # 3 floats from tag 0x2716, retained for inspection
)

# Compute UV for a vertex at (10, 20, 0) inches on an XY face
u, v = proj.compute_uv(
    px=10,
    py=20,
    pz=0,
    x_scale=100,
    y_scale=100,
    normal=(0, 0, 1),
)

Role of tag 0x2716

Tag 0x2716 carries a 3-vector adjacent to the projection matrix. Its role is not required for UV reconstruction in the observed modern files; for example, some projected textured faces store (0, 0, 0) in that field. skppy stores it in FaceUVProjection.origin for inspection but does not use it in the UV formula.

Legacy CFaceTextureCoords

Pre-ZIP files place CFaceTextureCoords among the entries owned by a face’s CAttributeContainer. It is a technical entry rather than a named AttributeDictionary; parsers must preserve its archive object identity and associate it with the owning CFace.

Version 4 stores a flags word per side:

  • bit 0x01: the side has an explicit texture transform

  • bit 0x02: the side is projected and the adjacent 3-vector is the projection direction

When bit 0x02 is set, SketchUp builds (sx, sy) from the projection direction instead of the face normal. The shared FaceUVProjection exposes it as projection_direction. This behavior and the UVQ division were confirmed against UVQ values returned by the documented SketchUp C API.

Tag 0x2717 - Texture Control Points

When a user positions a texture on a face, its control points are stored in a 0x2717 sub-record within the tex_proj_payload. Each pin is a pair:

0x2718  <- texture control-point record
  0x2719  <- raw texture position (2 x f64, texture-space inches)
  0x271A  <- model position (2 x f64, projected face coordinates)

The shared FaceUVProjection.pins keeps these pairs as UVPin values. The projection matrix remains the general mapping for the face. When a mesh corner coincides with a control point, its raw texture position is authoritative and is divided by the material’s physical texture scale before being sent to the renderer. Control points do not have to coincide with face vertices.

Degenerate UV Handling

skppy falls back to orientation-aware planar UV mapping when the 0x2715 matrix is singular and cannot be inverted.

Coordinate System Notes

  • SketchUp’s internal unit is the inch (1.0 = 1 inch).

  • Blender uses metres. The default import scale is 0.0254 (m/inch).

  • To convert a Blender-space vertex position back to local inches for UV computation, divide by the scale factor: local_inch = blender_metres / 0.0254.

  • UV computation must stay in local space; applying a group/instance world transform before UV computation would yield wrong results.

Material Texture Scale

The x_scale and y_scale parameters in compute_uv() come from the material’s texture definition:

x_in = mat.texture.x_scale  # texture width in inches
y_in = mat.texture.y_scale  # texture height in inches

These values are stored in the material.xml file inside the SKP ZIP archive and represent the real-world size of one texture tile.

Projection Selection by Effective Material

A per-face projection can still be present when the effective material comes from a containing group or component instance. The importer must apply that projection using the inherited material’s texture scale; falling back to planar UVs in this case turns normalized image mappings into tiled mappings.

Blender exposes one material and UV set for both sides of a polygon. When the same SketchUp material is assigned to both sides but only one side contains a projection, the importer uses that available projection. This preserves the visible texture placement instead of discarding it solely because the selected material reference came from the other side.

Face.resolve_material_mapping() defines the shared front/back policy used by mesh preparation and the Blender addon. An explicit front material wins. The back material is selected only when the front is unpainted, and an inherited material is selected only when neither side is painted. A projection from the opposite side is reused only for the same material; inherited materials may use either stored projection.

SketchUp’s Collada exporter is not an authoritative UV reference for these cases. An observed export contains separate front/back geometry with a tiled mapping on one side and a near-one-tile mapping on the other, so the side shown by a Collada viewer can differ from SketchUp’s intended appearance. Visual verification should use SketchUp or UVQ results from its documented C API.

Compatibility comparisons confirmed that the historical failure was not in the 3x3 matrix calculation: mesh preparation discarded an applicable projection after decoding. Keeping the effective side and projection together prevents that regression for both modern and legacy models.

Projection coordinates may retain an integer tile offset, such as a one-tile span around u=-8. Do not normalize that offset merely to force coordinates into 0..1; repeating textures render integer-shifted UVs equivalently, while removing a non-integer phase changes the intended placement.

Fallback: Planar UV (_planar_uv)

When no projection exists (or when the projection is singular), skppy computes UV by dropping the dominant face-normal axis and dividing the remaining coordinates by the material texture scale:

  • mostly +/-Z normal -> (x / x_scale, y / y_scale)

  • mostly +/-X normal -> (y / x_scale, z / y_scale)

  • mostly +/-Y normal -> (x / x_scale, z / y_scale)

This produces deterministic UV tiling for faces where no explicit projection was stored.