Solution de toby-bro pour Un cercle est un carré

misc algo

12 juin 2025

Ce problème a été de loin celui qui m’a le plus fait m’arracher des cheveux lors de ce FCSC.

Je passerai les détails mais après avoir passé plus d’une journée à faire des projections de segments sur des plans diagonaux puis sur des arrêtes pour refaire une sorte de projection cubique à la manière d’une projection sphérique du segment $[P, Q]$ et avoir vu que c’était pas la solution exacte mais une bonne approximation…

La méthode de la fourmi paresseuse

Étapes à suivre

  • déplier le cube
  • tracer un trait entre P et Q
  • mesurer le trait

Voilà c’était tout simple…

Bon maintenant il fallait implémenter ça en python, et l’implémentation est loin d’être triviale, désolé pour tous ceux qui n’ont pas fait d’algèbre, ou d’info graphique, ou n’importe quoi qui use et abuse de matrices, ça risque de piquer un peu.

L’algorithme en soi n’est pas très compliqué, c’est un dépliage récursif

  • Déplier récursivement le cube en commençant par la face de P
    • Est ce que Q est sur cette face
    • Dans notre dépliage peut-on encore déplier une face voisine
      • La déplier

Bon vient la partie coriace. Je me suis posé la question de comment savoir où atterirait le point Q après le dépliage, comment garder en mémoire les transformations et comment les appliquer. Je sais que ce seront des rotations, mais conserver l’axe, la direction, l’angle… était compliqué. Je me suis dit qu’en fait on était en dimension 3, dans un espace métrique, que les rotations sont des endomorphismes linéaires, et qu’en utilisant cela, la résultante de toutes mes transformations n’est que le produit de toutes mes matrices de rotations pour chacun des plis. Rien de bien sorcier. Toujours est-il que mon fichier de tests unitaires est plus long que mon programme pour résoudre le problème…

Bref je peux témoigner que le 🐱 n’a pas de vision dans l’espace, et ne peut pas résoudre les challs comme certains arguaient sur le discord un matin très tôt ! Bon ça aide pas qu’il faille en plus représenter les translations (ni que les translations ne sont pas des opérations linéaires en dimension 3) car non seulement on a une rotation mais en plus deux translations, une pour placer l’axe de rotation dans l’origine du repère, et une pour en revenir. J’ai donc opté pour utiliser des coordonnées homogènes (et donc des matrices 4x4) pour représenter ces transformations. Si vous voulez plus de détails sur pourquoi on utilise des matrices 4x4 vous pouvez consulter cette réponse.

Ce qu’il se passe en détail est donc : On commence de la face de P

  • Est ce que Q est sur la face si oui on arrête et on calcule la distance
    • Pour chaque face voisine qui n’a pas encore été visitée
      • On déplie la face (on calcule la matrice associée à cette transformation), est ce que Q est sur la face -> si oui on calcule sa nouvelle position $Q’$ une fois déplié en composant toutes les matrices de rotation $Q’ := R_1 R_2 … R_n Q$
      • On poursuit la récursivité jusqu’à ce qu’on ne puisse plus déplier de face supplémentaire et on renvoie toutes les coordonnées de Q que l’on a trouvé.

En ce qui concerne la matrice de la rotation on la détermine de la façon suivante :

  • On détermine le vecteur représentant l’axe de rotation si le cube n’avait pas été déplié
  • On détermine les vecteurs normaux des faces (pointant vers l’extérieur du cube), avant le dépliage
  • On applique la matrice de tous les dépliages jusqu’à présent à ces vecteurs, pour obtenir leur vraies positions dans l’espace après tant de dépliages successifs.
  • On translate l’ensemble vers l’origine du repère
  • On détermine la matrice de rotation
  • On l’orthonormalise
  • On vérifie que son déterminant est bien positif
  • On effectue le changement de base dans l’autre sens et on retourne aux véritables positions
  • On renvoie la nouvelle valeur de la matrice de rotation que l’on va multiplier à toutes les précédentes pour avoir la matrice représentant le dépliage de la prochaine face.

Attention il faut noter que chaque face à une matrice de dépliage différente car aucune ne subit les mêmes dépliages avant d’arriver dans le plan de P.

