COMPO · 2.12 · PARALLEL GROUPS

Параллельные
линии
и группы.

Модуль формализует параллельность как отношение между линиями и как структуру группы: общий axis-angle, отклонения, signed offsets, порядок по нормали, gap distribution и локальную согласованность. Он не оценивает ритм как композиционный эффект — это задача Compo 2.17.

API-контракт

00 / ownership

CONSUMES

Line[] Line.role / subrole / rigidity Line.geometryType Line.pathGeometry Line.visibility GeometryOwner.orientationAxis? GeometryOwner.sampleAtArcFraction(q)? GeometryOwner.flatten(tolerancePx)? OPTIONAL: Compo2.10.IntersectionRelation[] Compo2.06.contextualWeight? StylePreset.parallelGroups?

PROVIDES

ParallelRelation[] ParallelGroupDescriptor: groupId lineIds[] groupAxisDeg angularDispersionDeg canonicalAxisVector canonicalNormalVector offsets[] orderedLineIds[] gaps[] meanGap gapCV projectionOverlap[] groupBoundsN line.parallel.angleFit line.parallel.offsetStability line.parallel.localGroupCoherence ParallelCorrectionRequest[]

DOES NOT OWN

rhythmScore // 2.17 gridFit // 2.13 lineIntersection // 2.10 visualWeight // 2.06 globalBalance globalHierarchy globalEyeFlow negativeSpaceScore
Ключевой принцип: одинаковые gaps можно измерить здесь, но нельзя объявить их «хорошим ритмом». 2.12 сообщает геометрию группы; 2.17 решает, является ли распределение интервалов удачным ритмическим паттерном.

Пара линий

01 / orientation-invariant relation

Axis angle

θA ∈ [0°,180°) θB ∈ [0°,180°) raw = abs(θA-θB) angleDeviation = min(raw, 180°-raw) range: 0°..90°

DERIVED Направление endpoints не влияет на параллельность: линия 0° и та же ось 180° считаются параллельными.

Parallel candidate

isParallelCandidate = angleDeviation <= parallelTauDeg parallelAngleFit = 1 - clamp( angleDeviation / parallelTauDeg, 0,1 )

HEURISTIC parallelTauDeg — стартовый допуск, а не закон.

Если offset≈0 и направления совпадают, это может быть collinear case. Геометрическое overlap/контакт такого случая остаётся у 2.10; 2.12 лишь фиксирует, что оси совпадают.

Общая ось группы

02 / circular mean modulo 180°

Почему нельзя обычное среднее

angles: 1°, 179° arithmetic mean = 90° // WRONG but lines are almost parallel horizontally

Для недиректированной оси угол периодичен по 180°, поэтому используется удвоенный угол.

Doubled-angle mean

X = Σ w_i cos(2θ_i) Y = Σ w_i sin(2θ_i) groupAxisDeg = 0.5 * atan2(Y,X) normalize to [0°,180°)

DERIVED Это устойчиво для осей около 0°/180°.

R
resultant
R = sqrt(X²+Y²) / Σw

1 = сильное согласие направлений.

σ
angular dispersion
dispersionDescriptor = 1 - R

Без эстетической интерпретации.

u
canonical axis
u=(cosθ,sinθ) canonicalize sign: if ux<0 or (ux≈0 and uy<0): u=-u

Стабильный знак нужен для offsets и сортировки.

Signed offsets и gaps

03 / ordering across the normal

Normal coordinate

u = canonical group axis n = (-u.y, u.x) m_i = representativePoint(line_i) offset_i = dot(m_i - groupOrigin, n)

Для straight segment representativePoint = midpoint. Для curve/polyline можно использовать arc-length centroid или проекционный descriptor.

Ordering

sort lines by offset_i o[0] ≤ o[1] ≤ ... ≤ o[k-1] gap_i = o[i+1] - o[i] meanGap = mean(gap) gapCV = stdev(gap)/meanGap

gapCV здесь — только descriptor. Его ритмическая интерпретация принадлежит 2.17.

Units: хранить offsetPx и offsetShortN. Не смешивать normalized и pixel-space расстояния внутри одной формулы.

