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
Детерминированные тесты
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
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 здесь не считаются.