A chaque façon de déplier on va tomber sur la face avec Q, donc on va obtenir plein de positions de Q une fois sur la face de P et donc calculer plein de distances. Yapuka renvoyer la plus courte, rajouter un peu de pexpect pour interragir avec le serveur netcat, ouvrir un ticket parce que le RTT entre mon ordi et le serveur est trop élevé pour valider dans les temps, remercier les orgas, valider le chall.

tl;dr les fourmis sont sacrément fortes en algèbre, écrivez des units tests, l’algèbre c’est cool, déplier une feuille de papier c’est pas toujours trivial.

Si vous voulez consulter l’implémentation complète vous pouvez la trouver sur mon repo git :

Voici le fichier qui nous intéresse : minimum_distance.py

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Rectangle


class point:
    def __init__(self, P: list[int]) -> None:
        self.coordinates = P

    def distance(self, P: 'point') -> int:
        assert len(P.coordinates) == len(self.coordinates), 'Dimension mismatch'
        dist: int = 0
        for i, j in zip(self.coordinates, P.coordinates, strict=False):
            dist += (i - j) ** 2
        return dist

    def __str__(self) -> str:
        return f'[{", ".join(str(x) for x in self.coordinates)}]'

    def __repr__(self) -> str:
        return f'point({", ".join(str(x) for x in self.coordinates)})'


def get_rotation_matrix(edge_start: point, edge_end: point, angle: float) -> np.ndarray:
    """
    Returns a rotation matrix for rotating around an edge of the cube.

    Args:
        edge_start: The starting point of the edge
        edge_end: The ending point of the edge
        angle: The rotation angle in radians

    Returns:
        A 3x3 numpy array representing the rotation matrix
    """
    # Convert edge points to numpy arrays
    p1 = np.array(edge_start.coordinates)
    p2 = np.array(edge_end.coordinates)

    # Calculate the vector representing the rotation axis (the edge)
    axis = p2 - p1
    axis = axis / np.linalg.norm(axis)  # Normalize to unit vector

    # Create the rotation matrix around an arbitrary axis
    # Using Rodrigues' rotation formula
    a = axis[0]
    b = axis[1]
    c = axis[2]

    # Projection matrix
    P = np.array(
        [
            [a * a, a * b, a * c],
            [a * b, b * b, b * c],
            [a * c, b * c, c * c],
        ],
    )

    # Cross product matrix
    K = np.array(
        [
            [0, -c, b],
            [c, 0, -a],
            [-b, a, 0],
        ],
    )

    # Rodrigues' rotation formula
    R = P + (np.eye(3) - P) * np.cos(angle) + K * np.sin(angle)

    return R  # noqa: RET504


def get_face(p: point, length: int) -> int:
    """
    Determine which face of the cube the point is on.

    Returns:
        0: x = 0 (left)
        1: x = length (right)
        2: y = 0 (bottom)
        3: y = length (top)
        4: z = 0 (back)
        5: z = length (front)
    """
    x, y, z = p.coordinates

    if x == 0:
        return 0
    if x == length:
        return 1
    if y == 0:
        return 2
    if y == length:
        return 3
    if z == 0:
        return 4
    if z == length:
        return 5

    raise ValueError(f'Point {p} is not on the surface of the cube')


def is_on_face(face: int, ptc: np.ndarray, length: int) -> bool:
    epsilon = 1e-5  # Small tolerance for floating point precision

    if face == 0:
        return abs(ptc[0]) < epsilon
    if face == 1:
        return abs(ptc[0] - length) < epsilon
    if face == 2:
        return abs(ptc[1]) < epsilon
    if face == 3:
        return abs(ptc[1] - length) < epsilon
    if face == 4:
        return abs(ptc[2]) < epsilon
    if face == 5:
        return abs(ptc[2] - length) < epsilon

    raise ValueError(f'Invalid face {face} for point {ptc}')