Проекционное перекрытие

04 / do lines form one visual band?

Project bounds

for each line: project path bounds / flattened samples on axis u interval_i = [min dot(P,u), max dot(P,u)]

Overlap ratio

overlap = length( intersect(intervalA,intervalB) ) projectionOverlap = overlap / min(lengthA_proj,lengthB_proj)

Why it matters

Две короткие параллельные линии на противоположных концах холста не обязаны образовывать одну группу только из-за одинакового угла.

Group membership может учитывать angle + spatial proximity + projection overlap. Все thresholds остаются HEURISTIC/STYLE.

Параллельность кривых

05 / local tangent agreement

Не одна ось

for q in [0..1]: TA(q) = tangentA(q) TB(q) = tangentB(q) localDeviation(q) = axisAngleDifference( TA(q), TB(q) )

Для curve global angle180 недостаточен.

Curve parallelism

curveParallelFit = mean( kernel(localDeviation(q)) ) curveOffsetVariance = var( signedNormalDistance(q) )

Две offset-кривые имеют близкие tangents и относительно стабильную дистанцию по нормали.

2.12 не генерирует offset Bézier самостоятельно. Если требуется «сделать кривые более параллельными», создаётся correction request для 2.04.

Формирование групп

06 / graph clustering
for each line pair (i,j): angleOK = angleDeviation(i,j) <= tauAngle proximityOK = abs(offset_i - offset_j) <= tauGroupDistance projectionOK = projectionOverlap(i,j) >= minProjectionOverlap OR spatialDistance(i,j) <= localFallbackDistance if angleOK && proximityOK && projectionOK: add graph edge(i,j) groups = connectedComponents(graph) then for each group: recompute groupAxis recompute canonical normal recompute offsets sort compute gaps compute dispersion validate coherence
Connected-components caveat: цепочка A≈B, B≈C не гарантирует, что A≈C достаточно согласованы. После clustering обязателен group-level validation относительно общей оси.

Local group scoring

07 / no rhythm leakage

OWNS

line.parallel.angleFit line.parallel.offsetStability line.parallel.projectionCoherence line.parallel.groupMembershipConfidence line.parallel.localGroupCoherence group.parallel.angularDispersion group.parallel.offsets[] group.parallel.gaps[] group.parallel.gapCV // descriptor only

DOES NOT OWN

rhythm.regularityScore rhythm.progressionScore grid.alignmentScore global.balanceScore global.hierarchyScore negativeSpaceScore eyeFlowScore
localGroupCoherence = wA * angleCoherence + wP * projectionCoherence + wO * offsetOrderStability // gapCV is evidence only. // Do NOT reward gapCV≈0 here. // Rhythm belongs to Compo 2.17.
Composite-first: будущий 2.20 использует line.parallel.localGroupCoherence как основной vote. Pair/group components остаются diagnostics, если scorer явно не разрешает иное.

Correction requests

08 / bounded proposals

ROTATE

Попросить geometry-owner приблизить ось линии к groupAxis.

SHIFT

Сместить линию по нормали, не меняя ориентацию.

SPLIT GROUP

Разделить слабосвязанную группу на две.

REMOVE FROM GROUP

Не удалять линию из сцены — только снять membership.

ParallelCorrectionRequest { lineId?, groupId, reasonCode: ANGLE_OUTLIER | OFFSET_OUTLIER | LOW_PROJECTION_OVERLAP | CHAINING_INCONSISTENCY | CURVE_OFFSET_INSTABILITY, targetOwner: "Compo2.01" | "Compo2.04" | "Compo2.05" | "GLOBAL_SOLVER", requestedChange, bounds?, confidence }

Визуальная лаборатория

09 / measurable groups
COHERENT: одинаковая ось, стабильные offsets, высокий projection overlap.
Angular dispersion растёт. Группа может остаться одной только до заданного tolerance.
НЕ ГРУППА ПО УМОЛЧАНИЮ: угол совпадает, но проекционное/пространственное отношение слишком слабое.
Две offset-like curves: сравниваются local tangents и variance нормального расстояния.
2.12 сообщает gaps = [40,75]. Оценивать это как regular/progressive rhythm будет 2.17.

