@needle-tools/addressing 0.3.4-1790936280.dramatic-tiramisu.86ca165 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/anchor.ts CHANGED
@@ -1,71 +1,71 @@
1
- /**
2
- * The name-anchor scheme: an element named `Door #needle` derives its deterministic id
3
- * from the text before the marker instead of (name, index, file hash) — so the id
4
- * survives content changes and asset re-uploads as long as the name stays.
5
- *
6
- * CONTRACT NOTE — the marker set is part of the id contract. Markers may be ADDED (new
7
- * names start anchoring), but never removed or re-prioritized, and every WRITER of ids
8
- * (this package's consumers: cloud upload, editors, CLI) must agree on the set before a
9
- * new marker ships — a writer that anchors on a marker another writer doesn't know mints
10
- * DIFFERENT ids for the same file. The anchored id only hashes the VALUE (not the
11
- * marker), so all markers are aliases into the same id space.
12
- */
13
-
14
- /** the original marker — kept as a named export for existing consumers */
15
- export const ANCHOR_MARKER = "#needle";
16
-
17
- /**
18
- * All recognized markers, matched longest-first so overlapping suffixes resolve
19
- * deterministically. Currently one entry; see the contract note before adding more.
20
- */
21
- export const ANCHOR_MARKERS: readonly string[] = [ANCHOR_MARKER];
22
-
23
- export type ParsedAnchor = {
24
- /** the marker that matched (one of {@link ANCHOR_MARKERS}) */
25
- marker: string;
26
- /** the anchor value — the deterministic-id input */
27
- value: string;
28
- };
29
-
30
- /**
31
- * Parse an element name against all recognized markers (case-insensitive suffix match).
32
- * Returns null when no marker matches or the anchor value is empty.
33
- */
34
- export function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null {
35
- if (rawName == null) return null;
36
- const trimmed = rawName.trim();
37
- const lower = trimmed.toLowerCase();
38
- for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
39
- if (!lower.endsWith(marker)) continue;
40
- const value = normalizeName(trimmed.substring(0, trimmed.length - marker.length));
41
- return value.length > 0 ? { marker, value } : null;
42
- }
43
- return null;
44
- }
45
-
46
- /**
47
- * Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
48
- * three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
49
- * "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
50
- * stamped anchor and the live anchor would never match. Applied at PARSE time, so id
51
- * computation and resolution agree from one change point. Accepted trade: names differing
52
- * only by space-vs-underscore are one name.
53
- *
54
- * Exported because id derivation applies the SAME rule to unanchored names (see
55
- * {@link import("./id.js").idNameOf}) — one normalization, one change point.
56
- */
57
- export function normalizeName(value: string): string {
58
- return value.replace(/[\s_]+/g, " ").trim();
59
- }
60
-
61
- /**
62
- * Extract the anchor value from an element name, or null when the name carries no anchor.
63
- * Convenience over {@link parseAnchor} for callers that only need the id input.
64
- */
65
- export function parseAnchorValue(rawName: string | null | undefined): string | null {
66
- return parseAnchor(rawName)?.value ?? null;
67
- }
68
-
69
- function byLengthDescending(markers: readonly string[]): readonly string[] {
70
- return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
71
- }
1
+ /**
2
+ * The name-anchor scheme: an element named `Door #needle` derives its deterministic id
3
+ * from the text before the marker instead of (name, index, file hash) — so the id
4
+ * survives content changes and asset re-uploads as long as the name stays.
5
+ *
6
+ * CONTRACT NOTE — the marker set is part of the id contract. Markers may be ADDED (new
7
+ * names start anchoring), but never removed or re-prioritized, and every WRITER of ids
8
+ * (this package's consumers: cloud upload, editors, CLI) must agree on the set before a
9
+ * new marker ships — a writer that anchors on a marker another writer doesn't know mints
10
+ * DIFFERENT ids for the same file. The anchored id only hashes the VALUE (not the
11
+ * marker), so all markers are aliases into the same id space.
12
+ */
13
+
14
+ /** the original marker — kept as a named export for existing consumers */
15
+ export const ANCHOR_MARKER = "#needle";
16
+
17
+ /**
18
+ * All recognized markers, matched longest-first so overlapping suffixes resolve
19
+ * deterministically. Currently one entry; see the contract note before adding more.
20
+ */
21
+ export const ANCHOR_MARKERS: readonly string[] = [ANCHOR_MARKER];
22
+
23
+ export type ParsedAnchor = {
24
+ /** the marker that matched (one of {@link ANCHOR_MARKERS}) */
25
+ marker: string;
26
+ /** the anchor value — the deterministic-id input */
27
+ value: string;
28
+ };
29
+
30
+ /**
31
+ * Parse an element name against all recognized markers (case-insensitive suffix match).
32
+ * Returns null when no marker matches or the anchor value is empty.
33
+ */
34
+ export function parseAnchor(rawName: string | null | undefined): ParsedAnchor | null {
35
+ if (rawName == null) return null;
36
+ const trimmed = rawName.trim();
37
+ const lower = trimmed.toLowerCase();
38
+ for (const marker of byLengthDescending(ANCHOR_MARKERS)) {
39
+ if (!lower.endsWith(marker)) continue;
40
+ const value = normalizeName(trimmed.substring(0, trimmed.length - marker.length));
41
+ return value.length > 0 ? { marker, value } : null;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Names treat SPACES and UNDERSCORES as the same character (runs collapse to one space):
48
+ * three's GLTFLoader sanitizes node names on load (whitespace becomes "_"), so
49
+ * "Door Frame #needle" appears live as "Door_Frame_#needle" — without normalization a
50
+ * stamped anchor and the live anchor would never match. Applied at PARSE time, so id
51
+ * computation and resolution agree from one change point. Accepted trade: names differing
52
+ * only by space-vs-underscore are one name.
53
+ *
54
+ * Exported because id derivation applies the SAME rule to unanchored names (see
55
+ * {@link import("./id.js").idNameOf}) — one normalization, one change point.
56
+ */
57
+ export function normalizeName(value: string): string {
58
+ return value.replace(/[\s_]+/g, " ").trim();
59
+ }
60
+
61
+ /**
62
+ * Extract the anchor value from an element name, or null when the name carries no anchor.
63
+ * Convenience over {@link parseAnchor} for callers that only need the id input.
64
+ */
65
+ export function parseAnchorValue(rawName: string | null | undefined): string | null {
66
+ return parseAnchor(rawName)?.value ?? null;
67
+ }
68
+
69
+ function byLengthDescending(markers: readonly string[]): readonly string[] {
70
+ return markers.length > 1 ? [...markers].sort((a, b) => b.length - a.length) : markers;
71
+ }
@@ -1,77 +1,77 @@
1
- import type { ProjectDocument, ProjectDocumentAsset, ProjectDocumentReference } from "./types.js";
2
-
3
- /** Asset keys and shared resource identities for one document assembly. */
4
- export class DocumentAssetAssembly {
5
- private readonly assetBySrc = new Map<string, string>();
6
- private readonly referenceById = new Map<string, ProjectDocumentReference>();
7
- private readonly materialBySource = new Map<string, ProjectDocumentReference>();
8
- private readonly baseMaterialBySource = new Map<string, ProjectDocumentReference>();
9
-
10
- constructor(
11
- private readonly assets: Record<string, ProjectDocumentAsset>,
12
- private readonly references: ProjectDocumentReference[],
13
- base?: ProjectDocument | null,
14
- ) {
15
- for (const [key, asset] of Object.entries(assets)) this.assetBySrc.set(asset.src, key);
16
- for (const reference of references) {
17
- this.referenceById.set(reference.id, reference);
18
- const key = this.materialKey(reference, assets);
19
- if (key) this.materialBySource.set(key, reference);
20
- }
21
- for (const reference of base?.references ?? []) {
22
- const key = this.materialKey(reference, base?.assets ?? {});
23
- if (key) this.baseMaterialBySource.set(key, reference);
24
- }
25
- }
26
-
27
- addAsset(input: { src: string; name?: string; kind: string; identity?: { id: string; provider?: string } }): string {
28
- const existing = this.assetBySrc.get(input.src);
29
- if (existing !== undefined) return existing;
30
- const filename = input.src.split("?")[0].split("/").pop() || "material";
31
- const dot = filename.lastIndexOf(".");
32
- const stem = input.name || (dot > 0 ? filename.substring(0, dot) : filename);
33
- let key = stem;
34
- let n = 2;
35
- while (key in this.assets) key = `${stem}-${n++}`;
36
- this.assets[key] = { src: input.src, kind: input.kind, ...(input.identity ? { asset: { ...input.identity } } : {}) };
37
- this.assetBySrc.set(input.src, key);
38
- return key;
39
- }
40
-
41
- materialEntity(asset: string, material: { id: string; name?: string }, entityId: string): string {
42
- if (!this.assets[asset]) throw new Error(`Unknown document asset: ${asset}`);
43
- const key = JSON.stringify([this.assets[asset].src, material.id]);
44
- const existing = this.materialBySource.get(key);
45
- if (existing) return existing.id;
46
- const base = this.baseMaterialBySource.get(key);
47
- const reference: ProjectDocumentReference = base ? { ...base, asset } : {
48
- id: entityId, asset, address: { type: "material", id: material.id, ...(material.name ? { name: material.name } : {}) },
49
- };
50
- this.addReference(reference);
51
- this.materialBySource.set(key, reference);
52
- return reference.id;
53
- }
54
-
55
- environmentEntity(asset: string, entityId: string): string {
56
- if (!this.assets[asset]) throw new Error(`Unknown document asset: ${asset}`);
57
- this.addReference({ id: entityId, asset, address: { type: "hdr" } });
58
- return entityId;
59
- }
60
-
61
- private addReference(reference: ProjectDocumentReference): void {
62
- const existing = this.referenceById.get(reference.id);
63
- if (existing) {
64
- if (existing.asset !== reference.asset || existing.address?.type !== reference.address?.type || existing.address?.id !== reference.address?.id) {
65
- throw new Error(`Document reference id '${reference.id}' already identifies another resource`);
66
- }
67
- return;
68
- }
69
- this.references.push(reference);
70
- this.referenceById.set(reference.id, reference);
71
- }
72
-
73
- private materialKey(reference: ProjectDocumentReference, assets: Record<string, ProjectDocumentAsset>): string | null {
74
- return reference.address?.type === "material" && reference.address.id && assets[reference.asset]
75
- ? JSON.stringify([assets[reference.asset].src, reference.address.id]) : null;
76
- }
77
- }
1
+ import type { ProjectDocument, ProjectDocumentAsset, ProjectDocumentReference } from "./types.js";
2
+
3
+ /** Asset keys and shared resource identities for one document assembly. */
4
+ export class DocumentAssetAssembly {
5
+ private readonly assetBySrc = new Map<string, string>();
6
+ private readonly referenceById = new Map<string, ProjectDocumentReference>();
7
+ private readonly materialBySource = new Map<string, ProjectDocumentReference>();
8
+ private readonly baseMaterialBySource = new Map<string, ProjectDocumentReference>();
9
+
10
+ constructor(
11
+ private readonly assets: Record<string, ProjectDocumentAsset>,
12
+ private readonly references: ProjectDocumentReference[],
13
+ base?: ProjectDocument | null,
14
+ ) {
15
+ for (const [key, asset] of Object.entries(assets)) this.assetBySrc.set(asset.src, key);
16
+ for (const reference of references) {
17
+ this.referenceById.set(reference.id, reference);
18
+ const key = this.materialKey(reference, assets);
19
+ if (key) this.materialBySource.set(key, reference);
20
+ }
21
+ for (const reference of base?.references ?? []) {
22
+ const key = this.materialKey(reference, base?.assets ?? {});
23
+ if (key) this.baseMaterialBySource.set(key, reference);
24
+ }
25
+ }
26
+
27
+ addAsset(input: { src: string; name?: string; kind: string; identity?: { id: string; provider?: string } }): string {
28
+ const existing = this.assetBySrc.get(input.src);
29
+ if (existing !== undefined) return existing;
30
+ const filename = input.src.split("?")[0].split("/").pop() || "material";
31
+ const dot = filename.lastIndexOf(".");
32
+ const stem = input.name || (dot > 0 ? filename.substring(0, dot) : filename);
33
+ let key = stem;
34
+ let n = 2;
35
+ while (key in this.assets) key = `${stem}-${n++}`;
36
+ this.assets[key] = { src: input.src, kind: input.kind, ...(input.identity ? { asset: { ...input.identity } } : {}) };
37
+ this.assetBySrc.set(input.src, key);
38
+ return key;
39
+ }
40
+
41
+ materialEntity(asset: string, material: { id: string; name?: string }, entityId: string): string {
42
+ if (!this.assets[asset]) throw new Error(`Unknown document asset: ${asset}`);
43
+ const key = JSON.stringify([this.assets[asset].src, material.id]);
44
+ const existing = this.materialBySource.get(key);
45
+ if (existing) return existing.id;
46
+ const base = this.baseMaterialBySource.get(key);
47
+ const reference: ProjectDocumentReference = base ? { ...base, asset } : {
48
+ id: entityId, asset, address: { type: "material", id: material.id, ...(material.name ? { name: material.name } : {}) },
49
+ };
50
+ this.addReference(reference);
51
+ this.materialBySource.set(key, reference);
52
+ return reference.id;
53
+ }
54
+
55
+ environmentEntity(asset: string, entityId: string): string {
56
+ if (!this.assets[asset]) throw new Error(`Unknown document asset: ${asset}`);
57
+ this.addReference({ id: entityId, asset, address: { type: "hdr" } });
58
+ return entityId;
59
+ }
60
+
61
+ private addReference(reference: ProjectDocumentReference): void {
62
+ const existing = this.referenceById.get(reference.id);
63
+ if (existing) {
64
+ if (existing.asset !== reference.asset || existing.address?.type !== reference.address?.type || existing.address?.id !== reference.address?.id) {
65
+ throw new Error(`Document reference id '${reference.id}' already identifies another resource`);
66
+ }
67
+ return;
68
+ }
69
+ this.references.push(reference);
70
+ this.referenceById.set(reference.id, reference);
71
+ }
72
+
73
+ private materialKey(reference: ProjectDocumentReference, assets: Record<string, ProjectDocumentAsset>): string | null {
74
+ return reference.address?.type === "material" && reference.address.id && assets[reference.asset]
75
+ ? JSON.stringify([assets[reference.asset].src, reference.address.id]) : null;
76
+ }
77
+ }
@@ -1,37 +1,37 @@
1
- export interface DocumentCollectionSelectorOptions {
2
- matchesComponentType?(component: object, name: string): boolean;
3
- matchesComponentGuid?(component: object, guid: string): boolean;
4
- deriveComponentGuid?(referenceId: string, fileGuid: string): string;
5
- referenceId?: string;
6
- report?(message: string): void;
7
- }
8
-
9
- /** Resolve an index or field qualifier; ambiguous identities never select an arbitrary member. */
10
- export function resolveDocumentCollectionElement(
11
- collection: readonly unknown[], selector: string, options: DocumentCollectionSelectorOptions = {},
12
- ): unknown {
13
- if (/^\d+$/.test(selector)) return collection[Number(selector)];
14
- const qualifier = /^([A-Za-z_][A-Za-z0-9_-]*):([\s\S]*)$/.exec(selector);
15
- if (!qualifier) {
16
- options.report?.(`"${selector}" is neither an index nor a <field>:<value> qualifier`);
17
- return undefined;
18
- }
19
- const [, field, value] = qualifier;
20
- const elements = collection.filter((element): element is Record<string, unknown> =>
21
- element !== null && typeof element === "object");
22
- let matches = elements.filter(element => {
23
- if (field === "type") return options.matchesComponentType?.(element, value) ?? element.constructor?.name === value;
24
- if (field === "guid") return options.matchesComponentGuid?.(element, value)
25
- ?? (element.guid === value || element.needle_editor_guid === value);
26
- return element[field] === value;
27
- });
28
- if (!matches.length && field === "guid" && options.referenceId && options.deriveComponentGuid) {
29
- const derived = options.deriveComponentGuid(options.referenceId, value);
30
- matches = elements.filter(element => element.guid === derived);
31
- }
32
- if (matches.length > 1) {
33
- options.report?.(`"${selector}" matched ${matches.length} elements ? the document must name one unambiguously`);
34
- return undefined;
35
- }
36
- return matches[0];
37
- }
1
+ export interface DocumentCollectionSelectorOptions {
2
+ matchesComponentType?(component: object, name: string): boolean;
3
+ matchesComponentGuid?(component: object, guid: string): boolean;
4
+ deriveComponentGuid?(referenceId: string, fileGuid: string): string;
5
+ referenceId?: string;
6
+ report?(message: string): void;
7
+ }
8
+
9
+ /** Resolve an index or field qualifier; ambiguous identities never select an arbitrary member. */
10
+ export function resolveDocumentCollectionElement(
11
+ collection: readonly unknown[], selector: string, options: DocumentCollectionSelectorOptions = {},
12
+ ): unknown {
13
+ if (/^\d+$/.test(selector)) return collection[Number(selector)];
14
+ const qualifier = /^([A-Za-z_][A-Za-z0-9_-]*):([\s\S]*)$/.exec(selector);
15
+ if (!qualifier) {
16
+ options.report?.(`"${selector}" is neither an index nor a <field>:<value> qualifier`);
17
+ return undefined;
18
+ }
19
+ const [, field, value] = qualifier;
20
+ const elements = collection.filter((element): element is Record<string, unknown> =>
21
+ element !== null && typeof element === "object");
22
+ let matches = elements.filter(element => {
23
+ if (field === "type") return options.matchesComponentType?.(element, value) ?? element.constructor?.name === value;
24
+ if (field === "guid") return options.matchesComponentGuid?.(element, value)
25
+ ?? (element.guid === value || element.needle_editor_guid === value);
26
+ return element[field] === value;
27
+ });
28
+ if (!matches.length && field === "guid" && options.referenceId && options.deriveComponentGuid) {
29
+ const derived = options.deriveComponentGuid(options.referenceId, value);
30
+ matches = elements.filter(element => element.guid === derived);
31
+ }
32
+ if (matches.length > 1) {
33
+ options.report?.(`"${selector}" matched ${matches.length} elements ? the document must name one unambiguously`);
34
+ return undefined;
35
+ }
36
+ return matches[0];
37
+ }