def get_edges_for_faces(face_p: int, face_q: int, length: int) -> tuple[point, point]:
    """
    Get the edges that belong to the faces p and q.

    Args:
        face_p (int): first face
        face_q (int): second face

    Returns:
        tuple[point, point]: the points of the edge between both faces
    """
    assert face_p != face_q, 'Faces must be different'
    assert (face_p, face_q) not in [(0, 1), (2, 3), (4, 5)], 'Faces must be adjacent'
    x, y, z = None, None, None
    if face_p == 0 or face_q == 0:
        x = 0
    if face_p == 1 or face_q == 1:
        x = length
    if face_p == 2 or face_q == 2:
        y = 0
    if face_p == 3 or face_q == 3:
        y = length
    if face_p == 4 or face_q == 4:
        z = 0
    if face_p == 5 or face_q == 5:
        z = length
    if x is None and y is not None and z is not None:
        return point([0, y, z]), point([length, y, z])
    if y is None and x is not None and z is not None:
        return point([x, 0, z]), point([x, length, z])
    if z is None and x is not None and y is not None:
        return point([x, y, 0]), point([x, y, length])
    raise ValueError('Invalid faces or coordinates')


def get_adjacent_faces(face: int, length: int) -> list[tuple[int, tuple[point, point]]]:
    """
    Get adjacent faces and the edges they share with the current face.

    Returns:
        List of tuples (adjacent_face, (edge_start, edge_end))
    """
    adj_faces = []

    # Define corners of the cube

    corners = [
        point([0, 0, 0]),
        point([length, 0, 0]),
        point([0, length, 0]),
        point([length, length, 0]),
        point([0, 0, length]),
        point([length, 0, length]),
        point([0, length, length]),
        point([length, length, length]),
    ]
    # Define edges for each face and its adjacent faces
    if face == 0:  # x = 0 (left)
        adj_faces.extend(
            [
                (2, (corners[0], corners[4])),  # Left->Bottom
                (3, (corners[2], corners[6])),  # Left->Top
                (4, (corners[0], corners[2])),  # Left->Back
                (5, (corners[4], corners[6])),  # Left->Front
            ],
        )
    elif face == 1:  # x = length (right)
        adj_faces.extend(
            [
                (2, (corners[1], corners[5])),  # Right->Bottom
                (3, (corners[3], corners[7])),  # Right->Top
                (4, (corners[1], corners[3])),  # Right->Back
                (5, (corners[5], corners[7])),  # Right->Front
            ],
        )
    elif face == 2:  # y = 0 (bottom)
        adj_faces.extend(
            [
                (0, (corners[0], corners[4])),  # Bottom->Left
                (1, (corners[1], corners[5])),  # Bottom->Right
                (4, (corners[0], corners[1])),  # Bottom->Back
                (5, (corners[4], corners[5])),  # Bottom->Front
            ],
        )
    elif face == 3:  # y = length (top)
        adj_faces.extend(
            [
                (0, (corners[2], corners[6])),  # Top->Left
                (1, (corners[3], corners[7])),  # Top->Right
                (4, (corners[2], corners[3])),  # Top->Back
                (5, (corners[6], corners[7])),  # Top->Front
            ],
        )
    elif face == 4:  # z = 0 (back)
        adj_faces.extend(
            [
                (0, (corners[0], corners[2])),  # Back->Left
                (1, (corners[1], corners[3])),  # Back->Right
                (2, (corners[0], corners[1])),  # Back->Bottom
                (3, (corners[2], corners[3])),  # Back->Top
            ],
        )
    elif face == 5:  # z = length (front)
        adj_faces.extend(
            [
                (0, (corners[4], corners[6])),  # Front->Left
                (1, (corners[5], corners[7])),  # Front->Right
                (2, (corners[4], corners[5])),  # Front->Bottom
                (3, (corners[6], corners[7])),  # Front->Top
            ],
        )

    return adj_faces


