API-контракт
00 / ownership
CONSUMES
Line
Line.role / subrole / rigidity
Line.geometryType
Line.pathGeometry
Line.endpointLocks?
Grid
Grid.families[]
Grid.axes[]?
Grid.nodes[]?
Grid.boundsN
Grid.visibility?
OPTIONAL:
Compo2.12.ParallelGroupDescriptor
StylePreset.lineGrid?
PROVIDES
LineGridRelation:
gridId
familyId?
axisId?
axisAngleDeviationDeg
nearestOffsetPx
nearestOffsetShortN
projectionCoverage?
sourceNodeDistancePx?
targetNodeDistancePx?
segmentConformity?
applicability
confidence
Line.gridCandidates[]
line.grid.axisFit
line.grid.offsetFit
line.grid.endpointFit
line.grid.localGridFit
line.grid.snapCost
DOES NOT OWN
Grid geometry/schema owner
Grid node occupancy
global grid quality
global alignment score
rhythm score // 2.17
parallel group score // 2.12
line length // 2.07
line visual weight // 2.06
global hierarchy
global balance
Architecture: 2.13 оценивает линию относительно сетки. Он не создаёт собственную параллельную Grid-модель и не решает, хороша ли сама сетка для композиции.
Минимальный Grid adapter
01 / external contract
GridFamily
GridFamily {
id,
axisUnit,
normalUnit,
spacingPx?,
phasePx?,
finiteAxes[]?,
role?
}
Семейство параллельных grid-lines.
GridAxis
GridAxis {
id,
familyId,
pointN,
axisUnit,
extent?
}
Конкретная ось, если сетка конечная или немодульная.
GridNode
GridNode {
id,
pointN,
familyIds[],
locked?
}
Точка пересечения осей / разрешённый snap-anchor.
Если будущий grid-owner использует другую внутреннюю структуру, он обязан предоставить адаптер с эквивалентными операциями: nearestAxis(), nearestNode(), familyDirection().
Совпадение направления
02 / orientation modulo 180°
Axis deviation
θL = line axis angle
θG = grid family angle
raw = abs(θL - θG)
axisDeviation =
min(raw, 180°-raw)
axisFit =
1 - clamp(
axisDeviation / axisTauDeg,
0,1
)
Для прямого segment используется его axis. Для curve/polyline — отдельная политика ниже.
Applicability
role=axis/grid:
applicability ≈ high
divider:
medium/high
connector:
contextual
accent:
style-dependent
trajectory:
usually lower
Низкий grid-fit не является ошибкой, если роль линии не должна следовать сетке.
INITIAL HEURISTIC axisTauDeg нужен для candidate generation. Он не должен превращать все линии композиции в H/V или в одну grid family.
Расстояние до grid-line
03 / signed normal coordinate
Periodic family
n = family.normalUnit
s = dot(lineRepresentativePoint, n)
relative =
s - phasePx
k = round(relative / spacingPx)
nearestGridCoordinate =
phasePx + k*spacingPx
signedOffsetPx =
s - nearestGridCoordinate
DERIVED Для бесконечного регулярного family.
Offset fit
absOffset = abs(signedOffsetPx)
offsetFit =
1 - clamp(
absOffset /
offsetTauPx,
0,1
)
offsetTauPx должен масштабироваться через short side / stroke / grid spacing.
Не смешивать: axisFit отвечает за угол, offsetFit — за положение относительно ближайшей оси. Параллельная линии сетки линия может иметь axisFit=1, но offsetFit≈0.
Endpoints и grid nodes
04 / attachment to intersections
Nearest node
node = Grid.nearestNode(P)
dNodePx =
distance(P,node.point)
nodeFit =
1 - clamp(
dNodePx / nodeTauPx,
0,1
)
Locks first
if endpoint.locked:
do NOT snap
report only:
nodeDistance
possible mismatch
Сетка не имеет права ломать semantic endpoint connector-а.
Endpoint fit
endpointFit =
weightedMean(
sourceNodeFit?,
targetNodeFit?
)
N/A endpoints
excluded by applicability
Не каждая линия должна начинаться и заканчиваться на узлах сетки.
Для axis или divider часто важнее совпадение с grid-line, чем endpoint-to-node snap. Для connector ситуация может быть обратной.
Polyline и curve
05 / geometry-specific conformity
Polyline
for each segment:
find nearest family
compute axisFit
compute offsetFit
segmentConformity =
weightedMean(
segmentGridFit,
weights=segmentLength
)
vertexNodeFit optional
Ломаная может следовать нескольким grid families, например orthogonal H/V.
Curve
curveGridFit applicability
usually low unless style
explicitly requests it
sample tangents:
compare only if
curved_grid_family exists
otherwise:
axisFit = N/A
Обычную Bézier-кривую нельзя штрафовать за то, что её tangent постоянно отклоняется от прямоугольной сетки.
N/A policy: неприменимость должна уменьшать applicability, а не превращаться в нулевой score. Иначе система начнёт систематически уничтожать кривые и свободные accents.
Projection coverage
06 / finite grid axes
Line projection
project line path
onto grid axis u
I_line=[a,b]
Axis extent
finite grid axis:
I_grid=[g0,g1]
Coverage
overlap =
length(intersect(
I_line,I_grid
))
projectionCoverage =
overlap /
max(length(I_line),EPS)
Полезно, чтобы line не «snapнулась» к оси, которая находится далеко по длине.
Local grid scoring
07 / metric ownership
OWNS
line.grid.axisFit
line.grid.offsetFit
line.grid.endpointFit
line.grid.segmentConformity
line.grid.projectionCoverageFit
line.grid.snapCost
line.grid.localGridFit
line.grid.confidence
DOES NOT OWN
grid.globalQuality
grid.globalAlignmentScore
grid.nodeOccupancy
rhythm.score
parallel.localGroupCoherence
global.balance
global.hierarchy
global.negativeSpace
localGridFit =
weightedMeanApplicable([
axisFit,
offsetFit,
endpointFit,
segmentConformity,
projectionCoverageFit
])
candidateScore =
localGridFit
- wSnap * snapCost
// applicability depends on role/subrole/style.
// N/A metrics are excluded, not set to zero.
Composite-first: 2.20 должен использовать line.grid.localGridFit как основной grid-related vote. axis/offset/endpoint components — evidence по умолчанию.
Candidate generation
08 / snap requests
ROTATE
Приблизить line axis к grid family.
SHIFT
Сдвинуть параллельно normal до ближайшей оси.
SNAP ENDPOINT
Переместить свободный endpoint в grid node.
ORTHOGONALIZE
Для polyline предложить сегментам две совместимые grid families.
LineGridCandidate {
lineId,
gridId,
familyId?,
axisId?,
snapMode:
NONE |
ROTATE |
SHIFT |
ROTATE_AND_SHIFT |
SNAP_SOURCE |
SNAP_TARGET |
SNAP_BOTH |
ORTHOGONALIZE,
proposedTransform?,
targetNodes?,
localGridFit,
snapCost,
hardStatus,
reasonCodes[]
}
// 2.13 generates proposal.
// Geometry owner applies and recomputes LinePathGeometry.
Визуальная лаборатория
09 / grid cases
Детерминированные тесты
10 / regression
TEST 01 · angle
line axis=29°
grid family=30°
expected:
axisDeviation=1°
TEST 02 · periodic offset
spacing=50
phase=0
line coordinate=151
expected:
nearest=150
signedOffset=+1
TEST 03 · wrap
line axis=179°
grid axis=1°
expected:
axisDeviation=2°
not 178°
TEST 04 · locked endpoint
source.locked=true
nearestNodeDistance=2
expected:
REPORT_ONLY
must not snap source
TEST 05 · N/A curve
curve
rectilinear grid
style.followGrid=false
expected:
axisFit=N/A
not 0
TEST 06 · no grid ownership
Grid.spacing changes
expected:
2.13 recomputes relation
MUST NOT:
rewrite Grid itself
TEST 07 · orthogonal polyline
segment angles=[0,90,0]
grid families=[0,90]
expected:
segmentConformity=high
TEST 08 · offset independent
parallel to grid
offset=large
expected:
axisFit=1
offsetFit low
TEST 09 · no global leak
change:
global.balanceScore
expected:
LineGridRelation
UNCHANGED
DATA FOR LAYOUT ENGINE
11 / machine contract
{
"module": "Compo 2.13",
"name": "lines_and_grid",
"version": "1.0",
"scope": "LOCAL_LINE_GRID_RELATION",
"consumes": [
"Compo2.00.Line",
"Compo2.00.LinePathGeometry",
"Grid",
"GridFamily[]",
"GridAxis[]?",
"GridNode[]?",
"Compo2.12.ParallelGroupDescriptor?",
"StylePreset.lineGrid?"
],
"provides": [
"LineGridRelation",
"Line.gridCandidates[]"
],
"owns_metrics": [
"line.grid.axisFit",
"line.grid.offsetFit",
"line.grid.endpointFit",
"line.grid.segmentConformity",
"line.grid.projectionCoverageFit",
"line.grid.snapCost",
"line.grid.localGridFit",
"line.grid.confidence"
],
"metric_policy": {
"primary_vote": "line.grid.localGridFit",
"components": "diagnostic_only_by_default",
"not_applicable": "EXCLUDE_FROM_WEIGHTED_MEAN",
"global_grid_score": "FORBIDDEN_TO_OWN"
},
"initial_heuristics": {
"axis_tau_deg": {"value":3,"source":"INITIAL_HEURISTIC"},
"offset_tau_shortN": {"value":0.012,"source":"INITIAL_HEURISTIC"},
"node_tau_shortN": {"value":0.018,"source":"INITIAL_HEURISTIC"},
"projection_coverage_min": {"value":0.25,"source":"INITIAL_HEURISTIC"}
},
"hard_rules": [
"grid_geometry_must_come_from_grid_owner",
"orientation_comparison_must_be_modulo_180",
"axis_fit_and_offset_fit_must_remain_separate",
"locked_endpoints_must_not_be_snapped",
"semantic_connector_endpoints_must_not_be_overridden_by_grid",
"curve_grid_nonapplicability_must_be_NA_not_zero",
"foreign_geometry_changes_must_be_candidates_or_requests",
"normalized_and_pixel_distances_must_not_be_mixed"
],
"deferred_inputs": [
"grid.globalQuality",
"grid.globalAlignmentScore",
"grid.nodeOccupancy",
"Compo2.17.rhythmScore",
"global.balanceScore",
"global.hierarchyScore",
"global.negativeSpaceScore"
],
"recompute_when": [
"LinePathGeometry changes",
"Grid geometry changes",
"GridFamily parameters change",
"endpoint locks change",
"Line role/subrole/rigidity changes",
"StylePreset.lineGrid changes"
],
"finality": "LOCAL_GRID_RELATION_ONLY"
}
ARCHITECTURE CHECKPOINT: 2.13 использует результаты 2.12 и подготавливает данные для будущей общей grid-системы, но ownership сохраняется чистым: 2.12 — параллельная группа, 2.13 — локальная связь line↔grid, 2.17 — ритм интервалов, global grid owner — качество сетки целиком.