Детерминированные тесты

10 / regression

TEST 01 · wrap

θA=1° θB=179° expected: angleDeviation=2° not 178°

TEST 02 · exact

θA=35° θB=35° expected: deviation=0 parallelAngleFit=1

TEST 03 · opposite endpoints

A angle=20° B directed angle=200° axis angles: 20°,20° expected: parallel=true

TEST 04 · mean axis

angles=[1°,179°] expected: groupAxis≈0° not 90°

TEST 05 · gaps

offsets=[10,30,70] expected: sorted=[10,30,70] gaps=[20,40] meanGap=30

TEST 06 · no rhythm leak

change only: rhythm target profile expected: 2.12 gaps and angular metrics UNCHANGED

TEST 07 · far apart

same angle projectionOverlap=0 large spatial distance expected: not same group by default

TEST 08 · chaining

A≈B B≈C A far from groupAxis expected: group validation flags outlier

TEST 09 · ownership

angle outlier found 2.12 MAY: request rotate MUST NOT: rewrite 2.01 geometry itself

LIVE REFERENCE TESTS

IDInputComputedStatus

DATA FOR LAYOUT ENGINE

11 / machine contract
{ "module": "Compo 2.12", "name": "parallel_lines_and_groups", "version": "1.0", "scope": "LOCAL_PARALLEL_RELATIONS_AND_GROUPS", "consumes": [ "Compo2.00.Line[]", "Compo2.00.LinePathGeometry", "GeometryOwner.orientationAxis?", "GeometryOwner.sampleAtArcFraction(q)?", "GeometryOwner.flatten(tolerancePx)?", "Compo2.10.IntersectionRelation[]?", "Compo2.06.contextualWeight?", "StylePreset.parallelGroups?" ], "provides": [ "ParallelRelation[]", "ParallelGroupDescriptor", "ParallelCorrectionRequest[]" ], "owns_metrics": [ "line.parallel.angleFit", "line.parallel.offsetStability", "line.parallel.projectionCoherence", "line.parallel.groupMembershipConfidence", "line.parallel.localGroupCoherence", "group.parallel.angularDispersion", "group.parallel.offsets", "group.parallel.gaps", "group.parallel.gapCV" ], "metric_policy": { "primary_vote": "line.parallel.localGroupCoherence", "gapCV": "DESCRIPTOR_ONLY", "rhythm_metrics": "FORBIDDEN_TO_OWN" }, "initial_heuristics": { "parallel_tau_deg": {"value":3,"source":"INITIAL_HEURISTIC"}, "min_projection_overlap": {"value":0.25,"source":"INITIAL_HEURISTIC"}, "group_distance_shortN": {"value":0.18,"source":"INITIAL_HEURISTIC"}, "curve_sample_count": {"value":24,"source":"IMPLEMENTATION_HEURISTIC"} }, "hard_rules": [ "parallel_angle_must_be_orientation_invariant_modulo_180", "group_axis_must_use_circular_mean_modulo_180", "offset_sign_must_use_canonical_axis_orientation", "pixel_and_normalized_distances_must_not_be_mixed", "curve_geometry_must_come_from_geometry_owner", "group_chaining_requires_group_level_validation", "must_not_own_rhythm_score", "foreign_geometry_changes_must_be_requests" ], "deferred_inputs": [ "Compo2.13.gridFit", "Compo2.17.rhythmScore", "global.balanceScore", "global.hierarchyScore", "global.negativeSpaceScore", "global.eyeFlowScore" ], "recompute_when": [ "any member LinePathGeometry changes", "line membership changes", "role/subrole/rigidity changes", "group thresholds change", "curve sampling changes" ], "finality": "LOCAL_GROUP_STRUCTURE_ONLY" }
ARCHITECTURE CHECKPOINT: 2.12 создаёт геометрическую основу для будущих 2.13 и 2.17: сетка сможет потреблять group axis и offsets, а ритм — gaps и их распределение. Но ни grid-fit, ни rhythm-score здесь не считаются.