def calculate_unfolding_transformation(
    current_face: int,
    adjacent_face: int,
    edge: tuple[point, point],
    length: int,
    cumulative_transform: np.ndarray = np.eye(4),
) -> np.ndarray:
    """
    Calculate the transformation matrix for unfolding from current_face to adjacent_face
    taking into account previous unfolding transformations.

    Args:
        current_face: The index of the current face
        adjacent_face: The index of the adjacent face to unfold
        edge: The edge shared between the two faces (edge_start, edge_end)
        length: The length of the cube
        cumulative_transform: The accumulated transformations from previous unfolding steps

    Returns:
        A 4x4 transformation matrix that combines rotation and translation
    """
    edge_start, edge_end = edge
    edge_start_coords = np.array(edge_start.coordinates)
    edge_end_coords = np.array(edge_end.coordinates)

    # Calculate the edge vector in the current transformed space
    edge_start_homo = np.append(edge_start_coords, 1)
    edge_end_homo = np.append(edge_end_coords, 1)

    # Apply existing transforms to calculate current edge position
    transformed_start = cumulative_transform @ edge_start_homo
    transformed_end = cumulative_transform @ edge_end_homo

    # Calculate the edge vector in transformed space
    edge_vector = transformed_end[:3] - transformed_start[:3]
    edge_length = np.linalg.norm(edge_vector)
    axis = edge_vector / edge_length  # Normalize to unit vector

    # Original face normals in global coordinate system
    original_face_normals = {
        0: np.array([-1.0, 0.0, 0.0]),  # Left face normal (-x direction)
        1: np.array([1.0, 0.0, 0.0]),  # Right face normal (+x direction)
        2: np.array([0.0, -1.0, 0.0]),  # Bottom face normal (-y direction)
        3: np.array([0.0, 1.0, 0.0]),  # Top face normal (+y direction)
        4: np.array([0.0, 0.0, -1.0]),  # Back face normal (-z direction)
        5: np.array([0.0, 0.0, 1.0]),  # Front face normal (+z direction)
    }

    # Transform the face normals according to the cumulative transformation
    # We need to use the rotation part of the cumulative transform (3x3 upper-left matrix)
    rotation_part = cumulative_transform[:3, :3]

    # Transform both face normals
    normal1 = np.dot(rotation_part, original_face_normals[current_face])
    normal2 = np.dot(rotation_part, original_face_normals[adjacent_face])

    # Normalize the transformed normals
    normal1 = normal1 / np.linalg.norm(normal1)
    normal2 = normal2 / np.linalg.norm(normal2)

    # Calculate cross product of the transformed normals
    cross_prod = np.cross(normal1, normal2)
    cross_norm = np.linalg.norm(cross_prod)

    # If cross product is zero, the faces are parallel, which shouldn't happen for adjacent faces
    if cross_norm < 1e-10:
        raise ValueError(f'Faces {current_face} and {adjacent_face} appear to be parallel after transformation')

    # Normalize the cross product
    cross_unit = cross_prod / cross_norm

    # The rotation angle is always 90 degrees for cube unfolding
    rotation_angle = np.pi / 2

    # Check if the cross product and edge directions are aligned
    alignment = np.dot(cross_unit, axis)

    # If they're not aligned, flip the rotation direction
    if alignment < 0:
        rotation_angle = -rotation_angle

    # Build the rotation matrix relative to the transformed edge start
    translation_to_edge = np.eye(4)
    translation_to_edge[0:3, 3] = -transformed_start[:3]

    # Calculate the 3x3 rotation matrix using Rodrigues' formula
    a, b, c = axis
    K = np.array(
        [
            [0, -c, b],
            [c, 0, -a],
            [-b, a, 0],
        ],
    )
    P = np.outer(axis, axis)
    R = np.eye(3) * np.cos(rotation_angle) + K * np.sin(rotation_angle) + P * (1 - np.cos(rotation_angle))

    # Ensure orthogonality
    u = R[:, 0]
    v = R[:, 1] - np.dot(R[:, 1], u) * u / np.dot(u, u)
    w = np.cross(u, v)

    # Normalize
    u /= np.linalg.norm(u)
    v /= np.linalg.norm(v)
    w /= np.linalg.norm(w)

    R_ortho = np.column_stack((u, v, w))

    # Ensure it's a proper rotation (det = 1)
    if np.linalg.det(R_ortho) < 0:
        R_ortho[:, 2] = -R_ortho[:, 2]

    # Create 4x4 rotation matrix
    rotation_matrix = np.eye(4)
    rotation_matrix[0:3, 0:3] = R_ortho

    # Translation back
    translation_back = np.eye(4)
    translation_back[0:3, 3] = transformed_start[:3]

    # Combine transformations for this step
    transform_step = translation_back @ rotation_matrix @ translation_to_edge

    return transform_step


def unfold_cube(P: point, Q: point, length: int) -> list[np.ndarray]:
    """
    Find all possible positions of Q when unfolding the cube from P's face.

    Args:
        P: The starting point
        Q: The target point
        length: The length of the cube

    Returns:
        List of different positions of Q after unfolding the cube
    """
    face_P = get_face(P, length)
    face_Q = get_face(Q, length)

    # Track all positions of Q after different unfoldings
    all_Q_positions: list[np.ndarray] = []

    # Start the recursive unfolding
    unfold_recursive(P, Q, length, face_P, face_Q, {face_P}, np.eye(4), all_Q_positions)

    return all_Q_positions


def unfold_recursive(
    P: point,
    Q: point,
    length: int,
    current_face: int,
    target_face: int,
    visited_faces: set,
    cumulative_transform: np.ndarray,
    result: list,
) -> None:
    """
    Recursively unfold the cube to find all possible positions of Q.

    Args:
        P: The starting point
        Q: The target point
        length: The length of the cube
        current_face: The face currently being processed
        target_face: The face containing Q
        visited_faces: Set of faces already visited in this unfolding path
        cumulative_transform: Cumulative transformation matrix applied so far
        result: List to store all positions of Q after unfolding
    """
    # If we've reached the face with Q, calculate its unfolded position
    if current_face == target_face:
        # Convert Q coordinates to homogeneous coordinates (add 1 at the end)
        q_homogeneous = np.append(np.array(Q.coordinates, dtype=np.int64), 1)

        # Apply transformation
        unfolded_q_homogeneous = cumulative_transform @ q_homogeneous

        # Convert back to 3D coordinates and ensure result is integer
        unfolded_q = np.round(unfolded_q_homogeneous[0:3]).astype(np.int64)
        result.append(unfolded_q)
        return

    # Try unfolding each adjacent face
    for adj_face, edge in get_adjacent_faces(current_face, length):
        if adj_face not in visited_faces:
            # Calculate the transformation matrix for this unfolding step
            transform = calculate_unfolding_transformation(
                adj_face,
                current_face,
                edge,
                length,
                cumulative_transform,
            )

            # Update the cumulative transformation
            updated_transform = transform @ cumulative_transform

            # Mark this face as visited in this path
            new_visited = visited_faces.copy()
            new_visited.add(adj_face)

            # Recursively unfold from this new face
            unfold_recursive(
                P,
                Q,
                length,
                adj_face,
                target_face,
                new_visited,
                updated_transform,
                result,
            )


def plot_unfolded_cube(P: point, Q: point, length: int, save_path: str | None = None) -> None:
    """
    Plot all possible positions of Q when the cube is unfolded onto P's face.

    Args:
        P: The reference point
        Q: The target point
        length: The length of the cube
        save_path: Path to save the figure, if None, the figure is displayed
    """
    face_P = get_face(P, length)
    P_coords = np.array(P.coordinates)
    face_Q = get_face(Q, length)

    unfolded_positions = unfold_cube(P, Q, length)

    fig = plt.figure(figsize=(12, 10))
    ax = fig.add_subplot(111)

    # Determine axis mapping based on P's face
    if face_P == 0:  # x = 0
        x_idx, y_idx = 1, 2  # y, z axes
        ax_labels = ['y', 'z']
        face_label = 'x=0 (Left)'
    elif face_P == 1:  # x = length
        x_idx, y_idx = 1, 2  # y, z axes
        ax_labels = ['y', 'z']
        face_label = f'x={length} (Right)'
    elif face_P == 2:  # y = 0
        x_idx, y_idx = 0, 2  # x, z axes
        ax_labels = ['x', 'z']
        face_label = 'y=0 (Bottom)'
    elif face_P == 3:  # y = length
        x_idx, y_idx = 0, 2  # x, z axes
        ax_labels = ['x', 'z']
        face_label = f'y={length} (Top)'
    elif face_P == 4:  # z = 0
        x_idx, y_idx = 0, 1  # x, y axes
        ax_labels = ['x', 'y']
        face_label = 'z=0 (Back)'
    else:  # z = length
        x_idx, y_idx = 0, 1  # x, y axes
        ax_labels = ['x', 'y']
        face_label = f'z={length} (Front)'

    ax.add_patch(Rectangle((0, 0), length, length, fill=False, edgecolor='black', linewidth=2))

    p_x, p_y = P_coords[x_idx], P_coords[y_idx]
    ax.scatter(p_x, p_y, color='red', s=100, label=f'P{P.coordinates}')

    min_dist = float('inf')
    closest_q = None

    for i, unfolded_Q in enumerate(unfolded_positions):
        q_x, q_y = unfolded_Q[x_idx], unfolded_Q[y_idx]

        dist = np.sum((P_coords - unfolded_Q) ** 2)

        q_label = f'Q{i}({dist:.0f})'

        if dist < min_dist:
            min_dist = dist
            closest_q = (q_x, q_y, dist, i)

        ax.scatter(q_x, q_y, color='blue', alpha=0.6, s=50)
        ax.text(q_x + 1, q_y + 1, q_label, fontsize=8)

    if closest_q:
        q_x, q_y, dist, idx = closest_q
        ax.scatter(q_x, q_y, color='green', s=100, label=f'Closest Q{idx}(dist={dist:.0f})')
        ax.plot([p_x, q_x], [p_y, q_y], 'g--', linewidth=2)

    min_x, max_x = 0, length
    min_y, max_y = 0, length

    for unfolded_Q in unfolded_positions:
        q_x, q_y = unfolded_Q[x_idx], unfolded_Q[y_idx]
        min_x = min(min_x, q_x - 5)
        max_x = max(max_x, q_x + 5)
        min_y = min(min_y, q_y - 5)
        max_y = max(max_y, q_y + 5)

    ax.set_xlim(min_x, max_x)
    ax.set_ylim(min_y, max_y)
    ax.set_xlabel(ax_labels[0])
    ax.set_ylabel(ax_labels[1])
    ax.set_aspect('equal')

    ax.set_title(f'Unfolded Cube: P on {face_label}, Q on face {face_Q}\nMinimum distance = {min_dist:.0f}')

    ax.grid(True, linestyle='--', alpha=0.7)
    ax.legend(loc='upper right')

    if save_path:
        plt.savefig(save_path)
    else:
        plt.tight_layout()
        plt.show()


def minimumDistanceOnCube(length: int, P: point, Q: point) -> int:
    """
    Calculate the minimum distance between two points on a cube.

    Args:
        length: The length of the cube's edge
        P: The first point
        Q: The second point

    Returns:
        The minimum distance squared between the two points
    """
    face_P = get_face(P, length)
    face_Q = get_face(Q, length)

    if face_P == face_Q:
        return P.distance(Q)

    unfolded_positions = unfold_cube(P, Q, length)

    min_dist = float('inf')
    P_coords = np.array(P.coordinates, dtype=np.int64)

    for unfolded_Q in unfolded_positions:
        # Calculate squared distance ensuring integer math
        dist = np.sum((P_coords - unfolded_Q) ** 2)
        min_dist = min(min_dist, dist)

    return int(min_dist)  # Ensure the final result is an integer


if __name__ == '__main__':

    from random import randint

    def get_pt() -> point:
        return get_random_point_on_face(32)

    def get_random_point_on_face(length: int) -> point:
        face_p = randint(0, 5)
        r1 = randint(1, length - 1)
        r2 = randint(1, length - 1)

        if face_p // 2 == 0:  # x=0
            p = point([face_p * length, r1, r2])
        elif face_p // 2 == 1:  # y=0
            p = point([r1, (face_p % 2) * length, r2])
        elif face_p // 2 == 2:  # y=length
            p = point([r1, r2, (face_p % 2) * length])
        else:
            raise ValueError(f'Invalid face index: {face_p}')
        return p

    for _ in range(1000):
        pta = get_pt()
        ptb = get_pt()
        minimumDistanceOnCube(32, pta, ptb)